- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力 - realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份 - httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表 - web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换 - console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复 - docs: 用户文档三份 + README - 主代理: 破纪录加分接线(levels 未加载导致恒 0)
342 lines
13 KiB
Go
342 lines
13 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)
|
||
}
|
||
|
||
back := ProfileFromDomain(dp)
|
||
if !reflect.DeepEqual(back, pack.Profiles[0]) {
|
||
t.Errorf("domain 往返不一致:\n%+v\n%+v", back, pack.Profiles[0])
|
||
}
|
||
|
||
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)
|
||
}
|
||
if got := ChannelFromDomain(ch, "individual_top6"); got != pack.ChannelTemplates[0].Channels[0] {
|
||
t.Errorf("通道往返不一致: %+v", got)
|
||
}
|
||
|
||
in := pack.Standings.ToStandingsInput()
|
||
if in.IndividualToTeamWeight != 1 || in.ExcludeTeamEventMedals {
|
||
t.Errorf("榜单口径转换错误: %+v", in)
|
||
}
|
||
if !reflect.DeepEqual(in.TieBreak, pack.Standings.TieBreak) {
|
||
t.Errorf("tie-break 未传入聚合输入: %+v", in.TieBreak)
|
||
}
|
||
|
||
rb := pack.RecordBonus.ToDomain()
|
||
if rb.Enabled || rb.Mode != domain.RecordBonusMax || len(rb.Levels) != 1 {
|
||
t.Errorf("破纪录配置转换错误: %+v", rb)
|
||
}
|
||
if got := RecordBonusFromDomain(rb); !reflect.DeepEqual(got, pack.RecordBonus) {
|
||
t.Errorf("破纪录配置往返不一致: %+v", got)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|