Files
Scoreboard/server/internal/domain/matrix_test.go
T
AskaEth 7d5d94f815 feat: M0-M2 骨架落地(服务端/前端/宿主/数据库/积分引擎)
- server: Go 单二进制骨架,embed 内嵌前端,配置/迁移/HTTP 路由
- store: system.db + event.db 两套迁移,业务表 + 种子数据
- domain: 积分引擎(并列三口径/计分通道/流水/排名/破纪录)+ 68 个测试
- web: Vite + Vue 三入口骨架 + shared 层(类型/API/WebSocket 客户端)
- console: Electron 宿主骨架(选屏/防熄屏/进程守护/托盘)
2026-09-09 18:36:40 +08:00

458 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}