Files
AskaEth 877ff15823 feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(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 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00

422 lines
15 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}