package rules import ( "database/sql" "errors" "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) // 聚合口径(basis=aggregate 的 avg / sum / worst)在规则包这条路上的口径: // 解析认 avg/sum/worst、缺省即 avg;导入落库补成具体值;导出只写非缺省值(sum / worst), // 规则包里显式写 avg 与不写等价,不该被当成一次「通道改动」。 // // 现场背景:团体引体向上这类双计项目的团体通道,领导还没定按平均还是按总和, // 两种都要能在规则包里表达,且默认仍是升级前的平均值口径;越野 / 行军这类 // 「取团队最后一人的成绩」的项目要能写 worst。 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 } - eventCode: EVT_WORST name: 5 公里越野(队内最差) channels: - { scope: team, basis: aggregate, profile: team_top6, aggregateMode: worst } ` // 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", "团体引体向上(平均,显式写缺省值)"}, {"EVT_WORST", "5 公里越野(队内最差)"}, } { 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) } // worst = 按队内最差排名(方向随小项的 compare 走),取值本身不带方向,别读成 min。 if got := overrides["EVT_WORST"].AggregatesBy(); got != domain.AggregateWorst { t.Errorf("aggregateMode: worst 应解析成 worst,实际 %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) } // 「取最小」不能当取值写:worst 的方向由小项的 compare 决定,写 min 的项目一半会算反, // 所以 min / max 都必须是**非法取值**(报错让人改用 worst),而不是被静默当成某个方向。 for _, wrong := range []string{"min", "max"} { bad := strings.Replace(aggregateModePackYAML, "aggregateMode: worst", "aggregateMode: "+wrong, 1) if _, err := Parse([]byte(bad)); !errors.Is(err, ErrValidation) { t.Errorf("aggregateMode: %s 应报校验错误(改用 worst),实际 %v", wrong, 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) } if got := modeOf("EVT_WORST"); got != string(domain.AggregateWorst) { t.Errorf("EVT_WORST 落库 = %q,期望 worst", 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 / worst 都是非缺省口径,必须写进导出文件,否则「导出 → 导入」会把它们丢成平均。 if exportedMode["EVT_SUM"] != string(domain.AggregateSum) { t.Errorf("EVT_SUM 导出 = %q,期望 sum", exportedMode["EVT_SUM"]) } if exportedMode["EVT_WORST"] != string(domain.AggregateWorst) { t.Errorf("EVT_WORST 导出 = %q,期望 worst", exportedMode["EVT_WORST"]) } // 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 }