Files
Scoreboard/web/src/admin/tests/entry-heat.test.ts
T
AskaEth 08a5740fb2 feat: 分批发布 + 实时赛况页 + 显式批次层 + 成绩框实时提示 + 窄屏适配 + 版本号单一真相
## 分批发布(现场:100 米跑道只有 8 条,不能等所有人跑完才发)
- 空成绩从"错误"降级为"提示",不再阻塞发布;真会算错(写法无法识别、秒位 ≥ 60、
  手动名次缺名次)仍挡住。发布确认框说明"只发已填的 M 人,其余保持未发布"。
- 服务端核实:publishResults/computeEventScore 本就不会因缺成绩报错
  (collectCandidates 直接跳过没有 results 的报名项),根因在前端防呆。

## 实时赛况(新大屏页 + 定向接口)
- 新增 `GET /api/display/live`(guest 可读):返回「当前项目」判定 + 该项目**全量**已发布成绩
  (不受快照 latestResults 的 20 行截断)。回退链:manual 指定 > 排期命中 > 最近发布。
  设置键 display.live_events / display.live_source。
- 大屏新页 `live`「实时赛况」:1 个项目单栏、2 个自动左右分栏;失败保留上一次画面
  (下一场开始前持续显示上一场);30s 轮询(判定依赖当前时间)+ 快照变更触发。
- 管理端「大屏控制」新增「实时赛况」卡;该卡走 PUT /api/settings(admin-only),
  故对非管理员整组置灰 + 提示条(不新增角色)。

