Files
Scoreboard/web/src/admin/tests/entry-selection.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

307 lines
14 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 {
MANDATORY_COLUMNS,
OPTIONAL_COLUMNS,
REQUIRED_COLUMNS,
buildSelectionRows,
buildTeamSelectionRows,
buildTeamSelections,
emptyTeamPick,
entryRowLabel,
registeredAthleteIds,
registeredTeamIds,
selectableTeamIds,
selectionAllowsSubmit,
setPickedTeams,
teamSelectionAllowsSubmit,
toggleMemberPick,
toggleTeamPick,
toCsv,
} from '../components/baseData/entrySelection'
test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
assert.equal(toCsv([['a', 'b']]), 'a,b')
assert.equal(toCsv([['张,伟', 'x']]), '"张,伟",x')
assert.equal(toCsv([['张"伟', 'x']]), '"张""伟",x')
assert.equal(toCsv([['第一行\n第二行', 'x']]), '"第一行\n第二行",x')
// 同一格同时含逗号与引号:包裹一次,内部引号翻倍。
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
})
test('表头含证件号 / 性别 / 年龄 / 批次可选列,必填列不含这四列', () => {
assert.deepEqual(
[...REQUIRED_COLUMNS],
[
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age',
'bib', 'seed', 'heat', 'status', 'member_names',
],
)
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('gender') + 1], 'age', 'age 紧跟在 gender 之后')
assert.equal(REQUIRED_COLUMNS[REQUIRED_COLUMNS.indexOf('seed') + 1], 'heat', 'heat 紧跟在 seed 之后')
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age', 'heat'])
assert.equal(MANDATORY_COLUMNS.includes('id_card'), false)
assert.equal(MANDATORY_COLUMNS.includes('age'), false)
assert.equal(MANDATORY_COLUMNS.includes('heat'), false, '批次列可缺省,缺省即未分批')
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
})
test('buildSelectionRows 个人项目:一人一行,共 12 列并带上证件号 / 号码布', () => {
const rows = buildSelectionRows({
eventCode: 'M100',
categoryName: '男子组',
teamCode: 'F1',
mode: 'individual',
athletes: [
{ name: '张伟', idCard: '110101199001011234', bib: '101' },
{ name: '李强', idCard: null, bib: '102' },
{ name: '王芳', idCard: '110101199001011235', bib: null },
],
})
assert.equal(rows.length, 4, '表头 + 3 名运动员')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 12)
// 勾选报名的人员已在库里,年龄列留空表示「不修改」;批次列留空 = 未分批(到录入页再编排)。
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', '', 'registered', ''])
// 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', ''])
// 只有证件号时 bib 列留空。
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', ''])
})
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
const rows = buildSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teamCode: 'F2',
mode: 'team',
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
})
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
assert.equal(rows[1]?.length, 12)
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
})
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
const rows = buildSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teamCode: 'F2',
status: 'scratched',
mode: 'team',
athletes: [{ name: '王芳' }],
memberNames: ['刘洋', '王芳'],
})
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳'])
})
test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => {
const rows = buildSelectionRows({
eventCode: 'M100',
categoryName: '男子组',
teamCode: 'F1',
mode: 'individual',
athletes: [{ name: '无标识' }],
})
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', ''])
})
// 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。
test('buildSelectionRows 团体项目:0 名队员时 member_names 留空', () => {
const rows = buildSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teamCode: 'F2',
mode: 'team',
athletes: [],
})
assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名')
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', ''])
})
test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => {
assert.equal(selectionAllowsSubmit('team', 0), true, '团体项目允许只报单位')
assert.equal(selectionAllowsSubmit('team', 3), true)
assert.equal(selectionAllowsSubmit('individual', 0), false, '个人项目必须至少勾一人')
assert.equal(selectionAllowsSubmit('individual', 1), true)
})
// 团体报名改成「一次勾多个单位,每个单位一条报名」:产出必须与勾选顺序、列表顺序都稳定。
test('buildTeamSelectionRows 多个单位各一行,队员名单按 / 拼接,0 队员留空', () => {
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: [
{ teamCode: 'F1', memberNames: ['王芳', '刘洋'] },
{ teamCode: 'F2', memberNames: [] },
{ teamCode: 'F3', memberNames: ['张伟'] },
],
})
assert.equal(rows.length, 4, '表头 + 3 个单位')
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
assert.equal(rows[1]?.length, 12)
for (const row of rows.slice(1)) {
assert.equal(row[3], '', '团体行的 athlete_name 必须为空,服务端才按单位 upsert')
}
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋'])
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', ''])
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟'])
})
test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可覆盖', () => {
const empty = buildTeamSelectionRows({ eventCode: 'BB', categoryName: '混合组', teams: [] })
assert.equal(empty.length, 1, '不勾单位就不产出数据行')
const scratched = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
status: 'scratched',
teams: [{ teamCode: 'F1', memberNames: [] }],
})
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', ''])
})
test('toggleTeamPick 勾选 / 取消单位,取消不清队员名单', () => {
let state = emptyTeamPick()
state = toggleTeamPick(state, 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true)
assert.deepEqual(state.teamIds, [1, 2])
assert.deepEqual(toggleTeamPick(state, 2, true).teamIds, [1, 2], '重复勾选幂等')
state = toggleTeamPick(state, 1, false)
assert.deepEqual(state.teamIds, [2])
assert.deepEqual(state.memberIds[1], [11], '取消勾选保留队员名单')
assert.deepEqual(toggleTeamPick(state, 1, true).memberIds[1], [11], '重新勾选恢复原名单')
})
test('toggleMemberPick 单位没勾选也能先登记队员,各单位名单互不影响', () => {
let state = toggleMemberPick(emptyTeamPick(), 3, 31, true)
state = toggleMemberPick(state, 3, 32, true)
state = toggleMemberPick(state, 4, 41, true)
assert.deepEqual(state.teamIds, [], '只登记队员不会顺手勾上单位')
assert.deepEqual(state.memberIds[3], [31, 32])
assert.deepEqual(state.memberIds[4], [41])
assert.deepEqual(toggleMemberPick(state, 3, 31, true).memberIds[3], [31, 32], '重复勾选幂等')
state = toggleMemberPick(state, 3, 31, false)
assert.deepEqual(state.memberIds[3], [32])
assert.deepEqual(state.memberIds[4], [41], '改一个单位的名单不动其它单位')
})
test('setPickedTeams 全选 / 清空只改单位勾选,保留已登记的队员', () => {
let state = toggleMemberPick(emptyTeamPick(), 1, 11, true)
state = setPickedTeams(state, [1, 2, 3])
assert.deepEqual(state.teamIds, [1, 2, 3])
assert.deepEqual(state.memberIds[1], [11])
state = setPickedTeams(state, [])
assert.deepEqual(state.teamIds, [])
assert.deepEqual(state.memberIds[1], [11], '清空后名单还在,重新勾上即恢复')
})
test('selectableTeamIds 只含启用中且未报名的单位', () => {
const teams = [
{ id: 1, active: true },
{ id: 2, active: false },
{ id: 3, active: true },
]
assert.deepEqual(selectableTeamIds(teams, new Set([3])), [1], '已停用与已报名的都不进「全选」')
assert.deepEqual(selectableTeamIds(teams, new Set()), [1, 3])
assert.deepEqual(selectableTeamIds(teams, new Set([1, 3])), [], '全报过之后全选为空')
assert.deepEqual(selectableTeamIds([], new Set([1])), [])
})
test('buildTeamSelections 只产出勾选单位的队员,并按列表顺序取启用中的人', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
{ id: 3, code: 'F3' },
]
const athletes = [
{ id: 11, teamId: 1, name: '张伟', active: true },
{ id: 12, teamId: 1, name: '李强', active: true },
{ id: 13, teamId: 1, name: '王芳', active: false },
{ id: 21, teamId: 2, name: '刘洋', active: true },
]
let state = toggleTeamPick(emptyTeamPick(), 2, true)
state = toggleTeamPick(state, 1, true)
state = toggleMemberPick(state, 1, 12, true)
state = toggleMemberPick(state, 1, 13, true)
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 3, 99, true)
assert.deepEqual(buildTeamSelections(state, teams, athletes), [
{ teamCode: 'F1', memberNames: ['张伟', '李强'] },
{ teamCode: 'F2', memberNames: [] },
])
assert.deepEqual(
buildTeamSelections(setPickedTeams(state, []), teams, athletes),
[],
'没勾单位就没有要提交的行',
)
})
test('teamSelectionAllowsSubmit 至少勾一个单位,队员可以为 0 人', () => {
assert.equal(teamSelectionAllowsSubmit(0), false, '一个单位都不勾不能提交')
assert.equal(teamSelectionAllowsSubmit(1), true, '只报单位(0 队员)可以提交')
assert.equal(teamSelectionAllowsSubmit(5), true)
})
// 端到端拼接:勾选状态 → 多单位报名行 → 送进导入接口的 CSV 文本。
// 服务端按表头名字取值,列数与转义必须和导入表一致。
test('勾选状态转成 CSV 后表头与导入表一致,每个单位一行', () => {
const teams = [
{ id: 1, code: 'F1' },
{ id: 2, code: 'F2' },
]
const athletes = [
{ id: 11, teamId: 1, name: '张,伟', active: true },
{ id: 12, teamId: 1, name: '李强', active: true },
{ id: 21, teamId: 2, name: '王芳', active: true },
]
let state = toggleTeamPick(emptyTeamPick(), 1, true)
state = toggleTeamPick(state, 2, true)
state = toggleMemberPick(state, 1, 11, true)
state = toggleMemberPick(state, 1, 12, true)
const rows = buildTeamSelectionRows({
eventCode: 'BB',
categoryName: '混合组',
teams: buildTeamSelections(state, teams, athletes),
})
assert.equal(rows[1]?.length, REQUIRED_COLUMNS.length)
assert.equal(rows[2]?.length, REQUIRED_COLUMNS.length)
const lines = toCsv(rows).split('\n')
assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
assert.equal(lines.length, 3, '表头 + 两个单位')
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,"张,伟/李强"', '带逗号的姓名按 RFC 4180 转义')
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,', '只报单位时 member_names 留空')
})
test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只认具体运动员', () => {
const entries = [
{ categoryId: 7, teamId: 1, athleteId: 11 },
{ categoryId: 7, teamId: 1, athleteId: 12 },
{ categoryId: 7, teamId: 2, athleteId: null },
{ categoryId: 8, teamId: 3, athleteId: 13 },
]
assert.deepEqual([...registeredTeamIds(entries, 7)].sort((a, b) => a - b), [1, 2])
assert.deepEqual([...registeredTeamIds(entries, 9)], [], '别的组别不算已报名')
assert.deepEqual([...registeredTeamIds(entries, undefined)], [], '没选组别时没有已报名')
assert.deepEqual([...registeredAthleteIds(entries, 7, 1)].sort((a, b) => a - b), [11, 12])
assert.deepEqual([...registeredAthleteIds(entries, 7, 2)], [], '团体行不带运动员')
assert.deepEqual([...registeredAthleteIds(entries, 8, 3)], [13])
assert.deepEqual([...registeredAthleteIds(entries, undefined, 1)], [])
})
// 团体 / 接力行服务端不下发 athleteName(omitempty),回落到队员 / 单位名,
// 否则防呆校验的文案会变成「:缺少成绩」。
test('entryRowLabel 团体行回落到队员首人 / 单位', () => {
assert.equal(entryRowLabel({ athleteName: '张伟', memberNames: null, teamShort: '一队' }), '张伟')
assert.equal(entryRowLabel({ athleteName: '', memberNames: ['王芳', '刘洋'], teamShort: '一队' }), '王芳')
assert.equal(entryRowLabel({ athleteName: null, memberNames: [], teamShort: '一队' }), '一队')
assert.equal(entryRowLabel({ athleteName: null, memberNames: null, teamShort: '', teamName: '第一分公司' }), '第一分公司')
assert.equal(entryRowLabel({}), '')
})