Files
Scoreboard/web/src/admin/tests/entry-guards.test.ts
T
AskaEth e5e0944d26 feat: 分级拦截日志 + 两类日志文件;成绩录入防呆/解析/宽度修复
## 日志(用户要求:分级 + 每个拦截都要有指出代码行的调试日志 + 两类文件)
- 新增 internal/logging:拦截点捕获(file:line + 函数名,相对路径)、两类文件轮转、
  按级别/类别分流。httpapi 与 middleware 各自薄封装,无循环依赖。
- 两类目录(数据目录下):
  operations/     放行类(INFO「操作已放行」+ 常规运行日志,按 log.level)
  interceptions/  拦截类(WARN 中文原因 + 拦截点 + method/path/actor/role/requestId、
                  DEBUG 上下文、ERROR 服务端异常)—— **固定 debug 采集**,
                  否则默认 info 下"每次拦截的调试日志"根本看不见
- 命名 operations-YYYYMMDD[.N].log,按天+按大小轮转,每类保留 keepFiles 份;写盘失败
  降级只写控制台且只 Warn 一次。
- 所有业务错误统一走 newAPIError 捕获拦截点(11 处内联 &apiError{} 已收进构造函数,
  防止绕过);中间件层拦截(401/403/CSRF/未改初始密码/429)也留痕,响应体与状态码一字未变。
- config.yaml 的 log 段新增 dir / maxSizeMB / keepFiles(留空即默认)。
- 服务端包数 12 → 13(新增 internal/logging)。

## 成绩录入(用户实机踩坑)
- 真因一:成绩格 placeholder 是光秃秃的 `1:06.33`,而全仓库没有任何 ::placeholder 样式,
  示例文本与真实输入长得一样 → 用户把灰字当成已填内容,那 13 格其实是空的。
- 真因二:2 条报错是秒位 ≥ 60(1:70)或全角冒号(1:06);1:06 与 1:06.00 在解析器里
  本来就等价(都 = 66),格式不是问题。
- 真因三:成绩格没有宽度定义(旁边名次 72px、状态 110px 都有),被挤到第 4 位数字看不见。

修复:parseSecondsDetail 区分 empty/seconds-overflow/unrecognized;新增 entryGuards.ts
纯函数(录入页与审核页共用),报错**打出那一格的实际内容**并说明原因;解析器新认全角
冒号/全角数字/1分06秒 等写法;成绩格 min-width 112px(按 fontTools 实测文本宽度定);
示例文本改 `例 1:06.33` 等并给录入表格内的 ::placeholder 加淡色斜体;新增
onBeforeRouteLeave 离开守卫(原来切菜单会静默丢未保存成绩)。

测试:服务端 13 包全绿(372 个测试函数);前端 vue-tsc 通过、205 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q76,部署指南新增第 10 节「服务端日志与拦截复盘」)。
2026-09-11 01:59:58 +08:00

90 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test'
import assert from 'node:assert/strict'
import { createLeaveGuard, resultCellIssue } from '../pages/entryGuards'
// 用户实机反馈:只写「缺少成绩」会让人去改本来就对的成绩格式,
// 文案必须带上那一格的实际内容,并区分「空的 / 秒位越界 / 写法不认识」。
test('空格子:文案说明成绩为空并带出「(空)」', () => {
const issue = resultCellIssue('第 3 行 张三', ' ', 's')
assert.equal(issue?.kind, 'empty')
assert.equal(issue?.message, '第 3 行 张三:成绩为空(当前「(空)」),请填写成绩')
})
test('秒位 ≥ 60:文案带出原内容和可用的写法', () => {
const issue = resultCellIssue('第 3 行 张三', '1:70', 's')
assert.equal(issue?.kind, 'seconds-overflow')
assert.equal(issue?.message, '第 3 行 张三:秒位不能 ≥ 60(当前「1:70」),请写成 1:06 或 66 这类')
})
test('写法不认识:文案带出原内容和可填写法', () => {
const issue = resultCellIssue('第 3 行 张三', '约1分06秒', 's')
assert.equal(issue?.kind, 'unrecognized')
assert.equal(
issue?.message,
'第 3 行 张三:成绩写法无法识别(当前「约1分06秒」),可用 66、66.33、1:06、1:06.33、1分06秒',
)
})
test('三种失败原因的文案互不相同,且都能定位到那一格', () => {
const empty = resultCellIssue('第 3 行 张三', '', 's')
const overflow = resultCellIssue('第 3 行 张三', '1:70', 's')
const unknown = resultCellIssue('第 3 行 张三', '快点跑', 's')
const messages = [empty, overflow, unknown].map((issue) => issue?.message ?? '')
assert.equal(new Set(messages).size, 3)
for (const message of messages) {
assert.ok(message.startsWith('第 3 行 张三:'), message)
}
assert.ok(messages[1]?.includes('「1:70」'))
assert.ok(messages[2]?.includes('「快点跑」'))
assert.ok(messages[0]?.includes('「(空)」'))
})
test('容错后的写法不再报错', () => {
for (const text of ['1:06', '1:06', '1分06秒', '1分6秒', '1分06', '66', '66.33']) {
assert.equal(resultCellIssue('第 1 行 李四', text, 's'), null, text)
}
})
test('距离 / 计数项目的文案不套用时间格式提示', () => {
const issue = resultCellIssue('第 2 行 王五', '1.2.3', 'm')
assert.equal(issue?.kind, 'unrecognized')
assert.ok(issue?.message.includes('(当前「1.2.3」)'))
assert.ok(issue?.message.includes('米'))
assert.ok(!issue?.message.includes('1:06'), '不该拿时间格式去教距离项目')
})
// 路由离开守卫:dirty 为 true 时必须拦下,确认后重放那一次必须放行(否则死循环)。
test('未保存时离开被拦下,确认后重放放行', () => {
let dirty = true
const guard = createLeaveGuard(() => dirty)
assert.equal(guard.beforeLeave('/dashboard'), false)
assert.equal(guard.confirm(), '/dashboard')
assert.equal(guard.beforeLeave('/dashboard'), true)
// 用户取消后仍然拦下。
assert.equal(guard.beforeLeave('/review'), false)
guard.cancel()
assert.equal(guard.confirm(), null)
assert.equal(guard.beforeLeave('/review'), false)
// 保存后就不再拦。
dirty = false
assert.equal(guard.beforeLeave('/review'), true)
})
test('重放没走到守卫时,重新跳转结束仍要重新确认', () => {
const guard = createLeaveGuard(() => true)
assert.equal(guard.beforeLeave('/dashboard'), false)
assert.equal(guard.confirm(), '/dashboard')
// 这次跳转被别处直接拦下:reset 必须在下次离开前收回「已放行」。
guard.reset()
assert.equal(guard.beforeLeave('/review'), false)
})
test('没有未保存修改时直接放行,且不产生待确认的离开', () => {
const guard = createLeaveGuard(() => false)
assert.equal(guard.beforeLeave('/standings'), true)
assert.equal(guard.confirm(), null)
})