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
@@ -298,31 +298,40 @@ function sortManual(cands: Candidate[]): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合通道:先把同一主体的成绩汇总(avg 取平均 / sum 求和),再按汇总值排名
|
||||
* 聚合通道:先把同一主体的成绩汇总(avg 取平均 / sum 求和 / worst 取队内最差),再按汇总值排名
|
||||
* (scoring.go scoreByAggregate)。
|
||||
*
|
||||
* 汇总口径来自通道的 aggregateMode(缺省 avg);平均值分母由 avgMode 决定:
|
||||
* valid 只算有有效成绩的人;all 把该主体的全部报名项都算进去,没成绩的按 0 计入。
|
||||
* sum 只累加有效成绩,avgMode 在这里只决定「一个有效成绩都没有的主体算不算 0 分参与排名」。
|
||||
* worst 没有分母,avgMode 在这里**不起作用**:一个有效成绩都没有的主体一律不参与排名
|
||||
* (与 avg 的 valid 同一条规则)——不能把「没成绩」当成 0 去比:asc 里 0 最快、desc 里 0 最差,
|
||||
* 两边都会让「没人完赛」的队白拿名次。
|
||||
* scope=athlete 的主体是运动员,缺 athleteId 的行按服务端口径跳过。
|
||||
*/
|
||||
function aggregateCandidates(
|
||||
rows: readonly ScorableRow[],
|
||||
channel: ScoreChannel,
|
||||
avgMode: AvgMode,
|
||||
compare: CompareMode,
|
||||
): Candidate[] {
|
||||
const sumMode = normalizeAggregateMode(channel.aggregateMode) === 'sum'
|
||||
const mode = normalizeAggregateMode(channel.aggregateMode)
|
||||
const sumMode = mode === 'sum'
|
||||
const worstMode = mode === 'worst'
|
||||
const order: number[] = []
|
||||
const buckets = new Map<number, { row: ScorableRow; total: number; valid: number; registered: number }>()
|
||||
const buckets = new Map<
|
||||
number,
|
||||
{ row: ScorableRow; total: number; valid: number; registered: number; worst: number | null }
|
||||
>()
|
||||
for (const row of rows) {
|
||||
const subject = channel.scope === 'team' ? row.teamId : (row.athleteId ?? 0)
|
||||
if (subject === 0) continue
|
||||
const value = row.status === 'ok' ? row.resultValue : null
|
||||
let bucket = buckets.get(subject)
|
||||
if (bucket === undefined) {
|
||||
// valid 口径下没有任何有效成绩的主体不参与排名(两种汇总口径都一样)。
|
||||
// valid 口径下没有任何有效成绩的主体不参与排名(三种汇总口径都一样)。
|
||||
if (value === null && avgMode !== 'all') continue
|
||||
bucket = { row, total: 0, valid: 0, registered: 0 }
|
||||
bucket = { row, total: 0, valid: 0, registered: 0, worst: null }
|
||||
buckets.set(subject, bucket)
|
||||
order.push(subject)
|
||||
}
|
||||
@@ -330,25 +339,44 @@ function aggregateCandidates(
|
||||
if (value !== null) {
|
||||
bucket.total += value
|
||||
bucket.valid += 1
|
||||
if (bucket.worst === null || worseThan(value, bucket.worst, compare)) bucket.worst = value
|
||||
}
|
||||
}
|
||||
return order.map((subject) => {
|
||||
const out: Candidate[] = []
|
||||
for (const subject of order) {
|
||||
const bucket = buckets.get(subject) as {
|
||||
row: ScorableRow
|
||||
total: number
|
||||
valid: number
|
||||
registered: number
|
||||
worst: number | null
|
||||
}
|
||||
if (worstMode && bucket.worst === null) continue
|
||||
const count = avgMode === 'all' ? bucket.registered : bucket.valid
|
||||
const value = sumMode ? bucket.total : count > 0 ? bucket.total / count : 0
|
||||
return {
|
||||
row: bucket.row,
|
||||
value,
|
||||
rankIn: 0,
|
||||
subject,
|
||||
teamId: bucket.row.teamId,
|
||||
}
|
||||
})
|
||||
const value = worstMode
|
||||
? (bucket.worst as number)
|
||||
: sumMode
|
||||
? bucket.total
|
||||
: count > 0
|
||||
? bucket.total / count
|
||||
: 0
|
||||
out.push({ row: bucket.row, value, rankIn: 0, subject, teamId: bucket.row.teamId })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断 a 是不是比 b 更「差」——**方向由小项的 compare 决定,这是最容易写反的一处**
|
||||
* (scoring.go 的 worseValue 同款):
|
||||
*
|
||||
* asc (越小越好,如时间):更差 = 更大 →「队内最差」是**最后一名冲线**的那个;
|
||||
* desc(越大越好,如次数):更差 = 更小 →「队内最差」是**做得最少**的那个。
|
||||
*
|
||||
* 写死成 min 或 max 都会让一半的项目算反(越野会取到跑得最快的人、引体向上会取到做得最多的人),
|
||||
* 而两种错法读起来都很正常,所以预览与后端各有 asc / desc 的双向用例钉着。
|
||||
*/
|
||||
function worseThan(a: number, b: number, compare: CompareMode): boolean {
|
||||
return compare === 'desc' ? a < b : a > b
|
||||
}
|
||||
|
||||
function scoreChannel(
|
||||
@@ -368,7 +396,7 @@ function scoreChannel(
|
||||
|
||||
let groups: TieGroup[]
|
||||
if (basis === 'aggregate') {
|
||||
const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode))
|
||||
const aggregated = aggregateCandidates(rows, channel, normalizeAvgMode(event.avgMode), event.compare)
|
||||
sortByValue(aggregated, event.compare)
|
||||
groups = groupByValue(aggregated)
|
||||
} else if (basis === 'manual') {
|
||||
|
||||
@@ -33,14 +33,32 @@ const BASIS_OPTIONS: Array<{ value: ChannelBasis; label: string }> = [
|
||||
]
|
||||
|
||||
/**
|
||||
* 聚合口径的两个选项:现场在「按成员平均」与「按成员总和」之间快速切。
|
||||
* 聚合口径的三个选项:现场在「按成员平均」「按成员总和」「按队内最低分」之间快速切。
|
||||
* 与 basis=aggregate 配套使用,点一下即选中(不做二次确认,现场要的是快)。
|
||||
*
|
||||
* 「队内最低分」= 队内最差的那一份成绩(不是名字里的「最低」那么简单):升序项目(时间,越小越好)
|
||||
* 取的是**跑得最慢的那个人**(最后一名冲线),降序项目(次数,越大越好)取的是**做得最少的那个**,
|
||||
* 方向跟着小项的「比较方向」自动走 —— 所以提示里必须把两种方向都写出来,只写「最低」会让人按字面理解。
|
||||
*/
|
||||
const AGGREGATE_MODE_OPTIONS: Array<{ value: AggregateMode; label: string; hint: string }> = [
|
||||
{ value: 'avg', label: '平均', hint: '团体成绩 = 该单位成员成绩的平均值(缺省)' },
|
||||
{ value: 'sum', label: '总和', hint: '团体成绩 = 该单位成员成绩的总和' },
|
||||
{
|
||||
value: 'worst',
|
||||
label: '队内最低分',
|
||||
hint:
|
||||
'团体成绩 = 该单位成员里最差的那一份:升序项目(时间)取最后一名冲线的那个人,' +
|
||||
'降序项目(次数)取做得最少的那个(方向跟着小项的「比较方向」走)',
|
||||
},
|
||||
]
|
||||
|
||||
/** 行内小结:一句话说清这一行的团体成绩是怎么来的,现场核对时照这一行读。 */
|
||||
const AGGREGATE_MODE_SUMMARIES: Record<AggregateMode, string> = {
|
||||
avg: '团体成绩 = 该单位成员成绩的平均值',
|
||||
sum: '团体成绩 = 该单位成员成绩的总和',
|
||||
worst: '团体成绩 = 该单位成员里最差的那一份(时间项目取最后一名冲线的人 / 次数项目取做得最少的那个)',
|
||||
}
|
||||
|
||||
interface RowError {
|
||||
scope?: string
|
||||
basis?: string
|
||||
@@ -231,11 +249,14 @@ async function save(): Promise<void> {
|
||||
</div>
|
||||
|
||||
<div class="bd-note count-to-team-note">
|
||||
<b>团体分口径</b>:名次来源选「按队内平均」的通道,可在「<b>聚合口径</b>」一列点<b>平均 / 总和</b>直接切换——
|
||||
「平均」= 该单位成员成绩的平均值(缺省),「总和」= 该单位成员成绩的总和;切换后点「保存全部」生效,
|
||||
该小项立即重算。两种口径的排名在<b>各队人数相同时一致</b>,人数不同时可能不同。
|
||||
<b>团体分口径</b>:名次来源选「按队内平均」的通道,可在「<b>聚合口径</b>」一列点<b>平均 / 总和 / 队内最低分</b>直接切换——
|
||||
「平均」= 该单位成员成绩的平均值(缺省),「总和」= 该单位成员成绩的总和,
|
||||
「<b>队内最低分</b>」= 该单位成员里<b>最差的那一份</b>成绩(<b>时间项目取最后一名冲线的那个人,次数项目取做得最少的那个</b>,
|
||||
方向跟着小项的「比较方向」自动走——越野 / 行军这类项目要的就是它);切换后点「保存全部」生效,
|
||||
该小项立即重算。平均与总和两种口径的排名在<b>各队人数相同时一致</b>,人数不同时可能不同。
|
||||
平均值口径(只算有效成绩 / 全员计入)仍按小项的「平均值口径」设置,求和时它只决定
|
||||
「一个人都没成绩的单位算不算 0 分参与排名」。
|
||||
「一个人都没成绩的单位算不算 0 分参与排名」,「队内最低分」下它不起作用:
|
||||
一个人都没成绩的单位一律不参与排名(没人冲线就没有「最后一名」可言)。
|
||||
<br />
|
||||
「计入单位总分」关掉后,该通道产生的分只记在个人头上,不进单位总分(例如「个人+团体」小项的个人通道)。
|
||||
新增行的默认值按小项类型来:「个人+团体」的个人通道默认「不计入」(与规则包模板一致),
|
||||
@@ -271,15 +292,15 @@ async function save(): Promise<void> {
|
||||
<option v-for="option in BASIS_OPTIONS" :key="option.value" :value="option.value">{{ option.label }}</option>
|
||||
</select>
|
||||
<span v-if="row.basis === 'aggregate'" class="profile-summary bd-note">
|
||||
团体成绩 = 该单位成员成绩的{{ row.aggregateMode === 'sum' ? '总和' : '平均值' }}
|
||||
{{ AGGREGATE_MODE_SUMMARIES[row.aggregateMode] }}
|
||||
</span>
|
||||
<span v-if="errors[row.key]?.basis" class="row-error">{{ errors[row.key]?.basis }}</span>
|
||||
<span v-if="errors[row.key]?.scope" class="row-error">{{ errors[row.key]?.scope }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<!--
|
||||
现场要在「按成员平均」与「按成员总和」之间临时定口径,所以不放进下拉:
|
||||
两个按钮直接点,一下切换,选中态高亮;非聚合通道不显示(点了也没有意义)。
|
||||
现场要在「按成员平均」「按成员总和」「按队内最低分」之间临时定口径,所以不放进下拉:
|
||||
三个按钮直接点,一下切换,选中态高亮;非聚合通道不显示(点了也没有意义)。
|
||||
-->
|
||||
<div v-if="row.basis === 'aggregate'" class="agg-mode" role="group" aria-label="聚合口径">
|
||||
<button
|
||||
@@ -403,8 +424,8 @@ async function save(): Promise<void> {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
/*
|
||||
* 聚合口径的快速切换:做成按钮组而不是下拉,现场一眼看到当前是「平均」还是「总和」,
|
||||
* 点目标按钮即选中(高亮),配合「保存全部」生效。
|
||||
* 聚合口径的快速切换:做成按钮组而不是下拉,现场一眼看到当前是「平均」「总和」还是「队内最低分」,
|
||||
* 点目标按钮即选中(高亮),配合「保存全部」生效。三个按钮横排,内边距收窄一点免得把表格撑开。
|
||||
*/
|
||||
.agg-mode {
|
||||
display: inline-flex;
|
||||
@@ -413,7 +434,7 @@ async function save(): Promise<void> {
|
||||
overflow: hidden;
|
||||
}
|
||||
.agg-mode-btn {
|
||||
padding: 4px 10px;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--mx-t2);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
normalizeCountToTeamMedals,
|
||||
} from '@/shared'
|
||||
import type {
|
||||
AggregateMode,
|
||||
AvgMode,
|
||||
CompareMode,
|
||||
Event,
|
||||
@@ -114,6 +115,16 @@ const awardSummary = computed(() => {
|
||||
return `前 ${Math.max(...ranks)} 名`
|
||||
})
|
||||
|
||||
/**
|
||||
* 小项列表里聚合通道的「名次来源」称谓:与「计分通道」页的聚合口径按钮同一套说法,
|
||||
* 免得同一条通道在两张表里叫两个名字。worst 写「按队内最低分」,而不是背后的英文取值。
|
||||
*/
|
||||
const AGGREGATE_BASIS_LABELS: Record<AggregateMode, string> = {
|
||||
avg: '按队内平均',
|
||||
sum: '按队内总和',
|
||||
worst: '按队内最低分',
|
||||
}
|
||||
|
||||
const channelSummary = computed(() =>
|
||||
selectedChannels.value.map((channel) => {
|
||||
const profile = data.profileById(channel.profileId)
|
||||
@@ -125,7 +136,7 @@ const channelSummary = computed(() =>
|
||||
? '按成绩排名'
|
||||
: channel.basis === 'manual'
|
||||
? '手动名次'
|
||||
: `按队内${normalizeAggregateMode(channel.aggregateMode) === 'sum' ? '总和' : '平均'}`,
|
||||
: AGGREGATE_BASIS_LABELS[normalizeAggregateMode(channel.aggregateMode)],
|
||||
profile: profile?.name ?? `模板 #${channel.profileId}`,
|
||||
award: profile?.awardRanks ?? null,
|
||||
countToTeam: normalizeCountToTeam(channel.countToTeam),
|
||||
|
||||
@@ -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'))
|
||||
})
|
||||
@@ -9,7 +9,7 @@ import type { AggregateMode, AvgMode, Event, EventInput, ScoreChannel } from './
|
||||
|
||||
export const AVG_MODES: readonly AvgMode[] = ['valid', 'all']
|
||||
export const DEFAULT_AVG_MODE: AvgMode = 'valid'
|
||||
export const AGGREGATE_MODES: readonly AggregateMode[] = ['avg', 'sum']
|
||||
export const AGGREGATE_MODES: readonly AggregateMode[] = ['avg', 'sum', 'worst']
|
||||
/** 聚合通道默认按成员平均汇总;缺字段的老数据 / 老服务端都按它处理。 */
|
||||
export const DEFAULT_AGGREGATE_MODE: AggregateMode = 'avg'
|
||||
/** 通道默认计入单位总分;关掉后该通道的分只记在个人头上。 */
|
||||
|
||||
@@ -229,8 +229,8 @@ function normalizeChannels(raw: unknown): RuleChannel[] {
|
||||
// 保留「未配置」与「显式开启」的区别,导出页才能如实显示规则包原文口径。
|
||||
if (typeof rec.countToMedals === 'boolean') channel.countToMedals = rec.countToMedals
|
||||
if (typeof rec.countToTeamMedals === 'boolean') channel.countToTeamMedals = rec.countToTeamMedals
|
||||
// 汇总口径同样是三态:只在规则包显式写了 avg / sum 时落到对象上(缺省 = avg,不写)。
|
||||
if (rec.aggregateMode === 'avg' || rec.aggregateMode === 'sum') {
|
||||
// 汇总口径同样是三态:只在规则包显式写了 avg / sum / worst 时落到对象上(缺省 = avg,不写)。
|
||||
if (rec.aggregateMode === 'avg' || rec.aggregateMode === 'sum' || rec.aggregateMode === 'worst') {
|
||||
channel.aggregateMode = normalizeAggregateMode(rec.aggregateMode)
|
||||
}
|
||||
out.push(channel)
|
||||
|
||||
@@ -39,3 +39,30 @@ test('规则包模板仍接受原有三种类型', () => {
|
||||
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)
|
||||
}
|
||||
})
|
||||
+11
-4
@@ -85,10 +85,17 @@ export type AvgMode = 'valid' | 'all'
|
||||
|
||||
/**
|
||||
* 聚合通道(basis=aggregate)的汇总口径:
|
||||
* `avg` 按主体成员成绩的平均值排名(缺省);`sum` 按成员成绩的总和排名。
|
||||
* 与 avgMode 是两个维度:`sum` 下 avgMode 只决定「一个有效成绩都没有的单位算不算 0 分参与排名」。
|
||||
* `avg` 按主体成员成绩的平均值排名(缺省);`sum` 按成员成绩的总和排名;
|
||||
* `worst` 按成员成绩里**最差的一份**排名(队内最差 / 最后一名冲线)。
|
||||
*
|
||||
* `worst` 刻意不叫 `min` / `max`:取最小还是取最大**由小项的 compare 决定**
|
||||
* (asc 越小越好 → 取最大,即最后一名冲线;desc 越大越好 → 取最小,即做得最少的那个),
|
||||
* 写死成任何一个方向都会让一半的项目算反(见 admin/api/scoring.ts 的 worseThan)。
|
||||
*
|
||||
* 与 avgMode 是两个维度:`sum` 下 avgMode 只决定「一个有效成绩都没有的单位算不算 0 分参与排名」;
|
||||
* `worst` 没有分母,一个有效成绩都没有的单位一律不参与排名(与 avg 的 valid 同规则)。
|
||||
*/
|
||||
export type AggregateMode = 'avg' | 'sum'
|
||||
export type AggregateMode = 'avg' | 'sum' | 'worst'
|
||||
|
||||
export interface ScoreChannel {
|
||||
id: number
|
||||
@@ -98,7 +105,7 @@ export interface ScoreChannel {
|
||||
profileId: number
|
||||
sortOrder: number
|
||||
/**
|
||||
* basis=aggregate 通道的汇总口径:`avg` 按成员平均 / `sum` 按成员总和。
|
||||
* basis=aggregate 通道的汇总口径:`avg` 按成员平均 / `sum` 按成员总和 / `worst` 按队内最差。
|
||||
* 缺字段 = avg:老服务端不下发它(缺省值不下发),归一化见 normalizeAggregateMode。
|
||||
*/
|
||||
aggregateMode?: AggregateMode
|
||||
|
||||
Reference in new issue
Block a user