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 文件通过。
323 lines
12 KiB
Go
323 lines
12 KiB
Go
package rules
|
||
|
||
import (
|
||
"errors"
|
||
"reflect"
|
||
"strings"
|
||
"testing"
|
||
|
||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||
)
|
||
|
||
// 测试一律用内联 YAML,不读 rules/ 目录(该目录含敏感信息且不进版本库)。
|
||
const validPackYAML = `format: scoreboard-rules
|
||
formatVersion: 1
|
||
meta:
|
||
code: demo-rules
|
||
name: 示例规则包
|
||
version: 1.0.0
|
||
updatedAt: "2026-01-01"
|
||
author: tester
|
||
standings:
|
||
individualToTeamWeight: 1
|
||
tieBreak: [points, gold, silver, bronze, teamCode]
|
||
medalCountsTeamEvents: true
|
||
showIndividualBoard: true
|
||
showIndividualMedals: true
|
||
profiles:
|
||
- code: individual_top6
|
||
name: 个人项目前六名
|
||
awardRanks: 6
|
||
lastPlaceZero: false
|
||
tieMode: skip
|
||
rules:
|
||
- { rank: 1, points: 10 }
|
||
- { rank: 2, points: 8 }
|
||
- { rank: 3, points: 6 }
|
||
- { rank: 4, points: 3 }
|
||
- { rank: 5, points: 2 }
|
||
- { rank: 6, points: 1 }
|
||
- code: team_top6
|
||
name: 团体项目前六名
|
||
awardRanks: 2
|
||
tieMode: average
|
||
rules:
|
||
- { rank: 1, points: 15 }
|
||
- { rank: 2, points: 10 }
|
||
recordBonus:
|
||
enabled: false
|
||
mode: max
|
||
levels:
|
||
- { code: meet, name: 赛会纪录, points: 2 }
|
||
manualAdjustment:
|
||
enabled: false
|
||
reasonRequired: true
|
||
channelTemplates:
|
||
- match: { eventType: individual }
|
||
channels:
|
||
- { scope: athlete, basis: result, profile: individual_top6 }
|
||
- match: { eventType: team }
|
||
channels:
|
||
- { scope: team, basis: manual, profile: team_top6 }
|
||
eventOverrides:
|
||
- eventCode: EVT_DEMO
|
||
name: 示例项目
|
||
channels:
|
||
- { scope: athlete, basis: result, profile: individual_top6 }
|
||
- { scope: team, basis: manual, profile: team_top6 }
|
||
- eventCode: EVT_NOSCORE
|
||
name: 只排名不计分
|
||
scored: false
|
||
`
|
||
|
||
const minimalPackYAML = `format: scoreboard-rules
|
||
formatVersion: 1
|
||
meta: { code: demo, name: 示例 }
|
||
profiles:
|
||
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
|
||
`
|
||
|
||
func TestParseValidPack(t *testing.T) {
|
||
pack, err := Parse([]byte(validPackYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
|
||
if pack.Format != FormatName || pack.FormatVersion != FormatVersion {
|
||
t.Fatalf("格式字段解析错误: %+v", pack)
|
||
}
|
||
if pack.Meta.Code != "demo-rules" || pack.Meta.Name != "示例规则包" {
|
||
t.Errorf("meta 解析错误: %+v", pack.Meta)
|
||
}
|
||
if len(pack.Profiles) != 2 {
|
||
t.Fatalf("模板数量 = %d,期望 2", len(pack.Profiles))
|
||
}
|
||
if got := pack.Profiles[0].Rules[0]; got != (Rule{Rank: 1, Points: 10}) {
|
||
t.Errorf("名次 1 分值 = %+v,期望 10", got)
|
||
}
|
||
if pack.Profiles[0].TieMode != domain.TieSkip || pack.Profiles[1].TieMode != domain.TieAverage {
|
||
t.Errorf("并列口径解析错误: %q / %q", pack.Profiles[0].TieMode, pack.Profiles[1].TieMode)
|
||
}
|
||
// 未配置 recordBonusMode 时按 max 补齐(与 domain 默认一致)。
|
||
if pack.Profiles[0].RecordBonusMode != domain.RecordBonusMax {
|
||
t.Errorf("recordBonusMode 缺省值 = %q,期望 max", pack.Profiles[0].RecordBonusMode)
|
||
}
|
||
if len(pack.ChannelTemplates) != 2 || len(pack.EventOverrides) != 2 {
|
||
t.Errorf("通道模板/项目例外数量错误: %d / %d", len(pack.ChannelTemplates), len(pack.EventOverrides))
|
||
}
|
||
if pack.EventOverrides[1].Scored == nil || *pack.EventOverrides[1].Scored {
|
||
t.Errorf("EVT_NOSCORE 的 scored 应为 false: %+v", pack.EventOverrides[1].Scored)
|
||
}
|
||
if !pack.Standings.MedalCountsTeamEventsEnabled() || !pack.Standings.ShowIndividualBoardEnabled() {
|
||
t.Error("榜单开关缺省值应为 true")
|
||
}
|
||
}
|
||
|
||
func TestParseFillsDefaults(t *testing.T) {
|
||
pack, err := Parse([]byte(minimalPackYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
if pack.Standings.IndividualToTeamWeight != 1 {
|
||
t.Errorf("未配置权重时应补 1,实际 %v", pack.Standings.IndividualToTeamWeight)
|
||
}
|
||
if !reflect.DeepEqual(pack.Standings.TieBreak, DefaultTieBreak) {
|
||
t.Errorf("未配置 tieBreak 时应补默认值,实际 %v", pack.Standings.TieBreak)
|
||
}
|
||
if pack.RecordBonus.Mode != domain.RecordBonusMax {
|
||
t.Errorf("未配置 recordBonus.mode 时应补 max,实际 %q", pack.RecordBonus.Mode)
|
||
}
|
||
}
|
||
|
||
func TestMarshalRoundTripStable(t *testing.T) {
|
||
pack, err := Parse([]byte(validPackYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
|
||
first, err := Marshal(pack)
|
||
if err != nil {
|
||
t.Fatalf("Marshal: %v", err)
|
||
}
|
||
if !strings.HasPrefix(string(first), "# 积分规则包") {
|
||
t.Errorf("导出缺少注释头:\n%s", first)
|
||
}
|
||
|
||
again, err := Parse(first)
|
||
if err != nil {
|
||
t.Fatalf("重新 Parse 导出结果: %v", err)
|
||
}
|
||
second, err := Marshal(again)
|
||
if err != nil {
|
||
t.Fatalf("再次 Marshal: %v", err)
|
||
}
|
||
if string(first) != string(second) {
|
||
t.Errorf("往返序列化不稳定:\n--- 第一次 ---\n%s\n--- 第二次 ---\n%s", first, second)
|
||
}
|
||
if !reflect.DeepEqual(pack, again) {
|
||
t.Errorf("往返后规则包内容不一致:\n%+v\n%+v", pack, again)
|
||
}
|
||
}
|
||
|
||
func TestParseRejectsBadInput(t *testing.T) {
|
||
cases := []struct {
|
||
name string
|
||
yaml string
|
||
sentinel error
|
||
}{
|
||
{"空文件", " \n", ErrNotRulePack},
|
||
{"格式标识不符", strings.Replace(validPackYAML, "format: scoreboard-rules", "format: something-else", 1), ErrNotRulePack},
|
||
{"版本不兼容", strings.Replace(validPackYAML, "formatVersion: 1", "formatVersion: 2", 1), ErrIncompatibleVersion},
|
||
{"未知字段", validPackYAML + "\nbogusField: 1\n", nil},
|
||
{"缺 meta.code", strings.Replace(validPackYAML, " code: demo-rules\n", "", 1), ErrValidation},
|
||
{"缺 meta.name", strings.Replace(validPackYAML, " name: 示例规则包\n", "", 1), ErrValidation},
|
||
{"模板编码重复", strings.Replace(validPackYAML, " - code: team_top6", " - code: individual_top6", 1), ErrValidation},
|
||
{"录取名次为负", strings.Replace(validPackYAML, "awardRanks: 6", "awardRanks: -1", 1), ErrValidation},
|
||
{"并列口径非法", strings.Replace(validPackYAML, "tieMode: skip", "tieMode: random", 1), ErrValidation},
|
||
{"分值非负", strings.Replace(validPackYAML, " - { rank: 1, points: 10 }", " - { rank: 1, points: -10 }", 1), ErrValidation},
|
||
{"名次重复", strings.Replace(validPackYAML, " - { rank: 2, points: 8 }", " - { rank: 1, points: 8 }", 1), ErrValidation},
|
||
{"通道 scope 非法", strings.Replace(validPackYAML, "{ scope: athlete, basis: result, profile: individual_top6 }", "{ scope: unit, basis: result, profile: individual_top6 }", 1), ErrValidation},
|
||
{"通道 basis 非法", strings.Replace(validPackYAML, "{ scope: team, basis: manual, profile: team_top6 }", "{ scope: team, basis: guess, profile: team_top6 }", 1), ErrValidation},
|
||
{"通道引用不存在的模板", strings.Replace(validPackYAML, "profile: individual_top6 }", "profile: ghost_top6 }", 1), ErrValidation},
|
||
{"排序键未知", strings.Replace(validPackYAML, "tieBreak: [points, gold, silver, bronze, teamCode]", "tieBreak: [points, platinum]", 1), ErrValidation},
|
||
{"启用破纪录却没有档位", strings.Replace(validPackYAML, "recordBonus:\n enabled: false\n mode: max\n levels:\n - { code: meet, name: 赛会纪录, points: 2 }", "recordBonus:\n enabled: true\n mode: max\n levels: []", 1), ErrValidation},
|
||
// aggregate 的汇总口径(avg/max/min/count)没有落库列,已从类型里移除;写了必须报错而不是静默丢弃。
|
||
{"通道 aggregate 口径已移除", strings.Replace(validPackYAML, "{ scope: team, basis: manual, profile: team_top6 }", "{ scope: team, basis: aggregate, profile: team_top6, aggregate: avg }", 1), nil},
|
||
// 联队归属未实现,规则包里不允许出现相关配置。
|
||
{"联队归属未实现", strings.Replace(validPackYAML, "standings:\n", "standings:\n unionAttribution: split\n", 1), nil},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
_, err := Parse([]byte(tc.yaml))
|
||
if err == nil {
|
||
t.Fatal("期望报错,实际解析成功")
|
||
}
|
||
if tc.sentinel != nil && !errors.Is(err, tc.sentinel) {
|
||
t.Fatalf("期望 %v,实际 %v", tc.sentinel, err)
|
||
}
|
||
if tc.sentinel == nil && errors.Is(err, ErrIncompatibleVersion) {
|
||
t.Fatalf("未知字段不应报版本错误: %v", err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseRejectsStructuralProblems(t *testing.T) {
|
||
cases := map[string]string{
|
||
"没有模板": `format: scoreboard-rules
|
||
formatVersion: 1
|
||
meta: { code: demo, name: 示例 }
|
||
profiles: []
|
||
`,
|
||
"模板没有通道": `format: scoreboard-rules
|
||
formatVersion: 1
|
||
meta: { code: demo, name: 示例 }
|
||
profiles:
|
||
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
|
||
channelTemplates:
|
||
- match: { eventType: team }
|
||
channels: []
|
||
`,
|
||
"模板匹配条件为空": `format: scoreboard-rules
|
||
formatVersion: 1
|
||
meta: { code: demo, name: 示例 }
|
||
profiles:
|
||
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
|
||
channelTemplates:
|
||
- match: {}
|
||
channels:
|
||
- { scope: team, basis: manual, profile: p1 }
|
||
`,
|
||
"同一项目通道重复": `format: scoreboard-rules
|
||
formatVersion: 1
|
||
meta: { code: demo, name: 示例 }
|
||
profiles:
|
||
- { code: p1, name: 模板一, awardRanks: 1, tieMode: skip, rules: [ { rank: 1, points: 1 } ] }
|
||
eventOverrides:
|
||
- eventCode: EVT_X
|
||
channels:
|
||
- { scope: team, basis: manual, profile: p1 }
|
||
- { scope: team, basis: manual, profile: p1 }
|
||
`,
|
||
}
|
||
for name, yaml := range cases {
|
||
t.Run(name, func(t *testing.T) {
|
||
if _, err := Parse([]byte(yaml)); !errors.Is(err, ErrValidation) {
|
||
t.Fatalf("期望 ErrValidation,实际 %v", err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestParseRejectsMultipleDocuments(t *testing.T) {
|
||
if _, err := Parse([]byte(validPackYAML + "---\nformat: scoreboard-rules\n")); !errors.Is(err, ErrNotRulePack) {
|
||
t.Fatalf("多文档应报 ErrNotRulePack,实际 %v", err)
|
||
}
|
||
}
|
||
|
||
func TestProfileDomainConversion(t *testing.T) {
|
||
pack, err := Parse([]byte(validPackYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
|
||
dp := pack.Profiles[0].ToDomain(7)
|
||
if dp.ID != 7 || dp.Code != "individual_top6" || dp.TieMode != domain.TieSkip {
|
||
t.Errorf("ToDomain 结果错误: %+v", dp)
|
||
}
|
||
if len(dp.Rules) != 6 || dp.Rules[0] != (domain.ScoringRule{Rank: 1, Points: 10}) {
|
||
t.Errorf("ToDomain 明细错误: %+v", dp.Rules)
|
||
}
|
||
|
||
ch := pack.ChannelTemplates[0].Channels[0].ToDomain(3, 7, 0)
|
||
if ch.EventID != 3 || ch.ProfileID != 7 || ch.Scope != domain.ScopeAthlete || ch.Basis != domain.BasisResult {
|
||
t.Errorf("通道 ToDomain 结果错误: %+v", ch)
|
||
}
|
||
|
||
rb := pack.RecordBonus.ToDomain()
|
||
if rb.Enabled || rb.Mode != domain.RecordBonusMax || len(rb.Levels) != 1 {
|
||
t.Errorf("破纪录配置转换错误: %+v", rb)
|
||
}
|
||
}
|
||
|
||
// TestRecordBonusFromSettings 验证上层能从 settings 键值对读到破纪录档位:
|
||
// 只读 enabled/mode 而不读 levels,会让破纪录加分永远算 0。
|
||
func TestRecordBonusFromSettings(t *testing.T) {
|
||
settings := map[string]string{
|
||
settingRecordBonusEnabled: "true",
|
||
settingRecordBonusMode: "sum",
|
||
settingRecordBonusLevels: `[{"code":"meet","name":"赛会纪录","points":2},{"code":"city","name":"市级纪录","points":5}]`,
|
||
}
|
||
cfg := RecordBonusFromSettings(settings)
|
||
if !cfg.Enabled || cfg.Mode != domain.RecordBonusSum {
|
||
t.Fatalf("开关/口径错误: %+v", cfg)
|
||
}
|
||
if len(cfg.Levels) != 2 {
|
||
t.Fatalf("档位数 = %d,期望 2(levels 必须被解析)", len(cfg.Levels))
|
||
}
|
||
if cfg.Levels[1].Code != "city" || cfg.Levels[1].Points != 5 {
|
||
t.Fatalf("档位内容错误: %+v", cfg.Levels[1])
|
||
}
|
||
|
||
t.Run("缺省值", func(t *testing.T) {
|
||
cfg := RecordBonusFromSettings(map[string]string{})
|
||
if cfg.Enabled || cfg.Mode != domain.RecordBonusMax || len(cfg.Levels) != 0 {
|
||
t.Fatalf("缺省配置错误: %+v", cfg)
|
||
}
|
||
})
|
||
}
|
||
|
||
// TestRecordBonusSettingsRoundTrip 验证规则包写入 settings 后还能原样读回档位。
|
||
func TestRecordBonusSettingsRoundTrip(t *testing.T) {
|
||
pack, err := Parse([]byte(validPackYAML))
|
||
if err != nil {
|
||
t.Fatalf("Parse: %v", err)
|
||
}
|
||
got := RecordBonusFromSettings(settingsFromPack(pack))
|
||
want := pack.RecordBonus.ToDomain()
|
||
if !reflect.DeepEqual(got, want) {
|
||
t.Fatalf("settings 往返后破纪录配置不一致:\n%+v\n%+v", got, want)
|
||
}
|
||
}
|