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

472 lines
23 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 { ApiError } from '@/shared'
import type { Athlete } from '@/shared'
import {
entryAthleteText,
entryRosterHead,
entryRosterText,
normalizeEntryAthletes,
} from '@/shared'
import {
REQUIRED_COLUMNS,
buildTeamSelectionRows,
buildTeamSelections,
emptyTeamPick,
entryRowLabel,
planTeamAthleteSync,
toggleMemberPick,
toggleTeamPick,
} from '../components/baseData/entrySelection'
import { planEntryBatch } from '../pages/entryBatch'
import { athleteCellName } from '../pages/entryRowDisplay'
import {
MAX_ENTRY_ATHLETES,
addAthleteDraft,
athleteChoiceRows,
athleteDraftDirty,
athleteDraftIssues,
athleteDraftOf,
athleteDraftPayload,
entryAthletesEditable,
entryAthletesPath,
removeAthleteDraft,
submitEntryAthletes,
} from '../pages/entryAthletes'
import type { AthleteDraftRow } from '../pages/entryAthletes'
/**
* 团体报名的「队员身份」(devdocs/20)。界面这一侧要钉住的是:
* 1. 勾了人就把 **athleteId** 一起提交上去(姓名文字不再是唯一信息);
* 2. member_names 的文字名单原样保留 —— 没档案的人只能留文字,这条路不能丢;
* 3. 已存在的团体报名能改队员,走 PUT /api/entries/{id}/athletes,带 CSRF 与同一个幂等键;
* 4. 任何显示队员的地方都念成「姓名(号码布)」;**没有队员关联的老数据一个字符都不变**。
*/
const BIB = (id: number, name: string, bib: string | null) => ({ athleteId: id, name, bib })
// ── 显示:有队员 / 没队员两种 ────────────────────────────────────────────────
test('没有队员关联(老数据)时,队名单的显示与现在逐字一样', () => {
assert.equal(entryRosterText({ memberNames: ['王芳', '刘洋'] }), '王芳、刘洋')
assert.equal(entryRosterText({ memberNames: [] }), '')
assert.equal(entryRosterText({ memberNames: null }), null)
assert.equal(entryRosterText({}), null)
assert.equal(entryRosterText({ athletes: [], memberNames: ['王芳'] }), '王芳')
// 老服务端根本不下发这个字段:一样当「没有队员」。
assert.equal(entryRosterText({ athletes: null, memberNames: ['王芳'] }), '王芳')
assert.equal(entryRosterHead({ memberNames: ['王芳', '刘洋'] }), '王芳')
assert.equal(entryRosterHead({ memberNames: null }), '')
assert.equal(entryRosterHead({}), '')
// 表格姓名列与报错行名的回落口径都一个字没变。
assert.equal(athleteCellName({ athleteName: '', memberNames: ['李四', '王五'] }), '李四、王五')
assert.equal(athleteCellName({ athleteName: '', memberNames: null }), '—')
assert.equal(athleteCellName({ athleteName: '张三', memberNames: null }), '张三')
assert.equal(entryRowLabel({ memberNames: ['王芳'], teamShort: '一司', squad: '二队' }), '王芳')
assert.equal(entryRowLabel({ memberNames: null, teamShort: '', teamName: '第一分公司' }), '第一分公司')
})
test('有队员关联时显示「姓名(号码布)」;没有号码布的人只写姓名', () => {
const athletes = [BIB(11, '张伟', 'A001'), BIB(12, '李强', null)]
assert.equal(entryAthleteText(BIB(11, '张伟', ' A001 ')), '张伟(A001)', '号码布去掉两侧空白')
assert.equal(entryAthleteText(BIB(12, '李强', null)), '李强')
assert.equal(entryAthleteText(BIB(12, '李强', ' ')), '李强')
assert.equal(entryRosterText({ athletes }), '张伟(A001)、李强')
// 文字名单与队员是同一批人时不重复列一遍(勾选报名写出来的正是这种情况)。
assert.equal(entryRosterText({ athletes, memberNames: ['张伟', '李强'] }), '张伟(A001)、李强')
assert.equal(entryRosterHead({ athletes }), '张伟(A001)')
assert.equal(athleteCellName({ athleteName: '', memberNames: null, athletes }), '张伟(A001)、李强')
assert.equal(entryRowLabel({ athleteName: '', memberNames: null, athletes, teamShort: '一司' }), '张伟(A001)')
})
// 没档案的人(临时借调、没录档案):只能留在文字名单里,显示也要看得见。
test('队员关联与文字名单并存:没有档案的人仍留在文字名单里', () => {
const athletes = [BIB(11, '张伟', 'A001')]
assert.equal(entryRosterText({ athletes, memberNames: ['张伟', '临时工小王'] }), '张伟(A001)、临时工小王')
assert.equal(entryRosterText({ athletes: [], memberNames: ['临时工小王'] }), '临时工小王')
assert.equal(entryRosterText({ athletes, memberNames: null }), '张伟(A001)')
})
test('normalizeEntryAthletes:恒为数组、丢脏行、按 athleteId 去重、号码布归一', () => {
assert.deepEqual(normalizeEntryAthletes(undefined), [])
assert.deepEqual(normalizeEntryAthletes(null), [])
assert.deepEqual(normalizeEntryAthletes('x'), [])
assert.deepEqual(
normalizeEntryAthletes([
{ athleteId: 11, name: '张伟', bib: ' A001 ' },
{ athleteId: 11, name: '张伟', bib: 'A001' },
{ athleteId: 0, name: '没有 id' },
{ name: '缺 athleteId' },
null,
]),
[BIB(11, '张伟', 'A001')],
)
assert.deepEqual(normalizeEntryAthletes([{ athleteId: 12, name: '李强' }]), [BIB(12, '李强', null)])
assert.deepEqual(normalizeEntryAthletes([{ athleteId: 12 }]), [BIB(12, '', null)])
})
// ── 补报名 / 团体报名:勾了人就把 athleteId 带上 ──────────────────────────────
const ATHLETES = [
{ id: 11, teamId: 1, name: '张伟', bib: 'A001', active: true },
{ id: 12, teamId: 1, name: '李强', bib: null, active: true },
{ id: 13, teamId: 1, name: '王芳', bib: 'A003', active: false },
{ id: 21, teamId: 2, name: '刘洋', bib: 'B001', active: true },
]
test('勾了人:buildTeamSelections 把 athleteId 带上,memberNames 仍是姓名文字', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
]
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 12, true)
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 1, 13, true)
const selections = buildTeamSelections(state, teams, ATHLETES)
assert.equal(selections[0]?.teamId, 1, '带上单位 id:提交后要靠它找回刚写进去的那条报名')
assert.deepEqual(selections[0]?.memberNames, ['张伟', '李强'], '姓名文字按列表顺序,与改造前一样')
assert.deepEqual(selections[0]?.athletes, [BIB(11, '张伟', 'A001'), BIB(12, '李强', null)], '停用的人不进名单也不进关联')
assert.deepEqual(selections[1], {
teamId: 2,
teamCode: 'F2',
memberNames: [],
athletes: [],
}, '一个都没勾的单位没有队员关联(=只报单位)')
})
test('队员进的是人员关联,不是 CSV:member_names 仍是姓名文字、列数不变', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [
{
teamCode: 'F1',
memberNames: ['张伟', '李强'],
athletes: [BIB(11, '张伟', 'A001'), BIB(12, '李强', null)],
},
],
})
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS])
assert.deepEqual(rows[1], [
'BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '张伟/李强', '',
])
})
test('没档案的人仍走文字名单:队员关联为空时,姓名照样写进 member_names', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [{ teamCode: 'F1', memberNames: ['临时工小王'], athletes: [] }],
})
assert.equal(rows[1]?.[11], '临时工小王', '文字名单是没档案的人唯一的登记方式')
assert.deepEqual(rows[1]?.slice(12), [''], '队伍列照旧留空')
})
test('planTeamAthleteSync:按 组别+单位+队伍 找到刚提交的那条报名,只发 athleteId', () => {
const selections = [
{ teamId: 1, teamCode: 'F1', memberNames: ['张伟'], athletes: [BIB(11, '张伟', 'A001')] },
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
{ teamId: 3, teamCode: 'F3', memberNames: ['王芳'], squad: '二队', athletes: [BIB(31, '王芳', null)] },
{ teamId: 4, teamCode: 'F4', memberNames: [], athletes: [] },
]
const entries = [
{ id: 101, categoryId: 7, teamId: 2, squad: '', athletes: [BIB(21, '刘洋', 'B001')] },
{ id: 102, categoryId: 7, teamId: 2, squad: '', athletes: [] },
{ id: 103, categoryId: 7, teamId: 3, squad: '二队' },
{ id: 104, categoryId: 8, teamId: 3, squad: '二队' },
]
assert.deepEqual(planTeamAthleteSync(selections, entries, 7), [
// 单位 1:新报的名(entries 里还没有这一条)——导入那一步刚写进去,这里不该猜 id。
// 单位 2:原来挂着队员,这次一个都没勾 → 要发空数组把关联清掉。
// 单位 3:队伍名对上才算这一条;同单位同队名的另组别那条不算。
{ entryId: 101, athletes: [] },
{ entryId: 103, athletes: [{ athleteId: 31 }] },
])
})
test('planTeamAthleteSync:没有队员要写、原来也没有关联时一个请求都不发', () => {
const selections = [{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] }]
const entries = [{ id: 102, categoryId: 7, teamId: 2, squad: '', athletes: [] }]
assert.deepEqual(planTeamAthleteSync(selections, entries, 7), [])
// 一个单位都没勾(或组别没选)时也没有要同步的东西。
assert.deepEqual(planTeamAthleteSync([], entries, 7), [])
assert.deepEqual(planTeamAthleteSync(selections, entries, undefined), [])
})
// ── 改队员:PUT /api/entries/{id}/athletes ──────────────────────────────────
interface Captured {
url?: string
method?: string
headers?: Record<string, string>
body?: { athletes?: unknown; requestId?: unknown }
credentials?: string
}
let lastRequest: Captured = {}
let reply: () => Response = () => jsonResponse({ ok: true, data: {} })
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = {
url: String(input),
method: init?.method ?? 'GET',
headers: (init?.headers ?? {}) as Record<string, string>,
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
credentials: init?.credentials,
}
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
function withCsrfCookie<T>(run: () => Promise<T>): Promise<T> {
const original = (globalThis as { document?: unknown }).document
;(globalThis as { document?: unknown }).document = { cookie: 'a=1; csrf_token=tok-9; b=2' }
return run().finally(() => {
if (original === undefined) delete (globalThis as { document?: unknown }).document
else (globalThis as { document?: unknown }).document = original
})
}
test('改队员走 PUT /api/entries/{id}/athletes,带 CSRF 与同一个幂等键,body 只认 athleteId', async () => {
await withCsrfCookie(async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: { id: 7, athletes: [BIB(12, '李强', 'A002'), BIB(11, '张伟', 'A001')] },
}),
)
const outcome = await submitEntryAthletes(7, [{ athleteId: 12 }, { athleteId: 11 }])
assert.equal(entryAthletesPath(7), '/api/entries/7/athletes')
assert.equal(lastRequest.url, '/api/entries/7/athletes')
assert.equal(lastRequest.method, 'PUT')
assert.equal(lastRequest.credentials, 'include')
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-9')
assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8')
const headerId = lastRequest.headers?.['X-Request-Id']
assert.equal(typeof headerId, 'string', '写请求必须带幂等键(header)')
assert.equal(lastRequest.body?.requestId, headerId, 'header 与 body.requestId 是同一把钥匙')
assert.deepEqual(lastRequest.body?.athletes, [{ athleteId: 12 }, { athleteId: 11 }], '只认 athleteId,姓名 / 号码布不上行')
// 服务端回显的队员交给页面直接回填。
assert.deepEqual(outcome.athletes, [BIB(12, '李强', 'A002'), BIB(11, '张伟', 'A001')])
assert.equal(outcome.entry?.id, 7)
})
})
test('清空队员:空数组是合法载荷(=只报单位),不是「什么都没做」', async () => {
respondWith(() => jsonResponse({ ok: true, data: { id: 7, athletes: [] } }))
const outcome = await submitEntryAthletes(7, [])
assert.equal(lastRequest.method, 'PUT')
assert.deepEqual(lastRequest.body?.athletes, [])
assert.deepEqual(outcome.athletes, [])
})
test('服务端 400 的中文原文原样抛出,前端不改写', async () => {
respondWith(() =>
jsonResponse(
{
ok: false,
error: {
code: 'VALIDATION_FAILED',
message: '这条报名带着运动员(个人报名):它的身份就是那一个运动员,不能再挂队员——队员只属于「只报单位」的团体报名',
},
},
400,
),
)
await assert.rejects(
() => submitEntryAthletes(7, [{ athleteId: 11 }]),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'VALIDATION_FAILED')
assert.match(error.message, /不能再挂队员/)
return true
},
)
})
test('网络不可达时说清「没有改成功」,不能让操作员以为已经落库', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
await assert.rejects(
() => submitEntryAthletes(7, [{ athleteId: 11 }]),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.code, 'NETWORK')
assert.match(error.message, /没有改成功/)
return true
},
)
})
// ── 编辑器模型 ──────────────────────────────────────────────────────────────
function draftRow(athleteId: number, name: string, bib: string | null = null): AthleteDraftRow {
return { athleteId, name, bib }
}
function athlete(id: number, name: string, over: Partial<Athlete> = {}): Athlete {
return {
id,
name,
gender: null,
teamId: 1,
employeeNo: null,
idCard: null,
bib: null,
age: null,
active: true,
...over,
}
}
test('编辑器初值:摆出这条报名当前的队员;没动过就不发请求', () => {
const athletes = [BIB(11, '张伟', 'A001'), BIB(12, '李强', null)]
const draft = athleteDraftOf({ athletes })
assert.deepEqual(draft, [draftRow(11, '张伟', 'A001'), draftRow(12, '李强', null)])
assert.equal(athleteDraftDirty(draft, athleteDraftOf({ athletes })), false)
// 老数据(没有队员关联):打开就是空名单,一个勾都没有,等于没动过。
assert.deepEqual(athleteDraftOf({ athletes: [] }), [])
assert.deepEqual(athleteDraftOf({ athletes: null }), [])
assert.deepEqual(athleteDraftOf({}), [])
assert.deepEqual(athleteDraftPayload(draft), [{ athleteId: 11 }, { athleteId: 12 }])
})
test('加人 / 去人:顺序按点选先后,改回原样就不算改动', () => {
const original = athleteDraftOf({ athletes: [BIB(11, '张伟', 'A001')] })
let draft = addAthleteDraft(original, athlete(12, '李强', { bib: 'A002' }))
assert.deepEqual(draft, [draftRow(11, '张伟', 'A001'), draftRow(12, '李强', 'A002')])
assert.equal(athleteDraftDirty(draft, original), true)
// 同一个人加不进两次。
assert.deepEqual(addAthleteDraft(draft, athlete(12, '李强')), draft)
draft = removeAthleteDraft(draft, 12)
assert.equal(athleteDraftDirty(draft, original), false, '去掉刚加的人=回到初值')
assert.deepEqual(removeAthleteDraft(draft, 99), draft, '去掉不在名单里的人不动草稿')
})
test('勾选式增删:草稿的顺序就是「队里有谁」的排列,写进去的 sort_order 跟着它', () => {
let draft = addAthleteDraft([], athlete(11, '张伟', { bib: 'A001' }))
draft = addAthleteDraft(draft, athlete(12, '李强'))
assert.deepEqual(draft, [draftRow(11, '张伟', 'A001'), draftRow(12, '李强', null)])
assert.deepEqual(athleteDraftPayload(draft), [{ athleteId: 11 }, { athleteId: 12 }])
draft = removeAthleteDraft(draft, 11)
assert.deepEqual(draft, [draftRow(12, '李强', null)])
})
test('候选名单:本单位启用中的人才可选,名单里已有的(停用 / 借调 / 档案没了)也看得见', () => {
const draft = [draftRow(11, '张伟', 'A001'), draftRow(13, '王芳', null), draftRow(31, '临时借调', null)]
const rows = athleteChoiceRows(
[
athlete(11, '张伟', { bib: 'A001', employeeNo: 'E11' }),
athlete(12, '李强', { active: false }),
athlete(13, '王芳', { active: false }),
athlete(21, '刘洋', { teamId: 2, bib: 'B001' }),
athlete(14, '赵六', { teamId: 1, employeeNo: 'E14' }),
],
draft,
1,
)
assert.deepEqual(
rows.map((row) => `${row.name}${row.picked ? '✓' : ''}${row.note === '' ? '' : `·${row.note}`}`),
['张伟✓', '赵六', '王芳✓·已停用', '临时借调✓·档案里查不到'],
'本单位启用中的人在前,已经在名单里的仍然看得见(借调来的人还在队里)',
)
assert.equal(rows.find((row) => row.name === '张伟')?.employeeNo, 'E11')
assert.equal(rows.some((row) => row.name === '李强'), false, '停用的人不能再被勾进名单')
assert.equal(rows.some((row) => row.name === '刘洋'), false, '别的单位的人不进候选(除非已经在这条报名上)')
// 借调来的人(别的单位、仍然启用)在名单里要标明「其他单位」,不能谎报成已停用。
const borrowed = athleteChoiceRows([athlete(21, '刘洋', { teamId: 2 })], [draftRow(21, '刘洋', 'B001')], 1)
assert.deepEqual(borrowed.map((row) => `${row.name}·${row.note}`), ['刘洋·其他单位'])
})
test('校验:没有队员时合法(=只报单位);超过上限要说出来', () => {
assert.deepEqual(athleteDraftIssues([]), [])
const many = Array.from({ length: MAX_ENTRY_ATHLETES + 1 }, (_, index) =>
draftRow(index + 1, `选手${index + 1}`),
)
assert.match(athleteDraftIssues(many).join(';'), /最多 30 人/)
assert.deepEqual(athleteDraftIssues(many.slice(0, MAX_ENTRY_ATHLETES)), [])
})
test('entryAthletesEditable:只有纯团体报名(没有运动员的)能改队员', () => {
assert.equal(entryAthletesEditable({ athleteId: null }), true)
assert.equal(entryAthletesEditable({ athleteId: 11 }), false, '个人报名的身份就是那一个运动员')
})
test('批量确认框:有队员的团体行念全名单,没有队员关联的行一个字都没变', () => {
const base = { bib: null, team: '第一分公司', categoryId: 7, entryStatus: 'registered' as const }
const plan = planEntryBatch('remove', [
{ ...base, entryId: 1, name: '张伟(A001)', members: '张伟(A001)、李强(A002)' },
{ ...base, entryId: 2, name: '王芳' },
])
assert.equal(plan.changed, 2)
assert.ok(
plan.details.some((line) => line.includes('张伟(A001)、李强(A002)|号码 —|第一分公司')),
'队里的每个人都写在确认框里(删掉的是这一整条报名)',
)
assert.ok(
plan.details.some((line) => line.includes('· 王芳|号码 —|第一分公司')),
'没有队员关联的行与升级前逐字一致',
)
})
// ── 接线(SFC 挂不进 node --test,用源码契约钉住,与 entry-squad.test.ts 同一套写法) ──
const PICKER = readFileSync(new URL('../components/baseData/EntryPicker.vue', import.meta.url), 'utf8')
const ENTRY = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
const REVIEW = readFileSync(new URL('../pages/ReviewPage.vue', import.meta.url), 'utf8')
const RECORDS = readFileSync(new URL('../pages/RecordsPage.vue', import.meta.url), 'utf8')
const HISTORY = readFileSync(new URL('../pages/HistoryPage.vue', import.meta.url), 'utf8')
test('补报名接线:导入成功后再挂队员,失败不冒充「加入报名失败」', () => {
assert.match(
PICKER,
/await importSelection\(rows, event\.id\)[\s\S]{0,160}await syncTeamAthletes\(teams, category\.id\)/,
'先有报名才谈得上挂队员(athleteId 要挂到刚写出来的那条报名上)',
)
assert.match(PICKER, /planTeamAthleteSync\(selections, entries\.value, categoryId\)/)
assert.match(PICKER, /submitEntryAthletes\(item\.entryId, item\.athletes\)/)
// syncTeamAthletes 自己吞掉失败(报名已经写进去了),只单独提示队员没挂上。
assert.match(PICKER, /报名本身已经加入/)
// 示例数据模式没有真服务端:写请求发出去只会报一个假失败。
assert.match(PICKER, /if \(data\.isMock\.value\) return/)
})
test('录入页接线:只有纯团体行有「队员」入口,保存走 PUT 且不整表重拉', () => {
assert.match(ENTRY, /v-if="entryAthletesEditable\(row\)"/, '个人报名那一格不出现这个入口')
assert.match(ENTRY, /@click="openAthleteEditor\(row\)"/)
assert.match(ENTRY, /athletes: normalizeEntryAthletes\(entry\.athletes\)/, '老服务端不下发时按空数组')
assert.match(ENTRY, /const outcome = await submitEntryAthletes\(editor\.entryId, payload\)/)
// 没改动 / 有校验问题时不发请求(与成员单位弹窗同一口径)。
assert.match(ENTRY, /athleteEditorDirty\.value &&\s*athleteEditorIssues\.value\.length === 0/)
// 队员只决定分记给谁,不影响任何一行的成绩:保存后不整表重拉(会把没保存的成绩编辑冲掉)。
assert.match(ENTRY, /const saved = outcome\.athletes \?\? editor\.draft[\s\S]{0,400}void data\.refreshStandings\(\)/)
assert.doesNotMatch(ENTRY, /await submitEntryAthletes\(editor\.entryId, payload\)[\s\S]{0,400}loadEvent\(/)
})
test('显示接线:复核页 / 破纪录页 / 历史页的报名行都念队员(有队员看号码布,没队员不动)', () => {
assert.match(REVIEW, /\{\{ athleteCellName\(row\) \}\}/, '复核页运动员列走同一份口径')
assert.match(REVIEW, /row\.athleteName \|\| entryRosterText\(row\) \|\| team/, '报错文案里的行名也念队员')
assert.match(REVIEW, /athletes: normalizeEntryAthletes\(entry\.athletes\)/)
assert.match(RECORDS, /entry\.athleteName \?\? entryRosterText\(entry\) \?\? '—'/)
assert.match(HISTORY, /info\?\.athleteName \?\? entryRosterText\(info \?\? \{\}\) \?\? ''/)
})