## 需求(主人原话)
> 记分通道团体名次的名次来源缺一个模式,添加一个「按队内最低分」。
> 有一个特殊项目是取团队的最后一人的成绩(最后一名冲线)来排名的。
## 口径(这里最容易写反,所以定死)
新增 `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` 两张)。
219 lines
9.2 KiB
Go
219 lines
9.2 KiB
Go
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(®istered); 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)
|
||
}
|
||
}
|