Files
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

246 lines
9.4 KiB
Go

package rules
import (
"database/sql"
"errors"
"strings"
"testing"
"git.yeij.top/AskaEth/Scoreboard/server/internal/domain"
)
// 聚合口径(basis=aggregate 的 avg / sum / worst)在规则包这条路上的口径:
// 解析认 avg/sum/worst、缺省即 avg;导入落库补成具体值;导出只写非缺省值(sum / worst),
// 规则包里显式写 avg 与不写等价,不该被当成一次「通道改动」。
//
// 现场背景:团体引体向上这类双计项目的团体通道,领导还没定按平均还是按总和,
// 两种都要能在规则包里表达,且默认仍是升级前的平均值口径;越野 / 行军这类
// 「取团队最后一人的成绩」的项目要能写 worst。
const aggregateModePackYAML = `format: scoreboard-rules
formatVersion: 1
meta: { code: demo-rules, name: 示例规则包 }
standings: { individualToTeamWeight: 1, tieBreak: [points, gold, silver, bronze, teamCode] }
profiles:
- code: team_top6
name: 团体项目前六名
awardRanks: 2
tieMode: skip
rules:
- { rank: 1, points: 15 }
- { rank: 2, points: 10 }
recordBonus: { enabled: false, mode: max }
manualAdjustment: { enabled: false, reasonRequired: true }
channelTemplates:
- match: { eventType: individual }
channels:
- { scope: athlete, basis: result, profile: team_top6 }
eventOverrides:
- eventCode: EVT_SUM
name: 团体引体向上(总和)
channels:
- { scope: team, basis: aggregate, profile: team_top6, aggregateMode: sum }
- eventCode: EVT_AVG
name: 团体引体向上(平均,显式写缺省值)
channels:
- { scope: team, basis: aggregate, profile: team_top6, aggregateMode: avg }
- eventCode: EVT_WORST
name: 5 公里越野(队内最差)
channels:
- { scope: team, basis: aggregate, profile: team_top6, aggregateMode: worst }
`
// seedAggregateEvents 建三个可排名的团体小项(compare 必须有方向,否则 aggregate 通道用不了)。
func seedAggregateEvents(t *testing.T, db *sql.DB) {
t.Helper()
sportID := insertID(t, db, `INSERT INTO sports (code, name) VALUES ('demo-sport', '示例大项')`)
for _, ev := range []struct{ code, name string }{
{"EVT_SUM", "团体引体向上(总和)"},
{"EVT_AVG", "团体引体向上(平均,显式写缺省值)"},
{"EVT_WORST", "5 公里越野(队内最差)"},
} {
if _, err := db.Exec(`INSERT INTO events (sport_id, code, name, event_type, result_mode, result_type, compare, scored, multiplier)
VALUES (?, ?, ?, 'individual_team', 'auto', 'count', 'desc', 1, 1)`, sportID, ev.code, ev.name); err != nil {
t.Fatalf("插入项目 %s: %v", ev.code, err)
}
}
}
// TestParseAggregateMode 守解析:avg / sum 都认,缺省即 avg,拼错的取值必须报错。
func TestParseAggregateMode(t *testing.T) {
pack, err := Parse([]byte(aggregateModePackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
overrides := map[string]Channel{}
for _, ov := range pack.EventOverrides {
overrides[ov.EventCode] = (*ov.Channels)[0]
}
if got := overrides["EVT_SUM"].AggregatesBy(); got != domain.AggregateSum {
t.Errorf("aggregateMode: sum 应解析成 sum,实际 %q", got)
}
// 显式写 avg 与不写等价(缺省值),解析结果都要是 avg。
if got := overrides["EVT_AVG"].AggregatesBy(); got != domain.AggregateAvg {
t.Errorf("aggregateMode: avg 应解析成 avg,实际 %q", got)
}
// worst = 按队内最差排名(方向随小项的 compare 走),取值本身不带方向,别读成 min。
if got := overrides["EVT_WORST"].AggregatesBy(); got != domain.AggregateWorst {
t.Errorf("aggregateMode: worst 应解析成 worst,实际 %q", got)
}
// 普通通道(basis=result)不写该字段时同样是缺省 avg,不影响任何行为。
tmpl := pack.ChannelTemplates[0].Channels[0]
if tmpl.AggregateMode != "" || tmpl.AggregatesBy() != domain.AggregateAvg {
t.Errorf("缺省通道的汇总口径应为空(等价 avg),实际 %q", tmpl.AggregateMode)
}
// 拼错的取值必须报错:静默按 avg 处理会让现场以为切到了总和。
bad := strings.Replace(aggregateModePackYAML, "aggregateMode: sum", "aggregateMode: biggest", 1)
if _, err := Parse([]byte(bad)); !errors.Is(err, ErrValidation) {
t.Fatalf("非法聚合口径应报校验错误,实际 %v", err)
} else if !strings.Contains(err.Error(), "aggregateMode") {
t.Errorf("报错应点明 aggregateMode 字段,实际 %v", err)
}
// 「取最小」不能当取值写:worst 的方向由小项的 compare 决定,写 min 的项目一半会算反,
// 所以 min / max 都必须是**非法取值**(报错让人改用 worst),而不是被静默当成某个方向。
for _, wrong := range []string{"min", "max"} {
bad := strings.Replace(aggregateModePackYAML, "aggregateMode: worst", "aggregateMode: "+wrong, 1)
if _, err := Parse([]byte(bad)); !errors.Is(err, ErrValidation) {
t.Errorf("aggregateMode: %s 应报校验错误(改用 worst),实际 %v", wrong, err)
}
}
}
// TestApplyAndExportAggregateMode 守落库与导出:
// sum 落库并导出回来;显式 avg 落库成 avg、导出时按缺省省略(导出 → 导入不产生多余差异)。
func TestApplyAndExportAggregateMode(t *testing.T) {
db := openEventDB(t)
seedAggregateEvents(t, db)
next, err := Parse([]byte(aggregateModePackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
modeOf := func(code string) string {
t.Helper()
var mode string
if err := db.QueryRow(`SELECT c.aggregate_mode FROM event_score_channels c
JOIN events e ON e.id = c.event_id WHERE e.code = ? AND c.basis = 'aggregate'`, code).Scan(&mode); err != nil {
t.Fatalf("读取 %s 的聚合口径: %v", code, err)
}
return mode
}
if got := modeOf("EVT_SUM"); got != string(domain.AggregateSum) {
t.Errorf("EVT_SUM 落库 = %q,期望 sum", got)
}
// 缺省口径也落成具体值 avg,引擎侧不必再判空。
if got := modeOf("EVT_AVG"); got != string(domain.AggregateAvg) {
t.Errorf("EVT_AVG 落库 = %q,期望 avg", got)
}
if got := modeOf("EVT_WORST"); got != string(domain.AggregateWorst) {
t.Errorf("EVT_WORST 落库 = %q,期望 worst", got)
}
exported, err := Export(db, Meta{Code: "demo", Name: "导出"})
if err != nil {
t.Fatalf("Export: %v", err)
}
exportedMode := map[string]string{}
for _, ov := range exported.EventOverrides {
exportedMode[ov.EventCode] = (*ov.Channels)[0].AggregateMode
}
// sum / worst 都是非缺省口径,必须写进导出文件,否则「导出 → 导入」会把它们丢成平均。
if exportedMode["EVT_SUM"] != string(domain.AggregateSum) {
t.Errorf("EVT_SUM 导出 = %q,期望 sum", exportedMode["EVT_SUM"])
}
if exportedMode["EVT_WORST"] != string(domain.AggregateWorst) {
t.Errorf("EVT_WORST 导出 = %q,期望 worst", exportedMode["EVT_WORST"])
}
// avg 是缺省值,导出文件里不出现,往返比对不会凭空多出差异。
if exportedMode["EVT_AVG"] != "" {
t.Errorf("EVT_AVG 导出 = %q,期望省略(avg 是缺省值)", exportedMode["EVT_AVG"])
}
// 导出的包重新导入:库里已有 sum / avg,差异必须为空。
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
reparsed, err := Parse(mustMarshal(t, exported))
if err != nil {
t.Fatalf("重新 Parse 导出结果: %v", err)
}
cs, err := Diff(cur, reparsed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
if !cs.Empty() {
t.Fatalf("导出再导入不应有差异: %+v", cs.Channels)
}
}
// TestDiffIgnoresExplicitDefaultAggregateMode 守差异比对:
// 规则包显式写 avg 与库里缺省值等价,不能被当成「通道改动」而触发一次无意义的重算。
func TestDiffIgnoresExplicitDefaultAggregateMode(t *testing.T) {
db := openEventDB(t)
seedAggregateEvents(t, db)
next, err := Parse([]byte(aggregateModePackYAML))
if err != nil {
t.Fatalf("Parse: %v", err)
}
if _, err := Apply(db, &ChangeSet{Next: next}); err != nil {
t.Fatalf("Apply: %v", err)
}
cur, err := Load(db)
if err != nil {
t.Fatalf("Load: %v", err)
}
// 库里 EVT_AVG 是 avg(导出时按缺省省略),规则包再显式写一遍 avg:语义相同,不该有差异。
if cs, err := Diff(cur, next); err != nil {
t.Fatalf("Diff: %v", err)
} else if !cs.Empty() {
t.Fatalf("显式 avg 与缺省 avg 等价,不应有差异: %+v", cs.Channels)
}
// 对照:把 EVT_AVG 换成 sum 就必须报出一次通道改动(否则现场改了口径却不重算)。
target := strings.Replace(aggregateModePackYAML,
"{ scope: team, basis: aggregate, profile: team_top6, aggregateMode: avg }",
"{ scope: team, basis: aggregate, profile: team_top6, aggregateMode: sum }", 1)
changed, err := Parse([]byte(target))
if err != nil {
t.Fatalf("Parse 改口径后的规则包: %v", err)
}
cs, err := Diff(cur, changed)
if err != nil {
t.Fatalf("Diff: %v", err)
}
hit := false
for _, ch := range cs.Channels {
if ch.EventCode == "EVT_AVG" {
hit = true
}
}
if !hit {
t.Fatalf("把口径从 avg 改成 sum 必须报出通道改动,实际 %+v", cs.Channels)
}
}
// insertID 执行一条 INSERT 并返回自增 id。
func insertID(t *testing.T, db *sql.DB, query string, args ...any) int64 {
t.Helper()
res, err := db.Exec(query, args...)
if err != nil {
t.Fatalf("执行 SQL 失败: %v\n%s", err, query)
}
id, err := res.LastInsertId()
if err != nil {
t.Fatalf("获取 id: %v", err)
}
return id
}