Files
Scoreboard/server/internal/domain/standings_test.go
T
AskaEth 6841cf49aa fix(p2): 按审查清单修 103 条「建议修」+ 奖牌榜改名 + 两个守卫(dev_0.2.0)
## 奖牌榜改名(主人要求)
「团体奖牌榜」→「单位奖牌榜」,界面 / 大屏渲染页 / 导出件(工作表名与
下载文件名 `单位奖牌明细.xlsx`)/ 用户文档 全部统一;`DISPLAY_PAGES` 是
大屏控制页与大屏页的共同来源,改一处两边同时生效。旧名保留为别名,
老链接与老配置(`display.pages`)照常有效。真 Chromium 验证:14 个管理端
路由 + 7 个基础数据分区 0 报错,大屏页标题已是「单位奖牌榜」。

## P2 清单(123 条,103 条标「建议修」)
按 A/B/C/D 分组、以「文件所有权互斥」分 10 个代理并行推进,另有独立复核
代理逐条核对:61 条声称已修 / 55 条证实 / 4 条部分 / 0 条不成立 / 0 垃圾。

主要交付(按风险从高到低):
- 写入侧校验:update 路径补必填与 TrimSpace、模板 rules 重复 rank、
  「小项不存在」的四种回答、`defaultProfileId` 外键、decodeJSON 的 8MB
  上限与 413(A01–A06、D02、D03 批量上限 500)
- 危险操作:一键恢复加互斥(第二个请求当场 409)、恢复超时不再说
  「未执行」、`ConfirmDialog` pending 时锁死 ESC/遮罩/关闭
- 认证:改密与撤会话进同一事务、滑动续期写库失败不再假装成功、
  CSRF Cookie 丢失自动补发、密码长度按字符(原按字节)、
  反代 403 只在可信代理透传原 Host 时采信 `X-Forwarded-Host`(全等比较)、
  过期会话定期清理、登出补审计、登录限流可配
- 数据可见性:导出「已发布未结算」行改为 `名次 — / 积分 — / 状态未结算`、
  单位积分明细成绩列与其它导出件逐字一致、空单位也打合计行、
  基础数据包命中「已彻底删除的单位」整行跳过并报界面路径、
  报名导入换单位给明确提示、勾选报名有冲突不再假报成功
- 导入/导出:赛事包上限 512MB(**它是唯一带字体/图片的包**,字体上限
  20MB)而 multipart 内存档位独立钉在 16MB、工作簿表名尾随空格、
  导出件路由与文件名契约补全
- 前端:一次写请求只生成一个 requestId(幂等缓存此前从未命中)、
  基础数据包补 `X-Request-Id`、示例数据模式禁写扩到撤销发布/存草稿、
  访客 401 不再是裸错误、模板 `{{else if}}` 不再静默吞内容
- Console:按设计退出(切赛事/恢复)不再判成崩溃、旧数据目录读不出来
  会记日志、渲染层日志按上限截断、`service.js` 补 17 例测试

## 测试
服务端用例 610 → 674(13 包全绿,新增老库跨九条迁移升级、panic 形状、
429 落 interceptions、三态对照、avg_mode 分母、会话失效、请求体上限等)
前端 449 → 531(vue-tsc + build 通过);Console 28 → 54(19 个文件)。
另修复一条会让整包挂死 600s 的用例(测试主协程堵在 client.Do,defer
轮不到执行 → 改为可取消 + 探针超时,改坏也 7.5s 快速失败)。

## 两个新守卫(防止再出现「组件 setup 抛异常」这类盲区)
- `web/scripts/check-declaration-order.mjs` 已进测试命令:沿 watch/
  watchEffect 的 const 依赖链查 TDZ(正是「运动员页整块空白」的根因)
- `web/scripts/smoke-admin.mjs`(`pnpm smoke`):真 Chromium 点遍 14 个
  管理端路由 + 7 个基础数据分区 + 大屏页,任何 console 错误即失败

## 文档
- `docs/升级说明.md` 新增 dev_0.2.0 一节(三件升级必知:改名、审计前缀
  与历史记录、密码口径;现场变化;赛事包上限;反代与 CSRF)
