## 算分正确性(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 的双通道形态按要求改变。
233 lines
10 KiB
Go
233 lines
10 KiB
Go
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])
|
||
}
|
||
})
|
||
}
|