package rules import ( "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) } }) } // 「个人+团体」模板: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) } }