Files
Scoreboard/web/src/admin/tests/scoring-team-manual.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

290 lines
11 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 { readFileSync } from 'node:fs'
import type { Event, ScoreChannel, ScoringProfile, TeamRankEntry } from '@/shared'
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
/**
* 「按单位录名次」(scope=team + basis=manual)的预览口径,逐条对齐服务端
* domain/scoring.go 的 collectTeamManualCandidates 与 groupPoints。
*
* 现场报的 bug:台球按单位在录入页底部的「团体名次」卡片里录名次(channel_manual_ranks),
* 服务端算对了(六个单位合计 40 分),录入页的实时预览却每个单位都是 0——预览只读行上的
* rank,从不读 channel_manual_ranks。而那张预览又是发布确认框里「最大分差」的来源。
*/
function makeEvent(overrides: Partial<Event> = {}): Event {
return {
id: 1,
sportId: 1,
code: 'BALL',
name: '台球',
eventType: 'team',
resultMode: 'manual',
resultType: 'none',
compare: 'none',
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: 'team', basis: 'manual', profileId: 1, sortOrder: 1, countToTeam: true, ...overrides }
}
function makeContext(overrides: Partial<ScoreContext> = {}): ScoreContext {
return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides }
}
/** 团体名次分表:与规则包 team_top6 同一张表,第 1–6 名 15/10/8/4/2/1(六个单位合计 40 分,正是现场那组数)。 */
const TEAM_PROFILE = makeProfile({
rules: [
{ rank: 1, points: 15 },
{ rank: 2, points: 10 },
{ rank: 3, points: 8 },
{ rank: 4, points: 4 },
{ rank: 5, points: 2 },
{ rank: 6, points: 1 },
],
})
const SIX_UNITS: TeamRankEntry[] = [
{ teamId: 100, rank: 1 },
{ teamId: 101, rank: 2 },
{ teamId: 102, rank: 3 },
{ teamId: 103, rank: 4 },
{ teamId: 104, rank: 5 },
{ teamId: 105, rank: 6 },
]
function close(actual: number, expected: number): void {
assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`)
}
test('名次只填在「团体名次」卡片(channel_manual_ranks):六个单位合计 40 分', () => {
// 与服务端 httpapi 侧 team_manual_ranks_score_test.go 同输入同期望:同一份 channel_manual_ranks,
// 预览与引擎必须给出同一组数字。
// 团体名次不挂在任何报名项上:六个单位各录一个名次,成绩表里一行都没有 rank。
const rows: ScorableRow[] = [100, 101, 102, 103, 104, 105].map((teamId, index) => ({
entryId: index + 1,
teamId,
athleteId: index + 1,
resultValue: null,
rank: null,
status: 'ok',
isTie: false,
}))
const result = computeEventScore(
rows,
makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: SIX_UNITS }),
)
const expected = [15, 10, 8, 4, 2, 1]
let total = 0
for (const [index, teamId] of [100, 101, 102, 103, 104, 105].entries()) {
const points = result.teamPoints.get(teamId) ?? 0
close(points, expected[index] ?? 0)
total += points
}
close(total, 40)
})
test('团体手工通道只认按单位录的名次:填在个人行上的名次一律不进团体通道', () => {
// 服务端 collectTeamManualCandidates 只收 athlete_id == 0 的团体行;
// 预览若照旧读行上的 rank,就会出现「预览有分(10/9/8…)、服务端 0」。
const rows: ScorableRow[] = [10, 9, 8, 6, 4, 3].map((rank, index) => ({
entryId: index + 1,
teamId: 100 + index,
athleteId: index + 1,
resultValue: null,
rank,
status: 'ok',
isTie: false,
}))
const result = computeEventScore(rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE] }))
assert.equal(result.teamPoints.size, 0)
assert.equal(result.rowPoints.size, 0)
})
test('两个团体行的名次照旧生效,且被卡片里的名次覆盖', () => {
// 纯团体项目(篮球等)的成绩表兜底:团体行(athleteId 为 null)上录的名次。
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 fromRows = computeEventScore(rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE] }))
close(fromRows.teamPoints.get(100) ?? 0, 15)
close(fromRows.teamPoints.get(101) ?? 0, 10)
assert.equal(fromRows.ranks.get(1), 1)
// 同一单位两个来源都有时以 channel_manual_ranks 为准(服务端 overrideOf)。
const overridden = computeEventScore(
rows,
makeContext({
channels: [makeChannel()],
profiles: [TEAM_PROFILE],
teamRanks: [
{ teamId: 100, rank: 2 },
{ teamId: 101, rank: 1 },
],
}),
)
close(overridden.teamPoints.get(100) ?? 0, 10)
close(overridden.teamPoints.get(101) ?? 0, 15)
assert.equal(overridden.ranks.get(1), 2)
})
test('联队:按单位录的名次按份额拆给各成员单位(与服务端 jointTeamEntry + ledgerFromRanked 同口径)', () => {
// 服务端 joint_team_test.go 的场景:联队报名(A、B 各半)+ TeamRanks{主单位 A 第 1 名}、15 分表
// → A、B 各 7.5。名次挂在联队那条报名上(entryId 1),所以行分是整份 15,单位分才拆。
const profile = makeProfile({ rules: [{ rank: 1, points: 15 }] })
const result = computeEventScore(
[
{
entryId: 1,
teamId: 100,
athleteId: 0,
resultValue: null,
rank: null,
status: 'ok',
isTie: false,
shares: [
{ teamId: 100, weight: 1 },
{ teamId: 200, weight: 1 },
],
},
],
makeContext({ channels: [makeChannel()], profiles: [profile], teamRanks: [{ teamId: 100, rank: 1 }] }),
)
close(result.teamPoints.get(100) ?? 0, 7.5)
close(result.teamPoints.get(200) ?? 0, 7.5)
close(result.rowPoints.get(1) ?? 0, 15)
})
test('lastPlaceZero 只抹「起始名次正好落在末位」的那一组:两人并列第 1 不归零', () => {
// 服务端判据是 startRank == lastPlace(scoring.go groupPoints)。两人并列第 1 时
// 占位名次是 1、2,末位档 = 2,起始名次 1 != 2 → 照发第 1 名的分。
const profile = makeProfile({
lastPlaceZero: true,
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
],
})
const result = computeEventScore(
[makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 1)],
makeContext({
event: makeEvent({ eventType: 'individual', resultType: 'none' }),
channels: [makeChannel({ scope: 'athlete', basis: 'manual' })],
profiles: [profile],
}),
)
close(result.rowPoints.get(1) ?? 0, 10)
close(result.rowPoints.get(2) ?? 0, 10)
close(result.teamPoints.get(100) ?? 0, 10)
close(result.teamPoints.get(101) ?? 0, 10)
})
test('lastPlaceZero 仍要抹掉真正的末位那一组', () => {
const profile = makeProfile({
lastPlaceZero: true,
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 6 },
{ rank: 3, points: 3 },
],
})
const result = computeEventScore(
[makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 2), makeRowOf(3, null, 102, 3)],
makeContext({
event: makeEvent({ eventType: 'individual', resultType: 'none' }),
channels: [makeChannel({ scope: 'athlete', basis: 'manual' })],
profiles: [profile],
}),
)
close(result.rowPoints.get(1) ?? 0, 10)
close(result.rowPoints.get(2) ?? 0, 6)
close(result.rowPoints.get(3) ?? 0, 0)
})
test('compare=none 配 basis=result:预览报错,不静默按录入名次兜底', () => {
const profile = makeProfile({
rules: [
{ rank: 1, points: 10 },
{ rank: 2, points: 6 },
],
})
const result = computeEventScore(
[makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 2)],
makeContext({
event: makeEvent({ eventType: 'individual', resultMode: 'manual' }),
channels: [makeChannel({ scope: 'athlete', basis: 'result' })],
profiles: [profile],
}),
)
assert.ok(result.error, '服务端 domain.ScoreEvent 对这个配置返回 ErrCompareRequired(发布 409),预览必须同样报错')
assert.match(result.error.message, /比较方向/)
assert.equal(result.error.channelLabel, '个人名次 / 按成绩自动排名')
// 报错时不再产出任何分:预览不该给一个服务端算不出来的配置算分。
assert.equal(result.teamPoints.size, 0)
assert.equal(result.rowPoints.size, 0)
})
test('compare=none 配 basis=aggregate:同样报错', () => {
const result = computeEventScore(
[makeRowOf(1, 20, 100, null), makeRowOf(2, 16, 101, null)],
makeContext({
event: makeEvent({ eventType: 'team', resultType: 'count' }),
channels: [makeChannel({ basis: 'aggregate' })],
profiles: [TEAM_PROFILE],
}),
)
assert.ok(result.error, '聚合通道同样要先按成绩排名,compare=none 时服务端返回 ErrCompareRequired')
assert.match(result.error.message, /比较方向/)
})
test('compare=none 配 basis=manual:照常算分,不报错', () => {
const result = computeEventScore(
[],
makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: SIX_UNITS }),
)
assert.equal(result.error, null)
close(result.teamPoints.get(100) ?? 0, 15)
})
function makeRowOf(entryId: number, resultValue: number | null, teamId: number, rank: number | null): ScorableRow {
return { entryId, teamId, athleteId: entryId, resultValue, rank, status: 'ok', isTie: false }
}
// 录入页没有组件测试工具,接线部分按仓库既有做法(avg-mode.test.ts)读源码断言:
// 纯函数再有分,页面没把「团体名次」卡片喂进去,现场看到的还是 0。
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
test('录入页把「团体名次」卡片喂进预览算分(接线掉了就等于没修)', () => {
assert.match(
ENTRY_SOURCE,
/const previewTeamRanks = computed<TeamRankEntry\[\]>\(\(\) => teamRanksPayload\(teamRankRows\.value\)\.ranks\)/,
)
assert.match(ENTRY_SOURCE, /const score = computed\(\(\) => scoreWith\(visibleRows\.value, previewTeamRanks\.value\)\)/)
assert.match(ENTRY_SOURCE, /const savedTeamRanks = computed<TeamRankEntry\[\]>\(\(\) => \{/)
assert.match(ENTRY_SOURCE, /teamRanks,\n \}\)/)
})
test('录入页把「不挂在行上的单位分」从已发布基线里扣掉,差值才算得对', () => {
assert.match(ENTRY_SOURCE, /for \(const \[teamId, points\] of published\?\.unitPoints \?\? \[\]\)/)
assert.match(ENTRY_SOURCE, /const scoreConfigError = computed\(\(\) => score\.value\?\.error \?\? null\)/)
})