Files
Scoreboard/server/internal/domain/scoring_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

843 lines
31 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) {
// 1001 两人平均 (2+6)/2 = 4,与 1002 的 4 并列。
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 2},
{entryID: 2, athleteID: 2, teamID: 1001, value: 6},
{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)
}
for _, rr := range es.Ranked {
if rr.Scope != ScopeTeam || rr.Rank != 1 {
t.Fatalf("并列名次 = %+v,期望同为第 1 名", rr)
}
}
})
}
// TestAggregateAvgMode 覆盖聚合通道的平均值分母口径:
// valid 只算有有效成绩的人,all 把没成绩的人按 0 计入(分母 = 报名人数)。
func TestAggregateAvgMode(t *testing.T) {
// 1001 一人有成绩(10)、一人没成绩;1002 两人各 4 / 6。
// valid:1001 平均 10,1002 平均 5 → 1002 第一;
// all:1001 平均 10/2 = 5,与 1002 并列 → 各得第一名。
noValueBuild := func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, noValue: true},
{entryID: 3, athleteID: 3, teamID: 1002, value: 4},
{entryID: 4, athleteID: 4, teamID: 1002, value: 6},
})
}
// 1001 的第二个报名项连成绩行都没有(现场最常见的「没录成绩」):
// valid:1001 平均 10,1002 平均 6 → 1002 第一;all:1001 平均 10/2 = 5 → 1001 第一。
missingBuild := func() ([]Entry, []Result) {
return []Entry{
{ID: 1, EventID: 1, CategoryID: 1, TeamID: 1001, AthleteID: 1, AthleteName: "甲"},
{ID: 2, EventID: 1, CategoryID: 1, TeamID: 1001, AthleteID: 2, AthleteName: "乙"},
{ID: 3, EventID: 1, CategoryID: 1, TeamID: 1002, AthleteID: 3, AthleteName: "丙"},
}, []Result{
{ID: 100, EventID: 1, EntryID: 1, ResultValue: ptr(10), State: ResultStatePublished},
{ID: 300, EventID: 1, EntryID: 3, ResultValue: ptr(6), State: ResultStatePublished},
}
}
cases := []struct {
name string
build func() ([]Entry, []Result)
mode AvgMode
firstID int64
want1001 float64
want1002 float64
}{
{"只算有成绩的人", noValueBuild, AvgModeValid, 1002, 10, 15},
{"没成绩的按 0 计入", noValueBuild, AvgModeAll, 0, 15, 15},
{"未录成绩也算分母(valid)", missingBuild, AvgModeValid, 1002, 10, 15},
{"未录成绩也算分母(all)", missingBuild, AvgModeAll, 1001, 15, 10},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
entries, results := tc.build()
ev := teamEvent(1)
ev.AvgMode = tc.mode
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.want1001) || !nearlyEqual(got[1002], tc.want1002) {
t.Fatalf("平均口径 %s 得分 = %v,期望 1001:%v / 1002:%v", tc.mode, got, tc.want1001, tc.want1002)
}
if tc.firstID != 0 {
for _, rr := range es.Ranked {
if rr.TeamID == tc.firstID && rr.Rank != 1 {
t.Fatalf("单位 %d 名次 = %d,期望第 1 名", tc.firstID, rr.Rank)
}
}
}
})
}
t.Run("全队都没有有效成绩时不参与团体排名", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, noValue: true},
{entryID: 2, athleteID: 2, teamID: 1001, status: ResultStatusDNS},
{entryID: 3, athleteID: 3, teamID: 1002, value: 6},
{entryID: 4, athleteID: 4, teamID: 1002, value: 8},
})
ev := teamEvent(1)
ev.AvgMode = AvgModeValid
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 _, ok := got[1001]; ok {
t.Fatalf("无有效成绩的单位不应得分,实际 %v", got)
}
if !nearlyEqual(got[1002], 15) {
t.Fatalf("1002 得分 = %v,期望 15(顺延后独占第一)", got[1002])
}
for _, rr := range es.Ranked {
if rr.TeamID == 1001 {
t.Fatalf("无有效成绩的单位不应上榜:%+v", rr)
}
}
})
}
// TestAggregateChannelCountToTeam 覆盖「个人+团体」双通道:
// 个人通道的流水只记个人(count_to_team = 0),团体聚合通道的流水进单位总分。
func TestAggregateChannelCountToTeam(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 12},
{entryID: 2, athleteID: 2, teamID: 1001, value: 14},
{entryID: 3, athleteID: 3, teamID: 1002, value: 13},
})
notTeam := false
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1, CountToTeam: &notTeam},
teamAggregateChannel(2),
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
})
var personal, teamLedger []LedgerEntry
for _, l := range es.Ledger {
if l.CountToTeam {
teamLedger = append(teamLedger, l)
} else {
personal = append(personal, l)
}
}
if len(personal) != 3 {
t.Fatalf("个人通道流水 = %d 条,期望 3 条", len(personal))
}
for _, l := range personal {
if l.AthleteID == 0 {
t.Fatalf("个人通道流水应记在个人头上:%+v", l)
}
}
// 个人通道的 10/8/6 分不进单位总分;团体通道各队平均 13 / 13 → 并列各 15。
sums := SumByTeam(es.Ledger, 1)
if !nearlyEqual(sums[1001].Total, 15) || !nearlyEqual(sums[1002].Total, 15) {
t.Fatalf("单位总分 = %+v,期望各 15(个人通道不计入)", sums)
}
if !nearlyEqual(sums[1001].Individual, 0) {
t.Fatalf("不计入单位总分的个人流水被算进 Individual:%+v", sums[1001])
}
// 个人榜照常算上个人通道的分:12/13/14 米按升序拿 10/8/6。
athletePoints := SumByAthlete(es.Ledger)
if !nearlyEqual(athletePoints[1], 10) || !nearlyEqual(athletePoints[3], 8) || !nearlyEqual(athletePoints[2], 6) {
t.Fatalf("个人榜 = %v,期望 1:10 / 3:8 / 2:6", athletePoints)
}
if len(teamLedger) != 2 {
t.Fatalf("团体通道流水 = %d 条,期望 2 条", len(teamLedger))
}
}
// 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)
}
}
// TestIndividualTeamDualChannel 验证「个人+团体」按配置的两条通道双计:
// 同一份成绩同时产出个人通道与团体通道的名次(类型本身不参与计算)。
func TestIndividualTeamDualChannel(t *testing.T) {
entries, results := fixture(lineupValues(11.5, 11.8, 12.0))
ev := individualEvent(1)
ev.EventType = EventTypeIndividualTeam
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{
athleteResultChannel(1),
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
})
byScope := make(map[Scope]map[int64]int, 2)
for _, rr := range es.Ranked {
if byScope[rr.Scope] == nil {
byScope[rr.Scope] = make(map[int64]int, len(es.Ranked))
}
byScope[rr.Scope][rr.EntryID] = rr.Rank
}
for _, scope := range []Scope{ScopeAthlete, ScopeTeam} {
ranks := byScope[scope]
if ranks[1] != 1 || ranks[2] != 2 || ranks[3] != 3 {
t.Fatalf("%s 通道名次 = %v,期望 1/2/3", scope, ranks)
}
}
if len(es.Ledger) != 6 {
t.Errorf("双通道流水条数 = %d,期望 6(3 条个人 + 3 条团体)", len(es.Ledger))
}
}
// 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))
}
}