Files
Scoreboard/server/internal/rules/aggregate_mode_test.go
T
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

221 lines
8.0 KiB
Go

package rules
import (
"database/sql"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 聚合口径(basis=aggregate 的 avg / sum)在规则包这条路上的口径:
// 解析认 avg/sum、缺省即 avg;导入落库补成具体值;导出只写非缺省值(sum),
// 规则包里显式写 avg 与不写等价,不该被当成一次「通道改动」。
//
// 现场背景:团体引体向上这类双计项目的团体通道,领导还没定按平均还是按总和,
// 两种都要能在规则包里表达,且默认仍是升级前的平均值口径。
const aggregateModePackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo-rules, name: 示例规则包 }
standings: { individualToTeamWeight: 1, tieBreak: [points, gold, silver, bronze, teamCode] }
profiles:
- code: team_top6
name: 团体项目前六名
awardRanks: 2
tieMode: skip
rules:
- { rank: 1, points: 15 }
- { rank: 2, points: 10 }
recordBonus: { enabled: false, mode: max }
manualAdjustment: { enabled: false, reasonRequired: true }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: team_top6 }
eventOverrides:
- eventCode: EVT_SUM
name: 团体引体向上(总和)
channels:
- { scope: team, basis: aggregate, profile: team_top6, aggregateMode: sum }
- eventCode: EVT_AVG
name: 团体引体向上(平均,显式写缺省值)
channels:
- { scope: team, basis: aggregate, profile: team_top6, aggregateMode: avg }
`
// seedAggregateEvents 建两个可排名的团体小项(compare 必须有方向,否则 aggregate 通道用不了)。
func seedAggregateEvents(t *testing.T, db *sql.DB) {
t.Helper()
sportID := insertID(t, db, `INSERT INTO sports (code, name) VALUES ('demo-sport', '示例大项')`)
for _, ev := range []struct{ code, name string }{
{"EVT_SUM", "团体引体向上(总和)"},
{"EVT_AVG", "团体引体向上(平均,显式写缺省值)"},
} {
if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier)
VALUES (?, ?, ?, 'individual_team', 'auto', 'count', 'desc', 1, 1)`, sportID, ev.code, ev.name); err != nil {
t.Fatalf("插入项目 %s: %v", ev.code, err)
}
}
}
// TestParseAggregateMode 守解析:avg / sum 都认,缺省即 avg,拼错的取值必须报错。
func TestParseAggregateMode(t *testing.T) {
pack, err := Parse([]byte(aggregateModePackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
overrides := map[string]Channel{}
for _, ov := range pack.EventOverrides {
overrides[ov.EventCode] = (*ov.Channels)[0]
}
if got := overrides["EVT_SUM"].AggregatesBy(); got != domain.AggregateSum {
t.Errorf("aggregateMode: sum 应解析成 sum,实际 %q", got)
}
// 显式写 avg 与不写等价(缺省值),解析结果都要是 avg。
if got := overrides["EVT_AVG"].AggregatesBy(); got != domain.AggregateAvg {
t.Errorf("aggregateMode: avg 应解析成 avg,实际 %q", got)
}
// 普通通道(basis=result)不写该字段时同样是缺省 avg,不影响任何行为。
tmpl := pack.ChannelTemplates[0].Channels[0]
if tmpl.AggregateMode != "" || tmpl.AggregatesBy() != domain.AggregateAvg {
t.Errorf("缺省通道的汇总口径应为空(等价 avg),实际 %q", tmpl.AggregateMode)
}
// 拼错的取值必须报错:静默按 avg 处理会让现场以为切到了总和。
bad := strings.Replace(aggregateModePackYAML, "aggregateMode: sum", "aggregateMode: biggest", 1)
if _, err := Parse([]byte(bad)); !errors.Is(err, ErrValidation) {
t.Fatalf("非法聚合口径应报校验错误,实际 %v", err)
} else if !strings.Contains(err.Error(), "aggregateMode") {
t.Errorf("报错应点明 aggregateMode 字段,实际 %v", err)
}
}
// TestApplyAndExportAggregateMode 守落库与导出:
// sum 落库并导出回来;显式 avg 落库成 avg、导出时按缺省省略(导出 → 导入不产生多余差异)。
func TestApplyAndExportAggregateMode(t *testing.T) {
db := openEventDB(t)
seedAggregateEvents(t, db)
next, err := Parse([]byte(aggregateModePackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
modeOf := func(code string) string {
t.Helper()
var mode string
if err := db.QueryRow(`SELECT c.aggregate_mode FROM event_score_channels c
JOIN events e ON e.id = c.event_id WHERE e.code = ? AND c.basis = 'aggregate'`, code).Scan(&mode); err != nil {
t.Fatalf("读取 %s 的聚合口径: %v", code, err)
}
return mode
}
if got := modeOf("EVT_SUM"); got != string(domain.AggregateSum) {
t.Errorf("EVT_SUM 落库 = %q,期望 sum", got)
}
// 缺省口径也落成具体值 avg,引擎侧不必再判空。
if got := modeOf("EVT_AVG"); got != string(domain.AggregateAvg) {
t.Errorf("EVT_AVG 落库 = %q,期望 avg", got)
}
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
t.Fatalf("Export: %v", err)
}
exportedMode := map[string]string{}
for _, ov := range exported.EventOverrides {
exportedMode[ov.EventCode] = (*ov.Channels)[0].AggregateMode
}
// sum 是非缺省口径,必须写进导出文件,否则「导出 → 导入」会把总和口径丢成平均。
if exportedMode["EVT_SUM"] != string(domain.AggregateSum) {
t.Errorf("EVT_SUM 导出 = %q,期望 sum", exportedMode["EVT_SUM"])
}
// avg 是缺省值,导出文件里不出现,往返比对不会凭空多出差异。
if exportedMode["EVT_AVG"] != "" {
t.Errorf("EVT_AVG 导出 = %q,期望省略(avg 是缺省值)", exportedMode["EVT_AVG"])
}
// 导出的包重新导入:库里已有 sum / avg,差异必须为空。
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
reparsed, err := Parse(mustMarshal(t, exported))
if err != nil {
t.Fatalf("重新 Parse 导出结果: %v", err)
}
cs, err := Diff(cur, reparsed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !cs.Empty() {
t.Fatalf("导出再导入不应有差异: %+v", cs.Channels)
}
}
// TestDiffIgnoresExplicitDefaultAggregateMode 守差异比对:
// 规则包显式写 avg 与库里缺省值等价,不能被当成「通道改动」而触发一次无意义的重算。
func TestDiffIgnoresExplicitDefaultAggregateMode(t *testing.T) {
db := openEventDB(t)
seedAggregateEvents(t, db)
next, err := Parse([]byte(aggregateModePackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
// 库里 EVT_AVG 是 avg(导出时按缺省省略),规则包再显式写一遍 avg:语义相同,不该有差异。
if cs, err := Diff(cur, next); err != nil {
t.Fatalf("Diff: %v", err)
} else if !cs.Empty() {
t.Fatalf("显式 avg 与缺省 avg 等价,不应有差异: %+v", cs.Channels)
}
// 对照:把 EVT_AVG 换成 sum 就必须报出一次通道改动(否则现场改了口径却不重算)。
target := strings.Replace(aggregateModePackYAML,
"{ scope: team, basis: aggregate, profile: team_top6, aggregateMode: avg }",
"{ scope: team, basis: aggregate, profile: team_top6, aggregateMode: sum }", 1)
changed, err := Parse([]byte(target))
if err != nil {
t.Fatalf("Parse 改口径后的规则包: %v", err)
}
cs, err := Diff(cur, changed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
hit := false
for _, ch := range cs.Channels {
if ch.EventCode == "EVT_AVG" {
hit = true
}
}
if !hit {
t.Fatalf("把口径从 avg 改成 sum 必须报出通道改动,实际 %+v", cs.Channels)
}
}
// insertID 执行一条 INSERT 并返回自增 id。
func insertID(t *testing.T, db *sql.DB, query string, args ...any) int64 {
t.Helper()
res, err := db.Exec(query, args...)
if err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("获取 id: %v", err)
}
return id
}