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