- M3 认证/权限/审计/限流 + 全部 REST 接口 - M4 管理面板(MIUIx + 成绩录入/积分榜下钻/大屏控制) - M5a 大屏页面(六页 + 明细覆盖层 + 缩放适配) - M6 WebSocket 实时同步中心 - M7 xlsx 导出 + 赛事包 zip + 备份 - 规则包 YAML 导入导出(差异预览) - 字体 vendor 子集化(4 套 x2 字重) - 集成:realtime/export/rules 接入 main+httpapi;backup 子命令 - 修复:中间件包装 ResponseWriter 丢失 Hijacker,导致 WS 握手 501
295 lines
10 KiB
Go
295 lines
10 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},
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|