Files
Scoreboard/server/internal/domain/standings_test.go
T
AskaEth 877ff15823 feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修)
- 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同**
  (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致
- 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到
- 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢);
  另加守卫「compare=none 的小项不得配 basis=result/aggregate」
- 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0
  (7 人三人并列第 5 时第 5、6 名的分也一起没了)
- 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分

## 现场反馈(主人实测报的)
- 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组),
  与「作废」语义分开;取消路径不产生任何副作用
- 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」:
  管理端、大屏覆盖层、xlsx 备注列三处同源
- 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦
- 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响)

## 新功能
- 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切)

## 安装器
- 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、
  安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540
- 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败

自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。
端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00

486 lines
17 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)
}
}
}
// medalRanked 造一条能拿奖牌的名次明细:一个小项一条,避免被「同一主体只记一枚」去重掉。
func medalRanked(teamID, eventID int64, rank int) RankedResult {
rr := mkRanked(teamID, ScopeTeam, rank)
rr.EventID = eventID
rr.CategoryID = 1
return rr
}
// medalPodium 给一个单位凑出金/银/铜若干枚(各占一个小项),firstEventID 用来错开小项。
func medalPodium(teamID, firstEventID int64, gold, silver, bronze int) []RankedResult {
out := make([]RankedResult, 0, gold+silver+bronze)
next := firstEventID
for i := 0; i < gold; i++ {
out = append(out, medalRanked(teamID, next, 1))
next++
}
for i := 0; i < silver; i++ {
out = append(out, medalRanked(teamID, next, 2))
next++
}
for i := 0; i < bronze; i++ {
out = append(out, medalRanked(teamID, next, 3))
next++
}
return out
}
// TestMedalStandingsIndependentOfPointsBoard 是 06 报告 P1④ 的回归用例(主人 2026-09-13 拍板):
// 单位1 金牌 6 枚但只有 10 分,单位2 金牌 2 枚却拿了 30 分。
// 修前:单位奖牌榜直接沿用总积分榜的顺序与名次,单位1 排在单位2 后面(观众看到"金牌多的排后面")。
// 修后:奖牌榜按金→银→铜排,单位1 榜首;总积分榜仍按总分排,单位2 榜首——两个榜解耦。
func TestMedalStandingsIndependentOfPointsBoard(t *testing.T) {
ledger := []LedgerEntry{ledgerFor(1, 10), ledgerFor(2, 30)}
ranked := append(medalPodium(1, 1, 6, 0, 0), medalPodium(2, 101, 2, 0, 0)...)
in := StandingsInput{Teams: testTeams(1, 2), Ledger: ledger, Ranked: ranked}
points := BuildTeamStandings(in)
if len(points) != 2 || points[0].TeamID != 2 || points[0].Rank != 1 || points[1].TeamID != 1 || points[1].Rank != 2 {
t.Fatalf("总积分榜必须仍按总分排(单位2 30 分在前,名次 1):%+v", points)
}
medal := BuildMedalStandings(in)
if len(medal) != 2 {
t.Fatalf("奖牌榜行数 = %d,期望 2", len(medal))
}
if medal[0].TeamID != 1 || medal[0].Rank != 1 || medal[0].Gold != 6 {
t.Fatalf("奖牌榜榜首应为金牌 6 枚的单位1 且名次 1:%+v", medal)
}
if medal[1].TeamID != 2 || medal[1].Rank != 2 || medal[1].Gold != 2 {
t.Fatalf("奖牌榜次席应为金牌 2 枚的单位2 且名次 2:%+v", medal)
}
// 名次重排了,但总分列仍照实带出:单位2 在奖牌榜上依然是 30 分。
if !nearlyEqual(medal[1].Points, 30) {
t.Fatalf("奖牌榜的总分列 = %v,期望 30", medal[1].Points)
}
// 规则包的 tie_break 以 points 打头,也不能把奖牌榜拉回总分口径。
configured := in
configured.TieBreak = []string{TieBreakPoints, TieBreakGold, TieBreakSilver, TieBreakBronze, TieBreakTeamCode}
if got := BuildMedalStandings(configured); got[0].TeamID != 1 || got[0].Rank != 1 {
t.Fatalf("tie_break=points,… 时奖牌榜榜首 = 单位 %d,期望仍为金牌多的单位1:%+v", got[0].TeamID, got)
}
}
// 奖牌相同时才比下一级:银 → 铜 → 单位编码,名次始终是 1..n 的全序。
func TestMedalStandingsSortChain(t *testing.T) {
cases := []struct {
name string
ranked []RankedResult
want int64
}{
{"金相同比银", append(medalPodium(1, 1, 1, 0, 0), medalPodium(2, 101, 1, 1, 0)...), 2},
{"金、银相同比铜", append(medalPodium(1, 1, 1, 1, 0), medalPodium(2, 101, 1, 1, 1)...), 2},
{"奖牌全同按单位编码 A < B", append(medalPodium(1, 1, 1, 0, 0), medalPodium(2, 101, 1, 0, 0)...), 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rows := BuildMedalStandings(StandingsInput{Teams: testTeams(1, 2), Ranked: tc.ranked})
if rows[0].TeamID != tc.want {
t.Fatalf("榜首 = 单位 %d,期望 %d:%+v", rows[0].TeamID, tc.want, rows)
}
if rows[0].Rank != 1 || rows[1].Rank != 2 {
t.Fatalf("名次 = %d/%d,期望 1/2", rows[0].Rank, rows[1].Rank)
}
})
}
}
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)
}
}