Files
Scoreboard/web/src/admin/tests/scoring.test.ts
T
AskaEth 7a3e4ad365 feat(entry): 团体项目多队(迁移 0025)+ 修联队团体名次 / 大屏分项成绩 / 预览算分口径;版本 dev_0.5.5
## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `0025_entry_squad.sql`:`entries` 加 `squad TEXT NOT NULL DEFAULT ''`(自由文本队名,空串=不区分队伍);
  唯一索引由 `(category_id, team_id) WHERE athlete_id IS NULL` 换成 `(category_id, team_id, squad) WHERE athlete_id IS NULL`
  —— 原来那条正是「一个单位在一个组别只能报一条团体报名」的拦路虎。
- 导入:**第 13 列「队伍」**(`squad` / 队伍 / 队名 / 队伍名,可选,放最后);老 12 列表照常导入。
- 显示:`单位名 + 队名`(不加括号),榜单不写队名(分与牌本来就归单位)。
- 新增「改队名」:`PUT /api/entries/{id}/squad` + 「成员单位与份额」弹窗里的队伍输入框(只对纯团体报名显示)。
  改队名**不需要重算**(`grep squad server/internal/domain/` = 0 处,改名前后流水/名次/榜单逐字节相同)。
- **老数据零变化**:接口层 JSON 剥掉新字段后逐字节相同;导出层 9 份导出件全单元格 750 行 dump 的 md5 相同。

## 二、修:团体名次控件没体现联队
联队的成员单位在「团体名次」里各占一行 → **合并成一行**(标签用大屏/导出一致的联队名),名次**只落主单位**;
成员单位不再进保存载荷,顺手堵掉「看 A、B 各一行,给 B 也录一个名次 → B 重复拿分」。
被并掉的成员单位原来若录过名次,区块里点名提示「已并入联队那一行,保存后清除」,不静默吞。

## 三、修:大屏「分项成绩」排名错乱
现场:第 3 栏列出的项目排名跳着显示(3、6、9、14、15、16),羽毛球单打第 1 名不显示。
**根因**:`group.rows.slice(0, 6)` 取的是**数据到达顺序**,整条渲染链**没有一处读 `rank`**。
修法:按名次升序(并列保持原先后、无名次排最后)再截断 —— 与同页「实时赛况」的既有口径一致。
复现证据:修前 `actual: [3,6,9,14,15,16]`(与现场数字一致)、`expected: [1,2,3,4,5,6]`。
顺带澄清:「只有第 3 栏出问题」不是栏的问题,是那一栏分到的数据次序不成立。

## 四、修:录入页预览算分与后端口径不一致(四处 + 一条延伸)
现场:手动录名次的项目(台球)「积分(自动)」列全是 0,而后端**有分**。
1. **预览不读 `channel_manual_ranks`、也不按通道 scope 分名次来源** → 「按单位录名次」的项目预览全 0,反过来名次填在成绩表时预览有分而服务端 0;
2. **末位归零判据不同**:预览原本「区间跨末位就整组归零」,与后端的 `startRank == lastPlace` 不符(并列第 1 时预览 0、服务端 10);
3. **`compare=none` 时预览静默降级成 manual**(后端此时直接报错)→ 改为同口径:显示**原因**而不是一个算不出来的数字;
4. **管理端保存计分通道缺守卫**:`compare=none + basis=result/aggregate` 原先只有规则包导入拦,手工能配出来、到发布才报错 → 保存前 400 + 人话。
延伸:预览的「已发布分」基线**只扣行分**,不挂报名项的分(团体名次那类)没扣 → 发布确认框「最大分差」虚高;新增 `unitPoints` 一并扣。

## 五、修:手录名次项目的「成绩」列空白
`displayResult()` 在 `resultValue`/`resultText` 都拿不到时**漏出空值**(且函数声明返回 `string`)→
新增占位 `—`、类型收紧,实时赛况与底栏对齐同一口径。

## 文档
`docs/报名名单导入规范.md`(12 → 13 列)、`docs/使用手册.md`(多队章节)、
`docs/升级说明.md`(新增 `dev_0.5.5` 一节)、`debug/使用说明/` 七份同步。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **883 例** 0 fail / Console 68 例 0 fail。
- 各块都先写会红的测试、留了原始输出(含现场报的那组数字与「预览全 0」)。