## 显式批次层(组别之下)
- 迁移 0019 `entries.heat`;批次仅是录入/展示分组,**不参与算分**(有"分批与不分批结果
  完全一致"的断言守住)。批量设置接口 + 导入 CSV 可选列 heat + 录入页批次下拉 +
  一键均分(按 N 人切片,已有批次号接着往后排)。

## 成绩输入框实时悬浮提示
- 聚焦格旁的浮动气泡:pointer-events:none、不占布局不改行高、贴底自动上翻;
  合法显示解析结果(1:06 → = 66 秒)、可疑/不合法显示原因并带那一格原文;
  与发布防呆共用同一套纯函数(entryGuards.ts),口径不会分叉。

## 窄屏 / 手机端布局
- 真浏览器(Playwright + chromium)在 1440/1024/768/390 四宽度逐页审计 + 溢出探针,
  四轮迭代修复;桌面端 1440 前后像素差异 < 0.25%(无回归);修掉如「修改密码」输入框
  塌成 22px 这类真问题。

## 版本号单一真相
- 新增仓库根 `VERSION`:构建时注入二进制(-X main.version)并同步 console/package.json
  的版本(产物名跟随)。源码里不再有人工维护的版本号(兜底值改为 `dev`,故意不写数字)。

测试:服务端 13 包全绿;前端 vue-tsc 通过、249 例全过;Console 18 文件。
docs/ 与 devdocs/ 同步(FAQ 到 Q80、新增 devdocs/16 自定义大屏模板设计文档)。
2026-09-11 04:05:02 +08:00

129 lines
4.7 KiB
TypeScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
import {
applyHeatToRows,
filterRowsByHeat,
heatLabel,
heatOptions,
maxHeat,
planHeatSplit,
type HeatEntryRow,
} from '../pages/entryHeat'
type Row = HeatEntryRow & { name: string }
function rows(spec: Array<[string, number | null]>): Row[] {
return spec.map(([name, heat], index) => ({ entryId: index + 1, name, heat }))
}
test('heatLabel:未分批与第 N 批', () => {
assert.equal(heatLabel(null), '未分批')
assert.equal(heatLabel(1), '第 1 批')
assert.equal(heatLabel(12), '第 12 批')
})
test('heatOptions:全部 → 已有批次去重升序 → 未分批', () => {
const options = heatOptions(rows([['甲', 2], ['乙', null], ['丙', 1], ['丁', 2]]))
assert.deepEqual(options, [
{ value: 'all', label: '全部' },
{ value: 1, label: '第 1 批' },
{ value: 2, label: '第 2 批' },
{ value: 'none', label: '未分批' },
])
})
test('heatOptions:没有未分批的行时不给「未分批」选项,避免选中得到空表', () => {
assert.deepEqual(heatOptions(rows([['甲', 1], ['乙', 3]])), [
{ value: 'all', label: '全部' },
{ value: 1, label: '第 1 批' },
{ value: 3, label: '第 3 批' },
])
})
test('heatOptions:一条报名都没有时只有「全部」', () => {
assert.deepEqual(heatOptions([]), [{ value: 'all', label: '全部' }])
})
test('filterRowsByHeat:全部 / 指定批次 / 未分批,顺序不变', () => {
const list = rows([['甲', 1], ['乙', null], ['丙', 2], ['丁', 1]])
assert.deepEqual(
filterRowsByHeat(list, 'all').map((row) => row.name),
['甲', '乙', '丙', '丁'],
)
assert.deepEqual(
filterRowsByHeat(list, 1).map((row) => row.name),
['甲', '丁'],
)
assert.deepEqual(
filterRowsByHeat(list, 'none').map((row) => row.name),
['乙'],
)
assert.deepEqual(filterRowsByHeat(list, 9), [])
// 返回新数组,不改动入参。
assert.equal(list.length, 4)
})
test('maxHeat:取已有最大批次号,没有批次时是 0', () => {
assert.equal(maxHeat([]), 0)
assert.equal(maxHeat(rows([['甲', null], ['乙', null]])), 0)
assert.equal(maxHeat(rows([['甲', 2], ['乙', null], ['丙', 5]])), 5)
})
test('planHeatSplit:未分批的行按每批人数顺序切片,批次号从 1 开始', () => {
const list = rows([
['甲', null], ['乙', null], ['丙', null], ['丁', null], ['戊', null],
['己', null], ['庚', null], ['辛', null], ['壬', null], ['癸', null],
])
const plan = planHeatSplit(list, 8)
assert.ok(plan !== null)
assert.equal(plan.rowCount, 10)
assert.equal(plan.batchCount, 2)
assert.equal(plan.startHeat, 1)
assert.equal(plan.perHeat, 8)
assert.deepEqual(plan.allocations, [
{ heat: 1, entryIds: [1, 2, 3, 4, 5, 6, 7, 8] },
{ heat: 2, entryIds: [9, 10] },
])
})
test('planHeatSplit:已经分批的行不动,新批次号接着最大号往后排', () => {
const list = rows([
['甲', 3], ['乙', null], ['丙', null], ['丁', 1], ['戊', null],
])
const plan = planHeatSplit(list, 2)
assert.ok(plan !== null)
assert.equal(plan.startHeat, 4, '接着最大批次号 3 往后排,而不是从 1 覆盖')
assert.deepEqual(plan.allocations, [
{ heat: 4, entryIds: [2, 3] },
{ heat: 5, entryIds: [5] },
])
// 已分批的甲 / 丁不在任何一批里。
const touched = plan.allocations.flatMap((item) => item.entryIds)
assert.equal(touched.includes(1), false)
assert.equal(touched.includes(4), false)
})
test('planHeatSplit:每批人数大于剩余人数时只出一批', () => {
const plan = planHeatSplit(rows([['甲', null], ['乙', null], ['丙', null]]), 8)
assert.deepEqual(plan?.allocations, [{ heat: 1, entryIds: [1, 2, 3] }])
})
test('planHeatSplit:没有未分批的行、或每批人数非法时返回 null', () => {
assert.equal(planHeatSplit(rows([['甲', 1]]), 8), null, '全都分过批就不该有编排')
assert.equal(planHeatSplit(rows([['甲', null]]), 0), null)
assert.equal(planHeatSplit(rows([['甲', null]]), -3), null)
assert.equal(planHeatSplit(rows([['甲', null]]), 2.5), null)
})
test('applyHeatToRows:只改命中的行,其余行(含正在编辑的成绩)原样保留', () => {
const list = rows([['甲', null], ['乙', 1], ['丙', null]])
const next = applyHeatToRows(list, [1, 3], 7)
assert.deepEqual(next.map((row) => row.heat), [7, 1, 7])
assert.deepEqual(list.map((row) => row.heat), [null, 1, null], '不能改到入参')
const cleared = applyHeatToRows(next, [1], null)
assert.deepEqual(cleared.map((row) => row.heat), [null, 1, 7])
// 未命中的批次筛选值不受影响。
assert.deepEqual(applyHeatToRows(list, [], 5).map((row) => row.heat), [null, 1, null])
})