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 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, 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(run: () => Promise): Promise { 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 { 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 \?\? \{\}\) \?\? ''/) })