Files
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

233 lines
10 KiB
Go
Raw Permalink 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"
"testing"
)
// 聚合口径(通道的 aggregate_mode:avg 按成员平均 / sum 按成员总和)的引擎口径。
//
// 现场要在「平均」与「总和」之间临时定口径(团体引体向上这类双计项目领导还没拍板),
// 所以这里钉住三件事:缺省必须是 avg(升级前后榜单一分不差)、人数相同时两种口径排名一致、
// 人数不同时两者可能不同(现场不能默认它们等价)。
// aggScore 用指定汇总口径跑一次团体聚合通道,返回「单位 → 平均/总和值、名次、积分」。
func aggScore(t *testing.T, ev Event, mode AggregateMode, entries []Entry, results []Result) EventScore {
t.Helper()
ch := teamAggregateChannel(2)
ch.AggregateMode = mode
return mustScore(t, EventScoreInput{
Event: ev, Category: testCategory(),
Channels: []ScoreChannel{ch},
Profiles: map[int64]ScoringProfile{2: teamTop6()},
Entries: entries, Results: results,
})
}
// TestAggregateModeEqualRosterSameRanking:各队人数相同时 sum 与 avg 的排名与积分完全一致。
// 这是现场选口径的依据——人数整齐时选哪种都不影响榜单,人数不齐时才会分叉。
func TestAggregateModeEqualRosterSameRanking(t *testing.T) {
// 三队各 2 人,compare=desc(越大越好)。总和 = 平均 × 2,是同一个单调变换。
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 18},
{entryID: 2, athleteID: 2, teamID: 1001, value: 20},
{entryID: 3, athleteID: 3, teamID: 1002, value: 16},
{entryID: 4, athleteID: 4, teamID: 1002, value: 14},
{entryID: 5, athleteID: 5, teamID: 1003, value: 12},
{entryID: 6, athleteID: 6, teamID: 1003, value: 10},
})
ev := teamEvent(1)
ev.Compare = CompareDesc
avg := aggScore(t, ev, AggregateAvg, entries, results)
sum := aggScore(t, ev, AggregateSum, entries, results)
// 名次与积分逐队一致(录取前六名,三队都有分);汇总值本身当然不同(19 平均 / 38 总和)。
avgOutcome := aggregateByTeam(t, avg)
sumOutcome := aggregateByTeam(t, sum)
for _, teamID := range []int64{1001, 1002, 1003} {
if avgOutcome[teamID].rank != sumOutcome[teamID].rank || !nearlyEqual(avgOutcome[teamID].points, sumOutcome[teamID].points) {
t.Fatalf("人数相同时两种口径的名次/积分应一致:单位 %d avg=%+v sum=%+v",
teamID, avgOutcome[teamID], sumOutcome[teamID])
}
}
if !reflect.DeepEqual(pointsByTeam(avg.Ledger), pointsByTeam(sum.Ledger)) {
t.Fatalf("人数相同时两种口径的流水应一致:avg=%v sum=%v",
pointsByTeam(avg.Ledger), pointsByTeam(sum.Ledger))
}
// 顺带钉住名次本身:1001 平均 19 / 总和 38 → 第一。
got := aggregateByTeam(t, sum)
if got[1001].rank != 1 || got[1002].rank != 2 || got[1003].rank != 3 {
t.Fatalf("sum 口径名次 = %+v,期望 1001/1002/1003 依次第 1/2/3 名", got)
}
}
// TestAggregateModeRosterDiffersFlipsRanking:各队人数不同时两种口径的排名会不同。
// 1001 队 2 人(10 + 1)、1002 队 1 人(9):平均看 1002 强,总和看 1001 强。
func TestAggregateModeRosterDiffersFlipsRanking(t *testing.T) {
entries, results := fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, value: 1},
{entryID: 3, athleteID: 3, teamID: 1002, value: 9},
})
ev := teamEvent(1)
ev.Compare = CompareDesc
avg := aggregateByTeam(t, aggScore(t, ev, AggregateAvg, entries, results))
sum := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results))
// 平均:1001 = 5.5 第二、1002 = 9 第一。
if !nearlyEqual(avg[1001].value, 5.5) || avg[1001].rank != 2 || avg[1001].points != 10 {
t.Errorf("avg:1001 应 5.5 第 2 名 10 分,实际 %+v", avg[1001])
}
if !nearlyEqual(avg[1002].value, 9) || avg[1002].rank != 1 || avg[1002].points != 15 {
t.Errorf("avg:1002 应 9 第 1 名 15 分,实际 %+v", avg[1002])
}
// 总和:1001 = 11 第一、1002 = 9 第二。
if !nearlyEqual(sum[1001].value, 11) || sum[1001].rank != 1 || sum[1001].points != 15 {
t.Errorf("sum:1001 应 11 第 1 名 15 分,实际 %+v", sum[1001])
}
if !nearlyEqual(sum[1002].value, 9) || sum[1002].rank != 2 || sum[1002].points != 10 {
t.Errorf("sum:1002 应 9 第 2 名 10 分,实际 %+v", sum[1002])
}
if avg[1001].rank == sum[1001].rank {
t.Fatalf("人数不同时两种口径的名次不应相同:avg=%+v sum=%+v", avg, sum)
}
}
// TestAggregateModeDefaultsToAverage:缺省(不带 aggregateMode / 老库老客户端)就是 avg,
// 结果与显式写 avg 逐字段一致——现有数据与现有用例的行为一字不变。
func TestAggregateModeDefaultsToAverage(t *testing.T) {
if got := (ScoreChannel{}).aggregateMode(); got != AggregateAvg {
t.Fatalf("零值通道的汇总口径 = %q,期望 avg", got)
}
// 认不出的取值也退回 avg,不是退回 sum(库里有 CHECK,但内存里的对象仍要兜住)。
for _, bogus := range []AggregateMode{"", "average", "AVG", "SUM", "biggest"} {
if got := (ScoreChannel{AggregateMode: bogus}).aggregateMode(); got != AggregateAvg {
t.Errorf("取值 %q 应退回 avg,实际 %q", bogus, got)
}
}
// 与 TestAggregateAvgMode 同一个现场(compare=asc,越小越好):1001 一人有成绩 10、一人没成绩;
// 1002 两人各 4 / 6。valid 口径下 1002 平均 5 第一(15 分)、1001 平均 10 第二(10 分)
// ——这是升级前的既有结果。
legacy := []struct {
name string
mode AvgMode
build func() ([]Entry, []Result)
want map[int64]aggregateOutcome
}{
{
name: "valid",
mode: AvgModeValid,
build: 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},
})
},
want: map[int64]aggregateOutcome{
1002: {value: 5, points: 15, rank: 1},
1001: {value: 10, points: 10, rank: 2},
},
},
{
name: "all",
mode: AvgModeAll,
build: 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},
})
},
want: map[int64]aggregateOutcome{
1001: {value: 5, points: 15, rank: 1},
1002: {value: 5, points: 15, rank: 1},
},
},
}
for _, tc := range legacy {
t.Run("既有结果不变/"+tc.name, func(t *testing.T) {
entries, results := tc.build()
ev := teamEvent(1)
ev.AvgMode = tc.mode
// defaultScore:通道不带 aggregateMode(老库数据 / 老客户端保存的通道)。
defaultScore := aggScore(t, ev, "", entries, results)
explicitAvg := aggScore(t, ev, AggregateAvg, entries, results)
// 「缺省」与「显式 avg」必须逐字段一致(连流水顺序都一致)。
if !reflect.DeepEqual(defaultScore.Ledger, explicitAvg.Ledger) {
t.Errorf("缺省口径的流水与显式 avg 不一致:\n缺省 %+v\n显式 %+v", defaultScore.Ledger, explicitAvg.Ledger)
}
if !reflect.DeepEqual(defaultScore.Ranked, explicitAvg.Ranked) {
t.Errorf("缺省口径的名次与显式 avg 不一致:\n缺省 %+v\n显式 %+v", defaultScore.Ranked, explicitAvg.Ranked)
}
got := aggregateByTeam(t, defaultScore)
for teamID, want := range tc.want {
have := got[teamID]
if !nearlyEqual(have.value, want.value) || have.rank != want.rank || !nearlyEqual(have.points, want.points) {
t.Errorf("单位 %d = %+v,期望 %+v", teamID, have, want)
}
}
if got := pointsByTeam(defaultScore.Ledger); !reflect.DeepEqual(got, pointsByTeam(explicitAvg.Ledger)) {
t.Errorf("缺省口径的单位总分与显式 avg 不一致:%v / %v", got, pointsByTeam(explicitAvg.Ledger))
}
})
}
}
// TestAggregateModeSumWithAvgMode:sum 只累加有效成绩,avg_mode 在求和口径下只剩
// 「一个有效成绩都没有的单位算不算 0 分参与排名」这一条作用(valid 不参与 / all 参与)。
func TestAggregateModeSumWithAvgMode(t *testing.T) {
build := func() ([]Entry, []Result) {
return fixture([]participant{
{entryID: 1, athleteID: 1, teamID: 1001, value: 10},
{entryID: 2, athleteID: 2, teamID: 1001, value: 1},
{entryID: 3, athleteID: 3, teamID: 1002, value: 9},
// 1003 全队一条有效成绩都没有:valid 不参与、all 按 0 参与(与平均口径同规则)。
{entryID: 4, athleteID: 4, teamID: 1003, noValue: true},
})
}
t.Run("valid:没成绩的单位不成桶", func(t *testing.T) {
entries, results := build()
ev := teamEvent(1)
ev.Compare = CompareDesc
ev.AvgMode = AvgModeValid
got := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results))
if !nearlyEqual(got[1001].value, 11) || got[1001].rank != 1 {
t.Errorf("1001 应总和 11 第 1 名,实际 %+v", got[1001])
}
if !nearlyEqual(got[1002].value, 9) || got[1002].rank != 2 {
t.Errorf("1002 应总和 9 第 2 名,实际 %+v", got[1002])
}
if got[1003].rank != 0 || got[1003].points != 0 {
t.Errorf("1003 全队没成绩,不应参与排名,实际 %+v", got[1003])
}
})
t.Run("all:没成绩的单位按 0 参与、排在最后", func(t *testing.T) {
entries, results := build()
ev := teamEvent(1)
ev.Compare = CompareDesc
ev.AvgMode = AvgModeAll
got := aggregateByTeam(t, aggScore(t, ev, AggregateSum, entries, results))
// 求和:1001 = 11 第一;1002 = 9 第二;1003 = 0 第三(加 0 不改变其它队的和)。
if !nearlyEqual(got[1001].value, 11) || got[1001].rank != 1 {
t.Errorf("1001 应总和 11 第 1 名,实际 %+v", got[1001])
}
if !nearlyEqual(got[1002].value, 9) || got[1002].rank != 2 {
t.Errorf("1002 应总和 9 第 2 名,实际 %+v", got[1002])
}
if !nearlyEqual(got[1003].value, 0) || got[1003].rank != 3 {
t.Errorf("1003 应总和 0 第 3 名,实际 %+v", got[1003])
}
})
}