## ① 成绩「作废本条 / 重新录入」——手滑填错了怎么办
- 新增 `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 例。
197 lines
9.1 KiB
TypeScript
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"/)
|
|
})
|