Files
Scoreboard/web/src/admin/tests/records-breaks.test.ts
T
AskaEth ed2c0b3f3c feat: 主人拍板的 4 条全部落地(单条作废/重录、破纪录驳回、历史纪录、仪表盘卡片)+ 迁移 0021(dev_0.3.0)
## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `POST /api/results/{id}/reopen`:把已发布的那条退回草稿(state=draft、
  status=ok、rank=NULL、note=原因)并重算推榜;口径与既有 `void` 完全一致
  (回滚类动作,不受 `standings.live_on_publish` 约束)。
- 成绩录入页新增「操作」列:已发布行可「作废本条」,已作废行可「重新录入」,
  两者都必填原因、确认后立即重算+推大屏。**验收用例**:作废→重录→改→重发,
  名次/流水/榜单与「一开始就填对」逐字节一致。

## ② 破纪录的驳回 / 作废
- 迁移 `0021_record_breaks_note.sql`:`record_breaks` 加一列可空 `note`。
- 新增 `GET /api/record-breaks?state=`(recorder 起可读,guest 403)与
  `POST /api/record-breaks/{id}/reject`(必填原因):已批准的登记被作废时
  **撤回该笔加分、重算推榜、并把该级别当前纪录恢复为上一条**;`approve`
  放开为可对 rejected 再次批准。
- 破纪录页新增「已批准的破纪录」卡片(作废入口);原先只存在浏览器内存里的
  「拒绝」改为持久化(先登记再驳回,刷新不再复现)。

## ③ 历史纪录的写入路径(`records.source` 以前恒为「本届」)
- 新增 `POST /api/records`(source 只收 history)与 `DELETE /api/records/{id}`
  (删当前纪录时恢复上一条)。纪录库卡片新增「新增历史纪录」与「删除」。
- 至此《使用手册》6.2 承诺的「来源(本届/历史)」这一列才真正有意义。

## ④ 仪表盘补齐承诺的卡片
今天的小项(按 scheduledAt 的本地时区当天 + 进度)、草稿待发布、待确认破纪录、
大屏在线数;数据源读不到显示 `—` 而不是 0。文档里「管理端在线数」服务端没有
接口,按实际改写成本机管理端连接状态。

## 旧版数据包兼容(主人特别交代,三处验证)
迁移只加**可空**列、不重建表、不动数据行;旧赛事包导入后由 `main.go` 的
`store.MigrateEvent` 在启动时补跑迁移。证据:`migration_0021_test.go`
(旧库升级 + 幂等 + 老行取 NULL)、`migration_legacy_test.go` 扩到 0021、
`legacy_pack_upgrade_test.go`(真实旧 event.db 走 main 同一条路径后装配 API,
驳回老登记 → 加分撤回、纪录回退、旧数据逐表一行不差),另有真二进制冒烟。
未迁移的库上不存在任何新列读取。

## 文档
升级说明新增 dev_0.3.0(含「旧包仍可导入、自动补跑迁移」);使用手册新增
§4.10 与 §6.1/§6.2 明细;常见问题新增 Q98/Q99/Q100;devdocs 04/05/07/13/02
同步(含新接口表与错误分级)。

自检:服务端 13 包 ×2 全绿(httpapi 新增 reopen/reject/records/旧包 四个用例文件)、
前端 vue-tsc + 604 例 ×2 + build、Console 19 文件 + 54 例。
2026-09-12 01:27:09 +08:00

