package rules import ( "errors" "strings" "testing" "git.yeij.top/AskaEth/Scoreboard/server/internal/domain" ) // 本文件钉住工单 E:分值表的档位超出「录取档位数(awardRanks)」时必须拒收。 // // 算分引擎只在 award_ranks > 0 时按名次截断(domain/scoring.go 的 basePoints), // 而本包原先只拒 awardRanks < 0:一份「配到第 7 档、录取名次还留着 6」的规则包能过校验、 // 安静导入——第 7 档在模板页上写着分,榜上恒为 0,界面上看不出任何异常(审查 01 P1)。 // awardRanks: 0 表示不按名次封顶(既有语义),这种包必须继续放行。 // sevenRankPackYAML 是「配到第 7 档」的规则包;录取名次由用例替换成 6 / 0。 const sevenRankPackYAML = `format: scoreboard-rules formatVersion: 1 meta: { code: demo, name: 第七档 } profiles: - code: p7 name: 前七名 awardRanks: 6 tieMode: skip rules: - { rank: 1, points: 10 } - { rank: 2, points: 8 } - { rank: 3, points: 6 } - { rank: 4, points: 5 } - { rank: 5, points: 4 } - { rank: 6, points: 3 } - { rank: 7, points: 2 } channelTemplates: - match: { eventType: individual } channels: - { scope: athlete, basis: result, profile: p7 } ` // 录取名次 6、分值表到第 7 档:导入必须整体拒收,且说明要说清「哪一档超了」「怎么改」。 func TestParseRejectsAwardRanksOverflow(t *testing.T) { _, err := Parse([]byte(sevenRankPackYAML)) if !errors.Is(err, ErrValidation) { t.Fatalf("第 7 档 + awardRanks=6 应报 ErrValidation,实际 %v", err) } var ve *ValidationError if !errors.As(err, &ve) { t.Fatalf("应返回 *ValidationError,实际 %T", err) } var found bool for _, is := range ve.Issues { if is.Path != "profiles[0].awardRanks" { continue } found = true // 现场要能照着改:说清超出的档位、当前录取名次,以及「0 表示不封顶」这个出口。 for _, want := range []string{"第 7 档", "第 6 档", "0 表示不按名次封顶"} { if !strings.Contains(is.Msg, want) { t.Fatalf("说明里缺少 %q:%s", want, is.Msg) } } } if !found { t.Fatalf("问题应挂在 profiles[0].awardRanks 上,实际 %+v", ve.Issues) } } // 这条检查只在**写入**侧生效:导出(Marshal)走的是 Validate,必须继续容忍越档模板。 // 库里修好之前存下的历史配置如果让导不出来,GET /api/rules/export 与赛事项目包(备份)会一起 500, // 而它们正是现场排障与备份的入口——所以导出侧宁可原样导出,由用户在模板页上改。 func TestMarshalStillAllowsLegacyAwardRanksOverflow(t *testing.T) { pack := &RulePack{ Format: FormatName, FormatVersion: FormatVersion, Meta: Meta{Code: "legacy", Name: "历史越档模板"}, Profiles: []Profile{{ Code: "p7", Name: "前七名", AwardRanks: 6, TieMode: domain.TieSkip, Rules: []Rule{{1, 10}, {2, 8}, {3, 6}, {4, 5}, {5, 4}, {6, 3}, {7, 2}}, }}, } if err := pack.Validate(); err != nil { t.Fatalf("导出侧校验不该拦越档模板,实际 %v", err) } if _, err := Marshal(pack); err != nil { t.Fatalf("越档模板必须还能导出,实际 %v", err) } // 但同一份配置从导入进来时就要被拒(Parse → ValidateForImport)。 if _, err := Parse([]byte(sevenRankPackYAML)); !errors.Is(err, ErrValidation) { t.Fatalf("导入侧应拒收越档模板,实际 %v", err) } } // 两种合法形态不得被误杀:正好相等(第 6 档 + awardRanks 6)、不封顶(第 7 档 + awardRanks 0)。 func TestParseAcceptsAwardRanksCoveringRules(t *testing.T) { cases := map[string]string{ "正好相等": strings.Replace(sevenRankPackYAML, "awardRanks: 6", "awardRanks: 7", 1), "不封顶": strings.Replace(sevenRankPackYAML, "awardRanks: 6", "awardRanks: 0", 1), } for name, yaml := range cases { t.Run(name, func(t *testing.T) { pack, err := Parse([]byte(yaml)) if err != nil { t.Fatalf("应解析成功,实际 %v", err) } if got := len(pack.Profiles[0].Rules); got != 7 { t.Fatalf("分值表应保留 7 档,实际 %d", got) } }) } } // AwardRanksOverflow 是保存模板与导入规则包共用的判定,边界直接钉在函数上。 func TestAwardRanksOverflowBoundaries(t *testing.T) { cases := []struct { awardRanks int maxRuleRank int overflow bool }{ {0, 10, false}, // 0 = 不封顶 {-1, 10, false}, // 负数由别处拦,这里不重复报 {6, 0, false}, // 没有分值明细 {6, 6, false}, // 正好覆盖 {7, 6, false}, // 录取名次多于档位:合法(后面几档没人拿得到) {6, 7, true}, {6, 10, true}, } for _, tc := range cases { msg := AwardRanksOverflow(tc.awardRanks, tc.maxRuleRank) if tc.overflow != (msg != "") { t.Fatalf("awardRanks=%d maxRuleRank=%d 应 overflow=%v,实际 %q", tc.awardRanks, tc.maxRuleRank, tc.overflow, msg) } } // 超出的档位取的是「最大的那一档」,不是第一条超出的档位。 if msg := AwardRanksOverflow(6, 10); !strings.Contains(msg, "第 10 档") { t.Fatalf("应点名最大的超档档位,实际 %q", msg) } }