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` 两张)。
This commit is contained in:
AskaEth committed 2026-09-14 14:05:48 +08:00
1 parent 7a3e4ad365
commit 99c91f99f7
57 files changed
+4903 -94

No files matched your search

+471
View File
@@ -0,0 +1,471 @@
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 \?\? \{\}\) \?\? ''/)
})
+26 -6
View File
@@ -245,9 +245,18 @@ test('buildTeamSelections 只产出勾选单位的队员,并按列表顺序取
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 3, 99, true)
// 勾中的人同时带上队员关联(athleteId,devdocs/20):姓名文字照旧进 memberNames。
assert.deepEqual(buildTeamSelections(state, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟', '李强'] },
{ teamCode: 'F2', memberNames: [] },
{
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),
@@ -382,14 +391,25 @@ test('buildTeamSelections:填了队名的单位带上 squad,没填的与改
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true)
assert.deepEqual(buildTeamSelections(state, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟'] },
{ teamCode: 'F2', memberNames: [] },
{
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), [
{ teamCode: 'F1', memberNames: ['张伟'], squad: '二队' },
{ teamCode: 'F2', memberNames: [] },
{
teamId: 1,
teamCode: 'F1',
memberNames: ['张伟'],
athletes: [{ athleteId: 11, name: '张伟', bib: null }],
squad: '二队',
},
{ teamId: 2, teamCode: 'F2', memberNames: [], athletes: [] },
])
})
+3 -1
View File
@@ -55,7 +55,9 @@ test('复核页:单位列同样显示「单位名 + 队名」', () => {
assert.match(REVIEW, /\{\{ teamCellText\(row\) \}\}/)
assert.doesNotMatch(REVIEW, /\{\{ row\.teamShort \|\| row\.teamName \}\}/)
assert.match(REVIEW, /squad: normalizeSquad\(entry\.squad\)/)
assert.match(REVIEW, /row\.memberNames\?\.join\('、'\) \|\| team \|\| `#\$\{row\.entryId\}`/, '报错文案里的行名带队名')
// 行名的链还是「文字名单 → 队名 → #id」,只是文字名单换成了 entryRosterText(有队员关联时
// 念「姓名(号码布)」、没有关联时就是 memberNames 的拼法,逐字不变,见 entry-athletes.test.ts)。
assert.match(REVIEW, /entryRosterText\(row\) \|\| team \|\| `#\$\{row\.entryId\}`/, '报错文案里的行名带队名')
})
test('补报名:队伍输入框只在勾上单位后出现,不勾时界面与过去完全一样', () => {