import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import type { ScoreChannel, Team, TeamRanksView } from '@/shared' import { hasTeamManualChannel, parseTeamRankInput, teamRankFlaggedTeams, teamRankRowsOf, teamRankWarnings, teamRanksDirty, teamRanksPayload, } 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, } } 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('表格行:单位来自接口,已有名次回填,未填留空', () => { 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, /当前为访客只读视图,团体名次只能查看,无法录入/) })