import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { normalizeTeamShares, type ScoreChannel, type Team, type TeamRanksView } from '@/shared' import { hasTeamManualChannel, mergeJointTeamRankRows, mergedAwayRankNames, parseTeamRankInput, teamRankFlaggedTeams, teamRankRowsOf, teamRankWarnings, teamRanksDirty, teamRanksPayload, type TeamRankEntryRef, } from '../pages/teamRanks' import { mockDb } from '../api/mock' // 录入页「团体名次」区块(设计 17 §八第 3 条):纯逻辑在 teamRanks.ts, // 界面部分与仓库既有做法一致——没有组件测试工具,读源码断言关键接线。 const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8') function makeChannel(overrides: Partial = {}): ScoreChannel { return { id: 1, eventId: 108, scope: 'team', basis: 'manual', profileId: 2, sortOrder: 1, countToTeam: true, ...overrides } } function makeTeam(id: number, overrides: Partial = {}): Team { return { id, code: `T${id}`, name: `单位${id}`, shortName: `单位${id}`, logoPath: null, color: null, sortOrder: id, active: true, ...overrides, } } function makeView(overrides: Partial = {}): TeamRanksView { return { channels: [{ channelId: 3, scope: 'team', basis: 'manual', profileId: 2 }], teams: [ { teamId: 1, code: 'JG', name: '机关本部', shortName: '机关' }, { teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' }, ], ranks: [{ teamId: 1, rank: 2 }], ...overrides, } } // ── 联队(devdocs/19):一条报名挂多个单位时,团体名次区块要把成员单位合并成一行 ────── /** 一个成员单位的名字与编码(拼联队名 / 编码用)。 */ interface Member { id: number code: string name: string } const JG: Member = { id: 1, code: 'JG', name: '机关本部' } const F1: Member = { id: 2, code: 'F1', name: '第一分公司' } const F3: Member = { id: 3, code: 'F3', name: '第三分公司' } /** 一条报名:teamId 是主单位(entries.team_id),members 是份额里的成员单位(第一条即主单位)。 */ function makeEntry(teamId: number, members: readonly Member[]): TeamRankEntryRef { return { teamId, teamShares: normalizeTeamShares( members.map((member) => ({ teamId: member.id, weight: 1, teamCode: member.code, teamName: member.name })), ), } } test('联队:成员单位合并成一行,标签用大屏 / 导出同一口径的联队名', () => { const rows = teamRankRowsOf(makeView(), []) const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]) assert.deepEqual(merged.map((row) => row.teamId), [1], '成员单位不再单独占一行') assert.equal(merged[0]?.name, '机关本部+第一分公司联队') assert.equal(merged[0]?.shortName, null) assert.equal(merged[0]?.code, 'JG+F1', '编码用导入 / 导出的 A+B 写法') assert.deepEqual(merged[0]?.members.map((member) => member.teamId), [1, 2]) }) test('联队:名次落在主单位那一行,成员单位不再进保存载荷(重复计分就此堵死)', () => { const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 2 }, { teamId: 2, rank: 3 }] }), []) const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]) assert.equal(merged.length, 1) assert.equal(merged[0]?.input, '2', '主单位那一行原来录的名次回填到联队行') assert.equal(teamRanksDirty(merged), false) // 成员单位 2 库里那条名次 3 不再出现在载荷里:它不会被当成第二个名次主体。 assert.deepEqual(teamRanksPayload(merged), { scope: 'team', basis: 'manual', ranks: [{ teamId: 1, rank: 2 }], }) }) test('联队:输入框写回主单位那一行(副本会把草稿吞掉,保存按钮也不会亮)', () => { const rows = teamRankRowsOf(makeView(), []) const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]) merged[0]!.input = 4 assert.equal(rows[0]?.input, 4, '联队行的输入写回主单位那一行') assert.equal(teamRanksDirty(merged), true) assert.deepEqual(teamRanksPayload(merged).ranks, [{ teamId: 1, rank: 4 }]) }) test('组别里没有联队报名时,行与现在一字不差', () => { const rows = teamRankRowsOf(makeView(), []) // 单单位报名(份额只有一条)不是联队:连对象都不该换一个。 assert.deepEqual(mergeJointTeamRankRows(rows, [makeEntry(1, [JG])]), rows) assert.equal(mergeJointTeamRankRows(rows, [makeEntry(1, [JG])])[0], rows[0]) // 没有报名时同理。 assert.deepEqual(mergeJointTeamRankRows(rows, []), rows) }) test('成员单位自己也有报名时两条都列出来,且分得清', () => { const rows = teamRankRowsOf(makeView(), []) const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1]), makeEntry(2, [F1])]) assert.deepEqual(merged.map((row) => row.teamId), [1, 2], '别静默吞掉第一条') assert.equal(merged[0]?.name, '机关本部+第一分公司联队') assert.equal(merged[1]?.name, '第一分公司') assert.deepEqual(merged[1]?.members, [], '它自己那条不是联队') assert.equal(merged[1], rows[1], '普通行原样透传') }) test('一个单位同时是两个联队的成员单位时,两个联队各占一行', () => { const rows = teamRankRowsOf( makeView({ teams: [ { teamId: 1, code: 'JG', name: '机关本部', shortName: '机关' }, { teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' }, { teamId: 3, code: 'F3', name: '第三分公司', shortName: '三分公司' }, ], }), [], ) const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1]), makeEntry(2, [F1, F3])]) assert.deepEqual(merged.map((row) => row.teamId), [1, 2]) assert.equal(merged[0]?.name, '机关本部+第一分公司联队') assert.equal(merged[1]?.name, '第一分公司+第三分公司联队') }) test('主单位那一行不在列表里(单位停用 / 假删除)时不合并不吞', () => { const rows = teamRankRowsOf( makeView({ teams: [{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' }] }), [], ) assert.deepEqual(mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]), rows) }) test('参赛单位数按合并后的行数算', () => { const rows = teamRankRowsOf(makeView(), []) const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]) merged[0]!.input = '2' assert.match(teamRankWarnings(merged).join(';'), /超出参赛单位数 1/) assert.deepEqual([...teamRankFlaggedTeams(merged)], [1]) }) test('被并掉的成员单位原来录过的名次要点名(保存后会清掉,不静默改数)', () => { const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 2 }, { teamId: 2, rank: 3 }] }), []) assert.deepEqual(mergedAwayRankNames(rows, mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])), ['一分公司']) // 没录过名次的成员单位不点名。 const clean = teamRankRowsOf(makeView(), []) assert.deepEqual(mergedAwayRankNames(clean, mergeJointTeamRankRows(clean, [makeEntry(1, [JG, F1])])), []) }) test('只有 scope=team + basis=manual 的通道才需要「团体名次」区块', () => { assert.equal(hasTeamManualChannel([makeChannel()]), true) assert.equal(hasTeamManualChannel([makeChannel({ basis: 'aggregate' })]), false) assert.equal(hasTeamManualChannel([makeChannel({ scope: 'athlete' })]), false) assert.equal( hasTeamManualChannel([makeChannel({ scope: 'athlete', basis: 'result' }), makeChannel()]), true, ) assert.equal(hasTeamManualChannel([]), false) assert.equal(hasTeamManualChannel(null), false) assert.equal(hasTeamManualChannel(undefined), false) }) test('名次输入:空 / 0 / 负数 / 非整数一律按清空处理', () => { assert.equal(parseTeamRankInput('3'), 3) assert.equal(parseTeamRankInput(' 4 '), 4) // 留空 = 清空该单位,服务端口径是 rank 0。 assert.equal(parseTeamRankInput(''), 0) assert.equal(parseTeamRankInput(' '), 0) assert.equal(parseTeamRankInput('0'), 0) assert.equal(parseTeamRankInput('-2'), 0) assert.equal(parseTeamRankInput('2.5'), 0) assert.equal(parseTeamRankInput('abc'), 0) }) test('名次输入:输入框填了数字后 v-model 给的是 number,解析口径与字符串一致', () => { // type="number" 的 v-model 会被 Vue 自动转成 number(只有清空才是空串), // 所以 raw 是 number 时必须照样解析——直接 .trim() 会在渲染期抛 TypeError 导致白屏。 assert.equal(parseTeamRankInput(3), 3) assert.equal(parseTeamRankInput(0), 0) assert.equal(parseTeamRankInput(3.5), 0) }) test('input 是 number 时:提示 / 描红 / 保存载荷都不抛异常且结果正确', () => { const rows = teamRankRowsOf(makeView(), []) rows[0]!.input = 3 rows[1]!.input = 0 assert.deepEqual(teamRanksPayload(rows), { scope: 'team', basis: 'manual', ranks: [ { teamId: 1, rank: 3 }, { teamId: 2, rank: 0 }, ], }) // 超出参赛单位数(两个单位却填了 3):只提示不抛错。 assert.match(teamRankWarnings(rows).join(';'), /超出参赛单位数 2/) assert.deepEqual([...teamRankFlaggedTeams(rows)], [1]) // 重复名次(number 形态)同样算得出来。 rows[1]!.input = 3 assert.match(teamRankWarnings(rows).join(';'), /名次 3 重复 2 次/) assert.deepEqual([...teamRankFlaggedTeams(rows)], [1, 2]) }) test('改动检测:input 是 number、original 是字符串时不误判为已修改', () => { const rows = teamRankRowsOf(makeView(), []) // 回填的 original 是字符串 '2',输入框里没动过时 input 已经是 number 2。 rows[0]!.input = 2 assert.equal(teamRanksDirty(rows), false) rows[0]!.input = 3 assert.equal(teamRanksDirty(rows), true) }) test('表格行:单位来自接口,已有名次回填,未填留空', () => { const rows = teamRankRowsOf(makeView(), []) assert.deepEqual( rows.map((row) => [row.teamId, row.code, row.input, row.original]), [ [1, 'JG', '2', '2'], [2, 'F1', '', ''], ], ) assert.equal(teamRanksDirty(rows), false) }) test('接口没带单位时退回全局单位表,且不列停用 / 假删除的单位', () => { const rows = teamRankRowsOf(makeView({ teams: [], ranks: [] }), [ makeTeam(1), makeTeam(2, { active: false }), makeTeam(3, { deletedAt: '2026-01-01' }), ]) assert.deepEqual(rows.map((row) => row.teamId), [1]) }) test('rank=0(清空)在表格里显示为未填,而不是 0', () => { const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 0 }] }), []) assert.equal(rows[0]?.input, '') assert.equal(rows[0]?.original, '') }) test('改动检测与保存载荷:整组回传,清空发 0', () => { const rows = teamRankRowsOf(makeView(), []) rows[0]!.input = '1' rows[1]!.input = '' assert.equal(teamRanksDirty(rows), true) assert.deepEqual(teamRanksPayload(rows), { scope: 'team', basis: 'manual', ranks: [ { teamId: 1, rank: 1 }, { teamId: 2, rank: 0 }, ], }) }) test('名次重复只提示不拦截,超出单位数同样只是提醒', () => { const rows = teamRankRowsOf(makeView(), []) rows[0]!.input = '1' rows[1]!.input = '1' const warnings = teamRankWarnings(rows) assert.equal(warnings.length, 1) assert.match(warnings[0] ?? '', /名次 1 重复 2 次,允许并列/) assert.deepEqual([...teamRankFlaggedTeams(rows)], [1, 2]) // 超出参赛单位数(两个单位却填了 5)同样不抛错,只多一条提示。 rows[1]!.input = '5' assert.match(teamRankWarnings(rows).join(';'), /超出参赛单位数 2/) assert.deepEqual([...teamRankFlaggedTeams(rows)], [2]) }) test('没问题的输入不产生提示', () => { const rows = teamRankRowsOf(makeView(), []) rows[0]!.input = '1' rows[1]!.input = '2' assert.deepEqual(teamRankWarnings(rows), []) assert.equal(teamRankFlaggedTeams(rows).size, 0) }) test('示例数据:录入接口随行下发团体名次,保存后能原样读回', () => { const event = mockDb.eventDetail(108) assert.equal(hasTeamManualChannel(event.channels), true) const board = mockDb.entryBoard(108) const view = board.teamRanks.find((item) => item.channels.length > 0) assert.ok(view !== undefined, 'TPULL 应随行下发团体名次视图') assert.equal(view.categoryId, 108 * 10 + 1) const rows = teamRankRowsOf(view, []) assert.equal(rows.length, 7, '启用单位全部列出') // 示例数据里预填了前两个单位,用来演示「已有值回填」。 assert.deepEqual(rows.slice(0, 2).map((row) => row.input), ['1', '2']) // 改一个、清一个,保存后读回的口径与提交的一致。 rows[0]!.input = '3' rows[1]!.input = '' const saved = mockDb.saveTeamRanks(108, view.categoryId ?? 0, teamRanksPayload(rows)) const after = teamRankRowsOf(saved, []) assert.equal(after[0]?.input, '3') assert.equal(after[1]?.input, '', '空值 = 清空该单位') assert.equal(mockDb.teamRanks(108, view.categoryId ?? 0).ranks.length, 1) }) test('录入页按契约接线:区块可见性、随行数据优先、保存与 guest 只读', () => { // 区块出现条件 = 该组别有团体手工通道。 assert.match(ENTRY_SOURCE, /hasTeamManualChannel\(channels\.value\)/) assert.match(ENTRY_SOURCE, /v-if="showTeamRanks"/) // 数据来源:录入接口随行的 teamRanks 优先,拿不到才退到 GET team-ranks。 assert.match(ENTRY_SOURCE, /adminApi\.entryBoard\(/) assert.match(ENTRY_SOURCE, /adminApi\.teamRanks\(/) // 保存走 PUT,并且是整组替换的载荷。 assert.match(ENTRY_SOURCE, /adminApi\.saveTeamRanks\(eventId, categoryId, teamRanksPayload\(/) // 保存后整页刷新(名次 / 积分 / 榜单都会变)。 assert.match(ENTRY_SOURCE, /await reloadAfterSettlementChange\(\)/) // 说明文案必须点明「按单位录入」且不影响个人名次。 assert.match(ENTRY_SOURCE, /按单位<\/strong>录入的团体名次/) assert.match(ENTRY_SOURCE, /不影响个人名次/) // guest / 断线只读:整块置灰 + 提示条。 assert.match(ENTRY_SOURCE, /'is-readonly': !canWrite/) assert.match(ENTRY_SOURCE, /当前为访客只读视图,团体名次只能查看,无法录入/) }) test('录入页按契约接线:联队成员合并成一行、名次写到主单位', () => { // 表格行由本组别的报名行(teamShares 现成)合并而来,不为它多开一个接口。 assert.match(ENTRY_SOURCE, /mergeJointTeamRankRows\(teamRanksByCategory\.value\.get\(categoryId\) \?\? \[\], categoryRows\.value\)/) // 联队行要能一眼认出来:徽标 + 成员单位与份额(与大屏 / 成绩表同一读法)。 assert.match(ENTRY_SOURCE, /v-if="row\.members\.length > 0" class="mx-badge mx-badge-info">联队