Files
Scoreboard/web/src/admin/tests/entry-selection.test.ts
T
AskaEth 99c91f99f7 feat(entry): 团体报名「队员身份」—— 一条报名挂多个运动员,标识用号码布;版本 dev_0.5.6
## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」

**现状问题**:界面(`EntryPicker`)**本来就能勾多个运动员**,但提交时只把姓名拼成文字
(`entrySelection.ts` 的 `input.athletes.map(a => a.name)` 存进 `entries.member_names TEXT`)→ **身份全丢**
→ 系统不知道队里坐着谁 → **所有团体/对抗项目都给不出个人分、个人奖牌,上不了个人积分榜**。

## 口径
- **运动员的唯一标识是号码布**(主人明确更正,不是证件号)。
- **每个队员各拿一份完整的个人分** —— 是「**复制**」不是「拆」(与联队 `splitSharePoints` 正好相反)。
- **个人奖牌每人各一枚**;**单位总分与单位奖牌不受影响**(由通道开关 `count_to_team` / `count_to_team_medals` 控制)。

## 一、数据
- 迁移 `0026_entry_athletes.sql`:新表 `entry_athletes(entry_id, athlete_id, sort_order)`,PK `(entry_id, athlete_id)`,两个外键都 `ON DELETE CASCADE`。
  `entries` **一个字节没动**(`member_names` 保留为显示用名单;多队唯一键 `(category_id,team_id,squad) WHERE athlete_id IS NULL` 未触碰)。
- **老数据回填(尽力而为)**:拿 `member_names` 去 `athletes` 匹配 —— **号码布优先**,其次「姓名 + 单位」且同单位同名唯一;**匹配不上留空**,不硬猜。

## 二、引擎
- `RankedResult.MemberCopy`(报名项那份 `false`、队员副本 `true`);**只在 `scope=athlete` 通道展开**(`scope=team` 与 `basis=aggregate` 不展开)。
- **单位维度不重复计算的三道锁**(本单最大的坑):
  1. 队员副本流水 `count_to_team` **恒为 false** —— 单位总分那条 SQL 第一句就跳过它,而个人榜不看这个开关;
  2. 单位奖牌仍按**报名项**去重、且报名项那份**排在最前**;
  3. 队员副本**照抄 `TeamID` 与 `Shares`**(不换单位、不另造份额 —— 联队报名尤其危险)。
  反面也测了:个人奖牌若按报名项去重会**一枚都发不出来**(被 `AthleteID=0` 那份吃掉),故改为按「报名项 + 运动员」去重。

## 三、写 / 读
- `PUT /api/entries/{id}/athletes`(recorder+、CSRF):body `{"athletes":[{"athleteId":12}],"requestId":"..."}`,**只认 athleteId**;空数组=清空;
  改完当场重算推榜。400:athleteId 缺/不存在、同一人两次、>30 人、个人报名不能挂队员。
- 读:`entryDTO.athletes: [{athleteId, name, bib}]`(**始终是数组**);`memberNames` 原样保留,**两者可并存**(没档案的人只能留文字)。

## 四、导入
- 报名表「队员名单」列现在**按号码布认人**(命中即关联,同时保留文字);⚠️ **姓名完全不做匹配**(同名会认错,而认错的分已经上了榜);匹配不上**不报错、只当文字**。
- 边界逐条有 notice/报错:跨单位号码布**照关联 + notice**(号码布是唯一标识);同格重号只关联一次 + notice;**个人行**写了号码布只留文字 + notice;**认出 >30 人整行拦下**;成功时一条汇总 notice。
- **更新路必须重算**:老表先导入、成绩已发布,之后补上号码布重传 —— 不在更新路重算就会出现「名单里有队员、榜上没有他」(删掉该条件即红)。

## 五、界面与显示
- 补报名勾的人**真正提交**(导入成功后追加 `PUT /athletes`,**只发需要变的**:勾了人的单位、以及原来有队员这次一个没勾的(发 `[]`);一个都没勾且原来也没队员的一律不发);**队员写失败单独提示**(报名本身已加入,不冒充「加入报名失败」)。
- **改队员**:录入页「运动员/队」那格的**「队员」小按钮**(只有纯团体行才有),弹窗勾选式名单;**与「成员单位与份额」是两个独立写请求**(份额管「分给哪些单位」、队员管「这条报名里有哪几个人」)。
- 显示统一写法 **`姓名(号码布)`**(导出件;大屏只写姓名与 ` / ` 分隔 —— 号码布太长会把整屏字号压下去);**没有队员关联的老数据一个字不变**;
  ⚠️ **一条报名仍只占一行**(有护栏测试钉着);`squad`(队)与 `athletes`(队员)**正交**。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **921 例** 0 fail / Console 68 例 0 fail。