- 使用手册 / 常见问题 / 部署指南 / 上线检查清单按实现同步
2026-09-11 23:46:59 +08:00

398 lines
13 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"
func mkRanked(teamID int64, scope Scope, rank int) RankedResult {
return RankedResult{
TeamID: teamID,
Shares: []TeamShare{{TeamID: teamID, Weight: 1}},
Scope: scope,
Rank: rank,
AwardMedals: true,
CountToMedals: true,
CountToTeamMedals: true,
}
}
func mkAthleteRanked(athleteID, teamID int64, rank int) RankedResult {
r := mkRanked(teamID, ScopeAthlete, rank)
r.AthleteID = athleteID
return r
}
func TestTeamStandingsWeight(t *testing.T) {
ledger := []LedgerEntry{
ledgerFor(1, 10), // 仅记单位
ledgerForAthlete(1, 9, 10), // 记到个人
}
cases := []struct {
name string
weight float64
want float64
}{
{"系数 1", 1, 20},
{"系数 0.5", 0.5, 15},
{"系数 2", 2, 30},
{"系数 0 按 1 处理", 0, 20},
{"系数负数按 1 处理", -1, 20},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rows := BuildTeamStandings(StandingsInput{
Teams: testTeams(1), Ledger: ledger, IndividualToTeamWeight: tc.weight,
})
if !nearlyEqual(rows[0].Points, tc.want) {
t.Fatalf("总分 = %v,期望 %v", rows[0].Points, tc.want)
}
if !nearlyEqual(rows[0].TeamOnlyPoints, 10) || !nearlyEqual(rows[0].IndividualPoints, 10) {
t.Fatalf("分项 = %v/%v,期望 10/10", rows[0].TeamOnlyPoints, rows[0].IndividualPoints)
}
})
}
}
func TestTeamStandingsSortOrder(t *testing.T) {
cases := []struct {
name string
ranked []RankedResult
want int64
}{
{"总分优先", nil, 1},
{"金牌优先", []RankedResult{mkRanked(2, ScopeAthlete, 1)}, 2},
{
"银牌优先",
[]RankedResult{
mkRanked(1, ScopeAthlete, 1),
mkRanked(2, ScopeAthlete, 1),
mkRanked(2, ScopeAthlete, 2),
},
2,
},
{
"铜牌优先",
[]RankedResult{
mkRanked(1, ScopeAthlete, 1),
mkRanked(2, ScopeAthlete, 1),
mkRanked(1, ScopeAthlete, 2),
mkRanked(2, ScopeAthlete, 2),
mkRanked(2, ScopeAthlete, 3),
},
2,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 10)}
if tc.name == "总分优先" {
ledger[0].Points = 20
}
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: ledger, Ranked: tc.ranked})
if rows[0].TeamID != tc.want {
t.Fatalf("榜首 = 单位 %d,期望 %d", rows[0].TeamID, tc.want)
}
if rows[0].Rank != 1 || rows[1].Rank != 2 {
t.Fatalf("名次 = %d/%d,期望 1/2", rows[0].Rank, rows[1].Rank)
}
})
}
}
func TestTeamStandingsIncludesAllTeams(t *testing.T) {
t.Run("没有得分的单位也占一行", func(t *testing.T) {
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: []LedgerEntry{ledgerFor(1, 10)}})
if len(rows) != 2 {
t.Fatalf("行数 = %d,期望 2", len(rows))
}
if rows[1].TeamID != 2 || rows[1].Points != 0 {
t.Fatalf("第二行 = %+v,期望单位2零分", rows[1])
}
})
t.Run("流水里出现的未知单位也上榜", func(t *testing.T) {
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1), Ledger: []LedgerEntry{ledgerFor(9, 5)}})
if len(rows) != 2 || rows[0].TeamID != 9 {
t.Fatalf("行 = %+v,期望未知单位9以5分居首", rows)
}
})
}
func TestMedalStandings(t *testing.T) {
ranked := []RankedResult{
mkRanked(1, ScopeTeam, 1), // 团体项目金牌
mkRanked(2, ScopeAthlete, 1), // 个人项目金牌
mkRanked(2, ScopeAthlete, 3),
}
in := StandingsInput{Teams: testTeams(1, 2), Ranked: ranked}
rows := BuildMedalStandings(in)
if len(rows) != 2 {
t.Fatalf("行数 = %d,期望 2", len(rows))
}
var gold, silver, bronze, total int
for _, r := range rows {
gold += r.Gold
silver += r.Silver
bronze += r.Bronze
total += r.Total
}
if gold != 2 || silver != 0 || bronze != 1 || total != 3 {
t.Fatalf("奖牌合计 = %d/%d/%d(共%d),期望 2/0/1(共3)", gold, silver, bronze, total)
}
in.ExcludeTeamEventMedals = true
rows = BuildMedalStandings(in)
for _, r := range rows {
if r.TeamID == 1 && r.Gold != 0 {
t.Fatalf("排除团体项目奖牌后单位1金牌 = %d,期望 0", r.Gold)
}
}
}
func TestAthleteStandings(t *testing.T) {
ledger := []LedgerEntry{
ledgerForAthlete(1001, 1, 10),
ledgerForAthlete(1002, 2, 10),
}
athletes := []Athlete{
{ID: 1, Name: "张三", TeamID: 1001},
{ID: 2, Name: "李四", TeamID: 1002},
}
teams := []Team{
{ID: 1001, Code: "A", Name: "一单位"},
{ID: 1002, Code: "B", Name: "二单位"},
}
t.Run("金牌优先", func(t *testing.T) {
rows := BuildAthleteStandings(StandingsInput{
Teams: teams, Athletes: athletes, Ledger: ledger,
Ranked: []RankedResult{mkAthleteRanked(2, 1002, 1)},
})
if rows[0].AthleteID != 2 {
t.Fatalf("榜首 = %+v,期望运动员2(金牌优先)", rows)
}
})
t.Run("同分按姓名", func(t *testing.T) {
rows := BuildAthleteStandings(StandingsInput{Teams: teams, Athletes: athletes, Ledger: ledger})
if rows[0].AthleteID != 1 {
t.Fatalf("榜首 = %+v,期望运动员1(张三 < 李四)", rows)
}
})
t.Run("姓名与单位信息齐全", func(t *testing.T) {
rows := BuildAthleteStandings(StandingsInput{Teams: teams, Athletes: athletes, Ledger: ledger})
if rows[0].Name != "张三" || rows[0].TeamCode != "A" || rows[0].TeamName != "一单位" {
t.Fatalf("行 = %+v,期望姓名/单位信息齐全", rows[0])
}
})
t.Run("团体项目奖牌不计入个人榜", func(t *testing.T) {
rows := BuildAthleteStandings(StandingsInput{
Teams: teams, Athletes: athletes, Ledger: ledger,
Ranked: []RankedResult{{
TeamID: 1001, AthleteID: 1, Scope: ScopeTeam, Rank: 1,
Shares: []TeamShare{{TeamID: 1001, Weight: 1}},
AwardMedals: true,
CountToMedals: true,
CountToTeamMedals: true,
}},
})
for _, r := range rows {
if r.AthleteID == 1 && r.Gold != 0 {
t.Fatalf("个人榜不应统计团体项目奖牌: %+v", r)
}
}
})
}
func TestUnionMedals(t *testing.T) {
rr := RankedResult{
TeamID: 1, Rank: 1, Scope: ScopeAthlete,
Shares: []TeamShare{{TeamID: 1, Weight: 0.6}, {TeamID: 2, Weight: 0.4}},
AwardMedals: true,
CountToMedals: true,
CountToTeamMedals: true,
}
t.Run("拆分时两单位各记一金", func(t *testing.T) {
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ranked: []RankedResult{rr}})
for _, r := range rows {
if r.Gold != 1 {
t.Fatalf("单位 %d 金牌 = %d,期望 1", r.TeamID, r.Gold)
}
}
})
t.Run("整体归属只记主单位", func(t *testing.T) {
rr := rr
rr.Shares = []TeamShare{{TeamID: 1, Weight: 1}}
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ranked: []RankedResult{rr}})
if rows[0].TeamID != 1 || rows[0].Gold != 1 {
t.Fatalf("整体归属榜首 = %+v,期望单位1记1金", rows[0])
}
if rows[1].Gold != 0 {
t.Fatalf("单位2金牌 = %d,期望 0", rows[1].Gold)
}
})
}
func TestStandingsEmptyInput(t *testing.T) {
t.Run("无单位无流水", func(t *testing.T) {
if rows := BuildTeamStandings(StandingsInput{}); len(rows) != 0 {
t.Fatalf("行数 = %d,期望 0", len(rows))
}
})
t.Run("个人榜无数据", func(t *testing.T) {
if rows := BuildAthleteStandings(StandingsInput{}); len(rows) != 0 {
t.Fatalf("行数 = %d,期望 0", len(rows))
}
})
}
func TestSumByTeamIgnoresVoided(t *testing.T) {
ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(1, 5)}
ledger[1].Voided = true
got := SumByTeam(ledger, 1)
if !nearlyEqual(got[1].Total, 10) {
t.Fatalf("总分 = %v,期望只算未作废的 10", got[1].Total)
}
}
// TestTeamStandingsTieBreakConfig 验证 StandingsInput.TieBreak 真正参与排序:
// 配置 [teamCode] 时即使总分落后也按单位编码在前;未配置时仍是总分优先。
func TestTeamStandingsTieBreakConfig(t *testing.T) {
ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 20)}
def := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: ledger})
if def[0].TeamID != 2 {
t.Fatalf("默认口径榜首 = 单位 %d,期望 2(总分 20 优先)", def[0].TeamID)
}
cfg := BuildTeamStandings(StandingsInput{
Teams: testTeams(1, 2), Ledger: ledger, TieBreak: []string{TieBreakTeamCode},
})
if cfg[0].TeamID != 1 {
t.Fatalf("配置 [teamCode] 榜首 = 单位 %d,期望 1(编码 A < B)", cfg[0].TeamID)
}
if cfg[0].Rank != 1 || cfg[1].Rank != 2 {
t.Fatalf("名次 = %d/%d,期望 1/2", cfg[0].Rank, cfg[1].Rank)
}
}
// TestMedalDedupAcrossChannels 验证多通道项目一条成绩只记一枚奖牌(P0-2):
// buildRanked 会按通道把一条成绩展开成多条名次明细,去重后只保留优先级最高的通道名次。
func TestMedalDedupAcrossChannels(t *testing.T) {
ranked := func(scope Scope, channelID int64, rank int) RankedResult {
return RankedResult{
EventID: 1, CategoryID: 1, EntryID: 7, ChannelID: channelID, Scope: scope,
TeamID: 1, AthleteID: 101, Rank: rank,
Shares: []TeamShare{{TeamID: 1, Weight: 1}},
AwardMedals: true,
CountToMedals: true,
CountToTeamMedals: true,
}
}
teams := testTeams(1)
athletes := []Athlete{{ID: 101, Name: "甲", TeamID: 1}}
t.Run("双通道一条成绩只记一枚", func(t *testing.T) {
in := StandingsInput{Teams: teams, Athletes: athletes, Ranked: []RankedResult{
ranked(ScopeTeam, 2, 1), ranked(ScopeAthlete, 1, 1),
}}
rows := BuildTeamStandings(in)
if rows[0].Gold != 1 || rows[0].Medals != 1 {
t.Fatalf("单位奖牌 = %+v,期望只记 1 金", rows[0])
}
arows := BuildAthleteStandings(in)
if len(arows) != 1 || arows[0].Gold != 1 {
t.Fatalf("个人奖牌 = %+v,期望只记 1 金", arows)
}
})
t.Run("个人通道优先于团体通道", func(t *testing.T) {
in := StandingsInput{Teams: teams, Ranked: []RankedResult{
ranked(ScopeTeam, 1, 1), // 团体通道名次更靠前,但优先级更低
ranked(ScopeAthlete, 9, 2), // 个人通道优先,取银牌
}}
rows := BuildTeamStandings(in)
if rows[0].Gold != 0 || rows[0].Silver != 1 {
t.Fatalf("单位奖牌 = %+v,期望取个人通道的 1 银", rows[0])
}
})
t.Run("同 scope 取通道 id 小者", func(t *testing.T) {
in := StandingsInput{Teams: teams, Ranked: []RankedResult{
ranked(ScopeAthlete, 5, 3),
ranked(ScopeAthlete, 2, 1),
}}
rows := BuildTeamStandings(in)
if rows[0].Gold != 1 || rows[0].Bronze != 0 {
t.Fatalf("单位奖牌 = %+v,期望取通道 id=2 的 1 金", rows[0])
}
})
t.Run("不同报名项各记一枚", func(t *testing.T) {
other := ranked(ScopeAthlete, 1, 1)
other.EntryID = 8
other.AthleteID = 102
rows := BuildTeamStandings(StandingsInput{Teams: teams, Ranked: []RankedResult{
ranked(ScopeAthlete, 1, 1), other,
}})
if rows[0].Gold != 2 {
t.Fatalf("单位金牌 = %d,期望 2(两条成绩各一枚)", rows[0].Gold)
}
})
t.Run("聚合通道按主体去重", func(t *testing.T) {
a := RankedResult{
EventID: 1, CategoryID: 1, ChannelID: 1, Scope: ScopeAthlete, AthleteID: 101,
TeamID: 1, Rank: 1, Shares: []TeamShare{{TeamID: 1, Weight: 1}},
AwardMedals: true, CountToMedals: true, CountToTeamMedals: true,
}
b := a
b.ChannelID = 2
rows := BuildTeamStandings(StandingsInput{Teams: teams, Ranked: []RankedResult{a, b}})
if rows[0].Gold != 1 {
t.Fatalf("单位金牌 = %d,期望 1(同一主体只记一枚)", rows[0].Gold)
}
})
}
// TestAthleteStandingsTieBreakConfig 验证个人榜同样按配置排序。
func TestAthleteStandingsTieBreakConfig(t *testing.T) {
ledger := []LedgerEntry{ledgerForAthlete(1001, 1, 10), ledgerForAthlete(1002, 2, 10)}
athletes := []Athlete{{ID: 1, Name: "张三", TeamID: 1001}, {ID: 2, Name: "李四", TeamID: 1002}}
teams := []Team{{ID: 1001, Code: "B", Name: "二单位"}, {ID: 1002, Code: "A", Name: "一单位"}}
def := BuildAthleteStandings(StandingsInput{Teams: teams, Athletes: athletes, Ledger: ledger})
if def[0].AthleteID != 1 {
t.Fatalf("默认口径榜首 = 运动员 %d,期望 1(姓名 张三 < 李四)", def[0].AthleteID)
}
cfg := BuildAthleteStandings(StandingsInput{
Teams: teams, Athletes: athletes, Ledger: ledger, TieBreak: []string{TieBreakTeamCode},
})
if cfg[0].AthleteID != 2 {
t.Fatalf("配置 [teamCode] 榜首 = 运动员 %d,期望 2(单位编码 A < B)", cfg[0].AthleteID)
}
}
// 「个人+团体」项目的双计:个人通道的分不进单位总分,只有团体通道的分参与单位排名。
func TestTeamStandingsIgnoresPersonalOnlyLedger(t *testing.T) {
ledger := []LedgerEntry{
ledgerForPersonal(1, 101, 10), // 个人通道:单位1 不进分
ledgerFor(2, 8), // 团体通道
}
rows := BuildTeamStandings(StandingsInput{Teams: testTeams(1, 2), Ledger: ledger})
if rows[0].TeamID != 2 {
t.Fatalf("榜首 = 单位 %d,期望 2(个人通道不计入单位总分)", rows[0].TeamID)
}
if !nearlyEqual(rows[1].Points, 0) || !nearlyEqual(rows[1].IndividualPoints, 0) {
t.Fatalf("单位1 = %+v,期望 0 分", rows[1])
}
athlete := BuildAthleteStandings(StandingsInput{
Teams: testTeams(1, 2), Athletes: []Athlete{{ID: 101, Name: "张三", TeamID: 1}}, Ledger: ledger,
})
if len(athlete) != 1 || !nearlyEqual(athlete[0].Points, 10) {
t.Fatalf("个人榜 = %+v,期望仍保留个人通道的 10 分", athlete)
}
}