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

469 lines
18 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 (
"reflect"
"strconv"
"testing"
)
// 本文件守住 devdocs/17 的两条底线:
// 1. P0:同一份数据连续发布两次,流水与名次必须完全一致——团体通道绝不读
// 「athlete_id != 0 的报名项」的 results.rank(那是个人名次,读了会让同一份成绩发两次团体分);
// 2. 团体名次的来源只有 in.TeamRanks 与团体报名项(athlete_id == 0)的 results.rank 两处,
// 同一单位两边都有时以 TeamRanks 为准。
// teamPullupInput 构造 TEAM_PULLUP 场景:1 个小项、1 个组别、6 名运动员同属 A 队(1001),
// 成绩 30/29/28/27/26/25(引体向上,次数多者名次靠前),两条通道 =
// 个人通道(athlete + result + 个人表)+ 团体通道(team + manual + 团体表)。
// teamRanks 是录入员填的团体名次(可为空)。
func teamPullupInput(teamRanks []TeamManualRank) EventScoreInput {
values := []float64{30, 29, 28, 27, 26, 25}
ps := make([]participant, 0, len(values))
for i, v := range values {
ps = append(ps, participant{
entryID: int64(i + 1), athleteID: int64(101 + i), teamID: 1001,
name: "选手" + string(rune('A'+i)), value: v,
})
}
entries, results := fixture(ps)
ev := individualEvent(1)
ev.EventType = EventTypeIndividualTeam
ev.Compare = CompareDesc
return EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisManual, ProfileID: 2},
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries,
Results: results,
TeamRanks: teamRanks,
}
}
// writeBackComputedRanks 模拟 httpapi 的 applyComputedRanks:
// 只把个人通道(scope=athlete)算出的名次写回对应成绩的 results.rank,
// 且要给 manual 通道的「录入位」让路(工单 A-1 / A-2,与 httpapi.scoring.go 同口径):
// 配了 [athlete,manual] 时整项不写回,配了 [team,manual] 时团体报名项(AthleteID=0)不写回。
// 这里少一条判断,模拟出来的「第二次发布」就会跟真实系统不是一回事。
func writeBackComputedRanks(in EventScoreInput, ranked []RankedResult) {
athleteManual, teamManual := false, false
for _, ch := range in.Channels {
if ch.Basis != BasisManual {
continue
}
switch ch.Scope {
case ScopeAthlete:
athleteManual = true
case ScopeTeam:
teamManual = true
}
}
for _, rr := range ranked {
if rr.Scope != ScopeAthlete || rr.ResultID == 0 || rr.Rank <= 0 {
continue
}
if athleteManual || (teamManual && rr.AthleteID == 0) {
continue
}
for i := range in.Results {
if in.Results[i].ID == rr.ResultID {
in.Results[i].Rank = rr.Rank
}
}
}
}
// TestTeamEntryDualChannelPublishTwiceIsIdempotent 是工单 A-1 的引擎侧回归:
// team 类型小项 + 3 条团体报名项(athlete_id 为空)+ 双通道 [athlete/result, team/manual]。
// 团体报名项的 results.rank 是「单位名次」的录入位,个人通道的自动名次不许写进去,
// 否则第二次发布团体手工通道会把个人名次当成团体名次再发一遍分(10/8/6 → 25/18/14)。
func TestTeamEntryDualChannelPublishTwiceIsIdempotent(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, teamID: 1001, name: "甲队", value: 90},
{entryID: 2, teamID: 1002, name: "乙队", value: 80},
{entryID: 3, teamID: 1003, name: "丙队", value: 70},
})
ev := teamEvent(1)
ev.Compare = CompareDesc
in := EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
teamManualChannel(2),
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
}
first := mustScore(t, in)
writeBackComputedRanks(in, first.Ranked)
second := mustScore(t, in)
if !reflect.DeepEqual(first, second) {
t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second)
}
for _, r := range in.Results {
if r.Rank != 0 {
t.Fatalf("团体报名项的 results.rank 被自动名次写回,得到 %+v", r)
}
}
// 团体手工通道没有录入名次:两次发布都只有个人通道的 10/8/6。
got := pointsByTeam(second.Ledger)
for _, want := range []struct {
teamID int64
points float64
}{{1001, 10}, {1002, 8}, {1003, 6}} {
if !nearlyEqual(got[want.teamID], want.points) {
t.Fatalf("单位 %d = %v,期望 %v(团体名次未录,不该多出团体分):%v",
want.teamID, got[want.teamID], want.points, got)
}
}
if len(second.Ranked) != 3 {
t.Fatalf("名次明细 = %d 条,期望只有个人通道的 3 条:%+v", len(second.Ranked), second.Ranked)
}
}
// TestAthleteManualRankNotOverwritten 是工单 A-2 的引擎侧回归:
// [athlete,result] 与 [athlete,manual] 共存时,results.rank 是人工录入的个人名次,
// 自动名次不许覆盖它;反复「发布 → 写回」必须得到同一份结果。
func TestAthleteManualRankNotOverwritten(t *testing.T) {
// 成绩 30 / 29(compare=asc:数值小的靠前 → 自动名次本该是 乙1 第 1、甲1 第 2);
// 人工名次反着填(甲1 第 1、乙1 第 2)。
entries, results := fixture([]participant{
{entryID: 1, athleteID: 101, teamID: 1001, name: "甲", value: 30, rank: 1},
{entryID: 2, athleteID: 102, teamID: 1002, name: "乙", value: 29, rank: 2},
})
in := EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{
athleteResultChannel(1),
{ID: 2, EventID: 1, Scope: ScopeAthlete, Basis: BasisManual, ProfileID: 1},
},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
}
first := mustScore(t, in)
writeBackComputedRanks(in, first.Ranked)
second := mustScore(t, in)
if !reflect.DeepEqual(first, second) {
t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second)
}
if in.Results[0].Rank != 1 || in.Results[1].Rank != 2 {
t.Fatalf("人工名次被自动名次覆盖:甲=%d、乙=%d,期望 1 / 2", in.Results[0].Rank, in.Results[1].Rank)
}
// 自动通道 8/10 + 人工通道 10/8:两队各 18 分。
got := pointsByTeam(second.Ledger)
if !nearlyEqual(got[1001], 18) || !nearlyEqual(got[1002], 18) {
t.Fatalf("两队应各 18 分(自动 + 人工),得到 %v", got)
}
}
// TestTeamPullupPublishTwiceIsIdempotent 是 P0 回归:
// 「发布 → 把个人名次写回 results.rank → 再发布一次」两次的 Ledger 与 Ranked 必须逐字段一致。
func TestTeamPullupPublishTwiceIsIdempotent(t *testing.T) {
t.Run("团体名次已录:两次发布一致,A 队总分不跳变", func(t *testing.T) {
in := teamPullupInput([]TeamManualRank{{TeamID: 1001, Rank: 1}})
first := mustScore(t, in)
writeBackComputedRanks(in, first.Ranked)
second := mustScore(t, in)
if !reflect.DeepEqual(first.Ledger, second.Ledger) {
t.Fatalf("两次发布的流水不一致:\n第一次 %+v\n第二次 %+v", first.Ledger, second.Ledger)
}
if !reflect.DeepEqual(first.Ranked, second.Ranked) {
t.Fatalf("两次发布的名次不一致:\n第一次 %+v\n第二次 %+v", first.Ranked, second.Ranked)
}
// 团体通道的分数只来自团体名次:唯一一条单位流水,没有报名项 / 成绩(EntryID=0、ResultID=0)。
var teamLedger []LedgerEntry
for _, l := range second.Ledger {
if l.AthleteID == 0 {
teamLedger = append(teamLedger, l)
}
}
if len(teamLedger) != 1 {
t.Fatalf("团体流水 = %d 条,期望 1 条:%+v", len(teamLedger), teamLedger)
}
l := teamLedger[0]
if l.TeamID != 1001 || l.EntryID != 0 || l.ResultID != 0 {
t.Fatalf("团体流水 = %+v,期望只记单位 1001 且无报名项 / 成绩", l)
}
if !nearlyEqual(l.Points, 15) {
t.Fatalf("团体名次第 1 名的分 = %v,期望 15(团体表)", l.Points)
}
if l.SourceID != 2 {
t.Fatalf("sourceID 兜底应取通道 id=2,得到 %d", l.SourceID)
}
if l.SourceType != SourceResult {
t.Fatalf("SourceType = %q,期望 %q", l.SourceType, SourceResult)
}
// 团体通道只有一条名次明细,主体是单位,绝不消费那 6 条个人名次。
var teamRanked []RankedResult
for _, rr := range second.Ranked {
if rr.ChannelID == 2 {
teamRanked = append(teamRanked, rr)
}
}
if len(teamRanked) != 1 {
t.Fatalf("团体通道名次 = %d 条,期望 1 条(只认团体名次):%+v", len(teamRanked), teamRanked)
}
if teamRanked[0].EntryID != 0 || teamRanked[0].AthleteID != 0 || teamRanked[0].SubjectKey != "t:1001" {
t.Fatalf("团体名次 = %+v,期望合成主体 t:1001", teamRanked[0])
}
if teamRanked[0].Rank != 1 {
t.Fatalf("团体名次 Rank = %d,期望 1", teamRanked[0].Rank)
}
// 个人通道照旧按成绩排名 1..6。
ranks := rankByEntry(second.Ranked)
for i := 1; i <= 6; i++ {
if ranks[int64(i)] != i {
t.Fatalf("个人名次 = %v,期望 entry%d → %d", ranks, i, i)
}
}
// A 队总分 = 个人表 10+8+6+3+2+1 = 30,加团体名次第 1 名的 15,共 45;
// 第二次发布如果团体通道又读了一遍个人名次,这里会变成 85(P0 的真机表现)。
for _, es := range []EventScore{first, second} {
if got := SumByTeam(es.Ledger, 1)[1001].Total; !nearlyEqual(got, 45) {
t.Fatalf("A 队总分 = %v,期望 45(30 个人 + 15 团体)", got)
}
}
})
t.Run("团体名次未录:个人名次绝不进团体通道", func(t *testing.T) {
in := teamPullupInput(nil)
first := mustScore(t, in)
writeBackComputedRanks(in, first.Ranked)
second := mustScore(t, in)
if !reflect.DeepEqual(first, second) {
t.Fatalf("两次发布的计分结果不一致:\n第一次 %+v\n第二次 %+v", first, second)
}
for _, rr := range second.Ranked {
if rr.ChannelID == 2 {
t.Fatalf("没录团体名次时团体通道不应产出名次:%+v", rr)
}
}
if got := SumByTeam(second.Ledger, 1)[1001].Total; !nearlyEqual(got, 30) {
t.Fatalf("A 队总分 = %v,期望只算个人通道的 30(不跳变)", got)
}
})
}
// TestTeamManualChannelCandidateSources 守住团体手工通道的候选来源。
func TestTeamManualChannelCandidateSources(t *testing.T) {
score := func(t *testing.T, in EventScoreInput) EventScore {
t.Helper()
return mustScore(t, in)
}
t.Run("只认团体报名项兜底,个人报名项的名次不进团体通道", func(t *testing.T) {
entries, results := fixture([]participant{
// 个人报名项:录入名次第 1,是个人名次,团体通道不许读。
{entryID: 1, athleteID: 101, teamID: 1001, name: "甲", rank: 1, noValue: true},
// 团体报名项(athlete_id = 0):团体名次第 2。
{entryID: 2, teamID: 1002, rank: 2, noValue: true},
})
es := score(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1002], 10) {
t.Fatalf("单位 1002 = %v,期望团体名次第 2 名的 10 分:%v", got[1002], got)
}
if _, ok := got[1001]; ok {
t.Fatalf("个人报名项的名次进了团体通道:%v", got)
}
if len(es.Ranked) != 1 || es.Ranked[0].TeamID != 1002 || es.Ranked[0].EntryID != 2 {
t.Fatalf("团体名次明细 = %+v,期望只有单位 1002 的团体报名项", es.Ranked)
}
})
t.Run("TeamRanks 覆盖团体报名项的兜底名次", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, teamID: 1001, rank: 3, noValue: true},
})
es := score(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}},
})
if got := pointsByTeam(es.Ledger); !nearlyEqual(got[1001], 15) {
t.Fatalf("单位 1001 = %v,期望以 TeamRanks 的第 1 名计 15 分:%v", got[1001], got)
}
if len(es.Ranked) != 1 {
t.Fatalf("同一单位应只产出一条名次,得到 %d 条:%+v", len(es.Ranked), es.Ranked)
}
if es.Ranked[0].Rank != 1 || es.Ranked[0].EntryID != 1 || es.Ranked[0].SubjectKey != "e:1" {
t.Fatalf("覆盖后的名次 = %+v,期望报名项 1 记为第 1 名", es.Ranked[0])
}
})
t.Run("rank<=0 的团体名次视为未填,退回团体报名项", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, teamID: 1001, rank: 2, noValue: true},
})
es := score(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 0}, {TeamID: 1002, Rank: -1}},
})
got := pointsByTeam(es.Ledger)
if !nearlyEqual(got[1001], 10) {
t.Fatalf("单位 1001 = %v,期望退回团体报名项的第 2 名(10 分):%v", got[1001], got)
}
if _, ok := got[1002]; ok {
t.Fatalf("rank<=0 的团体名次不应产出名次:%v", got)
}
if len(es.Ranked) != 1 {
t.Fatalf("名次明细 = %d 条,期望 1 条:%+v", len(es.Ranked), es.Ranked)
}
})
t.Run("并列团体名次走模板的 tieMode", func(t *testing.T) {
ranks := []TeamManualRank{{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 1}}
cases := []struct {
name string
tie TieMode
points float64
}{
{"顺延(默认)", TieSkip, 15},
{"平均", TieAverage, 12.5},
{"就高", TieHigher, 15},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
profile := teamTop6()
profile.TieMode = tc.tie
es := score(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: profile},
TeamRanks: ranks,
})
if len(es.Ranked) != 2 {
t.Fatalf("并列名次 = %d 条,期望 2 条:%+v", len(es.Ranked), es.Ranked)
}
got := pointsByTeam(es.Ledger)
for _, rr := range es.Ranked {
if rr.Rank != 1 {
t.Fatalf("并列名次 = %+v,期望同为第 1 名", rr)
}
}
if !nearlyEqual(got[1001], tc.points) || !nearlyEqual(got[1002], tc.points) {
t.Fatalf("并列得分 = %v,期望各 %v", got, tc.points)
}
})
}
})
}
// TestRankedSubjectKeys 覆盖计分引擎各条产出路径的 SubjectKey 口径。
func TestRankedSubjectKeys(t *testing.T) {
t.Run("result 通道按报名项", func(t *testing.T) {
entries, results := fixture(lineupValues(11, 12))
es := mustScore(t, EventScoreInput{
Event: individualEvent(1), Category: testCategory(),
Channels: []ScoreChannel{athleteResultChannel(1)},
Profiles: map[int64]ScoringProfile{1: individualTop6()},
Entries: entries, Results: results,
})
for _, rr := range es.Ranked {
if want := "e:" + strconv.FormatInt(rr.EntryID, 10); rr.SubjectKey != want {
t.Fatalf("SubjectKey = %q,期望 %q(%+v)", rr.SubjectKey, want, rr)
}
}
})
t.Run("团体手工名次按单位", func(t *testing.T) {
es := mustScore(t, EventScoreInput{
Event: teamEvent(1), Category: testCategory(),
Channels: []ScoreChannel{teamManualChannel(2)},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
TeamRanks: []TeamManualRank{{TeamID: 1001, Rank: 1}, {TeamID: 1002, Rank: 2}},
})
want := []string{"t:1001", "t:1002"}
if len(es.Ranked) != len(want) {
t.Fatalf("名次条数 = %d,期望 %d", len(es.Ranked), len(want))
}
for i, key := range want {
if es.Ranked[i].SubjectKey != key {
t.Fatalf("第 %d 条 SubjectKey = %q,期望 %q", i, es.Ranked[i].SubjectKey, key)
}
}
})
t.Run("聚合通道按主体", func(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 11},
{entryID: 2, athleteID: 2, teamID: 1001, value: 12},
{entryID: 3, athleteID: 3, teamID: 1002, value: 13},
})
ev := teamEvent(1)
ev.Compare = CompareAsc
es := mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisAggregate, ProfileID: 1},
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisAggregate, ProfileID: 2},
},
Profiles: map[int64]ScoringProfile{1: individualTop6(), 2: teamTop6()},
Entries: entries, Results: results,
})
for _, rr := range es.Ranked {
var want string
if rr.Scope == ScopeAthlete {
want = "a:" + strconv.FormatInt(rr.AthleteID, 10)
} else {
want = "t:" + strconv.FormatInt(rr.TeamID, 10)
}
if rr.SubjectKey != want || rr.EntryID != 0 {
t.Fatalf("聚合名次 = %+v,期望 SubjectKey %q 且 EntryID=0", rr, want)
}
}
})
}
// TestSubjectKeyOfFallback 直接覆盖 subjectKeyOf 的四层口径(含退化兜底)。
func TestSubjectKeyOfFallback(t *testing.T) {
cases := []struct {
name string
rr RankedResult
want string
}{
{"有报名项按报名项", RankedResult{EntryID: 7, AthleteID: 101, TeamID: 1001, ChannelID: 3, Scope: ScopeAthlete}, "e:7"},
{"个人主体", RankedResult{AthleteID: 101, TeamID: 1001, ChannelID: 3, Scope: ScopeAthlete}, "a:101"},
{"单位主体", RankedResult{TeamID: 1001, ChannelID: 3, Scope: ScopeTeam}, "t:1001"},
{"团体名次但没有单位", RankedResult{AthleteID: 101, ChannelID: 3, Scope: ScopeTeam}, "ch:3"},
{"全空退化通道", RankedResult{ChannelID: 5}, "ch:5"},
{"连通道都没有", RankedResult{}, "ch:0"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := subjectKeyOf(tc.rr); got != tc.want {
t.Fatalf("subjectKeyOf = %q,期望 %q", got, tc.want)
}
})
}
}