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