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) } }) }