197 lines
9.1 KiB
TypeScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import type { RecordBreak } from '@/shared'
import {
IGNORE_BREAK_REASON,
breakBonusText,
breakKey,
breakNote,
handledBreakKeys,
RECORD_LEVEL_LABELS,
RECORD_LEVEL_OPTIONS,
recordSourceLabel,
} from '../pages/recordBreaks'
/**
* 破纪录的驳回 / 作废(P2 A41)。
*
* 以前只有「登记 + 批准」两条路:误判的纪录在系统内无法纠正,「忽略」还只存在浏览器内存里
* (刷新就回来)。现在驳回与作废都落服务端登记表,「已处理」的键用来抑制前端推导出来的疑似列表。
*
* 项目没有组件测试工具,纯逻辑直接测,界面接线读源码断言。
*/
const RECORDS_SOURCE = readFileSync(new URL('../pages/RecordsPage.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)
}
function makeBreak(overrides: Partial<RecordBreak>): RecordBreak {
return {
id: 1,
eventId: 101,
entryId: 7001,
level: 'meet',
resultValue: 11.2,
resultText: '11.20',
bonusPoints: 5,
state: 'pending',
approvedBy: null,
approvedAt: null,
...overrides,
}
}
// ── 已处理登记的键(抑制前端推导出的疑似列表) ───────────────────────────────
test('疑似条目的键与服务端登记行的键必须能对上(对不上就抑制不掉)', () => {
assert.equal(breakKey(101, 7001, 'meet'), '101-7001-meet')
})
test('approved / rejected 的登记会被抑制,pending 的不会', () => {
const keys = handledBreakKeys([
makeBreak({ id: 1, state: 'approved' }),
makeBreak({ id: 2, state: 'rejected', entryId: 7002 }),
makeBreak({ id: 3, state: 'pending', entryId: 7003 }),
])
assert.equal(keys.has(breakKey(101, 7001, 'meet')), true)
assert.equal(keys.has(breakKey(101, 7002, 'meet')), true)
assert.equal(keys.has(breakKey(101, 7003, 'meet')), false)
})
test('同一报名项的不同档位是两条独立的登记', () => {
const keys = handledBreakKeys([makeBreak({ state: 'approved', level: 'province' })])
assert.equal(keys.has(breakKey(101, 7001, 'province')), true)
assert.equal(keys.has(breakKey(101, 7001, 'meet')), false)
})
test('待确认列表按三层过滤(本机标记 + 服务端已处理)', () => {
const pending = section(RECORDS_SOURCE, 'const pendingBreaks = computed(', 'function levelRank(')
assert.match(pending, /!rejectedKeys\.value\.includes\(item\.key\)/)
assert.match(pending, /!approvedKeys\.value\.includes\(item\.key\)/)
assert.match(pending, /!handledKeys\.value\.has\(item\.key\)/)
})
test('推导疑似条目时用的就是 breakKey,两边不会各算一套', () => {
assert.match(RECORDS_SOURCE, /key: breakKey\(event\.id, entry\.id, best\.level\)/)
})
// ── 字段容错 ────────────────────────────────────────────────────────────────
test('来源:history / current 各有文案,缺字段显示 —(不冒充「历史」)', () => {
assert.equal(recordSourceLabel('history'), '历史')
assert.equal(recordSourceLabel('current'), '本届')
assert.equal(recordSourceLabel(null), '—')
assert.equal(recordSourceLabel(undefined), '—')
})
test('登记说明缺字段时不渲染', () => {
assert.equal(breakNote({ note: '现场判定不是破纪录' }), '现场判定不是破纪录')
assert.equal(breakNote({ note: ' 驳回 ' }), '驳回')
assert.equal(breakNote({ note: '' }), null)
assert.equal(breakNote({ note: null }), null)
assert.equal(breakNote({}), null)
})
test('加分缺字段时显示 —,不渲染成「+—」', () => {
assert.equal(breakBonusText({ bonusPoints: 5 }), '+5')
assert.equal(breakBonusText({ bonusPoints: 2.5 }), '+2.5')
assert.equal(breakBonusText({ bonusPoints: null }), '—')
assert.equal(breakBonusText({}), '—')
})
test('四个档位的文案与下拉选项齐全', () => {
assert.deepEqual(RECORD_LEVEL_OPTIONS.map((option) => option.value), [
'meet',
'city',
'province',
'national',
])
assert.equal(RECORD_LEVEL_LABELS.meet, '赛会')
assert.equal(RECORD_LEVEL_LABELS.national, '全国')
})
// ── 已批准的破纪录卡片 ──────────────────────────────────────────────────────
test('卡片数据来自 GET /api/record-breaks?state=approved', () => {
const loadBreaks = section(RECORDS_SOURCE, 'async function loadBreaks(', 'async function loadSuspected(')
assert.match(loadBreaks, /adminApi\.recordBreaks\('approved'\)/)
assert.match(loadBreaks, /adminApi\.recordBreaks\('rejected'\)/)
// 两态分开取、任一失败都只影响这张卡片:用 allSettled,不用 Promise.all(一个挂全挂)。
assert.match(loadBreaks, /Promise\.allSettled\(\[/)
assert.match(loadBreaks, /breaksUnavailable\.value = failure !== undefined/)
assert.match(RECORDS_SOURCE, /<span class="mx-card-title">已批准的破纪录<\/span>/)
})
test('接口读不到时卡片显示空态说明,不让整页报错', () => {
assert.match(RECORDS_SOURCE, /v-else-if="breaksUnavailable"/)
assert.match(RECORDS_SOURCE, /读不到破纪录登记/)
assert.match(RECORDS_SOURCE, /示例数据模式或后端版本较旧时没有这个接口/)
// 空态在「没有已批准的破纪录」之前判断:读不到不能被说成「没有」。
assert.ok(
RECORDS_SOURCE.indexOf('v-else-if="breaksUnavailable"') <
RECORDS_SOURCE.indexOf('approvedBreaks.length === 0'),
)
})
test('访客 403 与后端太旧分开说:权限问题不能显示成「接口不可用」', () => {
assert.match(RECORDS_SOURCE, /breaksForbidden\.value = failures\.some\(\(item\) => isForbidden\(item\.reason\)\)/)
assert.match(RECORDS_SOURCE, /访客只读:看不到破纪录登记/)
assert.match(RECORDS_SOURCE, /要求录入员及以上权限/)
})
test('每条登记一个「作废」按钮,调用 reject 并带上必填原因', () => {
assert.match(RECORDS_SOURCE, />\s*作废\s*<\/button>/)
const revoke = section(RECORDS_SOURCE, 'async function confirmRevoke(', 'function cancelRevoke(')
assert.match(revoke, /await adminApi\.rejectBreak\(item\.id, reason\)/)
assert.match(RECORDS_SOURCE, /:reason-required="true"/)
assert.match(RECORDS_SOURCE, /@confirm="confirmRevoke"/)
})
test('作废成功后刷新纪录库与榜单,并提示加分已撤回', () => {
const revoke = section(RECORDS_SOURCE, 'async function confirmRevoke(', 'function cancelRevoke(')
assert.match(revoke, /await Promise\.all\(\[loadRecords\(\), data\.refreshStandings\(\)\]\)/)
assert.match(revoke, /'加分已撤回,该级别纪录已恢复上一条'/)
assert.ok(
revoke.indexOf('rejectBreak') < revoke.indexOf('loadRecords()'),
'先作废再刷新,顺序反了会把旧数据当新数据',
)
})
test('访客只读时「作废」按钮置灰', () => {
const card = section(RECORDS_SOURCE, '<span class="mx-card-title">已批准的破纪录</span>', '</section>')
assert.match(card, /:disabled="!canWrite \|\| revoking"/)
})
// ── 「忽略」改成持久 ────────────────────────────────────────────────────────
test('忽略先登记再驳回,原因是固定的「现场判定不是破纪录」', () => {
const reject = section(RECORDS_SOURCE, 'async function confirmReject(', 'function blockSampleData(')
assert.match(reject, /adminApi\.client\.records\.createBreak\(/)
assert.match(reject, /await adminApi\.rejectBreak\(created\.id, IGNORE_BREAK_REASON\)/)
assert.equal(IGNORE_BREAK_REASON, '现场判定不是破纪录')
assert.ok(
reject.indexOf('createBreak') < reject.indexOf('rejectBreak(created.id'),
'必须先拿到 pending 行才能驳回',
)
})
test('写不进服务端时才退回本机标记,并如实说明刷新后会回来', () => {
const reject = section(RECORDS_SOURCE, 'async function confirmReject(', 'function blockSampleData(')
assert.match(reject, /toast\.warning\('已在本机忽略(未连接服务端)', '没有写入服务端,刷新页面后该条会重新出现'\)/)
// 不能出现「只在本地隐藏」这种旧说法:现在默认是落服务端的。
assert.doesNotMatch(RECORDS_SOURCE, /接口未提供拒绝动作/)
})
test('忽略确认框的正文按模式说清后果(示例数据下不能承诺「不会再出现」)', () => {
const message = section(RECORDS_SOURCE, 'const rejectMessage = computed(', 'async function confirmReject(')
assert.match(message, /示例数据模式没有后端可写:只在本机隐藏该条,刷新页面后它会重新出现。/)
assert.match(message, /会在服务端登记为一条破纪录记录并立即驳回/)
assert.match(RECORDS_SOURCE, /:message="rejectMessage"/)
})