package domain import "testing" // 本文件逐条实现 devdocs/09-测试策略.md 第 2 节的 18 条用例矩阵。 // 用例名带编号,go test -v 一眼能看出哪条矩阵用例通过。 // case 1:8 人参赛正常排名 → 9/7/6/5/4/3/2/1。 func TestMatrix01_NormalRanking8(t *testing.T) { entries, results := fixture(lineup(8)) es := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(3)}, Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, Entries: entries, Results: results, }) assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 6, 5, 4, 3, 2, 1}) } // case 2:两人并列第 2(skip)→ 7/7/-/5/4/3/2/1。 func TestMatrix02_TieAtSecondSkip(t *testing.T) { entries, results := fixture(lineupValues(1, 2, 2, 4, 5, 6, 7, 8)) es := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(3)}, Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, Entries: entries, Results: results, }) assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 7, 5, 4, 3, 2, 1}) ranks := rankByEntry(es.Ranked) if ranks[2] != 2 || ranks[3] != 2 || ranks[4] != 4 { t.Fatalf("并列名次错误:%v", ranks) } } // case 3:两人并列第 2(average)→ 6.5/6.5/-/5/4/3/2/1。 func TestMatrix03_TieAtSecondAverage(t *testing.T) { entries, results := fixture(lineupValues(1, 2, 2, 4, 5, 6, 7, 8)) es := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(3)}, Profiles: map[int64]ScoringProfile{3: nineProfile(TieAverage)}, Entries: entries, Results: results, }) assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 6.5, 6.5, 5, 4, 3, 2, 1}) } // case 4:三人并列第 3(skip)→ 6/6/6/-/-/3/2/1。 func TestMatrix04_ThreeWayTieAtThird(t *testing.T) { entries, results := fixture(lineupValues(1, 2, 3, 3, 3, 6, 7, 8)) es := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(3)}, Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, Entries: entries, Results: results, }) assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 6, 6, 6, 3, 2, 1}) } // case 5:团体项目双倍 → 18/14/12/… func TestMatrix05_TeamEventDoubled(t *testing.T) { ps := lineup(8) for i := range ps { ps[i].rank = i + 1 } entries, results := fixture(ps) es := mustScore(t, EventScoreInput{ Event: teamEvent(2), Category: testCategory(), Channels: []ScoreChannel{teamManualChannel(3)}, Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, Entries: entries, Results: results, }) assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{18, 14, 12, 10, 8, 6, 4, 2}) } // case 6:破纪录 + 双倍 → 成绩分双倍,破纪录加分不双倍。 func TestMatrix06_RecordBonusNotDoubled(t *testing.T) { entries, results := fixture(lineup(3)) es := mustScore(t, EventScoreInput{ Event: individualEvent(2), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(3)}, Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, Entries: entries, Results: results, RecordBonus: &RecordBonusConfig{ Enabled: true, Mode: RecordBonusMax, Levels: []RecordLevel{{Code: "meet", Name: "赛会纪录", Points: 2}}, }, RecordBreaks: []RecordBreak{{ ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved, }}, }) var bonus *LedgerEntry for i := range es.Ledger { if es.Ledger[i].SourceType == SourceRecordBonus { bonus = &es.Ledger[i] } } if bonus == nil { t.Fatal("未产生破纪录加分流水") } if !nearlyEqual(bonus.Points, 2) { t.Errorf("破纪录加分 = %v,期望 2(不随项目权重翻倍)", bonus.Points) } if got := pointsByEntry(es.Ledger)[1]; !nearlyEqual(got, 20) { t.Errorf("entry1 合计 = %v,期望 18(成绩分)+ 2(加分)= 20", got) } } // case 7:并列第一的奖牌计数 → 2 金 0 银 1 铜。 func TestMatrix07_TiedFirstMedalCount(t *testing.T) { entries, results := fixture(lineupValues(1, 1, 3)) es := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(3)}, Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, Entries: entries, Results: results, }) rows := BuildTeamStandings(StandingsInput{ Teams: testTeams(1001, 1002, 1003), Ledger: es.Ledger, Ranked: es.Ranked, }) var gold, silver, bronze int for _, r := range rows { gold += r.Gold silver += r.Silver bronze += r.Bronze } if gold != 2 || silver != 0 || bronze != 1 { t.Fatalf("奖牌统计 = %d金 %d银 %d铜,期望 2/0/1", gold, silver, bronze) } } // case 8:发布后撤销 → 流水作废,总分回退。 func TestMatrix08_UnpublishRevertsTotal(t *testing.T) { entries, results := fixture(lineup(3)) es := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, }) published := SumByTeam(es.Ledger, 1) if !nearlyEqual(published[1001].Total, 10) { t.Fatalf("发布后总分 = %v,期望 10", published[1001].Total) } voided, count := VoidByEvent(es.Ledger, 1, "admin", "撤销发布") if count != len(es.Ledger) { t.Fatalf("作废条数 = %d,期望 %d", count, len(es.Ledger)) } after := SumByTeam(voided, 1) for id, p := range after { if p.Total != 0 { t.Errorf("单位 %d 撤销后总分 = %v,期望 0", id, p.Total) } } } // case 9:修改已发布成绩 → 旧流水作废 + 新流水,审计可查。 func TestMatrix09_EditPublishedResult(t *testing.T) { entries, results := fixture(lineup(3)) in := EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, } first := mustScore(t, in) // 改判:entry1 变成最后一名,entry2、entry3 各前进一位。 entries[0].ID = 1 results[0].ResultValue = ptr(10) results[1].ResultValue = ptr(1) results[2].ResultValue = ptr(2) fresh := mustScore(t, in) rebuilt, err := RecomputeEventLedger(first.Ledger, 1, fresh.Ledger, "judge", "成绩更正") if err != nil { t.Fatalf("重算流水失败: %v", err) } if len(rebuilt) <= len(first.Ledger) { t.Fatalf("重算后流水条数 = %d,期望大于 %d(旧作废 + 新追加)", len(rebuilt), len(first.Ledger)) } for _, e := range rebuilt { if e.Voided && (e.VoidedBy != "judge" || e.VoidReason != "成绩更正") { t.Errorf("作废流水缺少审计信息: %+v", e) } } assertEntryPoints(t, pointsByEntry(rebuilt), []float64{6, 10, 8}) } // case 10:弃权者不占名次 → 名次顺延。 func TestMatrix10_WithdrawnDoesNotOccupyRank(t *testing.T) { ps := lineup(5) ps[2].status = ResultStatusWithdrawn entries, results := fixture(ps) es := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(3)}, Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, Entries: entries, Results: results, }) assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 0, 6, 5}) if _, ok := rankByEntry(es.Ranked)[3]; ok { t.Fatalf("弃权者不应出现在名次明细中: %v", rankByEntry(es.Ranked)) } } // case 11:报名不足 8 人 → 按实际名次取分。 func TestMatrix11_FewerThanAwardRanks(t *testing.T) { entries, results := fixture(lineup(5)) es := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(3)}, Profiles: map[int64]ScoringProfile{3: nineProfile(TieSkip)}, Entries: entries, Results: results, }) assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{9, 7, 6, 5, 4}) } // case 12:人工扣分后排名变化 → tie-break 正确重排。 func TestMatrix12_ManualPenaltyReorders(t *testing.T) { ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 10)} in := StandingsInput{Teams: testTeams(1, 2), Ledger: ledger} before := BuildTeamStandings(in) if before[0].TeamID != 1 { t.Fatalf("扣分前榜首 = 单位 %d,期望 1(同分时编码小者在前)", before[0].TeamID) } adjustments, err := AdjustmentsToLedger( ManualAdjustmentConfig{Enabled: true, ReasonRequired: true}, []PointAdjustment{{ID: 1, TeamID: 1, Delta: -3, Category: AdjustmentPenalty, Reason: "犯规扣分"}}, ) if err != nil { t.Fatalf("人工调整转换失败: %v", err) } in.Ledger = append(in.Ledger, adjustments...) after := BuildTeamStandings(in) if after[0].TeamID != 2 { t.Fatalf("扣分后榜首 = 单位 %d,期望 2", after[0].TeamID) } if !nearlyEqual(after[0].Points, 10) || !nearlyEqual(after[1].Points, 7) { t.Fatalf("扣分后总分 = %v/%v,期望 10/7", after[0].Points, after[1].Points) } } // case 13:同分 tie-break → 金牌 → 银牌 → 铜牌 → 编码。 func TestMatrix13_TieBreakOrder(t *testing.T) { cases := []struct { name string ranked []RankedResult want int64 }{ { name: "金牌优先", ranked: []RankedResult{{TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 1, Scope: ScopeAthlete}}, want: 2, }, { name: "银牌优先", ranked: []RankedResult{ {TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 1, Scope: ScopeAthlete}, {TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 1, Scope: ScopeAthlete}, {TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 2, Scope: ScopeAthlete}, }, want: 2, }, { name: "铜牌优先", ranked: []RankedResult{ {TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 3, Scope: ScopeAthlete}, {TeamID: 2, Shares: []TeamShare{{TeamID: 2, Weight: 1}}, Rank: 3, Scope: ScopeAthlete}, {TeamID: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}}, Rank: 3, Scope: ScopeAthlete}, }, want: 1, }, { name: "编码兜底", ranked: []RankedResult{}, want: 1, }, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { rows := BuildTeamStandings(StandingsInput{ Teams: testTeams(1, 2), Ledger: []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 10)}, Ranked: tc.ranked, }) if rows[0].TeamID != tc.want { t.Fatalf("榜首 = 单位 %d,期望 %d(%+v)", rows[0].TeamID, tc.want, rows) } }) } } // case 14:破纪录多级别 → 取最高档(max 模式)。 func TestMatrix14_RecordBonusMax(t *testing.T) { cfg := RecordBonusConfig{ Enabled: true, Mode: RecordBonusMax, Levels: []RecordLevel{ {Code: "meet", Points: 2}, {Code: "city", Points: 5}, {Code: "province", Points: 8}, }, } breaks := []RecordBreak{ {ID: 1, EntryID: 7, EventID: 1, Level: "meet", State: RecordBreakApproved}, {ID: 2, EntryID: 7, EventID: 1, Level: "city", State: RecordBreakApproved}, {ID: 3, EntryID: 7, EventID: 1, Level: "province", State: RecordBreakApproved}, } got := RecordBonusPoints(cfg, breaks) if len(got) != 1 || !nearlyEqual(got[0].Points, 8) { t.Fatalf("max 模式 = %+v,期望 8 分一条", got) } if len(got[0].Levels) != 3 { t.Errorf("档位记录 = %v,期望记录全部 3 个档位", got[0].Levels) } } // case 15:破纪录多级别(sum 模式)→ 累加。 func TestMatrix15_RecordBonusSum(t *testing.T) { cfg := RecordBonusConfig{ Enabled: true, Mode: RecordBonusSum, Levels: []RecordLevel{ {Code: "meet", Points: 2}, {Code: "city", Points: 5}, {Code: "province", Points: 8}, }, } breaks := []RecordBreak{ {ID: 1, EntryID: 7, EventID: 1, Level: "meet", State: RecordBreakApproved}, {ID: 2, EntryID: 7, EventID: 1, Level: "city", State: RecordBreakApproved}, {ID: 3, EntryID: 7, EventID: 1, Level: "province", State: RecordBreakApproved}, } got := RecordBonusPoints(cfg, breaks) if len(got) != 1 || !nearlyEqual(got[0].Points, 15) { t.Fatalf("sum 模式 = %+v,期望 15 分一条", got) } } // case 16:一条成绩归属两个单位(联队)→ 按配置拆分或整体归属。 func TestMatrix16_UnionEntry(t *testing.T) { ps := []participant{{ entryID: 1, athleteID: 101, teamID: 1001, name: "联队选手", value: 1, shares: []TeamShare{{TeamID: 1001, Weight: 3}, {TeamID: 1002, Weight: 2}}, }} entries, results := fixture(ps) split := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, Options: ScoreOptions{UnionAttribution: UnionSplit}, }) byTeam := pointsByTeam(split.Ledger) if !nearlyEqual(byTeam[1001], 6) || !nearlyEqual(byTeam[1002], 4) { t.Fatalf("拆分归属 = %v,期望 1001:6 / 1002:4", byTeam) } whole := mustScore(t, EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, Options: ScoreOptions{UnionAttribution: UnionWhole}, }) byTeam = pointsByTeam(whole.Ledger) if !nearlyEqual(byTeam[1001], 10) || byTeam[1002] != 0 { t.Fatalf("整体归属 = %v,期望 1001:10 / 1002:0", byTeam) } } // case 17:重复发布同一组 → 幂等,不产生重复流水。 func TestMatrix17_RepublishIdempotent(t *testing.T) { entries, results := fixture(lineup(3)) in := EventScoreInput{ Event: individualEvent(1), Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, } first := mustScore(t, in) second := mustScore(t, in) rebuilt, err := RecomputeEventLedger(first.Ledger, 1, second.Ledger, "admin", "重复发布") if err != nil { t.Fatalf("重算流水失败: %v", err) } if len(rebuilt) != len(first.Ledger) { t.Fatalf("重复发布后流水条数 = %d,期望 %d(幂等)", len(rebuilt), len(first.Ledger)) } before := SumByTeam(first.Ledger, 1) after := SumByTeam(rebuilt, 1) for id, p := range before { if !nearlyEqual(after[id].Total, p.Total) { t.Errorf("单位 %d 总分 = %v,期望 %v", id, after[id].Total, p.Total) } } } // case 18:乱序应用增量 → 最终状态一致。 func TestMatrix18_OutOfOrderIncrements(t *testing.T) { makeScore := func(eventID int64) []LedgerEntry { entries, results := fixture([]participant{{entryID: eventID, athleteID: eventID, teamID: eventID, value: 1}}) entries[0].EventID = eventID results[0].EventID = eventID return mustScore(t, EventScoreInput{ Event: Event{ID: eventID, Compare: CompareAsc, Scored: true, Multiplier: 1}, Category: testCategory(), Channels: []ScoreChannel{athleteResultChannel(1)}, Profiles: map[int64]ScoringProfile{1: individualTop6()}, Entries: entries, Results: results, }).Ledger } a := makeScore(11) b := makeScore(22) appendA := LedgerOp{Kind: LedgerOpAppend, Entries: a} appendB := LedgerOp{Kind: LedgerOpAppend, Entries: b} voidA := LedgerOp{Kind: LedgerOpVoidSource, SourceType: SourceResult, SourceID: a[0].SourceID, By: "admin", Reason: "撤销"} orders := map[string][]LedgerOp{ "A→B→撤销A": {appendA, appendB, voidA}, "B→A→撤销A": {appendB, appendA, voidA}, "A→撤销A→B": {appendA, voidA, appendB}, } var baseline map[int64]TeamPoints for name, ops := range orders { out, err := ApplyLedgerOps(nil, ops) if err != nil { t.Fatalf("%s: %v", name, err) } got := SumByTeam(out, 1) if baseline == nil { baseline = got continue } if len(got) != len(baseline) { t.Fatalf("%s: 单位数 %d,期望 %d", name, len(got), len(baseline)) } for id, p := range baseline { if !nearlyEqual(got[id].Total, p.Total) { t.Errorf("%s: 单位 %d 总分 = %v,期望 %v", name, id, got[id].Total, p.Total) } } } if !nearlyEqual(baseline[11].Total, 0) || !nearlyEqual(baseline[22].Total, 10) { t.Fatalf("最终状态 = %+v,期望 A 作废 0 分、B 保留 10 分", baseline) } }