Files
Scoreboard/web/src/admin/tests/display-instances-api.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

175 lines
6.0 KiB
TypeScript

import { test } from 'node:test'
import assert from 'node:assert/strict'
/**
* 大屏控制的接口契约:在线实例列表的字段、定向 goto 的请求形状,以及 404 原文的传递。
*
* 现场靠这份列表判断「局域网设备到底连上没有」,字段一错就会把在线设备显示成未知;
* 定向切页点名的实例全离线时服务端回 404,那句「目标大屏已离线」必须原样让操作员看到。
*/
interface Captured {
url?: string
method?: string
body?: unknown
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
})
}
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
let lastRequest: Captured = {}
// 必须在导入被测模块之前装好:ApiClient 在构造时就把当时的 fetch 绑进实例,
// 之后再替换 globalThis.fetch 对它无效。所以这里装一个固定函数,每个用例只换回复。
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
lastRequest = { url: String(input), method: init?.method ?? 'GET', body: init?.body }
return reply()
}) as typeof fetch
function respondWith(response: () => Response): void {
lastRequest = {}
reply = response
}
const { ApiError, adminApi } = await import('../api/client')
const CONSOLE_WINDOW = {
id: 'LAPTOP-1-2528732444',
online: true,
page: 'standings',
lastHeartbeat: '2026-09-11T10:00:00Z',
connectedAt: '2026-09-11T09:00:00Z',
remoteAddr: '127.0.0.1:52341',
ackVersion: 42,
slow: false,
screen: '2',
}
/** 局域网浏览器:没屏号,且 page / remoteAddr 因服务端 omitempty 可能整个字段都不在。 */
const LAN_BROWSER = {
id: 'display-9f3c11ab',
online: true,
lastHeartbeat: '2026-09-11T10:00:02Z',
connectedAt: '2026-09-11T09:58:00Z',
ackVersion: 7,
slow: true,
screen: '',
}
test('实例列表解析出屏号、来源 IP 与当前页', async () => {
respondWith(() => jsonResponse({ ok: true, data: [CONSOLE_WINDOW, LAN_BROWSER] }))
const list = await adminApi.displayInstances()
assert.equal(lastRequest.url, '/api/display/instances')
assert.equal(lastRequest.method, 'GET')
assert.equal(list.length, 2)
assert.deepEqual(list[0], CONSOLE_WINDOW)
// 局域网浏览器没有屏号,缺字段的 page / remoteAddr 归一成空串而不是 undefined。
assert.equal(list[1]?.screen, '')
assert.equal(list[1]?.remoteAddr, '')
assert.equal(list[1]?.page, '')
assert.equal(list[1]?.slow, true)
})
test('实例列表不是数组时报解析失败,不当成空列表', async () => {
respondWith(() => jsonResponse({ ok: true, data: { instances: [] } }))
await assert.rejects(() => adminApi.displayInstances(), (error: Error) =>
error.message.includes('大屏实例列表'),
)
})
test('定向 goto 带 targets,广播指令不带', async () => {
respondWith(() => jsonResponse({ ok: true, data: null }))
await adminApi.displayCommand({ action: 'goto', payload: { page: 'standings' }, targets: ['display-1'] })
assert.equal(lastRequest.url, '/api/display/command')
assert.equal(lastRequest.method, 'POST')
const targeted = JSON.parse(String(lastRequest.body)) as {
action: string
payload: { page: string }
targets?: string[]
}
assert.equal(targeted.action, 'goto')
assert.equal(targeted.payload.page, 'standings')
assert.deepEqual(targeted.targets, ['display-1'])
// 公告那类全屏指令必须继续走广播:带上 targets 就会只发给一台。
await adminApi.displayCommand({ action: 'announce', payload: { text: '集合', seconds: 10 } })
const broadcast = JSON.parse(String(lastRequest.body)) as Record<string, unknown>
assert.equal('targets' in broadcast, false)
})
// 点名的实例全离线时服务端回 404;吞成「操作失败」现场就不知道该刷新列表了。
test('目标大屏离线时 404 原文原样抛出', async () => {
respondWith(() =>
jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '目标大屏已离线,请刷新在线列表' } }, 404),
)
await assert.rejects(
() => adminApi.displayCommand({ action: 'goto', payload: { page: 'team-medals' }, targets: ['gone'] }),
(error: unknown) => {
assert.ok(error instanceof ApiError)
assert.equal(error.status, 404)
assert.equal(error.message, '目标大屏已离线,请刷新在线列表')
return true
},
)
})
// 看板每 5 秒轮询一次,后端没起来时给空列表就够了——这里弹错误只会盖住真正有用的信息。
test('后端连不上时实例列表给空数组,不抛错', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
assert.deepEqual(await adminApi.displayInstances(), [])
})
// 「实时赛况」当前项目由服务端判定,前端只消费结论:source / eventIds / events 原样透传。
test('实时赛况接口透传判定结果与入选原因', async () => {
respondWith(() =>
jsonResponse({
ok: true,
data: {
source: 'auto',
eventIds: [7],
events: [
{
event: { id: 7, name: '100米跑' },
reason: 'recent',
groups: [{ eventId: 7, categoryId: 1, categoryName: '男子', rows: [] }],
},
],
},
}),
)
const live = await adminApi.displayLive()
assert.equal(lastRequest.url, '/api/display/live')
assert.equal(lastRequest.method, 'GET')
assert.equal(live.source, 'auto')
assert.deepEqual(live.eventIds, [7])
assert.equal(live.events[0]?.event.name, '100米跑')
assert.equal(live.events[0]?.reason, 'recent')
assert.equal(live.events[0]?.groups.length, 1)
})
// 判定依赖排期与最近发布,示例数据里没有这套时序,连不上时给空结论而不是抛错。
test('后端连不上时实时赛况给空结论', async () => {
respondWith(() => {
throw new TypeError('fetch failed')
})
assert.deepEqual(await adminApi.displayLive(), { source: 'auto', eventIds: [], events: [] })
})