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` 两张)。
This commit is contained in:
1 parent
a85115c96e
commit
48208f395b
30 files changed
+1394
-85
No files matched your search
@@ -22,11 +22,16 @@ test('聚合口径缺字段 / 认不出的取值一律按 avg(老数据的口
|
||||
assert.equal(DEFAULT_AGGREGATE_MODE, 'avg')
|
||||
assert.equal(normalizeAggregateMode('sum'), 'sum')
|
||||
assert.equal(normalizeAggregateMode('avg'), 'avg')
|
||||
assert.equal(normalizeAggregateMode('worst'), 'worst')
|
||||
// 老服务端不下发该字段(缺省值不下发),旧缓存里的脏值同样按缺省处理。
|
||||
assert.equal(normalizeAggregateMode(undefined), 'avg')
|
||||
assert.equal(normalizeAggregateMode(null), 'avg')
|
||||
assert.equal(normalizeAggregateMode('SUM'), 'avg')
|
||||
assert.equal(normalizeAggregateMode('bogus'), 'avg')
|
||||
// 「取最小 / 取最大」不是取值:方向由小项的 compare 决定,写成 min / max 必须落回缺省,
|
||||
// 而不是被当成某个方向(那会让一半的项目算反,且页面上看不出来)。
|
||||
assert.equal(normalizeAggregateMode('min'), 'avg')
|
||||
assert.equal(normalizeAggregateMode('max'), 'avg')
|
||||
})
|
||||
|
||||
test('新增通道行默认按成员平均(avg),要用总和时现场切', () => {
|
||||
@@ -42,9 +47,12 @@ test('通道编辑器读写聚合口径:存库值回填、保存时随行回
|
||||
})
|
||||
|
||||
test('聚合口径是「一点即切」的显眼控件,且只在聚合通道上出现', () => {
|
||||
// 两个口径做成按钮组(不是下拉),现场一眼看到当前口径、一下点过去。
|
||||
// 三个口径做成按钮组(不是下拉),现场一眼看到当前口径、一下点过去。
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'avg', label: '平均', hint: /)
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'sum', label: '总和', hint: /)
|
||||
// 第三个口径的选项写成多行(提示文案长),两段分开断言,别把格式绑死在一条正则上。
|
||||
assert.match(CHANNELS_SOURCE, /value: 'worst'/)
|
||||
assert.match(CHANNELS_SOURCE, /label: '队内最低分'/)
|
||||
assert.match(CHANNELS_SOURCE, /class="agg-mode-btn"/)
|
||||
assert.match(CHANNELS_SOURCE, /:class="\{ 'is-active': row\.aggregateMode === option\.value \}"/)
|
||||
assert.match(CHANNELS_SOURCE, /@click="row\.aggregateMode = option\.value"/)
|
||||
@@ -53,3 +61,18 @@ test('聚合口径是「一点即切」的显眼控件,且只在聚合通道
|
||||
// 表头要让人找到它。
|
||||
assert.match(CHANNELS_SOURCE, /<th class="bd-select">聚合口径<\/th>/)
|
||||
})
|
||||
|
||||
test('新增的「队内最低分」在界面上说清方向,不把英文取值甩给操作员', () => {
|
||||
// 按钮文案是中文口径名,不是 worst;提示里必须两种方向都写出来
|
||||
// (升序 = 最后一名冲线、降序 = 做得最少的那个),否则操作员会按字面理解成「取最小的那个」。
|
||||
assert.match(CHANNELS_SOURCE, /label: '队内最低分'/)
|
||||
assert.match(CHANNELS_SOURCE, /最后一名冲线/)
|
||||
assert.match(CHANNELS_SOURCE, /做得最少的那个/)
|
||||
assert.doesNotMatch(CHANNELS_SOURCE, /label: 'worst'/)
|
||||
// 行内小结按口径给整句话,三个口径都要有(切到 worst 时不能显示平均那句)。
|
||||
assert.match(CHANNELS_SOURCE, /const AGGREGATE_MODE_SUMMARIES: Record<AggregateMode, string>/)
|
||||
assert.match(CHANNELS_SOURCE, /worst: '团体成绩 = 该单位成员里最差的那一份/)
|
||||
assert.match(CHANNELS_SOURCE, /\{\{ AGGREGATE_MODE_SUMMARIES\[row\.aggregateMode\] \}\}/)
|
||||
// 表格上方那段说明也要提到第三个口径,否则现场只看到按钮不知道它是干什么的。
|
||||
assert.match(CHANNELS_SOURCE, /平均 \/ 总和 \/ 队内最低分/)
|
||||
})
|
||||
@@ -0,0 +1,180 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { Event, ScoreChannel, ScoringProfile } from '@/shared'
|
||||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||||
|
||||
// 聚合口径的第三个取值 worst(按队内最差 / 最后一名冲线)在录入页预览引擎里的口径。
|
||||
//
|
||||
// 预览与服务端 domain/scoring.go 的 scoreByAggregate 必须**同一口径**:下面每一组用例的
|
||||
// 输入与期望值都与 server/internal/domain/aggregate_worst_test.go 里同款用例逐条对应
|
||||
// (同一组成绩、同一份积分表 15/10/8),两边算出来的**名次与分**要对得上。
|
||||
//
|
||||
// 最容易搞反的一点:worst 取的到底是最大还是最小,**取决于小项的 compare 方向**——
|
||||
// asc(时间,越小越好)取最大(最后一名冲线);desc(次数,越大越好)取最小(做得最少的那个)。
|
||||
|
||||
function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
return {
|
||||
id: 1,
|
||||
sportId: 1,
|
||||
code: 'TEAM_PULLUP',
|
||||
name: '团体引体向上',
|
||||
eventType: 'team',
|
||||
resultMode: 'auto',
|
||||
resultType: 'count',
|
||||
compare: 'desc',
|
||||
recordEligible: false,
|
||||
scored: true,
|
||||
awardMedals: true,
|
||||
multiplier: 1,
|
||||
status: 'pending',
|
||||
scheduledAt: null,
|
||||
venue: null,
|
||||
sortOrder: 1,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeProfile(): ScoringProfile {
|
||||
return {
|
||||
id: 2,
|
||||
code: 'team_top6',
|
||||
name: '团体项目前六名',
|
||||
tieMode: 'skip',
|
||||
recordBonusMode: 'max',
|
||||
rules: [
|
||||
{ rank: 1, points: 15 },
|
||||
{ rank: 2, points: 10 },
|
||||
{ rank: 3, points: 8 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function makeChannel(aggregateMode: 'avg' | 'sum' | 'worst' | undefined): ScoreChannel {
|
||||
return {
|
||||
id: 3,
|
||||
eventId: 1,
|
||||
scope: 'team',
|
||||
basis: 'aggregate',
|
||||
profileId: 2,
|
||||
sortOrder: 1,
|
||||
aggregateMode,
|
||||
countToTeam: true,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRow(entryId: number, resultValue: number | null, teamId: number): ScorableRow {
|
||||
return { entryId, teamId, athleteId: entryId, resultValue, rank: null, status: 'ok', isTie: false }
|
||||
}
|
||||
|
||||
/** 某个单位在这次计分里拿到的团体分(单位总分只来自这条聚合通道),按单位号排序后返回。 */
|
||||
function teamPoints(
|
||||
rows: ScorableRow[],
|
||||
aggregateMode: 'avg' | 'sum' | 'worst' | undefined,
|
||||
avgMode: Event['avgMode'] = 'valid',
|
||||
compare: Event['compare'] = 'desc',
|
||||
): Array<[number, number]> {
|
||||
const context: ScoreContext = {
|
||||
event: makeEvent({ avgMode, compare }),
|
||||
category: null,
|
||||
channels: [makeChannel(aggregateMode)],
|
||||
profiles: [makeProfile()],
|
||||
weight: 1,
|
||||
}
|
||||
const points = computeEventScore(rows, context).teamPoints
|
||||
return [...points.entries()].sort((a, b) => a[0] - b[0])
|
||||
}
|
||||
|
||||
test('worst:asc(时间)取队内**最慢**的那份 —— 最后一名冲线的队伍排前面', () => {
|
||||
// 100 队 12/15/20、101 队 13/14、102 队 30(分钟):队内最慢 20 / 14 / 30。
|
||||
const rows = [
|
||||
makeRow(1, 12, 100),
|
||||
makeRow(2, 15, 100),
|
||||
makeRow(3, 20, 100),
|
||||
makeRow(4, 13, 101),
|
||||
makeRow(5, 14, 101),
|
||||
makeRow(6, 30, 102),
|
||||
]
|
||||
// 越小越好:101(14)第 1、100(20)第 2、102(30)第 3 —— 与 domain 包同名用例逐条一致。
|
||||
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'asc'), [
|
||||
[100, 10],
|
||||
[101, 15],
|
||||
[102, 8],
|
||||
])
|
||||
})
|
||||
|
||||
test('worst:asc 下取最慢 —— 名次与 avg 不同(防止把 worst 当成 avg 的别名)', () => {
|
||||
// 100 队 10/30、101 队 20/21:队内最慢 30 / 21(101 更慢但更稳);平均 20 / 20.5。
|
||||
const rows = [makeRow(1, 10, 100), makeRow(2, 30, 100), makeRow(3, 20, 101), makeRow(4, 21, 101)]
|
||||
// 按队内最慢:101(21)第 1、100(30)第 2。
|
||||
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'asc'), [
|
||||
[100, 10],
|
||||
[101, 15],
|
||||
])
|
||||
// 按平均:100(20)第 1、101(20.5)第 2 —— 正好互换。
|
||||
assert.deepEqual(teamPoints(rows, 'avg', 'valid', 'asc'), [
|
||||
[100, 15],
|
||||
[101, 10],
|
||||
])
|
||||
})
|
||||
|
||||
test('worst:desc(次数)取队内**最少**的那份 —— 与 avg 的名次正好互换', () => {
|
||||
// 100 队 20 + 1 个(平均 10.5、队内最少 1),101 队 5 + 6 个(平均 5.5、队内最少 5)。
|
||||
const rows = [makeRow(1, 20, 100), makeRow(2, 1, 100), makeRow(3, 5, 101), makeRow(4, 6, 101)]
|
||||
// 越大越好:按队内最少算,101(5)第 1、100(1)第 2。
|
||||
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'desc'), [
|
||||
[100, 10],
|
||||
[101, 15],
|
||||
])
|
||||
// 平均口径下 100(10.5)第 1 —— 与 worst 相反,说明方向真的按 compare 取了。
|
||||
assert.deepEqual(teamPoints(rows, 'avg', 'valid', 'desc'), [
|
||||
[100, 15],
|
||||
[101, 10],
|
||||
])
|
||||
})
|
||||
|
||||
test('worst:队内最差相同的两队并列,并列后顺延(与既有并列口径一致)', () => {
|
||||
const rows = [makeRow(1, 10, 100), makeRow(2, 20, 100), makeRow(3, 20, 101), makeRow(4, 25, 102)]
|
||||
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'asc'), [
|
||||
[100, 15],
|
||||
[101, 15],
|
||||
[102, 8],
|
||||
])
|
||||
})
|
||||
|
||||
test('worst:一个有效成绩都没有的单位不参与排名(与 avg 的 valid 口径同一条规则)', () => {
|
||||
const rows = [makeRow(1, 12, 100), makeRow(2, 20, 100), makeRow(3, null, 101), makeRow(4, null, 101)]
|
||||
// asc 下 101 若按 0 参与会白拿第 1 名(0 是最快的),所以 valid 与 all 都不参与。
|
||||
for (const avgMode of ['valid', 'all'] as const) {
|
||||
assert.deepEqual(teamPoints(rows, 'worst', avgMode, 'asc'), [[100, 15]])
|
||||
}
|
||||
// 对照:avg 的 all 口径下零成绩单位按 0 参与(既有口径,不是 worst 的)。
|
||||
assert.deepEqual(teamPoints(rows, 'avg', 'all', 'asc'), [
|
||||
[100, 10],
|
||||
[101, 15],
|
||||
])
|
||||
})
|
||||
|
||||
test('worst:弃权 / 失格的人的成绩不算数(队内最差只在实际出赛的人里取)', () => {
|
||||
// 100 队:5、6 个,另有一人弃赛(那一格写了 0)。101 队:4、9 个。
|
||||
const scratched: ScorableRow = { ...makeRow(3, 0, 100), status: 'withdrawn' }
|
||||
const rows = [makeRow(1, 5, 100), makeRow(2, 6, 100), scratched, makeRow(4, 4, 101), makeRow(5, 9, 101)]
|
||||
// 越大越好:100 队队内最少 5 > 101 队队内最少 4 —— 100 第 1、101 第 2。
|
||||
assert.deepEqual(teamPoints(rows, 'worst', 'valid', 'desc'), [
|
||||
[100, 15],
|
||||
[101, 10],
|
||||
])
|
||||
// 两处判别力:① 把弃赛那条的 0 算进来,100 队就垫底(0 是最少的);② 按平均算,101(6.5)反而第一。
|
||||
assert.deepEqual(teamPoints(rows, 'avg', 'valid', 'desc'), [
|
||||
[100, 10],
|
||||
[101, 15],
|
||||
])
|
||||
})
|
||||
|
||||
test('worst:缺省仍是 avg(老数据 / 老服务端不下发该字段时口径一字不变)', () => {
|
||||
const rows = [makeRow(1, 20, 100), makeRow(2, 1, 100), makeRow(3, 5, 101), makeRow(4, 6, 101)]
|
||||
assert.deepEqual(teamPoints(rows, undefined), teamPoints(rows, 'avg'))
|
||||
// 与 worst 明显不同,证明确实落在 avg 一侧,而不是「怎么算都行」。
|
||||
assert.notDeepEqual(teamPoints(rows, undefined, 'valid', 'desc'), teamPoints(rows, 'worst', 'valid', 'desc'))
|
||||
})
|
||||
Reference in new issue
Block a user