## 算分正确性(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 的双通道形态按要求改变。
422 lines
15 KiB
Go
422 lines
15 KiB
Go
package rules
|
||
|
||
import (
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||
)
|
||
|
||
func testSnapshot(t *testing.T, profiles []Profile, events []EventInfo) *Snapshot {
|
||
t.Helper()
|
||
pack := RulePack{
|
||
Format: FormatName,
|
||
FormatVersion: FormatVersion,
|
||
Meta: Meta{Code: "demo", Name: "当前规则"},
|
||
Standings: Standings{IndividualToTeamWeight: 1, TieBreak: DefaultTieBreak},
|
||
Profiles: profiles,
|
||
}
|
||
canonical := pack.Canonical()
|
||
return &Snapshot{Pack: &canonical, Events: events}
|
||
}
|
||
|
||
func currentSnapshot(t *testing.T) *Snapshot {
|
||
t.Helper()
|
||
return testSnapshot(t,
|
||
[]Profile{
|
||
{
|
||
Code: "individual_top6", Name: "个人项目前六名", AwardRanks: 6, TieMode: domain.TieSkip,
|
||
Rules: []Rule{{1, 10}, {2, 8}, {3, 6}, {4, 3}, {5, 2}, {6, 1}},
|
||
},
|
||
{
|
||
Code: "team_top6", Name: "团体项目前六名", AwardRanks: 2, TieMode: domain.TieSkip,
|
||
Rules: []Rule{{1, 15}, {2, 10}},
|
||
},
|
||
},
|
||
[]EventInfo{
|
||
{ID: 1, Code: "EVT_IND", Name: "个人示例", EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeTime,
|
||
Scored: true, Multiplier: 1,
|
||
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}},
|
||
{ID: 2, Code: "EVT_TEAM", Name: "团体示例", EventType: domain.EventTypeTeam, ResultType: domain.ResultTypeNone,
|
||
Scored: true, Multiplier: 1,
|
||
Channels: []Channel{{Scope: domain.ScopeTeam, Basis: domain.BasisManual, Profile: "team_top6"}}},
|
||
{ID: 3, Code: "EVT_MATCH", Name: "对抗示例", EventType: domain.EventTypeMatch, ResultType: domain.ResultTypeNone,
|
||
Scored: true, Multiplier: 1,
|
||
Channels: []Channel{{Scope: domain.ScopeTeam, Basis: domain.BasisManual, Profile: "team_top6"}}},
|
||
})
|
||
}
|
||
|
||
const nextPackYAML = `format: scoreboard-rules
|
||
formatVersion: 1
|
||
meta: { code: demo, name: 目标规则包 }
|
||
standings:
|
||
individualToTeamWeight: 2
|
||
tieBreak: [points, gold, silver, bronze, teamCode]
|
||
medalCountsTeamEvents: true
|
||
showIndividualBoard: true
|
||
showIndividualMedals: true
|
||
profiles:
|
||
- code: individual_top6
|
||
name: 个人项目前六名
|
||
awardRanks: 6
|
||
tieMode: skip
|
||
rules:
|
||
- { rank: 1, points: 12 }
|
||
- { rank: 2, points: 9 }
|
||
- { rank: 3, points: 7 }
|
||
- { rank: 4, points: 4 }
|
||
- { rank: 5, points: 3 }
|
||
- { rank: 6, points: 2 }
|
||
- code: special_top3
|
||
name: 特别项目前三名
|
||
awardRanks: 3
|
||
tieMode: higher
|
||
rules:
|
||
- { rank: 1, points: 5 }
|
||
- { rank: 2, points: 3 }
|
||
- { rank: 3, points: 1 }
|
||
channelTemplates:
|
||
- match: { eventType: individual }
|
||
channels:
|
||
- { scope: athlete, basis: result, profile: individual_top6 }
|
||
- match: { eventType: team }
|
||
channels:
|
||
- { scope: team, basis: manual, profile: special_top3 }
|
||
eventOverrides:
|
||
- eventCode: EVT_MATCH
|
||
channels: []
|
||
- eventCode: EVT_GHOST
|
||
channels:
|
||
- { scope: team, basis: manual, profile: special_top3 }
|
||
`
|
||
|
||
func TestDiffDetectsAddModifyRemove(t *testing.T) {
|
||
cur := currentSnapshot(t)
|
||
next, err := Parse([]byte(nextPackYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
|
||
cs, err := Diff(cur, next)
|
||
if err != nil {
|
||
t.Fatalf("Diff: %v", err)
|
||
}
|
||
if cs.Empty() {
|
||
t.Fatal("差异不应为空")
|
||
}
|
||
|
||
kinds := map[string]ChangeKind{}
|
||
for _, p := range cs.Profiles {
|
||
kinds[p.Code] = p.Kind
|
||
}
|
||
if kinds["individual_top6"] != ChangeModified {
|
||
t.Errorf("individual_top6 应为 modified,实际 %q", kinds["individual_top6"])
|
||
}
|
||
if kinds["team_top6"] != ChangeRemoved {
|
||
t.Errorf("team_top6 应为 removed,实际 %q", kinds["team_top6"])
|
||
}
|
||
if kinds["special_top3"] != ChangeAdded {
|
||
t.Errorf("special_top3 应为 added,实际 %q", kinds["special_top3"])
|
||
}
|
||
for _, p := range cs.Profiles {
|
||
if p.Code == "individual_top6" && len(p.Fields) == 0 {
|
||
t.Error("修改的模板应列出变化字段")
|
||
}
|
||
if p.Code == "special_top3" && (p.Before != nil || p.After == nil) {
|
||
t.Error("新增模板的 Before 应为空、After 应有值")
|
||
}
|
||
}
|
||
|
||
channels := map[string]ChannelChange{}
|
||
for _, c := range cs.Channels {
|
||
channels[c.EventCode] = c
|
||
}
|
||
if c, ok := channels["EVT_TEAM"]; !ok || c.Kind != ChangeModified {
|
||
t.Errorf("EVT_TEAM 通道应变为 special_top3: %+v", c)
|
||
}
|
||
if c, ok := channels["EVT_MATCH"]; !ok || c.Kind != ChangeRemoved || len(c.After) != 0 {
|
||
t.Errorf("EVT_MATCH 通道应被清空: %+v", c)
|
||
}
|
||
if _, ok := channels["EVT_IND"]; ok {
|
||
t.Error("EVT_IND 通道未变化,不应出现在差异里")
|
||
}
|
||
if len(cs.UnknownEvents) != 1 || cs.UnknownEvents[0] != "EVT_GHOST" {
|
||
t.Errorf("未知项目识别错误: %v", cs.UnknownEvents)
|
||
}
|
||
|
||
if len(cs.Settings) != 1 || cs.Settings[0].Key != settingIndividualToTeamWeight {
|
||
t.Errorf("设置差异错误: %+v", cs.Settings)
|
||
}
|
||
if cs.Settings[0].Before != "1" || cs.Settings[0].After != "2" {
|
||
t.Errorf("权重差异值错误: %+v", cs.Settings[0])
|
||
}
|
||
}
|
||
|
||
func TestDiffIdenticalIsEmpty(t *testing.T) {
|
||
cur := currentSnapshot(t)
|
||
same, err := Diff(cur, cur.Pack)
|
||
if err != nil {
|
||
t.Fatalf("Diff: %v", err)
|
||
}
|
||
if !same.Empty() {
|
||
t.Errorf("同一份规则包不应产生差异: %+v", same)
|
||
}
|
||
}
|
||
|
||
func TestDiffRejectsInvalidPack(t *testing.T) {
|
||
cur := currentSnapshot(t)
|
||
bad, err := Parse([]byte(minimalPackYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
bad.FormatVersion = 99
|
||
if _, err := Diff(cur, bad); err == nil {
|
||
t.Fatal("非法规则包应报错")
|
||
}
|
||
}
|
||
|
||
func TestResolveTargetsPrecedence(t *testing.T) {
|
||
cur := currentSnapshot(t)
|
||
next, err := Parse([]byte(nextPackYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
targets, unknown, err := resolveTargets(cur, next)
|
||
if err != nil {
|
||
t.Fatalf("resolveTargets: %v", err)
|
||
}
|
||
|
||
if got := targets["EVT_IND"]; len(got) != 1 || got[0].Profile != "individual_top6" {
|
||
t.Errorf("模板展开错误: %+v", got)
|
||
}
|
||
if got := targets["EVT_TEAM"]; len(got) != 1 || got[0].Profile != "special_top3" {
|
||
t.Errorf("模板展开错误: %+v", got)
|
||
}
|
||
if got, ok := targets["EVT_MATCH"]; !ok || len(got) != 0 {
|
||
t.Errorf("override 的 channels: [] 应清空通道: %+v", got)
|
||
}
|
||
if _, ok := targets["EVT_GHOST"]; ok {
|
||
t.Error("库里不存在的项目不应出现在目标状态里")
|
||
}
|
||
if len(unknown) != 1 || unknown[0] != "EVT_GHOST" {
|
||
t.Errorf("未知项目识别错误: %v", unknown)
|
||
}
|
||
}
|
||
|
||
// TestResolveTargetsClearsChannelsForUnscored 验证「只排名不计分」的项目固定解析成 0 通道(口径 2):
|
||
// 否则它会继承模板通道,把名次带进奖牌榜。
|
||
func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) {
|
||
cur := currentSnapshot(t)
|
||
cur.Events = append(cur.Events, EventInfo{
|
||
ID: 4, Code: "EVT_NOSCORE", Name: "只排名示例",
|
||
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeTime,
|
||
Scored: false, Multiplier: 1,
|
||
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}},
|
||
})
|
||
|
||
t.Run("继承模板的项目也被清空", func(t *testing.T) {
|
||
next, err := Parse([]byte(nextPackYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
targets, _, err := resolveTargets(cur, next)
|
||
if err != nil {
|
||
t.Fatalf("resolveTargets: %v", err)
|
||
}
|
||
if got, ok := targets["EVT_NOSCORE"]; !ok || len(got) != 0 {
|
||
t.Fatalf("不计分项目的目标通道 = %+v,期望空", got)
|
||
}
|
||
|
||
cs, err := Diff(cur, next)
|
||
if err != nil {
|
||
t.Fatalf("Diff: %v", err)
|
||
}
|
||
removed := false
|
||
for _, c := range cs.Channels {
|
||
if c.EventCode == "EVT_NOSCORE" && c.Kind == ChangeRemoved && len(c.After) == 0 {
|
||
removed = true
|
||
}
|
||
}
|
||
if !removed {
|
||
t.Errorf("差异预览应显示不计分项目的通道被清空: %+v", cs.Channels)
|
||
}
|
||
})
|
||
|
||
t.Run("override 写了通道也清空", func(t *testing.T) {
|
||
pack := *cur.Pack
|
||
pack.EventOverrides = append(pack.EventOverrides, EventOverride{
|
||
EventCode: "EVT_NOSCORE",
|
||
Scored: BoolPtr(false),
|
||
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}),
|
||
})
|
||
targets, _, err := resolveTargets(cur, &pack)
|
||
if err != nil {
|
||
t.Fatalf("resolveTargets: %v", err)
|
||
}
|
||
if got, ok := targets["EVT_NOSCORE"]; !ok || len(got) != 0 {
|
||
t.Fatalf("不计分项目的目标通道 = %+v,期望空", got)
|
||
}
|
||
})
|
||
}
|
||
|
||
// compare=none 的个人小项(名次靠人工录入)不能被配上 basis=result / basis=aggregate 的通道:
|
||
// 这两种口径都要先按成绩排名,而 compare=none 没有名次可算,算分时必然返回 ErrCompareRequired。
|
||
// 以前这条配置能过,代价是「先换通道」(channel_ranks 被外键级联清空)已经提交、紧接着的重算才失败,
|
||
// 名次表停在空表状态且补不回来(审查 04 P0-1)。现在必须在落地之前整包拒收。
|
||
func TestResolveTargetsRejectsCompareRequiredChannelOnCompareNoneEvent(t *testing.T) {
|
||
cur := currentSnapshot(t)
|
||
cur.Events = append(cur.Events, EventInfo{
|
||
ID: 9, Code: "EVT_MANUAL", Name: "手榴弹投准",
|
||
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeNone,
|
||
Compare: domain.CompareNone, Scored: true, Multiplier: 1,
|
||
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisManual, Profile: "individual_top6"}},
|
||
})
|
||
|
||
next, err := Parse([]byte(nextPackYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
|
||
t.Run("模板套出来的 basis=result 被拦下", func(t *testing.T) {
|
||
_, _, err := resolveTargets(cur, next)
|
||
var conflict *CompareConflictError
|
||
if !errors.As(err, &conflict) {
|
||
t.Fatalf("compare=none 的小项被配上 basis=result 应报 CompareConflictError,得到 %v", err)
|
||
}
|
||
if conflict.EventCode != "EVT_MANUAL" || conflict.EventName != "手榴弹投准" {
|
||
t.Errorf("错误里必须指名是哪个小项,得到 %+v", conflict)
|
||
}
|
||
// 人话:说清哪个小项、为什么、怎么改。
|
||
msg := conflict.Error()
|
||
for _, want := range []string{"EVT_MANUAL", "手榴弹投准", "compare=none", "basis=result", "eventOverrides", "basis=manual"} {
|
||
if !strings.Contains(msg, want) {
|
||
t.Errorf("错误信息缺少 %q:%s", want, msg)
|
||
}
|
||
}
|
||
|
||
// 预览阶段就必须失败:不能预览说「就改这些」,落地时才炸。
|
||
if _, err := Diff(cur, next); !errors.As(err, &conflict) {
|
||
t.Errorf("Diff 也应报 CompareConflictError,得到 %v", err)
|
||
}
|
||
})
|
||
|
||
t.Run("显式 override 写 basis=result 一样被拦下", func(t *testing.T) {
|
||
patched := *next
|
||
patched.EventOverrides = append(append([]EventOverride(nil), next.EventOverrides...), EventOverride{
|
||
EventCode: "EVT_MANUAL",
|
||
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}),
|
||
})
|
||
var conflict *CompareConflictError
|
||
if _, _, err := resolveTargets(cur, &patched); !errors.As(err, &conflict) {
|
||
t.Errorf("override 显式写了 basis=result 也应拦下,得到 %v", err)
|
||
}
|
||
})
|
||
|
||
t.Run("override 改成 basis=manual 即放行", func(t *testing.T) {
|
||
patched := *next
|
||
patched.EventOverrides = append(append([]EventOverride(nil), next.EventOverrides...), EventOverride{
|
||
EventCode: "EVT_MANUAL",
|
||
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisManual, Profile: "individual_top6"}}),
|
||
})
|
||
targets, _, err := resolveTargets(cur, &patched)
|
||
if err != nil {
|
||
t.Fatalf("basis=manual 不该被拦:%v", err)
|
||
}
|
||
if got := targets["EVT_MANUAL"]; len(got) != 1 || got[0].Basis != domain.BasisManual {
|
||
t.Errorf("EVT_MANUAL 的目标通道 = %+v,期望一条 basis=manual", got)
|
||
}
|
||
// 规则包没覆盖到的其它小项不受影响。
|
||
if got := targets["EVT_IND"]; len(got) != 1 || got[0].Basis != domain.BasisResult {
|
||
t.Errorf("EVT_IND 的目标通道 = %+v,期望模板给的 basis=result", got)
|
||
}
|
||
})
|
||
|
||
t.Run("不计分的小项不参与校验", func(t *testing.T) {
|
||
unscored := currentSnapshot(t)
|
||
unscored.Events = append(unscored.Events, EventInfo{
|
||
ID: 10, Code: "EVT_OFF", Name: "不计分手工名次",
|
||
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeNone,
|
||
Compare: domain.CompareNone, Scored: false, Multiplier: 1,
|
||
})
|
||
targets, _, err := resolveTargets(unscored, next)
|
||
if err != nil {
|
||
t.Fatalf("只排名不计分的小项不该被拦:%v", err)
|
||
}
|
||
if got, ok := targets["EVT_OFF"]; !ok || len(got) != 0 {
|
||
t.Errorf("不计分小项的目标通道 = %+v,期望空", got)
|
||
}
|
||
})
|
||
}
|
||
|
||
// 「个人+团体」模板:match.eventType 支持 individual_team,并按 athlete + team 两条通道展开。
|
||
// 校验层不认识这个取值时会直接报错,因此 Parse 成功本身就是「不报未知类型」的断言。
|
||
// 这也是默认双计模板:个人通道按个人成绩排名且不计入单位总分,团体通道按平均成绩计分。
|
||
const individualTeamPackYAML = `format: scoreboard-rules
|
||
formatVersion: 1
|
||
meta: { code: demo, name: 个人+团体模板 }
|
||
standings:
|
||
individualToTeamWeight: 1
|
||
tieBreak: [points, gold]
|
||
profiles:
|
||
- code: individual_top6
|
||
name: 个人项目前六名
|
||
awardRanks: 1
|
||
tieMode: skip
|
||
rules:
|
||
- { rank: 1, points: 10 }
|
||
- code: team_top6
|
||
name: 团体项目前六名
|
||
awardRanks: 1
|
||
tieMode: skip
|
||
rules:
|
||
- { rank: 1, points: 15 }
|
||
channelTemplates:
|
||
- match: { eventType: individual_team }
|
||
channels:
|
||
- { scope: athlete, basis: result, profile: individual_top6, countToTeam: false }
|
||
- { scope: team, basis: aggregate, profile: team_top6 }
|
||
`
|
||
|
||
func TestResolveTargetsIndividualTeamTemplate(t *testing.T) {
|
||
cur := testSnapshot(t,
|
||
[]Profile{
|
||
{Code: "individual_top6", Name: "个人项目前六名", AwardRanks: 1, TieMode: domain.TieSkip, Rules: []Rule{{1, 10}}},
|
||
{Code: "team_top6", Name: "团体项目前六名", AwardRanks: 1, TieMode: domain.TieSkip, Rules: []Rule{{1, 15}}},
|
||
},
|
||
[]EventInfo{
|
||
{ID: 1, Code: "EVT_BOTH", Name: "团体引体向上", EventType: domain.EventTypeIndividualTeam,
|
||
ResultType: domain.ResultTypeCount, Scored: true, Multiplier: 1,
|
||
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}},
|
||
})
|
||
|
||
next, err := Parse([]byte(individualTeamPackYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
targets, _, err := resolveTargets(cur, next)
|
||
if err != nil {
|
||
t.Fatalf("resolveTargets: %v", err)
|
||
}
|
||
got := targets["EVT_BOTH"]
|
||
if len(got) != 2 {
|
||
t.Fatalf("individual_team 应展开成两条通道,实际 %+v", got)
|
||
}
|
||
if got[0].Scope != domain.ScopeAthlete || got[0].Basis != domain.BasisResult || got[0].Profile != "individual_top6" {
|
||
t.Errorf("个人通道 = %+v", got[0])
|
||
}
|
||
if got[0].CountsToTeam() {
|
||
t.Error("个人通道不应计入单位总分")
|
||
}
|
||
if got[1].Scope != domain.ScopeTeam || got[1].Basis != domain.BasisAggregate || got[1].Profile != "team_top6" {
|
||
t.Errorf("团体通道 = %+v", got[1])
|
||
}
|
||
if !got[1].CountsToTeam() {
|
||
t.Error("团体通道应计入单位总分")
|
||
}
|
||
// 解析缺省:没写 countToTeam 的通道一律计入单位总分,且不写成显式值。
|
||
if got[1].CountToTeam != nil {
|
||
t.Errorf("团体通道 countToTeam 应保持缺省(nil),实际 %v", *got[1].CountToTeam)
|
||
}
|
||
}
|