- 各块先红后绿并留原始输出;**老数据零变化**有 golden 对拍(引擎层 / 接口层 / 导出层逐行 dump)。

## 文档
`docs/报名名单导入规范.md`(队员列语义升级,列数仍是 13)、`docs/使用手册.md`(新增 4.13 团体报名的队员)、
`docs/升级说明.md`(新增 `dev_0.5.6` 一节)。

## 版本
`dev_0.5.5` → **`dev_0.5.6`**(含数据迁移 `0026`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。
2026-09-14 14:05:48 +08:00

585 lines
28 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 {
COLUMN_ALIASES,
MANDATORY_COLUMNS,
OPTIONAL_COLUMNS,
REQUIRED_COLUMNS,
SQUAD_COLUMN,
buildSelectionRows,
buildTeamSelectionRows,
buildTemplateCsv,
buildTeamSelections,
emptyTeamPick,
entryRowLabel,
importColumnIndex,
lookupImportColumn,
missingColumnMessage,
registeredAthleteIds,
registeredTeamIds,
registeredTeamSquads,
selectableTeamIds,
selectionAllowsSubmit,
setPickedTeams,
setTeamPickSquad,
teamSelectionAllowsSubmit,
templateFileName,
templateHeader,
toggleMemberPick,
toggleTeamPick,
toCsv,
} from '../components/baseData/entrySelection'
test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
assert.equal(toCsv([['a', 'b']]), 'a,b')
assert.equal(toCsv([['张,伟', 'x']]), '"张,伟",x')
assert.equal(toCsv([['张"伟', 'x']]), '"张""伟",x')
assert.equal(toCsv([['第一行\n第二行', 'x']]), '"第一行\n第二行",x')
// 同一格同时含逗号与引号:包裹一次,内部引号翻倍。
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
})
test('表头含证件号 / 性别 / 年龄 / 批次 / 队伍可选列,必填列不含这五列', () => {
assert.deepEqual(
[...REQUIRED_COLUMNS],
[
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age',
'bib', 'seed', 'heat', 'status', 'member_names', 'squad',
],
)
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('gender') + 1], 'age', 'age 紧跟在 gender 之后')
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('seed') + 1], 'heat', 'heat 紧跟在 seed 之后')
assert.equal(REQUIRED_COLUMNS.at(-1), SQUAD_COLUMN, '队伍列在最后:老表格一列都不会错位')
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age', 'heat', 'squad'])
assert.equal(MANDATORY_COLUMNS.includes('id_card'), false)
assert.equal(MANDATORY_COLUMNS.includes('age'), false)
assert.equal(MANDATORY_COLUMNS.includes('heat'), false, '批次列可缺省,缺省即未分批')
assert.equal(MANDATORY_COLUMNS.includes('squad'), false, '队伍列可缺省,缺省即不区分队伍')
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
})
test('buildSelectionRows 个人项目:一人一行,共 13 列并带上证件号 / 号码布', () => {
const rows = buildSelectionRows({
eventCode: 'M100',
categoryName: '男子组',
teamCode: 'F1',
mode: 'individual',
athletes: [
{ name: '张伟', idCard: '110101199001011234', bib: '101' },
{ name: '李强', idCard: null, bib: '102' },
{ name: '王芳', idCard: '110101199001011235', bib: null },
],
})
assert.equal(rows.length, 4, '表头 + 3 名运动员')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 13)
// 勾选报名的人员已在库里,年龄列留空表示「不修改」;批次列留空 = 未分批(到录入页再编排);
// 队伍列留空 = 个人报名没有「第几支队」这回事。
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', '', 'registered', '', ''])
// 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', '', ''])
// 只有证件号时 bib 列留空。
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', '', ''])
})
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
const rows = buildSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teamCode: 'F2',
mode: 'team',
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
})
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
assert.equal(rows[1]?.length, 13)
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨', ''])
})
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
const rows = buildSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teamCode: 'F2',
status: 'scratched',
mode: 'team',
athletes: [{ name: '王芳' }],
memberNames: ['刘洋', '王芳'],
})
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳', ''])
})
test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => {
const rows = buildSelectionRows({
eventCode: 'M100',
categoryName: '男子组',
teamCode: 'F1',
mode: 'individual',
athletes: [{ name: '无标识' }],
})
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', '', ''])
})
// 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。
test('buildSelectionRows 团体项目:0 名队员时 member_names 留空', () => {
const rows = buildSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teamCode: 'F2',
mode: 'team',
athletes: [],
})
assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名')
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', ''])
})
test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => {
assert.equal(selectionAllowsSubmit('team', 0), true, '团体项目允许只报单位')
assert.equal(selectionAllowsSubmit('team', 3), true)
assert.equal(selectionAllowsSubmit('individual', 0), false, '个人项目必须至少勾一人')
assert.equal(selectionAllowsSubmit('individual', 1), true)
})
// 团体报名改成「一次勾多个单位,每个单位一条报名」:产出必须与勾选顺序、列表顺序都稳定。
test('buildTeamSelectionRows 多个单位各一行,队员名单按 / 拼接,0 队员留空', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [
{ teamCode: 'F1', memberNames: ['王芳', '刘洋'] },
{ teamCode: 'F2', memberNames: [] },
{ teamCode: 'F3', memberNames: ['张伟'] },
],
})
assert.equal(rows.length, 4, '表头 + 3 个单位')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 13)
for (const row of rows.slice(1)) {
assert.equal(row[3], '', '团体行的 athlete_name 必须为空,服务端才按单位 upsert')
assert.equal(row[12], '', '不填队名时队伍列留空 = 不区分队伍')
}
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋', ''])
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', ''])
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟', ''])
})
test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可覆盖', () => {
const empty = buildTeamSelectionRows({ eventCode: 'BB', categoryName: '混合组', teams: [] })
assert.equal(empty.length, 1, '不勾单位就不产出数据行')
const scratched = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
status: 'scratched',
teams: [{ teamCode: 'F1', memberNames: [] }],
})
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', '', ''])
})
test('toggleTeamPick 勾选 / 取消单位,取消不清队员名单', () => {
let state = emptyTeamPick()
state = toggleTeamPick(state, 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true)
assert.deepEqual(state.teamIds, [1, 2])
assert.deepEqual(toggleTeamPick(state, 2, true).teamIds, [1, 2], '重复勾选幂等')
state = toggleTeamPick(state, 1, false)
assert.deepEqual(state.teamIds, [2])
assert.deepEqual(state.memberIds[1], [11], '取消勾选保留队员名单')
assert.deepEqual(toggleTeamPick(state, 1, true).memberIds[1], [11], '重新勾选恢复原名单')
})
test('toggleMemberPick 单位没勾选也能先登记队员,各单位名单互不影响', () => {
let state = toggleMemberPick(emptyTeamPick(), 3, 31, true)
state = toggleMemberPick(state, 3, 32, true)
state = toggleMemberPick(state, 4, 41, true)
assert.deepEqual(state.teamIds, [], '只登记队员不会顺手勾上单位')
assert.deepEqual(state.memberIds[3], [31, 32])
assert.deepEqual(state.memberIds[4], [41])
assert.deepEqual(toggleMemberPick(state, 3, 31, true).memberIds[3], [31, 32], '重复勾选幂等')
state = toggleMemberPick(state, 3, 31, false)
assert.deepEqual(state.memberIds[3], [32])
assert.deepEqual(state.memberIds[4], [41], '改一个单位的名单不动其它单位')
})
test('setPickedTeams 全选 / 清空只改单位勾选,保留已登记的队员', () => {
let state = toggleMemberPick(emptyTeamPick(), 1, 11, true)
state = setPickedTeams(state, [1, 2, 3])
assert.deepEqual(state.teamIds, [1, 2, 3])
assert.deepEqual(state.memberIds[1], [11])
state = setPickedTeams(state, [])
assert.deepEqual(state.teamIds, [])
assert.deepEqual(state.memberIds[1], [11], '清空后名单还在,重新勾上即恢复')
})
test('selectableTeamIds 只含启用中且未报名的单位', () => {
const teams = [
{ id: 1, active: true },
{ id: 2, active: false },
{ id: 3, active: true },
]
assert.deepEqual(selectableTeamIds(teams, new Set([3])), [1], '已停用与已报名的都不进「全选」')
assert.deepEqual(selectableTeamIds(teams, new Set()), [1, 3])
assert.deepEqual(selectableTeamIds(teams, new Set([1, 3])), [], '全报过之后全选为空')
assert.deepEqual(selectableTeamIds([], new Set([1])), [])
})
test('buildTeamSelections 只产出勾选单位的队员,并按列表顺序取启用中的人', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
{ id: 3, code: 'F3' },
]
const athletes = [
{ id: 11, teamId: 1, name: '张伟', active: true },
{ id: 12, teamId: 1, name: '李强', active: true },
{ id: 13, teamId: 1, name: '王芳', active: false },
{ id: 21, teamId: 2, name: '刘洋', active: true },
]
let state = toggleTeamPick(emptyTeamPick(), 2, true)
state = toggleTeamPick(state, 1, true)
state = toggleMemberPick(state, 1, 12, true)
state = toggleMemberPick(state, 1, 13, true)
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 3, 99, true)
// 勾中的人同时带上队员关联(athleteId,devdocs/20):姓名文字照旧进 memberNames。
assert.deepEqual(buildTeamSelections(state, teams, athletes), [
{
teamId: 1,
teamCode: 'F1',
memberNames: ['张伟', '李强'],
athletes: [
{ athleteId: 11, name: '张伟', bib: null },
{ athleteId: 12, name: '李强', bib: null },
],
},
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
])
assert.deepEqual(
buildTeamSelections(setPickedTeams(state, []), teams, athletes),
[],
'没勾单位就没有要提交的行',
)
})
test('teamSelectionAllowsSubmit 至少勾一个单位,队员可以为 0 人', () => {
assert.equal(teamSelectionAllowsSubmit(0), false, '一个单位都不勾不能提交')
assert.equal(teamSelectionAllowsSubmit(1), true, '只报单位(0 队员)可以提交')
assert.equal(teamSelectionAllowsSubmit(5), true)
})
// 端到端拼接:勾选状态 → 多单位报名行 → 送进导入接口的 CSV 文本。
// 服务端按表头名字取值,列数与转义必须和导入表一致。
test('勾选状态转成 CSV 后表头与导入表一致,每个单位一行', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
]
const athletes = [
{ id: 11, teamId: 1, name: '张,伟', active: true },
{ id: 12, teamId: 1, name: '李强', active: true },
{ id: 21, teamId: 2, name: '王芳', active: true },
]
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 1, 12, true)
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: buildTeamSelections(state, teams, athletes),
})
assert.equal(rows[1]?.length, REQUIRED_COLUMNS.length)
assert.equal(rows[2]?.length, REQUIRED_COLUMNS.length)
const lines = toCsv(rows).split('\n')
assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
assert.equal(lines.length, 3, '表头 + 两个单位')
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,"张,伟/李强",', '带逗号的姓名按 RFC 4180 转义')
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,,', '只报单位时 member_names 留空')
})
test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只认具体运动员', () => {
const entries = [
{ categoryId: 7, teamId: 1, athleteId: 11 },
{ categoryId: 7, teamId: 1, athleteId: 12 },
{ categoryId: 7, teamId: 2, athleteId: null },
{ categoryId: 8, teamId: 3, athleteId: 13 },
]
assert.deepEqual([...registeredTeamIds(entries, 7)].sort((a, b) => a - b), [1, 2])
assert.deepEqual([...registeredTeamIds(entries, 9)], [], '别的组别不算已报名')
assert.deepEqual([...registeredTeamIds(entries, undefined)], [], '没选组别时没有已报名')
assert.deepEqual([...registeredAthleteIds(entries, 7, 1)].sort((a, b) => a - b), [11, 12])
assert.deepEqual([...registeredAthleteIds(entries, 7, 2)], [], '团体行不带运动员')
assert.deepEqual([...registeredAthleteIds(entries, 8, 3)], [13])
assert.deepEqual([...registeredAthleteIds(entries, undefined, 1)], [])
})
// ── 队伍(迁移 0025):同一个单位在一个组别可以派多个队 ────────────────────────
test('不填队名时报名行为与改造前完全一致:队伍列留空、其余列一个字都没变', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [
{ teamCode: 'F1', memberNames: ['王芳'], squad: '' },
{ teamCode: 'F2', memberNames: [] },
],
})
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '表头就是导入表本身(队伍列在最后、可空)')
assert.equal(rows[1]?.length, REQUIRED_COLUMNS.length)
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳', ''])
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', ''])
assert.equal(SQUAD_COLUMN, 'squad', '列名是服务端 importColumns 里的规范名')
assert.equal(lookupImportColumn('队伍'), SQUAD_COLUMN, '中文表头「队伍」也认')
assert.equal(lookupImportColumn('队名'), SQUAD_COLUMN)
})
test('填了队名:队名进最后一列,其它列一个字不动', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [
{ teamCode: 'F1', memberNames: ['王芳', '刘洋'], squad: '一队' },
// 同一个单位换个队名 = 这个单位的第二支队(服务端按 组别 + 单位 + 队伍 upsert)。
{ teamCode: 'F1', memberNames: [], squad: '二队' },
// 没填队名的单位照样一行,队名格留空。
{ teamCode: 'F2', memberNames: ['张伟'] },
],
})
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS])
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋', '一队'])
assert.deepEqual(rows[2], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '', '二队'])
assert.deepEqual(rows[3], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '张伟', ''])
for (const line of toCsv(rows).split('\n')) {
assert.equal(line.split(',').length, REQUIRED_COLUMNS.length)
}
})
test('队名两侧空白去掉再上表:服务端存的就是去空白后的文本', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [{ teamCode: 'F1', memberNames: [], squad: ' 二队 ' }],
})
assert.equal(rows[1]?.[REQUIRED_COLUMNS.indexOf(SQUAD_COLUMN)], '二队')
})
test('setTeamPickSquad 只改这一个单位的队名,逐个字输入都保留', () => {
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = setTeamPickSquad(state, 1, '一')
state = setTeamPickSquad(state, 1, '一队')
assert.equal(state.squads?.[1], '一队')
assert.equal(state.squads?.[2], undefined, '另一个单位没填')
assert.deepEqual(state.teamIds, [1, 2], '填队名不动勾选')
// 清空输入框 = 改回不区分队伍,与从没填过一样。
assert.equal(setTeamPickSquad(state, 1, '').squads?.[1], '')
})
test('buildTeamSelections:填了队名的单位带上 squad,没填的与改造前逐字段相同', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
]
const athletes = [{ id: 11, teamId: 1, name: '张伟', active: true }]
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true)
assert.deepEqual(buildTeamSelections(state, teams, athletes), [
{
teamId: 1,
teamCode: 'F1',
memberNames: ['张伟'],
athletes: [{ athleteId: 11, name: '张伟', bib: null }],
},
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
])
const withSquad = setTeamPickSquad(state, 1, ' 二队 ')
assert.deepEqual(buildTeamSelections(withSquad, teams, athletes), [
{
teamId: 1,
teamCode: 'F1',
memberNames: ['张伟'],
athletes: [{ athleteId: 11, name: '张伟', bib: null }],
squad: '二队',
},
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
])
})
test('勾选状态 → CSV 端到端:一个单位两个队就是两行,队名在最后一列', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
]
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = setTeamPickSquad(state, 1, '一队')
state = setTeamPickSquad(state, 2, '青年队')
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: buildTeamSelections(state, teams, []),
})
const lines = toCsv(rows).split('\n')
assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,,一队')
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,,青年队')
})
test('registeredTeamSquads:按组别列出各单位已报名的队名,老报名(空串)不进表', () => {
const entries = [
{ categoryId: 7, teamId: 1, squad: '一队' },
{ categoryId: 7, teamId: 1, squad: '二队' },
{ categoryId: 7, teamId: 1, squad: '一队' },
{ categoryId: 7, teamId: 2, squad: '' },
{ categoryId: 7, teamId: 3 },
{ categoryId: 8, teamId: 4, squad: '一队' },
]
const squads = registeredTeamSquads(entries, 7)
assert.deepEqual(squads.get(1), ['一队', '二队'], '按报名表出现顺序、去重')
assert.equal(squads.has(2), false, '不区分队伍的老报名不进表,徽标仍只说「已报名」')
assert.equal(squads.has(3), false, '缺字段(老服务端)同样当空串')
assert.equal(squads.has(4), false, '别的组别不算')
assert.deepEqual([...registeredTeamSquads(entries, undefined)], [])
})
test('entryRowLabel 团体行带上队名:同一单位的两个队在报错文案里也分得开', () => {
assert.equal(entryRowLabel({ teamShort: '一司', squad: '二队' }), '一司二队')
assert.equal(entryRowLabel({ teamShort: '一司', teamName: '第一分公司', squad: '' }), '一司', '没队名时不变')
assert.equal(entryRowLabel({ teamName: '第一分公司', squad: '青年队' }), '第一分公司青年队')
assert.equal(entryRowLabel({ athleteName: '张伟', teamShort: '一司', squad: '二队' }), '张伟', '个人行只写姓名')
assert.equal(entryRowLabel({ memberNames: ['王芳'], teamShort: '一司', squad: '二队' }), '王芳')
})
// 团体 / 接力行服务端不下发 athleteName(omitempty),回落到队员 / 单位名,
// 否则防呆校验的文案会变成「:缺少成绩」。
test('entryRowLabel 团体行回落到队员首人 / 单位', () => {
assert.equal(entryRowLabel({ athleteName: '张伟', memberNames: null, teamShort: '一队' }), '张伟')
assert.equal(entryRowLabel({ athleteName: '', memberNames: ['王芳', '刘洋'], teamShort: '一队' }), '王芳')
assert.equal(entryRowLabel({ athleteName: null, memberNames: [], teamShort: '一队' }), '一队')
assert.equal(entryRowLabel({ athleteName: null, memberNames: null, teamShort: '', teamName: '第一分公司' }), '第一分公司')
assert.equal(entryRowLabel({}), '')
})
// ── CSV 模板:中英两套表头,列顺序与列数完全一致 ──────────────────────────────
test('两种语言的模板表头逐列对应:列数、顺序一致,只有语言不同', () => {
const zh = templateHeader('zh')
const en = templateHeader('en')
assert.deepEqual(en, [...REQUIRED_COLUMNS], '英文模板就是英文规范名')
assert.equal(zh.length, en.length, '两种模板列数必须一样')
assert.deepEqual(zh, [
'项目编码', '组别', '单位编码', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', '队伍',
])
assert.equal(zh.at(-1), '队伍', '队伍列也列在模板最后,与服务端 importColumns 同序')
// 每一列的中文写法必须与服务端 handlers_config.go 的别名逐字一致,否则中文模板传回去会报缺列。
assert.deepEqual(
zh,
REQUIRED_COLUMNS.map((column) => COLUMN_ALIASES[column][0]),
)
assert.equal(new Set(zh).size, zh.length, '中文表头不能有两列写成同一个词')
assert.equal(new Set(en).size, en.length)
})
test('模板 CSV:只有表头语言不同,示例行与列数完全相同', () => {
const zh = buildTemplateCsv('zh').split('\n')
const en = buildTemplateCsv('en').split('\n')
assert.equal(zh.length, en.length, '两种模板行数一致')
assert.equal(zh[0], templateHeader('zh').join(','))
assert.equal(en[0], templateHeader('en').join(','))
assert.deepEqual(zh.slice(1), en.slice(1), '示例行共用,只有表头换语言')
for (const line of zh) {
assert.equal(line.split(',').length, REQUIRED_COLUMNS.length, '每行都是 13 列')
}
})
test('模板文件名带语言后缀,下载下来分得清中英', () => {
assert.equal(templateFileName('zh'), 'entries-template-zh.csv')
assert.equal(templateFileName('en'), 'entries-template-en.csv')
assert.notEqual(templateFileName('zh'), templateFileName('en'), '两个文件名不能重名')
})
// ── 表头识别:英文规范名与中文别名同等对待 ────────────────────────────────────
test('lookupImportColumn 认英文名、中文别名,并容错全角 / BOM / 首尾空格', () => {
assert.equal(lookupImportColumn('event_code'), 'event_code')
assert.equal(lookupImportColumn('项目编码'), 'event_code')
assert.equal(lookupImportColumn('小项编码'), 'event_code')
assert.equal(lookupImportColumn('队员名单'), 'member_names')
assert.equal(lookupImportColumn('队伍'), 'squad', '队伍列的中文表头')
assert.equal(lookupImportColumn('EVENT_CODE'), 'event_code', '大小写不敏感')
assert.equal(lookupImportColumn(' event_code '), 'event_code', '首尾空格')
assert.equal(lookupImportColumn('\uFEFF项目编码'), 'event_code', 'BOM 粘在第一格列名上')
assert.equal(lookupImportColumn('\u3000姓名 '), 'athlete_name', '中文输入法的全角空格')
assert.equal(lookupImportColumn('EVENT_CODE'), 'event_code', '全角字母与全角下划线')
assert.equal(lookupImportColumn('报名项目'), undefined, '认不出的多余列忽略')
assert.equal(lookupImportColumn(''), undefined)
})
test('importColumnIndex 中英混用每列各自认;同一列写两遍直接拦下', () => {
const index = importColumnIndex([
'event_code', '组别', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', '队伍',
])
assert.equal(index.get('event_code'), 0)
assert.equal(index.get('category'), 1)
assert.equal(index.get('member_names'), 11, '下标按混用后的实际列序取')
assert.equal(index.get('squad'), 12, '队伍列在最后')
assert.equal(index.size, REQUIRED_COLUMNS.length)
// 老表格(12 列、没有队伍列)照样认得:队伍是可选列,缺列 = 不区分队伍(老流程一个字不用改)。
const legacy = importColumnIndex([
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age', 'bib', 'seed', 'heat', 'status', 'member_names',
])
assert.equal(legacy.size, REQUIRED_COLUMNS.length - 1)
assert.equal(legacy.has('squad'), false)
for (const header of [
['event_code', '项目编码', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'],
['event_code', 'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'],
]) {
assert.throws(() => importColumnIndex(header), /表头重复/, '同一列两种写法要拦下,不能静默丢一列')
}
})
test('缺列提示中英并列,且点到表头行', () => {
const message = missingColumnMessage('athlete_name')
assert.match(message, /缺少列 athlete_name(姓名)/)
assert.match(message, /第 1 行/)
assert.match(message, /中文与英文表头都认/)
})
// ImportSection 的四个下载按钮:现场人多手快,文案必须一眼能分(SFC 跑不进 node --test,
// 这里用源码契约断言接线:按钮确实在、点了确实按语言下载)。
//
// 两种模板:单表 CSV(「中文表头 / 英文表头」,前端本地拼)与分工作表 XLSX
// (「分工作表 · 中文表头 / 分工作表 · 英文表头」,由服务端按当前小项与组别生成)。
test('ImportSection 有四个模板下载按钮:单表两套表头 + 分工作表两套表头', () => {
const source = readFileSync(new URL('../components/baseData/ImportSection.vue', import.meta.url), 'utf8')
assert.match(source, />\s*<MxIcon name="export"[^<]*\/>\s*中文表头\s*</, '有「中文表头」按钮')
assert.match(source, />\s*<MxIcon name="export"[^<]*\/>\s*英文表头\s*</, '有「英文表头」按钮')
assert.match(source, />\s*<MxIcon name="export"[^<]*\/>\s*分工作表 · 中文表头\s*</, '有「分工作表 · 中文表头」按钮')
assert.match(source, />\s*<MxIcon name="export"[^<]*\/>\s*分工作表 · 英文表头\s*</, '有「分工作表 · 英文表头」按钮')
assert.match(source, /downloadTemplate\('zh'\)/)
assert.match(source, /downloadTemplate\('en'\)/)
assert.match(source, /downloadSheetTemplate\('zh'\)/)
assert.match(source, /downloadSheetTemplate\('en'\)/)
assert.match(source, /saveBlob\(blob, templateFileName\(language\)\)/, '单表文件名按语言取,两个文件不重名')
assert.match(source, /saveBlob\(blob, templateSheetFileName\(language\)\)/, '分工作表文件名按语言取')
assert.match(source, /buildTemplateCsv\(language\)/)
// 分工作表模板必须走服务端(浏览器里造不出多工作表 xlsx),且失败时要把服务端那句话显示出来。
assert.match(source, /data\.downloadEntriesTemplate\(language\)/)
assert.match(source, /templateError\.value = caught instanceof Error \? caught\.message : /)
assert.doesNotMatch(source, /entries-template\.csv/, '不再用不分语言的老文件名')
// 分工作表模板由服务端生成、以 Blob 交回本地存盘:不能把接口地址直接塞进 <a href>
// (服务端拒绝时回的是 JSON 错误体,浏览器会把那份 JSON 存成一个「下载成功」的文件)。
assert.match(source, /URL\.createObjectURL\(blob\)/)
})