Files
Scoreboard/web/src/shared/tests/rules.test.ts
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

69 lines
3.0 KiB
TypeScript
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.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { normalizeRulePack } from '../rules'
// 「个人+团体」是服务端新增的小项类型;规则包模板按 eventType 匹配它时必须原样保留,
// 否则前端归一化会把 match.eventType 丢掉,模板会退化成「匹配全部项目」。
test('规则包模板保留 eventType: individual_team', () => {
const pack = normalizeRulePack({
format: 'scoreboard-rules',
formatVersion: 1,
meta: { code: 'demo', name: '示例规则' },
profiles: [{ code: 'individual_top6', name: '个人项目前六名', awardRanks: 1, tieMode: 'skip', rules: [{ rank: 1, points: 10 }] }],
channelTemplates: [
{
match: { eventType: 'individual_team' },
channels: [
{ scope: 'athlete', basis: 'result', profile: 'individual_top6' },
{ scope: 'team', basis: 'manual', profile: 'individual_top6' },
],
},
],
})
assert.ok(pack !== null)
assert.equal(pack.channelTemplates[0]?.match.eventType, 'individual_team')
assert.equal(pack.channelTemplates[0]?.channels.length, 2)
})
test('规则包模板仍接受原有三种类型', () => {
for (const eventType of ['individual', 'team', 'match'] as const) {
const pack = normalizeRulePack({
format: 'scoreboard-rules',
formatVersion: 1,
meta: { code: 'demo', name: '示例规则' },
profiles: [{ code: 'p', name: '模板', awardRanks: 1, tieMode: 'skip', rules: [{ rank: 1, points: 1 }] }],
channelTemplates: [{ match: { eventType }, channels: [{ scope: 'athlete', basis: 'result', profile: 'p' }] }],
})
assert.ok(pack !== null)
assert.equal(pack.channelTemplates[0]?.match.eventType, eventType)
}
})
// 规则包里显式写的汇总口径要落到通道对象上(缺省 = 不写 = avg,导出页才能如实显示原文口径);
// 认不出的取值不落到对象上,也不会把 avg / sum / worst 之外的东西带进写请求。
test('规则包通道保留 aggregateMode:avg / sum / worst 都认,缺省不写', () => {
const build = (aggregateMode: unknown) =>
normalizeRulePack({
format: 'scoreboard-rules',
formatVersion: 1,
meta: { code: 'demo', name: '示例规则' },
profiles: [{ code: 'p', name: '模板', awardRanks: 1, tieMode: 'skip', rules: [{ rank: 1, points: 1 }] }],
channelTemplates: [
{ match: { eventType: 'team' }, channels: [{ scope: 'team', basis: 'aggregate', profile: 'p', aggregateMode }] },
],
})
for (const mode of ['avg', 'sum', 'worst'] as const) {
const pack = build(mode)
assert.ok(pack !== null)
assert.equal(pack.channelTemplates[0]?.channels[0]?.aggregateMode, mode)
}
// 缺省(不写)与认不出的取值都不落到对象上:既不凭空写缺省值,也不把 min / max 当取值带过去。
for (const missing of [undefined, '', 'min', 'max', 'bogus']) {
const pack = build(missing)
assert.ok(pack !== null)
assert.equal(pack.channelTemplates[0]?.channels[0]?.aggregateMode, undefined)
}
})