Files
Scoreboard/server/internal/domain/scoring_test.go
T
AskaEth 7f9701c465 fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力
- realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份
- httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表
- web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换
- console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复
- docs: 用户文档三份 + README
- 主代理: 破纪录加分接线(levels 未加载导致恒 0)
2026-09-09 21:06:40 +08:00

654 lines
24 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 (
"errors"
"testing"
)
// TestGroupPointsTieModes 直接验证「一组并列者拿多少分」的分支,覆盖三种口径与边界。
func TestGroupPointsTieModes(t *testing.T) {
ev := individualEvent(1)
cat := testCategory()
cases := []struct {
name string
profile ScoringProfile
start int
size int
last int
want float64
}{
{"skip 单人第1", nineProfile(TieSkip), 1, 1, 8, 9},
{"skip 单人第2", nineProfile(TieSkip), 2, 1, 8, 7},
{"skip 两人并列第2", nineProfile(TieSkip), 2, 2, 8, 7},
{"average 两人并列第2", nineProfile(TieAverage), 2, 2, 8, 6.5},
{"higher 两人并列第2", nineProfile(TieHigher), 2, 2, 8, 7},
{"skip 三人并列第3", nineProfile(TieSkip), 3, 3, 8, 6},
{"average 三人并列第3", nineProfile(TieAverage), 3, 3, 8, 5},
{"higher 三人并列第3", nineProfile(TieHigher), 3, 3, 8, 6},
{"average 两人并列第6", nineProfile(TieAverage), 6, 2, 8, 2.5},
{"higher 两人并列第6", nineProfile(TieHigher), 6, 2, 8, 3},
{"skip 超出录取名次", nineProfile(TieSkip), 9, 1, 9, 0},
{"空模板口径按 skip", ScoringProfile{AwardRanks: 8, Rules: nineProfile(TieSkip).Rules}, 2, 1, 8, 7},
{"lastPlaceZero 抹掉最后一名", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 1, 1, 1, 0},
{"lastPlaceZero 抹掉并列末位整组", ScoringProfile{AwardRanks: 8, LastPlaceZero: true, Rules: nineProfile(TieSkip).Rules}, 2, 2, 3, 0},
{"权重相乘", nineProfile(TieSkip), 1, 1, 8, 9},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := groupPoints(tc.profile, tc.start, tc.size, tc.last, ev, cat)
if !nearlyEqual(got, tc.want) {
t.Fatalf("groupPoints = %v,期望 %v", got, tc.want)
}
})
}
t.Run("项目与组别权重相乘", func(t *testing.T) {
ev := individualEvent(2)
cat := EventCategory{ID: 1, Multiplier: 3}
got := groupPoints(nineProfile(TieSkip), 1, 1, 8, ev, cat)
if !nearlyEqual(got, 54) {
t.Fatalf("9 × 2 × 3 = %v,期望 54", got)
}
})
}
// TestAwardRanksAndLastPlaceZero 覆盖录取名次与「最后一名 0 分」的组合边界。
func TestAwardRanksAndLastPlaceZero(t *testing.T) {
cases := []struct {
name string
profile ScoringProfile
n int
want []float64
}{
{
"录取6名,7人参赛,末位不强制0",
ScoringProfile{AwardRanks: 6, Rules: individualTop6().Rules},
7, []float64{10, 8, 6, 3, 2, 1, 0},
},
{
"录取6名,7人参赛,末位强制0",
ScoringProfile{AwardRanks: 6, LastPlaceZero: true, Rules: individualTop6().Rules},
7, []float64{10, 8, 6, 3, 2, 1, 0},
},
{
"录取6名,5人参赛,末位不强制0",
ScoringProfile{AwardRanks: 6, Rules: individualTop6().Rules},
5, []float64{10, 8, 6, 3, 2},
},
{
"录取6名,5人参赛,末位强制0",
ScoringProfile{AwardRanks: 6, LastPlaceZero: true, Rules: individualTop6().Rules},
5, []float64{10, 8, 6, 3, 0},
},
{
"只录取3名",
ScoringProfile{AwardRanks: 3, Rules: individualTop6().Rules},
5, []float64{10, 8, 6, 0, 0},
},
{
"不配录取名次时以表为准",
ScoringProfile{Rules: individualTop6().Rules},
8, []float64{10, 8, 6, 3, 2, 1, 0, 0},
},
{
"仅1人参赛且末位强制0",
ScoringProfile{AwardRanks: 6, LastPlaceZero: true, Rules: individualTop6().Rules},
1, []float64{0},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := fixture(lineup(tc.n))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(9)},
Profiles: map[int64]ScoringProfile{9: tc.profile},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), tc.want)
})
}
}
// TestMultiplierCombinations 验证「积分表 × 项目权重 × 组别权重」,权重缺失按 1。
func TestMultiplierCombinations(t *testing.T) {
cases := []struct {
name string
eventMult float64
categoryMult float64
want float64
}{
{"默认 1×1", 1, 1, 10},
{"项目权重 2", 2, 1, 20},
{"组别权重 2", 1, 2, 20},
{"双权重 2×3", 2, 3, 60},
{"权重缺失按 1", 0, 0, 10},
{"项目权重 0.5", 0.5, 1, 5},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := fixture(lineup(1))
es := mustScore(t, EventScoreInput{
Event: individualEvent(tc.eventMult),
Category: EventCategory{ID: 1, Multiplier: tc.categoryMult},
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
if got := pointsByEntry(es.Ledger)[1]; !nearlyEqual(got, tc.want) {
t.Fatalf("积分 = %v,期望 %v", got, tc.want)
}
})
}
}
// TestScopeAndLedgerAttribution 验证通道 scope 决定流水记给个人还是单位。
func TestScopeAndLedgerAttribution(t *testing.T) {
t.Run("athlete 通道记到个人", func(t *testing.T) {
entries, results := fixture(lineup(1))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
e := es.Ledger[0]
if e.AthleteID != 101 || e.TeamID != 1001 {
t.Fatalf("流水归属 = 单位 %d / 个人 %d,期望 1001/101", e.TeamID, e.AthleteID)
}
})
t.Run("team 通道仅记单位", func(t *testing.T) {
ps := lineup(1)
ps[0].rank = 1
entries, results := fixture(ps)
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
if es.Ledger[0].AthleteID != 0 {
t.Fatalf("team 通道的 AthleteID = %d,期望 0", es.Ledger[0].AthleteID)
}
if !nearlyEqual(es.Ledger[0].Points, 15) {
t.Fatalf("团体第一名积分 = %v,期望 15", es.Ledger[0].Points)
}
})
t.Run("athlete 通道但报名项无运动员时退化为单位流水", func(t *testing.T) {
entries, results := fixture([]participant{{entryID: 1, teamID: 1001, value: 1}})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
if es.Ledger[0].AthleteID != 0 {
t.Fatalf("无运动员的报名项 AthleteID = %d,期望 0", es.Ledger[0].AthleteID)
}
})
}
// TestAggregateChannel 覆盖 aggregate 通道:先按主体汇总(求和)、再排名。
func TestAggregateChannel(t *testing.T) {
build := func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 1},
{entryID: 2, athleteID: 2, teamID: 1001, value: 2},
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
})
}
cases := []struct {
name string
compare CompareMode
wantA float64
wantB float64
}{
{"求和后升序", CompareAsc, 15, 10},
{"求和后降序", CompareDesc, 10, 15},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := build()
ev := teamEvent(1)
ev.Compare = tc.compare
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1001], tc.wantA) || !nearlyEqual(got[1002], tc.wantB) {
t.Fatalf("聚合排名 = %v,期望 1001:%v / 1002:%v", got, tc.wantA, tc.wantB)
}
})
}
t.Run("汇总后同分并列", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 1},
{entryID: 2, athleteID: 2, teamID: 1001, value: 3},
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
})
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamAggregateChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1001], 15) || !nearlyEqual(got[1002], 15) {
t.Fatalf("同分并列 = %v,期望各 15", got)
}
})
}
// TestStatusExclusions 覆盖弃权 / DNS / DSQ 等「0 分且不占名次」的情形。
func TestStatusExclusions(t *testing.T) {
cases := []struct {
name string
status ResultStatus
entryStatus EntryStatus
state ResultState
want []float64
}{
{"成绩 ok", ResultStatusOK, "", "", []float64{10, 8, 6}},
{"成绩状态空值按 ok", "", "", "", []float64{10, 8, 6}},
{"成绩 DSQ", ResultStatusDQ, "", "", []float64{10, 0, 8}},
{"成绩 DNS", ResultStatusDNS, "", "", []float64{10, 0, 8}},
{"成绩 DNF", ResultStatusDNF, "", "", []float64{10, 0, 8}},
{"成绩 withdrawn", ResultStatusWithdrawn, "", "", []float64{10, 0, 8}},
{"报名 scratched", "", EntryStatusScratched, "", []float64{10, 0, 8}},
{"报名 dq", "", EntryStatusDQ, "", []float64{10, 0, 8}},
{"报名 dns", "", EntryStatusDNS, "", []float64{10, 0, 8}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ps := lineup(3)
ps[1].status = tc.status
ps[1].entryStatus = tc.entryStatus
ps[1].state = tc.state
entries, results := fixture(ps)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), tc.want)
})
}
}
// TestDraftState 验证草稿不参与计分,除非显式要求。
func TestDraftState(t *testing.T) {
cases := []struct {
name string
state ResultState
includeDrafts bool
want []float64
}{
{"草稿默认不计分", ResultStateDraft, false, []float64{10, 0, 8}},
{"草稿显式计入", ResultStateDraft, true, []float64{10, 8, 6}},
{"已发布正常计分", ResultStatePublished, false, []float64{10, 8, 6}},
{"状态空值按已发布", "", false, []float64{10, 8, 6}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ps := lineup(3)
ps[1].state = tc.state
entries, results := fixture(ps)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
Options: ScoreOptions{IncludeDrafts: tc.includeDrafts},
})
assertEntryPoints(t, pointsByEntry(es.Ledger), tc.want)
})
}
}
// TestScoredFlagAndNoChannels 验证「只排名不计分」:不产生流水,但必须照样产出名次。
func TestScoredFlagAndNoChannels(t *testing.T) {
cases := []struct {
name string
scored bool
channels []ScoreChannel
}{
{"scored=false 配了通道也不计分", false, []ScoreChannel{athleteResultChannel(1)}},
{"scored=true 但没有通道", true, nil},
{"scored=false 且没有通道", false, nil},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := fixture(lineup(3))
ev := individualEvent(1)
ev.Scored = tc.scored
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: tc.channels,
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
if len(es.Ledger) != 0 {
t.Fatalf("只排名不计分不应产生流水,实际 %d 条: %+v", len(es.Ledger), es.Ledger)
}
if len(es.Ranked) != 3 {
t.Fatalf("名次条数 = %d,期望 3(排名与计分解耦)", len(es.Ranked))
}
for _, rr := range es.Ranked {
if rr.Points != 0 {
t.Errorf("只排名不计分时 Points = %v,期望 0", rr.Points)
}
}
if ranks := rankByEntry(es.Ranked); ranks[1] != 1 || ranks[2] != 2 || ranks[3] != 3 {
t.Fatalf("名次 = %v,期望 1/2/3", ranks)
}
})
}
}
// TestRankOnlyRespectsResultMode 验证没有通道时按小项自身的录入方式排名(manual 直接读录入名次)。
func TestRankOnlyRespectsResultMode(t *testing.T) {
ps := lineup(3)
for i := range ps {
ps[i].noValue = true
}
ps[0].rank, ps[1].rank, ps[2].rank = 2, 3, 1
entries, results := fixture(ps)
ev := individualEvent(1)
ev.Scored = false
ev.ResultMode = ResultModeManual
ev.Compare = CompareNone
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Entries: entries, Results: results,
})
if len(es.Ledger) != 0 {
t.Fatalf("只排名不计分不应产生流水,实际 %d 条", len(es.Ledger))
}
ranks := rankByEntry(es.Ranked)
if ranks[3] != 1 || ranks[1] != 2 || ranks[2] != 3 {
t.Fatalf("名次 = %v,期望 entry3→1 / entry1→2 / entry2→3", ranks)
}
}
// TestScoreEventErrors 覆盖配置类错误,避免错配置静默算错分。
func TestScoreEventErrors(t *testing.T) {
entries, results := fixture(lineup(1))
base := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(404)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}
if _, err := ScoreEvent(base); !errors.Is(err, ErrProfileNotFound) {
t.Errorf("模板缺失错误 = %v,期望 ErrProfileNotFound", err)
}
bad := base
bad.Channels = []ScoreChannel{{ID: 1, Scope: ScopeAthlete, Basis: "magic", ProfileID: 1}}
if _, err := ScoreEvent(bad); !errors.Is(err, ErrUnknownBasis) {
t.Errorf("未知 basis 错误 = %v,期望 ErrUnknownBasis", err)
}
noCompare := base
noCompare.Channels = []ScoreChannel{athleteResultChannel(1)}
noCompare.Event.Compare = CompareNone
if _, err := ScoreEvent(noCompare); !errors.Is(err, ErrCompareRequired) {
t.Errorf("compare=none 错误 = %v,期望 ErrCompareRequired", err)
}
noCompareAgg := noCompare
noCompareAgg.Channels = []ScoreChannel{teamAggregateChannel(1)}
if _, err := ScoreEvent(noCompareAgg); !errors.Is(err, ErrCompareRequired) {
t.Errorf("聚合 compare=none 错误 = %v,期望 ErrCompareRequired", err)
}
}
// TestCompareDirections 验证 asc / desc 两种成绩排序方向。
func TestCompareDirections(t *testing.T) {
t.Run("asc 越小越好", func(t *testing.T) {
entries, results := fixture(lineupValues(12, 11, 13))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{8, 10, 6})
})
t.Run("desc 越大越好", func(t *testing.T) {
entries, results := fixture(lineupValues(12, 11, 13))
ev := individualEvent(1)
ev.Compare = CompareDesc
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{8, 6, 10})
})
}
// TestMultipleChannelsDoubleCount 复现「团体引体向上」的双通道:个人通道 + 团体通道都计分。
func TestMultipleChannelsDoubleCount(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 1},
{entryID: 2, athleteID: 2, teamID: 1001, value: 3},
{entryID: 3, athleteID: 3, teamID: 1002, value: 2},
{entryID: 4, athleteID: 4, teamID: 1002, value: 4},
{entryID: 5, teamID: 1001, rank: 1, noValue: true},
{entryID: 6, teamID: 1002, rank: 2, noValue: true},
})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1), teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
})
var personal, teamOnly int
for _, e := range es.Ledger {
if e.AthleteID != 0 {
personal++
} else {
teamOnly++
}
}
if personal != 4 || teamOnly != 2 {
t.Fatalf("流水构成 = %d 条个人 / %d 条单位,期望 4/2", personal, teamOnly)
}
got := SumByTeam(es.Ledger, 1)
// 单位1001:团体通道15 + 个人10 + 6 = 31;单位1002:10 + 8 + 3 = 21。
if !nearlyEqual(got[1001].Total, 31) || !nearlyEqual(got[1002].Total, 21) {
t.Fatalf("单位总分 = %v,期望 1001:31 / 1002:21", got)
}
}
// TestSameUnitMultipleAthletesBothScore 复现文档示例 2:同单位两人都计分。
func TestSameUnitMultipleAthletesBothScore(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 1},
{entryID: 2, athleteID: 2, teamID: 1002, value: 2},
{entryID: 3, athleteID: 3, teamID: 1003, value: 3},
{entryID: 4, athleteID: 4, teamID: 1004, value: 4},
{entryID: 5, athleteID: 5, teamID: 1001, value: 5},
})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
got := SumByTeam(es.Ledger, 1)
if !nearlyEqual(got[1001].Total, 12) {
t.Fatalf("单位总分 = %v,期望第1名10 + 第5名2 = 12", got[1001].Total)
}
}
// TestOneAthleteMultipleEvents 验证一人多项独立计分、个人总分累加。
func TestOneAthleteMultipleEvents(t *testing.T) {
entries1, results1 := fixture([]participant{{entryID: 1, athleteID: 1, teamID: 1001, value: 1}})
entries2, results2 := fixture([]participant{{entryID: 2, athleteID: 1, teamID: 1001, value: 2}})
entries2[0].EventID = 2
results2[0].EventID = 2
ledger := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries1, Results: results1,
}).Ledger
ledger = append(ledger, mustScore(t, EventScoreInput{
Event: Event{ID: 2, Compare: CompareAsc, Scored: true, Multiplier: 1},
Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries2, Results: results2,
}).Ledger...)
if got := SumByAthlete(ledger)[1]; !nearlyEqual(got, 20) {
t.Fatalf("个人总分 = %v,期望 10 + 10 = 20", got)
}
}
// TestUnionSharesNormalization 覆盖联队份额的归一化与退化情形。
func TestUnionSharesNormalization(t *testing.T) {
score := func(shares []TeamShare, mode UnionAttribution) map[int64]float64 {
entries, results := fixture([]participant{{
entryID: 1, athleteID: 1, teamID: 1001, value: 1, shares: shares,
}})
return pointsByTeam(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: mode},
}).Ledger)
}
cases := []struct {
name string
shares []TeamShare
mode UnionAttribution
want map[int64]float64
}{
{"份额 3:2 拆分为 6:4", []TeamShare{{1001, 3}, {1002, 2}}, UnionSplit, map[int64]float64{1001: 6, 1002: 4}},
{"份额 1:1 平分为 5:5", []TeamShare{{1001, 1}, {1002, 1}}, UnionSplit, map[int64]float64{1001: 5, 1002: 5}},
{"权重为 0 的份额被丢弃", []TeamShare{{1001, 0}, {1002, 1}}, UnionSplit, map[int64]float64{1002: 10}},
{"份额全非法时退回主单位", []TeamShare{{1001, 0}, {1002, 0}}, UnionSplit, map[int64]float64{1001: 10}},
{"无份额时归主单位", nil, UnionSplit, map[int64]float64{1001: 10}},
{"整体归属只记主单位", []TeamShare{{1001, 3}, {1002, 2}}, UnionWhole, map[int64]float64{1001: 10}},
{"归属口径空值等同整体", []TeamShare{{1001, 3}, {1002, 2}}, "", map[int64]float64{1001: 10}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := score(tc.shares, tc.mode)
for id, want := range tc.want {
if !nearlyEqual(got[id], want) {
t.Fatalf("单位 %d 得分 = %v,期望 %v(全部 %v)", id, got[id], want, got)
}
}
for id, p := range got {
if p != 0 {
if _, ok := tc.want[id]; !ok {
t.Fatalf("单位 %d 不应得分,实际 %v", id, p)
}
}
}
})
}
}
// TestTieDetectionEpsilon 验证「成绩相同」的判定精度。
func TestTieDetectionEpsilon(t *testing.T) {
t.Run("差值在容差内视为并列", func(t *testing.T) {
entries, results := fixture(lineupValues(12.01, 12.01+1e-10, 13))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 10, 6})
})
t.Run("差值超过容差不算并列", func(t *testing.T) {
entries, results := fixture(lineupValues(12.01, 12.02, 13))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8, 6})
})
}
// TestMissingResultOrEntry 验证缺成绩、成绩指向不存在的报名项都不会算出分。
func TestMissingResultOrEntry(t *testing.T) {
t.Run("没有成绩的报名项不计分", func(t *testing.T) {
entries, results := fixture(lineup(3))
results = results[:2] // entry3 还没有成绩
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8, 0})
})
t.Run("孤儿成绩被忽略", func(t *testing.T) {
entries, results := fixture(lineup(2))
results = append(results, Result{ID: 999, EventID: 1, EntryID: 404, ResultValue: ptr(0.5), State: ResultStatePublished})
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8})
})
t.Run("别的组别的成绩不串场", func(t *testing.T) {
entries, results := fixture(lineup(3))
entries[2].CategoryID = 2
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
assertEntryPoints(t, pointsByEntry(es.Ledger), []float64{10, 8, 0})
})
}
// TestNoChannelsSkipsRecordBonus 验证「0 个通道 = 只排名不计分」:
// 即使项目 scored=true 且破了纪录,也不写任何流水(破纪录加分也跳过)。
func TestNoChannelsSkipsRecordBonus(t *testing.T) {
entries, results := fixture(lineup(2))
cfg := bonusConfig(RecordBonusMax)
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Entries: entries, Results: results,
RecordBonus: &cfg,
RecordBreaks: []RecordBreak{{
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
Level: "meet", State: RecordBreakApproved,
}},
})
if len(es.Ledger) != 0 {
t.Fatalf("没有通道时不应产生流水,实际 %d 条: %+v", len(es.Ledger), es.Ledger)
}
if len(es.Ranked) != 2 {
t.Fatalf("名次条数 = %d,期望 2", len(es.Ranked))
}
}