Files
Scoreboard/server/internal/rules/award_ranks_test.go
T
AskaEth 877ff15823 feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修)
- 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同**
  (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致
- 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到
- 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢);
  另加守卫「compare=none 的小项不得配 basis=result/aggregate」
- 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0
  (7 人三人并列第 5 时第 5、6 名的分也一起没了)
- 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分

## 现场反馈(主人实测报的)
- 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组),
  与「作废」语义分开;取消路径不产生任何副作用
- 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」:
  管理端、大屏覆盖层、xlsx 备注列三处同源
- 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦
- 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响)

## 新功能
- 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切)

## 安装器
- 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、
  安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540
- 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败

自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。
端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
2026-09-13 15:20:18 +08:00

140 lines
5.1 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"
"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)
}
}