Files
Scoreboard/server/migrations/event/migration_0027_test.go
T
AskaEth 48208f395b feat(scoring): 新增「队内最差(最后一名冲线)」聚合口径;版本 dev_0.5.11
## 需求(主人原话)
> 记分通道团体名次的名次来源缺一个模式,添加一个「按队内最低分」。
> 有一个特殊项目是取团队的最后一人的成绩(最后一名冲线)来排名的。

## 口径(这里最容易写反,所以定死)
新增 `aggregate_mode = 'worst'`。**不叫 `min`/`max`** —— 它的方向是**派生**的,叫 `min` 会让
「越大越好」的那半项目被写错。**跟着项目的比较方向自动取**:

| 项目 `compare` | 含义 | 队内最差 = |
| --- | --- | --- |
| `asc`(越小越好,如时间) | 行军 / 越野 | **max** = 最后一名冲线的那个人 |
| `desc`(越大越好,如次数) | 引体向上 | **min** = 做得最少的那个 |

方向判据**各只有一处**:Go 的 `domain.worseValue`、前端预览的 `worseThan`,
两边都有 asc/desc 双向用例钉着。规则包里写 `min`/`max` 会被校验**拦下并报错**。

## 改动
- **迁移 `0027`**:SQLite 改不了已有的 `CHECK`,按官方做法**重建 `event_score_channels`**,
  取值放开到 `avg/sum/worst`;⚠️ 重建时**先把子表 `channel_ranks` 寄存到临时表再原样灌回**
  (父表被 DROP 会级联清空名次 —— 这一步漏了就会把已发布的名次全丢)。
- **引擎**:`scoreByAggregate` 支持第三种口径。
- **接口 / 规则包**:读、写、校验、快照、导出五处都认第三个取值。
- **界面**:「聚合口径」由**两个按钮变三个**(平均 / 总和 / **队内最低分**),
  提示文案写明两种方向,不丢一个 `worst` 让人猜。
- **前端预览同步**(`web/src/admin/api/scoring.ts`)—— 前一版刚修完「预览与后端算分不一致」,
  这次不允许再开一个新口子。
- 文档:`docs/使用手册.md`、`docs/升级说明.md`、`devdocs/18` 各补一处。

## 两条边界(都是刻意的)
- **弃权 / 失格不算参与,且它的成绩不代表队伍**:队里 5 / 6 / 弃赛(0) → 队内最差是 **5**,不是 0。
  否则谁都可以挂一个弃赛的人在队里垫底。
- **一个有效成绩都没有的主体一律不参与排名**(`avg_mode` 在这一口径下不起作用)。
  若按 0 参与:`asc` 里 0 是全场最快、`desc` 里 0 是全场最差 —— 两边都会让没完赛的队白拿名次。

## 验证
- **老口径逐字节不变**:新增 golden `TestAggregateModesLegacyUnchanged`(`avg` / `sum` 结果与改动前一致)。
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **953 例** 0 fail / Console 96 例 0 fail。
- 迁移用例覆盖:老行原样、子表名次一行不少、id 不漂、非法值仍拒、幂等、级联删除仍在。

