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