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) } }