## 版本
`dev_0.5.10` → **`dev_0.5.11`**(含数据迁移 `0027`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-16 23:55:12 +08:00

219 lines
9.2 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 event
import (
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/store"
)
// 0027:聚合通道的汇总口径放开到第三个取值 worst(按队内最差排名)。
//
// 五条底线:
// - 老数据零变化:每条老通道的 aggregate_mode 原样保留(0023 的默认 avg 与后配的 sum),
// 其它列一列不改、行数一行不少;
// - **子表 channel_ranks 的名次一行不少**(这条迁移要重建父表,父表被 DROP 时会级联清空子表,
// 必须原样灌回);
// - id 不能漂:老通道的 id 是 channel_ranks 的外键,换号就等于把名次全删了;
// - 取值放开到 avg / sum / worst,拼错的仍被 CHECK 挡下;
// - 幂等:连跑两次不报错、不重复登记。
func TestMigration0027ChannelAggregateModeWorst(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0027") // 回到「升级前」:库结构到 0026 为止
stmts := []string{
`INSERT INTO teams(code, name) VALUES ('T1', '第一分公司')`,
`INSERT INTO sports(code, name) VALUES ('SP1', '田径')`,
// 升序项目(时间,越小越好):最后一名冲线要用它。
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES (1, 'MARCH', '5 公里越野', 'team', 'auto', 'time', 'asc', 1, 1, 'ongoing')`,
`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`,
// 三条老通道:缺省 avg、显式 sum、以及一条非聚合通道(它也有这列,同样要原样留着)。
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode)
VALUES (1, 'team', 'aggregate', 1, 1, 'avg')`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode)
VALUES (1, 'athlete', 'aggregate', 1, 2, 'sum')`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order)
VALUES (1, 'team', 'manual', 1, 3)`,
// 子表里两条名次:重建父表时必须原样回来(挂的是通道 2)。
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank, points, result_value)
VALUES (1, 1, 2, 'aggregate', 'team', 't:1', 1, 15, 12.5)`,
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank, points, result_value)
VALUES (1, 1, 2, 'aggregate', 'team', 't:2', 2, 10, 13)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造老库数据 %q: %v", s, err)
}
}
// 升级前:worst 这一取值还不该被接受。
if _, err := db.Exec(`UPDATE event_score_channels SET aggregate_mode = 'worst' WHERE id = 1`); err == nil {
t.Fatal("升级前不应接受 aggregate_mode = 'worst'")
}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级赛事库: %v", err)
}
// 老行逐列原样(id 尤其不能漂:channel_ranks 的外键挂在它上面)。
want := []struct {
id int64
scope string
basis string
mode string
sortOrd int
}{
{1, "team", "aggregate", "avg", 1},
{2, "athlete", "aggregate", "sum", 2},
{3, "team", "manual", "avg", 3},
}
rows, err := db.Query(`SELECT id, scope, basis, aggregate_mode, sort_order FROM event_score_channels ORDER BY id`)
if err != nil {
t.Fatalf("读取升级后的通道: %v", err)
}
var seen []struct {
id int64
scope string
basis string
mode string
sortOrd int
}
for rows.Next() {
var got struct {
id int64
scope string
basis string
mode string
sortOrd int
}
if err := rows.Scan(&got.id, &got.scope, &got.basis, &got.mode, &got.sortOrd); err != nil {
_ = rows.Close()
t.Fatalf("扫描通道: %v", err)
}
seen = append(seen, got)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
t.Fatalf("遍历通道: %v", err)
}
_ = rows.Close()
if len(seen) != len(want) {
t.Fatalf("通道数 = %d,期望 %d:%+v", len(seen), len(want), seen)
}
for i := range want {
if seen[i] != want[i] {
t.Fatalf("第 %d 条通道 = %+v,期望 %+v", i+1, seen[i], want[i])
}
}
// 子表名次一行不少、内容一字不差。
var ranks int
if err := db.QueryRow(`SELECT COUNT(*) FROM channel_ranks`).Scan(&ranks); err != nil {
t.Fatalf("统计通道名次: %v", err)
}
if ranks != 2 {
t.Fatalf("channel_ranks 行数 = %d,期望 2(重建父表时被级联清空了?)", ranks)
}
var subjectKey, basis, scope string
var rank int
var points, resultValue float64
if err := db.QueryRow(`SELECT subject_key, basis, scope, rank, points, result_value FROM channel_ranks
WHERE channel_id = 2 AND rank = 1`).
Scan(&subjectKey, &basis, &scope, &rank, &points, &resultValue); err != nil {
t.Fatalf("读取保留下来的通道名次: %v", err)
}
if subjectKey != "t:1" || basis != "aggregate" || scope != "team" || rank != 1 || points != 15 || resultValue != 12.5 {
t.Fatalf("通道名次被改动了:%s/%s/%s rank=%d points=%v value=%v", subjectKey, basis, scope, rank, points, resultValue)
}
// 外键仍然指着同一张表:级联删除还要照旧生效。
var channelRanksDDL string
if err := db.QueryRow(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'channel_ranks'`).Scan(&channelRanksDDL); err != nil {
t.Fatalf("读取 channel_ranks 定义: %v", err)
}
if !strings.Contains(channelRanksDDL, "REFERENCES event_score_channels(id) ON DELETE CASCADE") {
t.Fatalf("channel_ranks 对通道的外键定义被改动了:\n%s", channelRanksDDL)
}
// 取值放开:worst 能写进去,拼错的仍被挡下。
if _, err := db.Exec(`UPDATE event_score_channels SET aggregate_mode = 'worst' WHERE id = 1`); err != nil {
t.Fatalf("升级后应能写入 aggregate_mode = 'worst': %v", err)
}
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode)
VALUES (1, 'athlete', 'manual', 1, 4, 'biggest')`); err == nil {
t.Fatal("非法 aggregate_mode 应被 CHECK 拒绝")
}
// 新增通道时自增号接着往下走(重建表不能把 id 序列重置成 1,那会与既有名次撞号)。
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order)
VALUES (1, 'athlete', 'manual', 1, 4)`); err != nil {
t.Fatalf("新增通道: %v", err)
}
var maxID int64
if err := db.QueryRow(`SELECT MAX(id) FROM event_score_channels`).Scan(&maxID); err != nil {
t.Fatalf("读取通道最大 id: %v", err)
}
if maxID != 4 {
t.Fatalf("新增通道的 id = %d,期望 4(自增号应接着老数据往下走)", maxID)
}
// 幂等:再跑一次不报错、登记仍只有一条。
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("重复升级应无副作用: %v", err)
}
var registered, total int
if err := db.QueryRow(`SELECT COUNT(*) FROM schema_migrations WHERE name = '0027_channel_aggregate_mode_worst.sql'`).Scan(&registered); err != nil {
t.Fatalf("查询迁移登记: %v", err)
}
if registered != 1 {
t.Fatalf("迁移登记数 = %d,期望 1", registered)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM event_score_channels`).Scan(&total); err != nil {
t.Fatalf("统计通道: %v", err)
}
if total != 4 {
t.Fatalf("重复升级后通道数 = %d,期望 4", total)
}
}
// 重建父表最容易出的事故是「外键与级联被改坏」:空子表也要能照常写名次、照常级联删除。
func TestMigration0027ForeignKeyStillCascades(t *testing.T) {
db := openEventDB(t)
applyBefore(t, db, "0027")
stmts := []string{
`INSERT INTO sports(code, name) VALUES ('SP9', '测试')`,
`INSERT INTO events(sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier, status)
VALUES ((SELECT id FROM sports WHERE code = 'SP9'), 'X', 'x', 'team', 'auto', 'time', 'asc', 1, 1, 'ongoing')`,
`INSERT INTO event_categories(event_id, name, multiplier, sort_order) VALUES (1, '男子', 1, 0)`,
`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode)
VALUES (1, 'team', 'aggregate', 1, 1, 'avg')`,
`INSERT INTO channel_ranks(event_id, category_id, channel_id, basis, scope, subject_key, rank, points)
VALUES (1, 1, 1, 'aggregate', 'team', 't:1', 1, 15)`,
}
for _, s := range stmts {
if _, err := db.Exec(s); err != nil {
t.Fatalf("造老库数据 %q: %v", s, err)
}
}
if err := store.MigrateEvent(db); err != nil {
t.Fatalf("升级赛事库: %v", err)
}
// 新口径的通道能照常落库。
if _, err := db.Exec(`INSERT INTO event_score_channels(event_id, scope, basis, profile_id, sort_order, aggregate_mode)
VALUES (1, 'athlete', 'aggregate', 1, 2, 'worst')`); err != nil {
t.Fatalf("写入 worst 通道: %v", err)
}
// 级联删除照旧:删掉通道 1,挂它的名次跟着没。
if _, err := db.Exec(`DELETE FROM event_score_channels WHERE id = 1`); err != nil {
t.Fatalf("删除通道: %v", err)
}
var left int
if err := db.QueryRow(`SELECT COUNT(*) FROM channel_ranks WHERE channel_id = 1`).Scan(&left); err != nil {
t.Fatalf("统计级联后的名次: %v", err)
}
if left != 0 {
t.Fatalf("重建后级联删除失效:通道删了还留着 %d 条名次", left)
}
}