## 算分正确性(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 的双通道形态按要求改变。
221 lines
8.0 KiB
Go
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
|
|
}
|