Files
Scoreboard/server/internal/domain/matrix_test.go
T
AskaEth 6bd7bd1627 feat: 「个人+团体」项目真正落地——通道级不计入单位 + 队内平均排名
口径(用户确认):
- 个人分按个人成绩排名给,**不计入单位总分**(下沉到通道级开关,不再动全局系数)
- 团体成绩 = 每队成员成绩的**平均值** → 排名(并列顺延)→ 团体模板给分 → 计入单位总分
- 平均值口径是**小项级单选项**:默认「只算有效成绩」;「全部报名人员计入」= 分母为报名条数、
  没成绩按 0 加入分子;在**成绩录入页**就地切换并立即保存

服务端:
- 迁移 0017:score_ledger 加 count_to_team(重建,无子表);event_score_channels 加 count_to_team;
  events 加 avg_mode(默认 valid)
- 规则包 Channel 加 countToTeam(缺省 true);channelTemplates 支持 individual_team →
  默认 athlete(result, 不计入单位) + team(aggregate 队内平均)
- 计分引擎:SumByTeam 跳过 count_to_team=0 的流水;实现 basis=aggregate 的队内平均团体排名
  (valid 分母=有有效成绩人数;all 分母=报名人数;某队 0 有效成绩不参与)

前端:
- 计分通道页:每行通道「计入单位总分」勾选 + basis 下拉「按队内平均」
- 成绩录入页:仅有队内平均通道的小项显示「平均值口径」单选(默认只算有效成绩),改动即存
- 录入页「实时积分预览」按新口径对齐(原先按主体求和且双通道重复计入,与发布后不一致)
- 大屏/管理端团体成绩只显示数值,不出现「平均」字样

自检:服务端 12 包全绿;前端 vue-tsc + build、node --test 134/134;console 16 文件通过。
2026-09-10 12:02:00 +08:00

314 lines
11 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 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)
}
// 人工扣分:单位1 -3 分(本期入口关闭,这里直接构造调整流水验证榜单口径)。
in.Ledger = append(in.Ledger, LedgerEntry{
TeamID: 1, SourceType: SourceAdjustment, SourceID: 1, Points: -3, CountToTeam: true,
})
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)
}
}