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 }