Files
Scoreboard/server/internal/rules/parse_test.go
T
AskaEth 7f9701c465 fix: 审查修复波次(P0x4 + P1x30+)
- domain/rules: 计分与排名解耦、破纪录按组别、tieBreak 生效、调整分不丢、移除 aggregate 假能力
- realtime: 新建 internal/dto,快照三项契约对齐,/ws 只认会话身份
- httpapi: 流水幂等、standings 整表载荷、广播接线、强制改密、XFF 防伪造、records 写入、审计、赛事包内容、发布前备份、result.points、实例列表
- web: FormData 上传、YAML 解析、快照归一化、大屏解析层、bootstrap 降级、访客判定、主题切换
- console: 优雅关闭握手、中文化、免验证改管理员账号、选屏与日志轮转修复
- docs: 用户文档三份 + README
- 主代理: 破纪录加分接线(levels 未加载导致恒 0)
2026-09-09 21:06:40 +08:00

342 lines
13 KiB
Go
Raw 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 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)
}
}