P0(9 条全修): - 多通道项目奖牌重复计数:domain 层按 (EventID,CategoryID,EntryID/Subject) 去重, 通道优先级 athlete→team→id - 「只排名不计分」项目(scored:false)不再进奖牌榜,REST/WS/导出口径统一 - aggregate 流水 entry_id 恒 NULL 导致重算后分数翻倍:作废范围改按 event+category 定位 - 已发布成绩仍可被「存草稿」覆盖并清空 rank:服务端 409 STATE_INVALID + 前端禁编辑 - voidResult / 规则导入重算后名次不回写:接住 Ranked 调 applyComputedRanks - 赛事包覆盖导入可能「旧项目已删、新项目没成」:先改名成功再清理,失败回滚 - 导入赛事包可覆盖正在运行的赛事目录:name==当前项目直接 400 - Console spawn 失败后守护器永久卡死:清 child/pid + 调度重启 + stop 超时兜底 - /ws 快照向非 admin 下发 admin-only 配置:按角色裁剪 settings P1(计分口径 / 契约 / 现场): - standings.tieBreak 三处接线;profiles[].recordBonusMode 生效;破纪录校验 RecordEligible 与 entry 归属;破纪录登记校验已发布成绩 - export.loadRanked 与主路径对齐;ListBackups reason 解析;CLI backup 前缀修正 (原来只增不减会撑爆磁盘);单位积分明细/导出统一权重折算;停用单位展示 - 规则导入原子化(重算并入同一事务)+ 返回 compatible/affectedEventCount/ recalculatedLedgerRows;登录审计 user_id 修正 - WS 策略性断开用 Close(code,reason);HelloMessage 补 page/instanceId; 实例注册表清理;settings/单位/项目写操作补 change_log 触发大屏刷新 - 大屏:全量快照不再被版本守卫丢弃;个人积分榜刷新;公告返回页/轮播;行数截断; REST 竞态;1008 停止重连;握手超时;时区补 Z;401 仅 AUTH_REQUIRED 才登出 - 管理端:published 行禁编辑;saveDraft 补 unit(1:06.33 显示);前端预览改用服务端 points;空成绩行不再恒「已修改」;修订详情按 id 拉取;切项目请求序号与 dirty 保护; 发布后刷新项目状态;NAV/路由按角色过滤;recorder 不再打 admin-only 接口 - Console:大屏窗口销毁后重建;大屏 URL 复用 host 归一化;backup.bat 改纯 ASCII (已在 Meowhome 实跑验证) 自检:服务端 gofmt/build/vet 通过、12 包测试全绿;前端 vue-tsc + build 通过、 node --test 24/24;console check-syntax 12 文件通过。
351 lines
12 KiB
Go
351 lines
12 KiB
Go
package domain
|
||
|
||
import "testing"
|
||
|
||
func bonusConfig(mode RecordBonusMode) RecordBonusConfig {
|
||
return RecordBonusConfig{
|
||
Enabled: true, Mode: mode,
|
||
Levels: []RecordLevel{
|
||
{Code: "meet", Name: "赛会纪录", Points: 2},
|
||
{Code: "city", Name: "市级纪录", Points: 5},
|
||
{Code: "province", Name: "省级纪录", Points: 8},
|
||
},
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusDisabled(t *testing.T) {
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
cfg.Enabled = false
|
||
got := RecordBonusPoints(cfg, []RecordBreak{{EntryID: 1, Level: "meet", State: RecordBreakApproved}})
|
||
if got != nil {
|
||
t.Fatalf("未启用时 = %+v,期望 nil", got)
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusStateFilter(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
state RecordBreakState
|
||
want float64
|
||
}{
|
||
{"已通过计入", RecordBreakApproved, 2},
|
||
{"待审核不计入", RecordBreakPending, 0},
|
||
{"已驳回不计入", RecordBreakRejected, 0},
|
||
{"状态空值不计入", "", 0},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := RecordBonusPoints(bonusConfig(RecordBonusMax), []RecordBreak{{EntryID: 1, Level: "meet", State: tc.state}})
|
||
if tc.want == 0 {
|
||
if len(got) != 0 {
|
||
t.Fatalf("结果 = %+v,期望空", got)
|
||
}
|
||
return
|
||
}
|
||
if len(got) != 1 || !nearlyEqual(got[0].Points, tc.want) {
|
||
t.Fatalf("结果 = %+v,期望 %v 分", got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusUnknownLevel(t *testing.T) {
|
||
got := RecordBonusPoints(bonusConfig(RecordBonusMax), []RecordBreak{{EntryID: 1, Level: "galaxy", State: RecordBreakApproved}})
|
||
if len(got) != 0 {
|
||
t.Fatalf("未配置档位 = %+v,期望空(不能凭空加分)", got)
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusModes(t *testing.T) {
|
||
breaks := []RecordBreak{
|
||
{ID: 1, EntryID: 1, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EntryID: 1, Level: "city", State: RecordBreakApproved},
|
||
{ID: 3, EntryID: 1, Level: "province", State: RecordBreakApproved},
|
||
}
|
||
cases := []struct {
|
||
name string
|
||
mode RecordBonusMode
|
||
want float64
|
||
}{
|
||
{"max 取最高档", RecordBonusMax, 8},
|
||
{"sum 累加", RecordBonusSum, 15},
|
||
{"模式空值按 max", "", 8},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
got := RecordBonusPoints(bonusConfig(tc.mode), breaks)
|
||
if len(got) != 1 || !nearlyEqual(got[0].Points, tc.want) {
|
||
t.Fatalf("结果 = %+v,期望 %v", got, tc.want)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusGroupsByEntry(t *testing.T) {
|
||
breaks := []RecordBreak{
|
||
{ID: 1, EntryID: 2, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EntryID: 1, Level: "city", State: RecordBreakApproved},
|
||
{ID: 3, EntryID: 1, Level: "meet", State: RecordBreakApproved},
|
||
}
|
||
got := RecordBonusPoints(bonusConfig(RecordBonusMax), breaks)
|
||
if len(got) != 2 {
|
||
t.Fatalf("条数 = %d,期望 2(按报名项分组)", len(got))
|
||
}
|
||
if got[0].EntryID != 1 || got[1].EntryID != 2 {
|
||
t.Fatalf("顺序 = %+v,期望按 EntryID 升序", got)
|
||
}
|
||
if !nearlyEqual(got[0].Points, 5) || !nearlyEqual(got[1].Points, 2) {
|
||
t.Fatalf("分值 = %v/%v,期望 5/2", got[0].Points, got[1].Points)
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusDedupLevel(t *testing.T) {
|
||
breaks := []RecordBreak{
|
||
{ID: 1, EntryID: 1, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EntryID: 1, Level: "meet", State: RecordBreakApproved},
|
||
}
|
||
got := RecordBonusPoints(bonusConfig(RecordBonusSum), breaks)
|
||
if len(got) != 1 || !nearlyEqual(got[0].Points, 2) {
|
||
t.Fatalf("重复档位 = %+v,期望只计一次 2 分", got)
|
||
}
|
||
if len(got[0].Levels) != 1 {
|
||
t.Fatalf("档位列表 = %v,期望去重", got[0].Levels)
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusNoBreaks(t *testing.T) {
|
||
if got := RecordBonusPoints(bonusConfig(RecordBonusMax), nil); got != nil {
|
||
t.Fatalf("无破纪录登记 = %+v,期望 nil", got)
|
||
}
|
||
}
|
||
|
||
func TestRecordBonusLedger(t *testing.T) {
|
||
entries, results := fixture(lineup(2))
|
||
base := EventScoreInput{
|
||
Event: individualEvent(1), Category: testCategory(),
|
||
Channels: []ScoreChannel{athleteResultChannel(1)},
|
||
Profiles: map[int64]ScoringProfile{1: individualTop6()},
|
||
Entries: entries, Results: results,
|
||
RecordBreaks: []RecordBreak{{
|
||
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
|
||
Level: "meet", State: RecordBreakApproved,
|
||
}},
|
||
}
|
||
|
||
t.Run("未启用不产生加分流水", func(t *testing.T) {
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
cfg.Enabled = false
|
||
base.RecordBonus = &cfg
|
||
es := mustScore(t, base)
|
||
for _, e := range es.Ledger {
|
||
if e.SourceType == SourceRecordBonus {
|
||
t.Fatalf("不应有加分流水: %+v", e)
|
||
}
|
||
}
|
||
})
|
||
|
||
t.Run("启用后归属到人", func(t *testing.T) {
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
base.RecordBonus = &cfg
|
||
es := mustScore(t, base)
|
||
var found bool
|
||
for _, e := range es.Ledger {
|
||
if e.SourceType != SourceRecordBonus {
|
||
continue
|
||
}
|
||
found = true
|
||
if e.TeamID != 1001 || e.AthleteID != 101 || !nearlyEqual(e.Points, 2) {
|
||
t.Fatalf("加分流水 = %+v,期望 1001/101 记 2 分", e)
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatal("未产生加分流水")
|
||
}
|
||
})
|
||
|
||
t.Run("联队按份额拆分加分", func(t *testing.T) {
|
||
ps := []participant{{
|
||
entryID: 1, athleteID: 101, teamID: 1001, value: 1,
|
||
shares: []TeamShare{{TeamID: 1001, Weight: 3}, {TeamID: 1002, Weight: 2}},
|
||
}}
|
||
e2, r2 := fixture(ps)
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
es := mustScore(t, EventScoreInput{
|
||
Event: individualEvent(1), Category: testCategory(),
|
||
Channels: []ScoreChannel{athleteResultChannel(1)},
|
||
Profiles: map[int64]ScoringProfile{1: individualTop6()},
|
||
Entries: e2, Results: r2,
|
||
RecordBonus: &cfg,
|
||
RecordBreaks: []RecordBreak{{
|
||
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
|
||
Level: "meet", State: RecordBreakApproved,
|
||
}},
|
||
Options: ScoreOptions{UnionAttribution: UnionSplit},
|
||
})
|
||
var bonus float64
|
||
for _, e := range es.Ledger {
|
||
if e.SourceType == SourceRecordBonus && e.TeamID == 1002 {
|
||
bonus += e.Points
|
||
}
|
||
}
|
||
if !nearlyEqual(bonus, 0.8) {
|
||
t.Fatalf("单位1002 的加分 = %v,期望 2 × 0.4 = 0.8", bonus)
|
||
}
|
||
})
|
||
}
|
||
|
||
// recordBonusPointsOf 统计一次计分产出的破纪录加分总和。
|
||
func recordBonusPointsOf(t *testing.T, in EventScoreInput) float64 {
|
||
t.Helper()
|
||
var got float64
|
||
for _, e := range mustScore(t, in).Ledger {
|
||
if e.SourceType == SourceRecordBonus {
|
||
got += e.Points
|
||
}
|
||
}
|
||
return got
|
||
}
|
||
|
||
// TestRecordBonusRequiresEligibleEvent 验证只有「可破纪录」的项目才产生加分(P1-5)。
|
||
func TestRecordBonusRequiresEligibleEvent(t *testing.T) {
|
||
entries, results := fixture(lineup(1))
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
in := EventScoreInput{
|
||
Event: individualEvent(1), Category: testCategory(),
|
||
Channels: []ScoreChannel{athleteResultChannel(1)},
|
||
Profiles: map[int64]ScoringProfile{1: individualTop6()},
|
||
Entries: entries, Results: results,
|
||
RecordBonus: &cfg,
|
||
RecordBreaks: []RecordBreak{{
|
||
ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001,
|
||
Level: "meet", State: RecordBreakApproved,
|
||
}},
|
||
}
|
||
|
||
in.Event.RecordEligible = false
|
||
if got := recordBonusPointsOf(t, in); got != 0 {
|
||
t.Fatalf("不可破纪录项目的加分 = %v,期望 0", got)
|
||
}
|
||
|
||
in.Event.RecordEligible = true
|
||
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 2) {
|
||
t.Fatalf("可破纪录项目的加分 = %v,期望 2", got)
|
||
}
|
||
}
|
||
|
||
// TestRecordBonusUsesHitChannelProfileMode 验证 max/sum 口径取「命中通道」所属模板的配置,
|
||
// 而不是全局 cfg.Mode(P1-3)。
|
||
func TestRecordBonusUsesHitChannelProfileMode(t *testing.T) {
|
||
entries, results := fixture(lineup(1))
|
||
athleteProfile := individualTop6()
|
||
athleteProfile.RecordBonusMode = RecordBonusSum
|
||
teamProfile := teamTop6()
|
||
teamProfile.RecordBonusMode = RecordBonusMax
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
in := EventScoreInput{
|
||
Event: individualEvent(1), Category: testCategory(),
|
||
// 团体通道排在前面、通道 id 更小,个人通道仍应优先命中。
|
||
Channels: []ScoreChannel{
|
||
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
|
||
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
|
||
},
|
||
Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile},
|
||
Entries: entries, Results: results,
|
||
RecordBonus: &cfg,
|
||
RecordBreaks: []RecordBreak{
|
||
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved},
|
||
},
|
||
}
|
||
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) {
|
||
t.Fatalf("加分 = %v,期望 7(个人通道 sum:2+5)", got)
|
||
}
|
||
}
|
||
|
||
// TestRecordBonusProfileModeOverridesGlobal 验证模板口径优先于全局口径。
|
||
func TestRecordBonusProfileModeOverridesGlobal(t *testing.T) {
|
||
entries, results := fixture(lineup(1))
|
||
profile := individualTop6()
|
||
profile.RecordBonusMode = RecordBonusMax
|
||
cfg := bonusConfig(RecordBonusSum)
|
||
in := EventScoreInput{
|
||
Event: individualEvent(1), Category: testCategory(),
|
||
Channels: []ScoreChannel{athleteResultChannel(1)},
|
||
Profiles: map[int64]ScoringProfile{1: profile},
|
||
Entries: entries, Results: results,
|
||
RecordBonus: &cfg,
|
||
RecordBreaks: []RecordBreak{
|
||
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "city", State: RecordBreakApproved},
|
||
},
|
||
}
|
||
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 5) {
|
||
t.Fatalf("加分 = %v,期望 5(模板 max 优先于全局 sum)", got)
|
||
}
|
||
}
|
||
|
||
// TestRecordBonusTeamEntryUsesTeamChannelMode 验证没有运动员的报名项命中团体通道的模板口径。
|
||
func TestRecordBonusTeamEntryUsesTeamChannelMode(t *testing.T) {
|
||
entries, results := fixture([]participant{{entryID: 1, teamID: 1001, value: 1, rank: 1}})
|
||
athleteProfile := individualTop6()
|
||
athleteProfile.RecordBonusMode = RecordBonusMax
|
||
teamProfile := teamTop6()
|
||
teamProfile.RecordBonusMode = RecordBonusSum
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
in := EventScoreInput{
|
||
Event: teamEvent(1), Category: testCategory(),
|
||
Channels: []ScoreChannel{
|
||
{ID: 1, EventID: 1, Scope: ScopeAthlete, Basis: BasisResult, ProfileID: 1},
|
||
{ID: 2, EventID: 1, Scope: ScopeTeam, Basis: BasisResult, ProfileID: 2},
|
||
},
|
||
Profiles: map[int64]ScoringProfile{1: athleteProfile, 2: teamProfile},
|
||
Entries: entries, Results: results,
|
||
RecordBonus: &cfg,
|
||
RecordBreaks: []RecordBreak{
|
||
{ID: 1, EventID: 1, EntryID: 1, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EventID: 1, EntryID: 1, TeamID: 1001, Level: "city", State: RecordBreakApproved},
|
||
},
|
||
}
|
||
if got := recordBonusPointsOf(t, in); !nearlyEqual(got, 7) {
|
||
t.Fatalf("加分 = %v,期望 7(团体通道 sum:2+5)", got)
|
||
}
|
||
}
|
||
|
||
// TestRecordBonusLedgerFiltersByCategory 验证按「小项+组别」过滤:
|
||
// 上层逐组别调用 ScoreEvent,别的组别的破纪录登记不能在本组别重复记一次加分。
|
||
func TestRecordBonusLedgerFiltersByCategory(t *testing.T) {
|
||
entries, results := fixture(lineup(2))
|
||
entries[1].CategoryID = 2 // entry2 属于另一个组别
|
||
cfg := bonusConfig(RecordBonusMax)
|
||
es := mustScore(t, EventScoreInput{
|
||
Event: individualEvent(1), Category: testCategory(),
|
||
Channels: []ScoreChannel{athleteResultChannel(1)},
|
||
Profiles: map[int64]ScoringProfile{1: individualTop6()},
|
||
Entries: entries, Results: results,
|
||
RecordBonus: &cfg,
|
||
RecordBreaks: []RecordBreak{
|
||
{ID: 1, EventID: 1, EntryID: 1, AthleteID: 101, TeamID: 1001, Level: "meet", State: RecordBreakApproved},
|
||
{ID: 2, EventID: 1, EntryID: 2, AthleteID: 102, TeamID: 1002, Level: "meet", State: RecordBreakApproved},
|
||
},
|
||
})
|
||
|
||
var thisCategory, otherCategory int
|
||
for _, e := range es.Ledger {
|
||
if e.SourceType != SourceRecordBonus {
|
||
continue
|
||
}
|
||
switch e.EntryID {
|
||
case 1:
|
||
thisCategory++
|
||
case 2:
|
||
otherCategory++
|
||
}
|
||
}
|
||
if thisCategory != 1 {
|
||
t.Fatalf("本组别加分流水 = %d 条,期望 1", thisCategory)
|
||
}
|
||
if otherCategory != 0 {
|
||
t.Fatalf("别的组别的加分不应记入本组别,实际 %d 条", otherCategory)
|
||
}
|
||
}
|