## 需求(主人原话)
> 「双打我给他定义的是团队赛事,团队赛事不应该本来就是可以配置多个人的吗」
> 「把队员身份证留住,**应该是服务于整个团体报名**」
**现状问题**:界面(`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` 两张)。
585 lines
28 KiB
TypeScript
585 lines
28 KiB
TypeScript
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\)/)
|
||
})
|