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

360 lines
15 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 { normalizeTeamShares, type ScoreChannel, type Team, type TeamRanksView } from '@/shared'
import {
hasTeamManualChannel,
mergeJointTeamRankRows,
mergedAwayRankNames,
parseTeamRankInput,
teamRankFlaggedTeams,
teamRankRowsOf,
teamRankWarnings,
teamRanksDirty,
teamRanksPayload,
type TeamRankEntryRef,
} from '../pages/teamRanks'
import { mockDb } from '../api/mock'
// 录入页「团体名次」区块(设计 17 §八第 3 条):纯逻辑在 teamRanks.ts,
// 界面部分与仓库既有做法一致——没有组件测试工具,读源码断言关键接线。
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
return { id: 1, eventId: 108, scope: 'team', basis: 'manual', profileId: 2, sortOrder: 1, countToTeam: true, ...overrides }
}
function makeTeam(id: number, overrides: Partial<Team> = {}): Team {
return {
id,
code: `T${id}`,
name: `单位${id}`,
shortName: `单位${id}`,
logoPath: null,
color: null,
sortOrder: id,
active: true,
...overrides,
}
}
function makeView(overrides: Partial<TeamRanksView> = {}): TeamRanksView {
return {
channels: [{ channelId: 3, scope: 'team', basis: 'manual', profileId: 2 }],
teams: [
{ teamId: 1, code: 'JG', name: '机关本部', shortName: '机关' },
{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' },
],
ranks: [{ teamId: 1, rank: 2 }],
...overrides,
}
}
// ── 联队(devdocs/19):一条报名挂多个单位时,团体名次区块要把成员单位合并成一行 ──────
/** 一个成员单位的名字与编码(拼联队名 / 编码用)。 */
interface Member {
id: number
code: string
name: string
}
const JG: Member = { id: 1, code: 'JG', name: '机关本部' }
const F1: Member = { id: 2, code: 'F1', name: '第一分公司' }
const F3: Member = { id: 3, code: 'F3', name: '第三分公司' }
/** 一条报名:teamId 是主单位(entries.team_id),members 是份额里的成员单位(第一条即主单位)。 */
function makeEntry(teamId: number, members: readonly Member[]): TeamRankEntryRef {
return {
teamId,
teamShares: normalizeTeamShares(
members.map((member) => ({ teamId: member.id, weight: 1, teamCode: member.code, teamName: member.name })),
),
}
}
test('联队:成员单位合并成一行,标签用大屏 / 导出同一口径的联队名', () => {
const rows = teamRankRowsOf(makeView(), [])
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
assert.deepEqual(merged.map((row) => row.teamId), [1], '成员单位不再单独占一行')
assert.equal(merged[0]?.name, '机关本部+第一分公司联队')
assert.equal(merged[0]?.shortName, null)
assert.equal(merged[0]?.code, 'JG+F1', '编码用导入 / 导出的 A+B 写法')
assert.deepEqual(merged[0]?.members.map((member) => member.teamId), [1, 2])
})
test('联队:名次落在主单位那一行,成员单位不再进保存载荷(重复计分就此堵死)', () => {
const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 2 }, { teamId: 2, rank: 3 }] }), [])
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
assert.equal(merged.length, 1)
assert.equal(merged[0]?.input, '2', '主单位那一行原来录的名次回填到联队行')
assert.equal(teamRanksDirty(merged), false)
// 成员单位 2 库里那条名次 3 不再出现在载荷里:它不会被当成第二个名次主体。
assert.deepEqual(teamRanksPayload(merged), {
scope: 'team',
basis: 'manual',
ranks: [{ teamId: 1, rank: 2 }],
})
})
test('联队:输入框写回主单位那一行(副本会把草稿吞掉,保存按钮也不会亮)', () => {
const rows = teamRankRowsOf(makeView(), [])
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
merged[0]!.input = 4
assert.equal(rows[0]?.input, 4, '联队行的输入写回主单位那一行')
assert.equal(teamRanksDirty(merged), true)
assert.deepEqual(teamRanksPayload(merged).ranks, [{ teamId: 1, rank: 4 }])
})
test('组别里没有联队报名时,行与现在一字不差', () => {
const rows = teamRankRowsOf(makeView(), [])
// 单单位报名(份额只有一条)不是联队:连对象都不该换一个。
assert.deepEqual(mergeJointTeamRankRows(rows, [makeEntry(1, [JG])]), rows)
assert.equal(mergeJointTeamRankRows(rows, [makeEntry(1, [JG])])[0], rows[0])
// 没有报名时同理。
assert.deepEqual(mergeJointTeamRankRows(rows, []), rows)
})
test('成员单位自己也有报名时两条都列出来,且分得清', () => {
const rows = teamRankRowsOf(makeView(), [])
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1]), makeEntry(2, [F1])])
assert.deepEqual(merged.map((row) => row.teamId), [1, 2], '别静默吞掉第一条')
assert.equal(merged[0]?.name, '机关本部+第一分公司联队')
assert.equal(merged[1]?.name, '第一分公司')
assert.deepEqual(merged[1]?.members, [], '它自己那条不是联队')
assert.equal(merged[1], rows[1], '普通行原样透传')
})
test('一个单位同时是两个联队的成员单位时,两个联队各占一行', () => {
const rows = teamRankRowsOf(
makeView({
teams: [
{ teamId: 1, code: 'JG', name: '机关本部', shortName: '机关' },
{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' },
{ teamId: 3, code: 'F3', name: '第三分公司', shortName: '三分公司' },
],
}),
[],
)
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1]), makeEntry(2, [F1, F3])])
assert.deepEqual(merged.map((row) => row.teamId), [1, 2])
assert.equal(merged[0]?.name, '机关本部+第一分公司联队')
assert.equal(merged[1]?.name, '第一分公司+第三分公司联队')
})
test('主单位那一行不在列表里(单位停用 / 假删除)时不合并不吞', () => {
const rows = teamRankRowsOf(
makeView({ teams: [{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' }] }),
[],
)
assert.deepEqual(mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]), rows)
})
test('参赛单位数按合并后的行数算', () => {
const rows = teamRankRowsOf(makeView(), [])
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
merged[0]!.input = '2'
assert.match(teamRankWarnings(merged).join(';'), /超出参赛单位数 1/)
assert.deepEqual([...teamRankFlaggedTeams(merged)], [1])
})
test('被并掉的成员单位原来录过的名次要点名(保存后会清掉,不静默改数)', () => {
const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 2 }, { teamId: 2, rank: 3 }] }), [])
assert.deepEqual(mergedAwayRankNames(rows, mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])), ['一分公司'])
// 没录过名次的成员单位不点名。
const clean = teamRankRowsOf(makeView(), [])
assert.deepEqual(mergedAwayRankNames(clean, mergeJointTeamRankRows(clean, [makeEntry(1, [JG, F1])])), [])
})
test('只有 scope=team + basis=manual 的通道才需要「团体名次」区块', () => {
assert.equal(hasTeamManualChannel([makeChannel()]), true)
assert.equal(hasTeamManualChannel([makeChannel({ basis: 'aggregate' })]), false)
assert.equal(hasTeamManualChannel([makeChannel({ scope: 'athlete' })]), false)
assert.equal(
hasTeamManualChannel([makeChannel({ scope: 'athlete', basis: 'result' }), makeChannel()]),
true,
)
assert.equal(hasTeamManualChannel([]), false)
assert.equal(hasTeamManualChannel(null), false)
assert.equal(hasTeamManualChannel(undefined), false)
})
test('名次输入:空 / 0 / 负数 / 非整数一律按清空处理', () => {
assert.equal(parseTeamRankInput('3'), 3)
assert.equal(parseTeamRankInput(' 4 '), 4)
// 留空 = 清空该单位,服务端口径是 rank 0。
assert.equal(parseTeamRankInput(''), 0)
assert.equal(parseTeamRankInput(' '), 0)
assert.equal(parseTeamRankInput('0'), 0)
assert.equal(parseTeamRankInput('-2'), 0)
assert.equal(parseTeamRankInput('2.5'), 0)
assert.equal(parseTeamRankInput('abc'), 0)
})
test('名次输入:输入框填了数字后 v-model 给的是 number,解析口径与字符串一致', () => {
// type="number" 的 v-model 会被 Vue 自动转成 number(只有清空才是空串),
// 所以 raw 是 number 时必须照样解析——直接 .trim() 会在渲染期抛 TypeError 导致白屏。
assert.equal(parseTeamRankInput(3), 3)
assert.equal(parseTeamRankInput(0), 0)
assert.equal(parseTeamRankInput(3.5), 0)
})
test('input 是 number 时:提示 / 描红 / 保存载荷都不抛异常且结果正确', () => {
const rows = teamRankRowsOf(makeView(), [])
rows[0]!.input = 3
rows[1]!.input = 0
assert.deepEqual(teamRanksPayload(rows), {
scope: 'team',
basis: 'manual',
ranks: [
{ teamId: 1, rank: 3 },
{ teamId: 2, rank: 0 },
],
})
// 超出参赛单位数(两个单位却填了 3):只提示不抛错。
assert.match(teamRankWarnings(rows).join(';'), /超出参赛单位数 2/)
assert.deepEqual([...teamRankFlaggedTeams(rows)], [1])
// 重复名次(number 形态)同样算得出来。
rows[1]!.input = 3
assert.match(teamRankWarnings(rows).join(';'), /名次 3 重复 2 次/)
assert.deepEqual([...teamRankFlaggedTeams(rows)], [1, 2])
})
test('改动检测:input 是 number、original 是字符串时不误判为已修改', () => {
const rows = teamRankRowsOf(makeView(), [])
// 回填的 original 是字符串 '2',输入框里没动过时 input 已经是 number 2。
rows[0]!.input = 2
assert.equal(teamRanksDirty(rows), false)
rows[0]!.input = 3
assert.equal(teamRanksDirty(rows), true)
})
test('表格行:单位来自接口,已有名次回填,未填留空', () => {
const rows = teamRankRowsOf(makeView(), [])
assert.deepEqual(
rows.map((row) => [row.teamId, row.code, row.input, row.original]),
[
[1, 'JG', '2', '2'],
[2, 'F1', '', ''],
],
)
assert.equal(teamRanksDirty(rows), false)
})
test('接口没带单位时退回全局单位表,且不列停用 / 假删除的单位', () => {
const rows = teamRankRowsOf(makeView({ teams: [], ranks: [] }), [
makeTeam(1),
makeTeam(2, { active: false }),
makeTeam(3, { deletedAt: '2026-01-01' }),
])
assert.deepEqual(rows.map((row) => row.teamId), [1])
})
test('rank=0(清空)在表格里显示为未填,而不是 0', () => {
const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 0 }] }), [])
assert.equal(rows[0]?.input, '')
assert.equal(rows[0]?.original, '')
})
test('改动检测与保存载荷:整组回传,清空发 0', () => {
const rows = teamRankRowsOf(makeView(), [])
rows[0]!.input = '1'
rows[1]!.input = ''
assert.equal(teamRanksDirty(rows), true)
assert.deepEqual(teamRanksPayload(rows), {
scope: 'team',
basis: 'manual',
ranks: [
{ teamId: 1, rank: 1 },
{ teamId: 2, rank: 0 },
],
})
})
test('名次重复只提示不拦截,超出单位数同样只是提醒', () => {
const rows = teamRankRowsOf(makeView(), [])
rows[0]!.input = '1'
rows[1]!.input = '1'
const warnings = teamRankWarnings(rows)
assert.equal(warnings.length, 1)
assert.match(warnings[0] ?? '', /名次 1 重复 2 次,允许并列/)
assert.deepEqual([...teamRankFlaggedTeams(rows)], [1, 2])
// 超出参赛单位数(两个单位却填了 5)同样不抛错,只多一条提示。
rows[1]!.input = '5'
assert.match(teamRankWarnings(rows).join(';'), /超出参赛单位数 2/)
assert.deepEqual([...teamRankFlaggedTeams(rows)], [2])
})
test('没问题的输入不产生提示', () => {
const rows = teamRankRowsOf(makeView(), [])
rows[0]!.input = '1'
rows[1]!.input = '2'
assert.deepEqual(teamRankWarnings(rows), [])
assert.equal(teamRankFlaggedTeams(rows).size, 0)
})
test('示例数据:录入接口随行下发团体名次,保存后能原样读回', () => {
const event = mockDb.eventDetail(108)
assert.equal(hasTeamManualChannel(event.channels), true)
const board = mockDb.entryBoard(108)
const view = board.teamRanks.find((item) => item.channels.length > 0)
assert.ok(view !== undefined, 'TPULL 应随行下发团体名次视图')
assert.equal(view.categoryId, 108 * 10 + 1)
const rows = teamRankRowsOf(view, [])
assert.equal(rows.length, 7, '启用单位全部列出')
// 示例数据里预填了前两个单位,用来演示「已有值回填」。
assert.deepEqual(rows.slice(0, 2).map((row) => row.input), ['1', '2'])
// 改一个、清一个,保存后读回的口径与提交的一致。
rows[0]!.input = '3'
rows[1]!.input = ''
const saved = mockDb.saveTeamRanks(108, view.categoryId ?? 0, teamRanksPayload(rows))
const after = teamRankRowsOf(saved, [])
assert.equal(after[0]?.input, '3')
assert.equal(after[1]?.input, '', '空值 = 清空该单位')
assert.equal(mockDb.teamRanks(108, view.categoryId ?? 0).ranks.length, 1)
})
test('录入页按契约接线:区块可见性、随行数据优先、保存与 guest 只读', () => {
// 区块出现条件 = 该组别有团体手工通道。
assert.match(ENTRY_SOURCE, /hasTeamManualChannel\(channels\.value\)/)
assert.match(ENTRY_SOURCE, /v-if="showTeamRanks"/)
// 数据来源:录入接口随行的 teamRanks 优先,拿不到才退到 GET team-ranks。
assert.match(ENTRY_SOURCE, /adminApi\.entryBoard\(/)
assert.match(ENTRY_SOURCE, /adminApi\.teamRanks\(/)
// 保存走 PUT,并且是整组替换的载荷。
assert.match(ENTRY_SOURCE, /adminApi\.saveTeamRanks\(eventId, categoryId, teamRanksPayload\(/)
// 保存后整页刷新(名次 / 积分 / 榜单都会变)。
assert.match(ENTRY_SOURCE, /await reloadAfterSettlementChange\(\)/)
// 说明文案必须点明「按单位录入」且不影响个人名次。
assert.match(ENTRY_SOURCE, /按<strong>单位<\/strong>录入的团体名次/)
assert.match(ENTRY_SOURCE, /不影响个人名次/)
// guest / 断线只读:整块置灰 + 提示条。
assert.match(ENTRY_SOURCE, /'is-readonly': !canWrite/)
assert.match(ENTRY_SOURCE, /当前为访客只读视图,团体名次只能查看,无法录入/)
})
test('录入页按契约接线:联队成员合并成一行、名次写到主单位', () => {
// 表格行由本组别的报名行(teamShares 现成)合并而来,不为它多开一个接口。
assert.match(ENTRY_SOURCE, /mergeJointTeamRankRows\(teamRanksByCategory\.value\.get\(categoryId\) \?\? \[\], categoryRows\.value\)/)
// 联队行要能一眼认出来:徽标 + 成员单位与份额(与大屏 / 成绩表同一读法)。
assert.match(ENTRY_SOURCE, /v-if="row\.members\.length > 0" class="mx-badge mx-badge-info">联队</)
assert.match(ENTRY_SOURCE, /shareText\(member\)/)
// 被并掉的成员单位原来录过的名次要点名,别静默清掉。
assert.match(ENTRY_SOURCE, /mergedAwayRankNames\(/)
assert.match(ENTRY_SOURCE, /teamRankMergedAwayNames/)
})