## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修) - 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同** (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致 - 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到 - 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢); 另加守卫「compare=none 的小项不得配 basis=result/aggregate」 - 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0 (7 人三人并列第 5 时第 5、6 名的分也一起没了) - 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分 ## 现场反馈(主人实测报的) - 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组), 与「作废」语义分开;取消路径不产生任何副作用 - 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」: 管理端、大屏覆盖层、xlsx 备注列三处同源 - 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦 - 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响) ## 新功能 - 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切) ## 安装器 - 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、 安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540 - 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败 自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。 端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
140 lines
5.1 KiB
Go
140 lines
5.1 KiB
Go
package rules
|
||
|
||
import (
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||
)
|
||
|
||
// 本文件钉住工单 E:分值表的档位超出「录取档位数(awardRanks)」时必须拒收。
|
||
//
|
||
// 算分引擎只在 award_ranks > 0 时按名次截断(domain/scoring.go 的 basePoints),
|
||
// 而本包原先只拒 awardRanks < 0:一份「配到第 7 档、录取名次还留着 6」的规则包能过校验、
|
||
// 安静导入——第 7 档在模板页上写着分,榜上恒为 0,界面上看不出任何异常(审查 01 P1)。
|
||
// awardRanks: 0 表示不按名次封顶(既有语义),这种包必须继续放行。
|
||
|
||
// sevenRankPackYAML 是「配到第 7 档」的规则包;录取名次由用例替换成 6 / 0。
|
||
const sevenRankPackYAML = `format: scoreboard-rules
|
||
formatVersion: 1
|
||
meta: { code: demo, name: 第七档 }
|
||
profiles:
|
||
- code: p7
|
||
name: 前七名
|
||
awardRanks: 6
|
||
tieMode: skip
|
||
rules:
|
||
- { rank: 1, points: 10 }
|
||
- { rank: 2, points: 8 }
|
||
- { rank: 3, points: 6 }
|
||
- { rank: 4, points: 5 }
|
||
- { rank: 5, points: 4 }
|
||
- { rank: 6, points: 3 }
|
||
- { rank: 7, points: 2 }
|
||
channelTemplates:
|
||
- match: { eventType: individual }
|
||
channels:
|
||
- { scope: athlete, basis: result, profile: p7 }
|
||
`
|
||
|
||
// 录取名次 6、分值表到第 7 档:导入必须整体拒收,且说明要说清「哪一档超了」「怎么改」。
|
||
func TestParseRejectsAwardRanksOverflow(t *testing.T) {
|
||
_, err := Parse([]byte(sevenRankPackYAML))
|
||
if !errors.Is(err, ErrValidation) {
|
||
t.Fatalf("第 7 档 + awardRanks=6 应报 ErrValidation,实际 %v", err)
|
||
}
|
||
var ve *ValidationError
|
||
if !errors.As(err, &ve) {
|
||
t.Fatalf("应返回 *ValidationError,实际 %T", err)
|
||
}
|
||
var found bool
|
||
for _, is := range ve.Issues {
|
||
if is.Path != "profiles[0].awardRanks" {
|
||
continue
|
||
}
|
||
found = true
|
||
// 现场要能照着改:说清超出的档位、当前录取名次,以及「0 表示不封顶」这个出口。
|
||
for _, want := range []string{"第 7 档", "第 6 档", "0 表示不按名次封顶"} {
|
||
if !strings.Contains(is.Msg, want) {
|
||
t.Fatalf("说明里缺少 %q:%s", want, is.Msg)
|
||
}
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("问题应挂在 profiles[0].awardRanks 上,实际 %+v", ve.Issues)
|
||
}
|
||
}
|
||
|
||
// 这条检查只在**写入**侧生效:导出(Marshal)走的是 Validate,必须继续容忍越档模板。
|
||
// 库里修好之前存下的历史配置如果让导不出来,GET /api/rules/export 与赛事项目包(备份)会一起 500,
|
||
// 而它们正是现场排障与备份的入口——所以导出侧宁可原样导出,由用户在模板页上改。
|
||
func TestMarshalStillAllowsLegacyAwardRanksOverflow(t *testing.T) {
|
||
pack := &RulePack{
|
||
Format: FormatName,
|
||
FormatVersion: FormatVersion,
|
||
Meta: Meta{Code: "legacy", Name: "历史越档模板"},
|
||
Profiles: []Profile{{
|
||
Code: "p7", Name: "前七名", AwardRanks: 6, TieMode: domain.TieSkip,
|
||
Rules: []Rule{{1, 10}, {2, 8}, {3, 6}, {4, 5}, {5, 4}, {6, 3}, {7, 2}},
|
||
}},
|
||
}
|
||
if err := pack.Validate(); err != nil {
|
||
t.Fatalf("导出侧校验不该拦越档模板,实际 %v", err)
|
||
}
|
||
if _, err := Marshal(pack); err != nil {
|
||
t.Fatalf("越档模板必须还能导出,实际 %v", err)
|
||
}
|
||
// 但同一份配置从导入进来时就要被拒(Parse → ValidateForImport)。
|
||
if _, err := Parse([]byte(sevenRankPackYAML)); !errors.Is(err, ErrValidation) {
|
||
t.Fatalf("导入侧应拒收越档模板,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
// 两种合法形态不得被误杀:正好相等(第 6 档 + awardRanks 6)、不封顶(第 7 档 + awardRanks 0)。
|
||
func TestParseAcceptsAwardRanksCoveringRules(t *testing.T) {
|
||
cases := map[string]string{
|
||
"正好相等": strings.Replace(sevenRankPackYAML, "awardRanks: 6", "awardRanks: 7", 1),
|
||
"不封顶": strings.Replace(sevenRankPackYAML, "awardRanks: 6", "awardRanks: 0", 1),
|
||
}
|
||
for name, yaml := range cases {
|
||
t.Run(name, func(t *testing.T) {
|
||
pack, err := Parse([]byte(yaml))
|
||
if err != nil {
|
||
t.Fatalf("应解析成功,实际 %v", err)
|
||
}
|
||
if got := len(pack.Profiles[0].Rules); got != 7 {
|
||
t.Fatalf("分值表应保留 7 档,实际 %d", got)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
// AwardRanksOverflow 是保存模板与导入规则包共用的判定,边界直接钉在函数上。
|
||
func TestAwardRanksOverflowBoundaries(t *testing.T) {
|
||
cases := []struct {
|
||
awardRanks int
|
||
maxRuleRank int
|
||
overflow bool
|
||
}{
|
||
{0, 10, false}, // 0 = 不封顶
|
||
{-1, 10, false}, // 负数由别处拦,这里不重复报
|
||
{6, 0, false}, // 没有分值明细
|
||
{6, 6, false}, // 正好覆盖
|
||
{7, 6, false}, // 录取名次多于档位:合法(后面几档没人拿得到)
|
||
{6, 7, true},
|
||
{6, 10, true},
|
||
}
|
||
for _, tc := range cases {
|
||
msg := AwardRanksOverflow(tc.awardRanks, tc.maxRuleRank)
|
||
if tc.overflow != (msg != "") {
|
||
t.Fatalf("awardRanks=%d maxRuleRank=%d 应 overflow=%v,实际 %q",
|
||
tc.awardRanks, tc.maxRuleRank, tc.overflow, msg)
|
||
}
|
||
}
|
||
// 超出的档位取的是「最大的那一档」,不是第一条超出的档位。
|
||
if msg := AwardRanksOverflow(6, 10); !strings.Contains(msg, "第 10 档") {
|
||
t.Fatalf("应点名最大的超档档位,实际 %q", msg)
|
||
}
|
||
}
|