## 分批发布(现场: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 自定义大屏模板设计文档)。
129 lines
4.7 KiB
TypeScript
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])
|
|
})
|