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 的双通道形态按要求改变。
This commit is contained in:
1 parent
78b14efaa9
commit
877ff15823
181 files changed
+8747
-371
No files matched your search
@@ -1,6 +1,8 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
|
||||
@@ -258,6 +260,95 @@ func TestResolveTargetsClearsChannelsForUnscored(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// compare=none 的个人小项(名次靠人工录入)不能被配上 basis=result / basis=aggregate 的通道:
|
||||
// 这两种口径都要先按成绩排名,而 compare=none 没有名次可算,算分时必然返回 ErrCompareRequired。
|
||||
// 以前这条配置能过,代价是「先换通道」(channel_ranks 被外键级联清空)已经提交、紧接着的重算才失败,
|
||||
// 名次表停在空表状态且补不回来(审查 04 P0-1)。现在必须在落地之前整包拒收。
|
||||
func TestResolveTargetsRejectsCompareRequiredChannelOnCompareNoneEvent(t *testing.T) {
|
||||
cur := currentSnapshot(t)
|
||||
cur.Events = append(cur.Events, EventInfo{
|
||||
ID: 9, Code: "EVT_MANUAL", Name: "手榴弹投准",
|
||||
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeNone,
|
||||
Compare: domain.CompareNone, Scored: true, Multiplier: 1,
|
||||
Channels: []Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisManual, Profile: "individual_top6"}},
|
||||
})
|
||||
|
||||
next, err := Parse([]byte(nextPackYAML))
|
||||
if err != nil {
|
||||
t.Fatalf("Parse: %v", err)
|
||||
}
|
||||
|
||||
t.Run("模板套出来的 basis=result 被拦下", func(t *testing.T) {
|
||||
_, _, err := resolveTargets(cur, next)
|
||||
var conflict *CompareConflictError
|
||||
if !errors.As(err, &conflict) {
|
||||
t.Fatalf("compare=none 的小项被配上 basis=result 应报 CompareConflictError,得到 %v", err)
|
||||
}
|
||||
if conflict.EventCode != "EVT_MANUAL" || conflict.EventName != "手榴弹投准" {
|
||||
t.Errorf("错误里必须指名是哪个小项,得到 %+v", conflict)
|
||||
}
|
||||
// 人话:说清哪个小项、为什么、怎么改。
|
||||
msg := conflict.Error()
|
||||
for _, want := range []string{"EVT_MANUAL", "手榴弹投准", "compare=none", "basis=result", "eventOverrides", "basis=manual"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Errorf("错误信息缺少 %q:%s", want, msg)
|
||||
}
|
||||
}
|
||||
|
||||
// 预览阶段就必须失败:不能预览说「就改这些」,落地时才炸。
|
||||
if _, err := Diff(cur, next); !errors.As(err, &conflict) {
|
||||
t.Errorf("Diff 也应报 CompareConflictError,得到 %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("显式 override 写 basis=result 一样被拦下", func(t *testing.T) {
|
||||
patched := *next
|
||||
patched.EventOverrides = append(append([]EventOverride(nil), next.EventOverrides...), EventOverride{
|
||||
EventCode: "EVT_MANUAL",
|
||||
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisResult, Profile: "individual_top6"}}),
|
||||
})
|
||||
var conflict *CompareConflictError
|
||||
if _, _, err := resolveTargets(cur, &patched); !errors.As(err, &conflict) {
|
||||
t.Errorf("override 显式写了 basis=result 也应拦下,得到 %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("override 改成 basis=manual 即放行", func(t *testing.T) {
|
||||
patched := *next
|
||||
patched.EventOverrides = append(append([]EventOverride(nil), next.EventOverrides...), EventOverride{
|
||||
EventCode: "EVT_MANUAL",
|
||||
Channels: ChannelListPtr([]Channel{{Scope: domain.ScopeAthlete, Basis: domain.BasisManual, Profile: "individual_top6"}}),
|
||||
})
|
||||
targets, _, err := resolveTargets(cur, &patched)
|
||||
if err != nil {
|
||||
t.Fatalf("basis=manual 不该被拦:%v", err)
|
||||
}
|
||||
if got := targets["EVT_MANUAL"]; len(got) != 1 || got[0].Basis != domain.BasisManual {
|
||||
t.Errorf("EVT_MANUAL 的目标通道 = %+v,期望一条 basis=manual", got)
|
||||
}
|
||||
// 规则包没覆盖到的其它小项不受影响。
|
||||
if got := targets["EVT_IND"]; len(got) != 1 || got[0].Basis != domain.BasisResult {
|
||||
t.Errorf("EVT_IND 的目标通道 = %+v,期望模板给的 basis=result", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("不计分的小项不参与校验", func(t *testing.T) {
|
||||
unscored := currentSnapshot(t)
|
||||
unscored.Events = append(unscored.Events, EventInfo{
|
||||
ID: 10, Code: "EVT_OFF", Name: "不计分手工名次",
|
||||
EventType: domain.EventTypeIndividual, ResultType: domain.ResultTypeNone,
|
||||
Compare: domain.CompareNone, Scored: false, Multiplier: 1,
|
||||
})
|
||||
targets, _, err := resolveTargets(unscored, next)
|
||||
if err != nil {
|
||||
t.Fatalf("只排名不计分的小项不该被拦:%v", err)
|
||||
}
|
||||
if got, ok := targets["EVT_OFF"]; !ok || len(got) != 0 {
|
||||
t.Errorf("不计分小项的目标通道 = %+v,期望空", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 「个人+团体」模板:match.eventType 支持 individual_team,并按 athlete + team 两条通道展开。
|
||||
// 校验层不认识这个取值时会直接报错,因此 Parse 成功本身就是「不报未知类型」的断言。
|
||||
// 这也是默认双计模板:个人通道按个人成绩排名且不计入单位总分,团体通道按平均成绩计分。
|
||||
|
||||
Reference in new issue
Block a user