package domain import "testing" func bonusConfig(mode RecordBonusMode) RecordBonusConfig { return RecordBonusConfig{ Enabled: true, Mode: mode, Levels: []RecordLevel{ {Code: "meet", Name: "赛会纪录", Points: 2}, {Code: "city", Name: "市级纪录", Points: 5}, {Code: "province", Name: "省级纪录", Points: 8}, }, } } func TestRecordBonusDisabled(t *testing.T) { cfg := bonusConfig(RecordBonusMax) cfg.Enabled = false got := RecordBonusPoints(cfg, []RecordBreak{{EntryID: 1, Level: "meet", State: RecordBreakApproved}}) if got != nil { t.Fatalf("未启用时 = %+v,期望 nil", got) } } func TestRecordBonusStateFilter(t *testing.T) { cases := []struct { name string state RecordBreakState want float64 }{ {"已通过计入", RecordBreakApproved, 2}, {"待审核不计入", RecordBreakPending, 0}, {"已驳回不计入", RecordBreakRejected, 0}, {"状态空值不计入", "", 0}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := RecordBonusPoints(bonusConfig(RecordBonusMax), []RecordBreak{{EntryID: 1, Level: "meet", State: tc.state}}) if tc.want == 0 { if len(got) != 0 { t.Fatalf("结果 = %+v,期望空", got) } return } if len(got) != 1 || !nearlyEqual(got[0].Points, tc.want) { t.Fatalf("结果 = %+v,期望 %v 分", got, tc.want) } }) } } func TestRecordBonusUnknownLevel(t *testing.T) { got := RecordBonusPoints(bonusConfig(RecordBonusMax), []RecordBreak{{EntryID: 1, Level: "galaxy", State: RecordBreakApproved}}) if len(got) != 0 { t.Fatalf("未配置档位 = %+v,期望空(不能凭空加分)", got) } } func TestRecordBonusModes(t *testing.T) { breaks := []RecordBreak{ {ID: 1, EntryID: 1, Level: "meet", State: RecordBreakApproved}, {ID: 2, EntryID: 1, Level: "city", State: RecordBreakApproved}, {ID: 3, EntryID: 1, Level: "province", State: RecordBreakApproved}, } cases := []struct { name string mode RecordBonusMode want float64 }{ {"max 取最高档", RecordBonusMax, 8}, {"sum 累加", RecordBonusSum, 15}, {"模式空值按 max", "", 8}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { got := RecordBonusPoints(bonusConfig(tc.mode), breaks) if len(got) != 1 || !nearlyEqual(got[0].Points, tc.want) { t.Fatalf("结果 = %+v,期望 %v", got, tc.want) } }) } } func TestRecordBonusGroupsByEntry(t *testing.T) { breaks := []RecordBreak{ {ID: 1, EntryID: 2, Level: "meet", State: RecordBreakApproved}, {ID: 2, EntryID: 1, Level: "city", State: RecordBreakApproved}, {ID: 3, EntryID: 1, Level: "meet", State: RecordBreakApproved}, } got := RecordBonusPoints(bonusConfig(RecordBonusMax), breaks) if len(got) != 2 { t.Fatalf("条数 = %d,期望 2(按报名项分组)", len(got)) } if got[0].EntryID != 1 || got[1].EntryID != 2 { t.Fatalf("顺序 = %+v,期望按 EntryID 升序", got) } if !nearlyEqual(got[0].Points, 5) || !nearlyEqual(got[1].Points, 2) { t.Fatalf("分值 = %v/%v,期望 5/2", got[0].Points, got[1].Points) } } func TestRecordBonusDedupLevel(t *testing.T) { breaks := []RecordBreak{ {ID: 1, EntryID: 1, Level: "meet", State: RecordBreakApproved}, {ID: 2, EntryID: 1, Level: "meet", State: RecordBreakApproved}, } got := RecordBonusPoints(bonusConfig(RecordBonusSum), breaks) if len(got) != 1 || !nearlyEqual(got[0].Points, 2) { t.Fatalf("重复档位 = %+v,期望只计一次 2 分", got) } if len(got[0].Levels) != 1 { t.Fatalf("档位列表 = %v,期望去重", got[0].Levels) } } func TestRecordBonusNoBreaks(t *testing.T) { if got := RecordBonusPoints(bonusConfig(RecordBonusMax), nil); got != nil { t.Fatalf("无破纪录登记 = %+v,期望 nil", got) } } func TestRecordBonusLedger(t *testing.T) { entries, results := fixture(lineup(2)) base := EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, RecordBreaks: []RecordBreak{{ ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved, }}, } t.Run("未启用不产生加分流水", func(t *testing.T) { cfg := bonusConfig(RecordBonusMax) cfg.Enabled = false base.RecordBonus = &cfg es := mustScore(t, base) for _, e := range es.Ledger { if e.SourceType == SourceRecordBonus { t.Fatalf("不应有加分流水: %+v", e) } } }) t.Run("启用后归属到人", func(t *testing.T) { cfg := bonusConfig(RecordBonusMax) base.RecordBonus = &cfg es := mustScore(t, base) var found bool for _, e := range es.Ledger { if e.SourceType != SourceRecordBonus { continue } found = true if e.TeamID != 1001 || e.AthleteID != 101 || !nearlyEqual(e.Points, 2) { t.Fatalf("加分流水 = %+v,期望 1001/101 记 2 分", e) } } if !found { t.Fatal("未产生加分流水") } }) } // recordBonusPointsOf 统计一次计分产出的破纪录加分总和。 func recordBonusPointsOf(t *testing.T, in EventScoreInput) float64 { t.Helper() var got float64 for _, e := range mustScore(t, in).Ledger { if e.SourceType == SourceRecordBonus { got += e.Points } } return got } // TestRecordBonusRequiresEligibleEvent 验证只有「可破纪录」的项目才产生加分(P1-5)。 func TestRecordBonusRequiresEligibleEvent(t *testing.T) { entries, results := fixture(lineup(1)) cfg := bonusConfig(RecordBonusMax) in := EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, RecordBonus: &cfg, RecordBreaks: []RecordBreak{{ ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved, }}, } in.Event.RecordEligible = false if got := recordBonusPointsOf(t, in); got != 0 { t.Fatalf("不可破纪录项目的加分 = %v,期望 0", got) } in.Event.RecordEligible = true if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) { t.Fatalf("可破纪录项目的加分 = %v,期望 2", got) } } // TestRecordBonusUsesHitChannelProfileMode 验证 max/sum 口径取「命中通道」所属模板的配置, // 而不是全局 cfg.Mode(P1-3)。 func TestRecordBonusUsesHitChannelProfileMode(t *testing.T) { entries, results := fixture(lineup(1)) athleteProfile := individualTop6() athleteProfile.RecordBonusMode = RecordBonusSum teamProfile := teamTop6() teamProfile.RecordBonusMode = RecordBonusMax cfg := bonusConfig(RecordBonusMax) in := EventScoreInput{ Event: individualEvent(1), Category: testCategory(), // 团体通道排在前面、通道 id 更小,个人通道仍应优先命中。 Channels: []ScoreChannel{ {ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2}, {ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1}, }, Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile}, Entries: entries, Results: results, RecordBonus: &cfg, RecordBreaks: []RecordBreak{ {ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved}, {ID: 2, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved}, }, } if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) { t.Fatalf("加分 = %v,期望 7(个人通道 sum:2+5)", got) } } // TestRecordBonusProfileModeOverridesGlobal 验证模板口径优先于全局口径。 func TestRecordBonusProfileModeOverridesGlobal(t *testing.T) { entries, results := fixture(lineup(1)) profile := individualTop6() profile.RecordBonusMode = RecordBonusMax cfg := bonusConfig(RecordBonusSum) in := EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: profile}, Entries: entries, Results: results, RecordBonus: &cfg, RecordBreaks: []RecordBreak{ {ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved}, {ID: 2, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved}, }, } if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 5) { t.Fatalf("加分 = %v,期望 5(模板 max 优先于全局 sum)", got) } } // TestRecordBonusTeamEntryUsesTeamChannelMode 验证没有运动员的报名项命中团体通道的模板口径。 func TestRecordBonusTeamEntryUsesTeamChannelMode(t *testing.T) { entries, results := fixture([]participant{{entryID: 1, teamID: 1001, value: 1, rank: 1}}) athleteProfile := individualTop6() athleteProfile.RecordBonusMode = RecordBonusMax teamProfile := teamTop6() teamProfile.RecordBonusMode = RecordBonusSum cfg := bonusConfig(RecordBonusMax) in := EventScoreInput{ Event: teamEvent(1), Category: testCategory(), Channels: []ScoreChannel{ {ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1}, {ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2}, }, Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile}, Entries: entries, Results: results, RecordBonus: &cfg, RecordBreaks: []RecordBreak{ {ID: 1, EventID: 1, EntryID: 1, TeamID: 1001, Level: "meet", State: RecordBreakApproved}, {ID: 2, EventID: 1, EntryID: 1, TeamID: 1001, Level: "city", State: RecordBreakApproved}, }, } if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) { t.Fatalf("加分 = %v,期望 7(团体通道 sum:2+5)", got) } } // TestRecordBonusLedgerFiltersByCategory 验证按「小项+组别」过滤: // 上层逐组别调用 ScoreEvent,别的组别的破纪录登记不能在本组别重复记一次加分。 func TestRecordBonusLedgerFiltersByCategory(t *testing.T) { entries, results := fixture(lineup(2)) entries[1].CategoryID = 2 // entry2 属于另一个组别 cfg := bonusConfig(RecordBonusMax) es := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, RecordBonus: &cfg, RecordBreaks: []RecordBreak{ {ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved}, {ID: 2, EventID: 1, EntryID: 2, AthleteID: 102, TeamID: 1002, Level: "meet", State: RecordBreakApproved}, }, }) var thisCategory, otherCategory int for _, e := range es.Ledger { if e.SourceType != SourceRecordBonus { continue } switch e.EntryID { case 1: thisCategory++ case 2: otherCategory++ } } if thisCategory != 1 { t.Fatalf("本组别加分流水 = %d 条,期望 1", thisCategory) } if otherCategory != 0 { t.Fatalf("别的组别的加分不应记入本组别,实际 %d 条", otherCategory) } } // bonusLedgerOfEntry 取某报名项名下两种流水的分值:成绩分与破纪录加分。 func bonusLedgerOfEntry(es EventScore, entryID int64) (rankPoints, bonusPoints float64) { for _, l := range es.Ledger { if l.EntryID != entryID { continue } switch l.SourceType { case SourceResult: rankPoints += l.Points case SourceRecordBonus: bonusPoints += l.Points } } return rankPoints, bonusPoints } // TestRecordBonusFollowsRankableStatus 验证破纪录加分与名次分同一口径(P1-6): // 报名项退赛 / 弃权 / 失格,或成绩被作废 / 未发布时都不该再有加分流水。 // // 除了逐条钉期望值,还断言「名次分有没有,破纪录加分就有没有」——两条通道是同一条成绩的 // 两种产出,判据必须同源(EntryStatus.rankable / ResultStatus.rankable), // 各写一套就会出现「退赛的人拿不到名次分、却继续拿加分」。 func TestRecordBonusFollowsRankableStatus(t *testing.T) { cases := []struct { name string mutate func(entries *[]Entry, results *[]Result) want bool }{ {"正常参赛且成绩有效", func(*[]Entry, *[]Result) {}, true}, {"报名项退赛", func(entries *[]Entry, _ *[]Result) { (*entries)[0].Status = EntryStatusScratched }, false}, {"报名项失格", func(entries *[]Entry, _ *[]Result) { (*entries)[0].Status = EntryStatusDQ }, false}, {"报名项未出场", func(entries *[]Entry, _ *[]Result) { (*entries)[0].Status = EntryStatusDNS }, false}, {"成绩作废", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusWithdrawn }, false}, {"成绩失格", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusDQ }, false}, {"成绩未出场", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusDNS }, false}, {"成绩未完赛", func(_ *[]Entry, results *[]Result) { (*results)[0].Status = ResultStatusDNF }, false}, {"成绩仍是草稿", func(_ *[]Entry, results *[]Result) { (*results)[0].State = ResultStateDraft }, false}, {"没有成绩行", func(_ *[]Entry, results *[]Result) { *results = nil }, false}, {"状态留空按正常处理", func(entries *[]Entry, results *[]Result) { (*entries)[0].Status = "" (*results)[0].Status = "" }, true}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { entries, results := fixture(lineup(1)) tc.mutate(&entries, &results) cfg := bonusConfig(RecordBonusMax) es := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, RecordBonus: &cfg, RecordBreaks: []RecordBreak{{ ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved, }}, }) rankPoints, bonusPoints := bonusLedgerOfEntry(es, 1) if tc.want && !nearlyEqual(bonusPoints, 2) { t.Fatalf("加分 = %v,期望 2", bonusPoints) } if !tc.want && bonusPoints != 0 { t.Fatalf("不该产生破纪录加分,得到 %v", bonusPoints) } if got := rankPoints > 0; got != (bonusPoints > 0) { t.Fatalf("成绩分与破纪录加分必须同生同灭,得到 成绩分=%v 加分=%v", rankPoints, bonusPoints) } }) } } // TestRecordBonusComesBackAfterRestore 验证状态判据是「看当前状态」而不是「一次性作废」: // 退赛 / 作废后再改回正常,同一个登记还要能把加分还回来(现场纠正误判就靠这条)。 func TestRecordBonusComesBackAfterRestore(t *testing.T) { entries, results := fixture(lineup(1)) cfg := bonusConfig(RecordBonusMax) in := EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, RecordBonus: &cfg, RecordBreaks: []RecordBreak{{ ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved, }}, } entries[0].Status = EntryStatusScratched if got := recordBonusPointsOf(t, in); got != 0 { t.Fatalf("退赛后的加分 = %v,期望 0", got) } entries[0].Status = EntryStatusRegistered if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) { t.Fatalf("恢复参赛后的加分 = %v,期望 2", got) } results[0].Status = ResultStatusWithdrawn if got := recordBonusPointsOf(t, in); got != 0 { t.Fatalf("成绩作废后的加分 = %v,期望 0", got) } results[0].Status = ResultStatusOK if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) { t.Fatalf("成绩改回有效后的加分 = %v,期望 2", got) } }