import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { RESULT_ACTION_CONFIRM, RESULT_ACTION_SUCCESS, RESULT_ROW_ACTION_LABEL, RESULT_ROW_ACTION_TITLE, RESULT_ROW_ACTION_WORD, resultActionTarget, resultRowAction, } from '../pages/entryResultActions' /** * 成绩录入页「作废 / 重新录入」(P2 A16)。 * * 后端早有 `POST /api/results/{id}/void`,管理端却没有任何入口:现场改一个人填错的成绩, * 只能「整组撤销发布 → 改 → 重发」,同组其它人的名次与积分会被整体回退再重算。 * * 项目没有组件测试工具(无 @vue/test-utils / jsdom),判定逻辑抽在 entryResultActions 里直接测, * 界面接线按仓库既有做法读源码断言。 */ const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8') const DIALOG_SOURCE = readFileSync(new URL('../components/ConfirmDialog.vue', import.meta.url), 'utf8') function section(source: string, start: string, end: string): string { const from = source.indexOf(start) const to = source.indexOf(end, from) assert.ok(from >= 0 && to > from, `源码里找不到 ${start} … ${end} 这一段`) return source.slice(from, to) } // ── 哪一行显示哪个按钮 ────────────────────────────────────────────────────── test('已发布且未作废的行显示「作废本条」', () => { assert.equal(resultRowAction({ resultState: 'published', status: 'ok' }), 'void') assert.equal(resultRowAction({ resultState: 'published', status: 'dq' }), 'void') assert.equal(resultRowAction({ resultState: 'published', status: 'dns' }), 'void') }) test('已作废(published + withdrawn)的行显示「重新录入」', () => { // 服务端 void 只改 status、不动 state,所以「已作废」等于「已发布 + withdrawn」。 assert.equal(resultRowAction({ resultState: 'published', status: 'withdrawn' }), 'reopen') }) test('草稿行(含退回草稿后的行)不显示任何行操作:本来就是可编辑的', () => { assert.equal(resultRowAction({ resultState: 'draft', status: 'withdrawn' }), null) assert.equal(resultRowAction({ resultState: 'draft', status: 'ok' }), null) assert.equal(resultRowAction({ resultState: null, status: 'ok' }), null) }) test('按钮文案逐字:作废本条 / 重新录入', () => { assert.equal(RESULT_ROW_ACTION_LABEL.void, '作废本条') assert.equal(RESULT_ROW_ACTION_LABEL.reopen, '重新录入') assert.match(RESULT_ROW_ACTION_TITLE.void, /不再计入名次与积分/) assert.match(RESULT_ROW_ACTION_WORD.void, /作废/) }) // ── 动的是哪一条成绩 ──────────────────────────────────────────────────────── test('操作目标是成绩 id(接口按它定位),不是报名项 id', () => { const target = resultActionTarget({ entryId: 7001, resultId: 9001, resultState: 'published', status: 'ok', resultText: '11.58', }) assert.deepEqual(target, { action: 'void', resultId: 9001, entryId: 7001, resultText: '11.58', }) }) test('没有成绩 id 或不是已发布行时,不产生可提交的目标', () => { const base = { entryId: 7001, resultState: 'published' as const, status: 'ok' as const, resultText: '11.58' } assert.equal(resultActionTarget({ ...base, resultId: null }), null) assert.equal(resultActionTarget({ ...base, resultId: 9001, resultState: 'draft' }), null) }) // ── 界面接线 ──────────────────────────────────────────────────────────────── test('确认框文案写清了后果,且作废原因必填', () => { assert.equal(RESULT_ACTION_CONFIRM.void.title, '确认作废本条成绩?') assert.equal( RESULT_ACTION_CONFIRM.void.message, '作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新。', ) assert.equal(RESULT_ACTION_CONFIRM.reopen.title, '确认重新录入本条成绩?') assert.match(RESULT_ACTION_CONFIRM.reopen.message, /退回草稿(可编辑)/) assert.match(ENTRY_SOURCE, /:reason-required="true"/) assert.match(ENTRY_SOURCE, /@confirm="confirmResultAction"/) }) test('作废调用 void、退回草稿调用 reopen,提交的都是成绩 id', () => { const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(') assert.match(confirm, /await adminApi\.voidResult\(target\.resultId, \{ reason \}\)/) assert.match(confirm, /await adminApi\.reopenResult\(target\.resultId, \{ reason \}\)/) assert.doesNotMatch(confirm, /(voidResult|reopenResult)\(target\.entryId/) }) test('作废 / 退回成功后刷新本页数据与榜单', () => { const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(') // 名次与积分由服务端重算:必须整表重拉 + 刷新榜单,不能只在本地行上打补丁。 assert.match(confirm, /await reloadAfterSettlementChange\(\)/) assert.ok( confirm.indexOf('reloadAfterSettlementChange') < confirm.indexOf('toast.success'), '刷新要发生在成功提示之前,否则提示先说成功、页面还没变', ) const reload = section(ENTRY_SOURCE, 'async function reloadAfterSettlementChange(', 'async function confirmSettle(') assert.match(reload, /await reload\(\)/) assert.match(reload, /data\.refreshStandings\(\)/) }) test('成功后给 toast,失败时原样报错', () => { const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(') assert.match(confirm, /const success = RESULT_ACTION_SUCCESS\[target\.action\]/) assert.match(confirm, /toast\.success\(success\.title, success\.detail\)/) assert.match(confirm, /toast\.error\(target\.action === 'void' \? '作废失败' : '退回草稿失败'/) assert.match(RESULT_ACTION_SUCCESS.void.title, /名次与积分已重算/) }) test('示例数据模式在发请求前拦下,提示与既有口径一致', () => { const request = section(ENTRY_SOURCE, 'function requestResultAction(', 'async function confirmResultAction(') assert.match(request, /if \(data\.isMock\) \{/) assert.match(request, /sampleDataBlockedNotice\(RESULT_ROW_ACTION_WORD\[target\.action\]\)/) assert.ok( request.indexOf('data.isMock') < request.indexOf('resultAction.value = target'), 'isMock 判断要发生在打开确认框之前,示例数据下不弹一个点了也没用的框', ) }) test('访客只读时按钮保留但置灰(与既有 data.canWrite 同一口径)', () => { const cell = section(ENTRY_SOURCE, 'class="center action-cell"', '') assert.match(cell, /:disabled="!canWrite \|\| resultActionBusy"/) assert.match(cell, /\{\{ rowActionLabel\(row\) \}\}/) assert.match(cell, /@click="requestResultAction\(row\)"/) }) test('行能不能改只看服务端返回的 state:退回草稿后必须重新可编辑', () => { // 退回草稿(reopen)由服务端把 state 改回 draft;页面只有按 state 判定锁,行才会重新可编辑。 assert.match( ENTRY_SOURCE, /function rowLocked\(row: EditableRow\): boolean \{\n\s*return row\.resultState === 'published'\n\}/, ) assert.match(ENTRY_SOURCE, /resultState: result\.state \?\? null|resultState: result\?\.state \?\? null/) assert.match(ENTRY_SOURCE, /:disabled="!canWrite \|\| rowLocked\(row\)"/) }) test('已作废的行有独立徽标,不会被显示成「已发布」', () => { assert.match( ENTRY_SOURCE, /v-if="row\.resultState === 'published' && row\.status === 'withdrawn'"/, ) assert.match(ENTRY_SOURCE, />已作废<\/span>/) }) test('表格多了一列「操作」,空状态行的 colspan 跟着改', () => { assert.match(ENTRY_SOURCE, /操作<\/th>/) assert.match(ENTRY_SOURCE, //) assert.doesNotMatch(ENTRY_SOURCE, //) }) // ── ConfirmDialog 的必填原因 ──────────────────────────────────────────────── test('原因为空时不提交(emit confirm),只亮出提示', () => { const submit = section(DIALOG_SOURCE, 'function requestConfirm(', 'function onKeydown(') assert.match(submit, /if \(reasonMissing\.value\) \{/) assert.match(submit, /reasonTouched\.value = true/) assert.ok( submit.indexOf('reasonMissing.value') < submit.indexOf("emit('confirm'"), '必填判断要在 emit 之前', ) assert.match(submit, /emit\('confirm', reason\.value\.trim\(\)\)/) }) test('每次打开弹窗都清空上一次的原因', () => { assert.match(DIALOG_SOURCE, /watch\(\n\s*\(\) => props\.open,/) assert.match(DIALOG_SOURCE, /reason\.value = ''\n\s*reasonTouched\.value = false/) }) test('没有必填原因的老用法不受影响:reasonRequired 默认 false', () => { assert.match(DIALOG_SOURCE, /reasonRequired: false/) assert.match(DIALOG_SOURCE, /