Files
Scoreboard/server/internal/domain/scoring_test.go
T
AskaEth 53a0cb225c feat: 备份恢复 / 图片清理 / 单位恢复 / 审计过滤 / 模板引擎 M1 + 两个导入缺陷修复
用户点名的三件事(模板功能、删死代码、剩余缺口)这一批全部落地,另修掉两个刚暴露的导入缺陷:

**新增能力**
- 备份恢复:`POST /api/backup/restore`(admin)。先把校验做在拍快照之前(只读打开 + integrity_check +
  关键表校验 + 异类库标志表互斥),再用 VACUUM INTO 拍「恢复前安全副本」,然后关连接、整文件替换、
  触发优雅重启;`-wal` 还在时直接拒绝(宁可失败也不让旧库 WAL 套到新库上);恢复后审计直写"恢复之后
  还在的那份库"(不走通用 Auditor,否则记录只会落在被替换掉的旧文件上)。顺带修掉:裸服务端连续恢复
  第二次会在拍副本处崩出一句看不懂的 500,现在回 503「上次恢复后尚未重启,请先重启服务端」。
- 安全副本保留策略:每类库保留最近 5 份,独立于 .db 的 20 份滚动清理。
- 图片管理:`DELETE /api/uploads/{name}`(被引用 409 并列出引用点)、`GET /api/uploads/orphans`、
  `POST /api/uploads/orphans/delete`(只删列出的孤儿,宁漏勿误),全部 admin(与字体删除同档);
  `deploy/backup.bat` 补上 uploads/ 归档(logs 不归档:可再生的诊断且会无上限增长)。
- 已彻底删除单位的恢复:`POST /api/teams/{id}/restore`(与 hideTeam 同档)。幂等;恢复不会把「停用」
  变成「启用」;真机确认恢复前后榜单逐字节一致、change_log 最大值不变(数据一行没动,不需要重算)。
  界面加「显示已彻底删除的单位」(仅 admin)与恢复按钮;非 admin 的删除确认框里写明"本账号无法自行恢复"。
- 审计日志按 `action` 过滤(子串模糊,输入里的 %/_/\ 转义成字面量)。
- 删除死表 recent_projects(新增 system 迁移 0006,未动已应用的迁移)。
- points_override 不再"设了没效果":写入侧 400 + 引擎侧守卫,提示改用「积分调整」。
- 联队份额空壳清理(UnionAttribution/UnionSplit/Entry.TeamShares/resolvedShares 等;TeamShare 类型保留,
  因为 export/realtime/奖牌流水三处在生产路径上构造它)。
- 自定义大屏模板 M1:插值引擎(parseTemplate/renderTemplate),任何情况都不抛异常、未定义→空串+警告、
  警告按 (code,path) 合并计数、无 eval/innerHTML,57 个新用例。

**两个真缺陷(前两个已随 0.1.6 出包)**
- 基础数据包导入「排序」列口径不一致:小项/组别留空保持原值,单位/大项留空被写成 0(plan 没从库里
  现值起手)→ 四张表统一为「留空保持原值」。
- 同类第二处:布尔列「整列被删掉」被当成"取默认值",会把库里所有停用的单位/大项静默改回启用 →
  改为「缺列 = 不动」(写侧与解析侧都收口,补了"缺列保持/空格子=是/显式否=停用"三组对照用例)。

**删死代码**
- createEntry / updateEntry / deleteEntry / entryEventID / checkCategoryInEvent / getEntry /
  normalizeEntryStatus / ExportBaseDataXLSX(全仓 grep 零引用)。⚠️ 随之记入文档:「组别必须属于该小项」
  这道校验现在只由导入路径"按小项+组别名解析"天然保证,将来若新增从请求体接收 categoryId 的接口,
  必须自己补校验(库层没有复合外键)。

**文档**
- devdocs 全面同步(01/02/03/04/05/06/07/09/11/12/13/README;含身份口径、延迟结算、大屏单栏新版式、
  新增路由与设置键、R18/R19 风险);docs 复核 + 第二轮(身份口径三个易写反的分支、图片删除、备份覆盖、
  审计过滤、单位恢复、去掉硬编码版本号、上线检查清单 +5 项);debug/使用说明 6 份逐份 diff 一致。

测试:服务端 13 包(连跑两遍)、前端 406 例、Console 28 例 + 18 文件语法检查 —— 全绿。
版本号 dev_0.1.6 → dev_0.1.7。
2026-09-11 19:26:36 +08:00

813 lines
30 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)
}
}
// TestScoreEventRejectsPointsOverride:引擎不读 points_override,静默忽略就是
// 「设置成功但毫无效果」;带它的成绩必须显式报错,不能照常算出别的分。
func TestScoreEventRejectsPointsOverride(t *testing.T) {
entries, results := fixture([]participant{{entryID: 1, athleteID: 1, teamID: 1001, value: 1}})
override := 99.0
results[0].PointsOverride = &override
_, err := ScoreEvent(EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
if !errors.Is(err, ErrPointsOverrideUnsupported) {
t.Fatalf("带 pointsOverride 的成绩应报 ErrPointsOverrideUnsupported,实际 %v", err)
}
}
// 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))
}
}