## 版本
`dev_0.5.4` → **`dev_0.5.5`**(含数据迁移 `0025`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。

## 下一批
`dev_0.5.6` = **团体报名队员身份**(把队员从文字名单变成人员关联,标识用**号码布**),工单见 `devdocs/20-团体报名队员身份.md`。
2026-09-14 11:45:32 +08:00

355 lines
13 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 type { Event, EventCategory, ScoreChannel, ScoringProfile } from '@/shared'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
// 逐条对齐服务端 internal/domain/scoring.go 的 ScoreEvent 口径。
// 运行:node --test --import ./src/shared/tests/register.mjs src/admin/tests/
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 1,
sportId: 1,
code: 'M100',
name: '100 米',
eventType: 'individual',
resultMode: 'auto',
resultType: 'time',
compare: 'asc',
recordEligible: false,
scored: true,
awardMedals: true,
multiplier: 1,
status: 'pending',
scheduledAt: null,
venue: null,
sortOrder: 1,
remark: null,
avgMode: 'valid',
...overrides,
}
}
function makeProfile(overrides: Partial<ScoringProfile> = {}): ScoringProfile {
return { id: 1, code: 'p', name: 'p', tieMode: 'skip', recordBonusMode: 'max', rules: [], ...overrides }
}
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
return { id: 1, eventId: 1, scope: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: true, ...overrides }
}
function makeRow(entryId: number, resultValue: number | null, teamId: number, rank: number | null = null): ScorableRow {
return { entryId, teamId, athleteId: entryId, resultValue, rank, status: 'ok', isTie: false }
}
function makeContext(overrides: Partial<ScoreContext> = {}): ScoreContext {
return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides }
}
function close(actual: number, expected: number): void {
assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`)
}
test('awardRanks 之外的名次一律 0 分', () => {
const profile = makeProfile({
awardRanks: 2,
rules: [
{ rank: 1, points: 9 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
],
})
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 11, 101), makeRow(3, 12, 102)],
makeContext({ channels: [makeChannel()], profiles: [profile] }),
)
close(result.rowPoints.get(1) ?? 0, 9)
close(result.rowPoints.get(2) ?? 0, 6)
close(result.rowPoints.get(3) ?? 0, 0)
close(result.teamPoints.get(102) ?? 0, 0)
})
test('lastPlaceZero 让末位整组 0 分', () => {
const profile = makeProfile({
lastPlaceZero: true,
rules: [
{ rank: 1, points: 9 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
],
})
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 11, 101), makeRow(3, 12, 102)],
makeContext({ channels: [makeChannel()], profiles: [profile] }),
)
close(result.rowPoints.get(1) ?? 0, 9)
close(result.rowPoints.get(2) ?? 0, 6)
close(result.rowPoints.get(3) ?? 0, 0)
})
test('跨末位的并列组不整组归零:只有起始名次正好落在末位的那一组才 0 分', () => {
// 与服务端 domain/scoring.go groupPoints 逐字同口径(Go 用例「lastPlaceZero 不牵连跨末位的并列组」):
// lastPlaceZero 的判据是 startRank == lastPlace。3 人里两人并列第 2(占 2、3),末位档 = 3,
// 起始名次 2 != 3 → 第 2 名的 6 分照发;整组归零会连带抹掉本可拿分的第 2 名。
const profile = makeProfile({
lastPlaceZero: true,
rules: [
{ rank: 1, points: 9 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
],
})
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 11, 101), makeRow(3, 11, 102)],
makeContext({ channels: [makeChannel()], profiles: [profile] }),
)
close(result.rowPoints.get(1) ?? 0, 9)
close(result.rowPoints.get(2) ?? 0, 6)
close(result.rowPoints.get(3) ?? 0, 6)
})
test('scored=false 只排名不计分', () => {
const profile = makeProfile({
rules: [
{ rank: 1, points: 9 },
{ rank: 2, points: 6 },
],
})
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 11, 101)],
makeContext({ event: makeEvent({ scored: false }), channels: [makeChannel()], profiles: [profile] }),
)
assert.equal(result.ranks.get(1), 1)
assert.equal(result.ranks.get(2), 2)
close(result.rowPoints.get(1) ?? 0, 0)
close(result.teamPoints.get(100) ?? 0, 0)
})
test('多通道叠加,个人通道分按权重折算进单位总分', () => {
const athleteProfile = makeProfile({ id: 1, rules: [{ rank: 1, points: 10 }] })
const teamProfile = makeProfile({ id: 2, rules: [{ rank: 1, points: 4 }] })
const result = computeEventScore(
[makeRow(1, 10, 100)],
makeContext({
channels: [
makeChannel({ id: 1, scope: 'athlete', profileId: 1 }),
makeChannel({ id: 2, scope: 'team', profileId: 2 }),
],
profiles: [athleteProfile, teamProfile],
weight: 2,
}),
)
// 行分是两条通道的原始分之和;折算到单位时只有个人通道乘系数。
close(result.rowPoints.get(1) ?? 0, 14)
close(result.rowTeamPoints.get(1) ?? 0, 24)
close(result.teamPoints.get(100) ?? 0, 24)
})
test('并列 average 取占用名次区间的均值', () => {
const profile = makeProfile({
tieMode: 'average',
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
{ rank: 4, points: 1 },
],
})
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 10, 101), makeRow(3, 10, 102)],
makeContext({ channels: [makeChannel()], profiles: [profile] }),
)
close(result.rowPoints.get(1) ?? 0, (10 + 6 + 3) / 3)
close(result.rowPoints.get(2) ?? 0, (10 + 6 + 3) / 3)
close(result.rowPoints.get(3) ?? 0, (10 + 6 + 3) / 3)
assert.equal(result.ranks.get(1), 1)
})
test('并列 higher 取区间最高档', () => {
const profile = makeProfile({
tieMode: 'higher',
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
],
})
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 10, 101), makeRow(3, 10, 102)],
makeContext({ channels: [makeChannel()], profiles: [profile] }),
)
close(result.rowPoints.get(1) ?? 0, 10)
close(result.rowPoints.get(2) ?? 0, 10)
close(result.rowPoints.get(3) ?? 0, 10)
})
test('没有计分通道时算出名次但一律 0 分', () => {
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 11, 101)],
makeContext({ channels: [], profiles: [] }),
)
assert.equal(result.ranks.get(1), 1)
assert.equal(result.ranks.get(2), 2)
close(result.rowPoints.get(1) ?? 0, 0)
close(result.teamPoints.get(100) ?? 0, 0)
})
test('manual 通道使用录入名次,不做顺推改写', () => {
const profile = makeProfile({
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 6 },
],
})
const result = computeEventScore(
[makeRow(1, null, 100, 2), makeRow(2, null, 101, 1)],
makeContext({
event: makeEvent({ resultMode: 'manual', compare: 'none' }),
channels: [makeChannel({ basis: 'manual' })],
profiles: [profile],
}),
)
close(result.rowPoints.get(1) ?? 0, 6)
close(result.rowPoints.get(2) ?? 0, 10)
assert.equal(result.ranks.get(1), 2)
assert.equal(result.ranks.get(2), 1)
})
test('项目与组别权重相乘', () => {
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }] })
const category: EventCategory = {
id: 7,
eventId: 1,
name: '甲组',
multiplier: 3,
gender: null,
ageMin: null,
ageMax: null,
sortOrder: 1,
}
const result = computeEventScore(
[makeRow(1, 10, 100)],
makeContext({ event: makeEvent({ multiplier: 2 }), category, channels: [makeChannel()], profiles: [profile] }),
)
close(result.rowPoints.get(1) ?? 0, 60)
})
// 团体行的 athleteId 是 null(服务端 entries.athlete_id 为空,0 队员的「只报单位」报名也是这种行),
// 团体通道的排名主体是单位,不能因为没有运动员就把行丢掉。
test('团体行(athleteId 为 null)按单位通道正常拿名次与积分', () => {
const profile = makeProfile({
rules: [
{ rank: 1, points: 9 },
{ rank: 2, points: 6 },
],
})
const rows: ScorableRow[] = [
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false },
{ entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
]
const result = computeEventScore(
rows,
makeContext({
event: makeEvent({ eventType: 'team', resultMode: 'manual', compare: 'none', resultType: 'none' }),
channels: [makeChannel({ scope: 'team', basis: 'manual' })],
profiles: [profile],
}),
)
assert.equal(result.ranks.get(1), 1)
assert.equal(result.ranks.get(2), 2)
close(result.rowPoints.get(1) ?? 0, 9)
close(result.rowTeamPoints.get(1) ?? 0, 9)
close(result.teamPoints.get(100) ?? 0, 9)
close(result.teamPoints.get(101) ?? 0, 6)
})
test('countToTeam=false 的通道只算个人分,不进单位总分', () => {
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }] })
const result = computeEventScore(
[makeRow(1, 10, 100)],
makeContext({
channels: [makeChannel({ basis: 'result', countToTeam: false })],
profiles: [profile],
weight: 2,
}),
)
close(result.rowPoints.get(1) ?? 0, 10)
close(result.rowTeamPoints.get(1) ?? 0, 0)
close(result.teamPoints.get(100) ?? 0, 0)
// 名次照算,只是分不落到单位头上。
assert.equal(result.ranks.get(1), 1)
})
test('「个人+团体」:个人通道不进单位总分,团体通道按队内平均记分', () => {
const athleteProfile = makeProfile({ id: 1, rules: [{ rank: 1, points: 10 }, { rank: 2, points: 8 }] })
const teamProfile = makeProfile({ id: 2, rules: [{ rank: 1, points: 15 }, { rank: 2, points: 8 }] })
const rows = [makeRow(1, 18, 100), makeRow(2, 20, 100), makeRow(3, 16, 101)]
const result = computeEventScore(
rows,
makeContext({
event: makeEvent({ eventType: 'individual_team', resultType: 'count', compare: 'desc' }),
channels: [
makeChannel({ id: 1, scope: 'athlete', basis: 'result', profileId: 1, countToTeam: false }),
makeChannel({ id: 2, scope: 'team', basis: 'aggregate', profileId: 2, countToTeam: true }),
],
profiles: [athleteProfile, teamProfile],
}),
)
// 个人分照给:20 排第 1、18 排第 2、16 排第 3(录取前 2 名)。
close(result.rowPoints.get(2) ?? 0, 10)
close(result.rowPoints.get(1) ?? 0, 8)
// 单位总分只来自团体通道:100 队平均 19 排第 1,101 队平均 16 排第 2。
// 个人通道若也计入,100 队会多出个人第 1 名的 10 分。
close(result.teamPoints.get(100) ?? 0, 15)
close(result.teamPoints.get(101) ?? 0, 8)
})
test('aggregate 通道按队内平均排名(valid 只算有效成绩)', () => {
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }, { rank: 2, points: 5 }] })
const result = computeEventScore(
[makeRow(1, 10, 100), makeRow(2, 20, 100), makeRow(3, 16, 101)],
makeContext({
event: makeEvent({ eventType: 'team', resultType: 'count', compare: 'desc' }),
channels: [makeChannel({ scope: 'team', basis: 'aggregate' })],
profiles: [profile],
}),
)
// 100 队平均 15 < 101 队平均 16,desc 下 101 队第一。
close(result.teamPoints.get(101) ?? 0, 10)
close(result.teamPoints.get(100) ?? 0, 5)
})
test('avgMode=all 把没成绩的报名项按 0 计入平均分母', () => {
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }, { rank: 2, points: 5 }] })
const makeRows = (): ScorableRow[] => [makeRow(1, 20, 100), makeRow(2, 20, 100), makeRow(3, null, 100), makeRow(4, 16, 101)]
const context = (avgMode: Event['avgMode']): ScoreContext =>
makeContext({
event: makeEvent({ eventType: 'team', resultType: 'count', compare: 'desc', avgMode }),
channels: [makeChannel({ scope: 'team', basis: 'aggregate' })],
profiles: [profile],
})
// valid:100 队只按两条有效成绩平均(20)→ 第一。
const valid = computeEventScore(makeRows(), context('valid'))
close(valid.teamPoints.get(100) ?? 0, 10)
close(valid.teamPoints.get(101) ?? 0, 5)
// all:第 3 条没成绩按 0 计入,100 队平均 40/3 → 落到第二。
const all = computeEventScore(makeRows(), context('all'))
close(all.teamPoints.get(101) ?? 0, 10)
close(all.teamPoints.get(100) ?? 0, 5)
})
test('团体行只配了录入名次(无计分通道)时也能算出名次', () => {
const rows: ScorableRow[] = [
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
{ entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false },
]
const result = computeEventScore(
rows,
makeContext({ event: makeEvent({ eventType: 'team', resultMode: 'manual', compare: 'none', resultType: 'none' }) }),
)
assert.equal(result.ranks.get(1), 2)
assert.equal(result.ranks.get(2), 1)
})