Files
Scoreboard/web/src/display/tests/detail-overlay-athlete.test.ts
T
AskaEth 7a3e4ad365 feat(entry): 团体项目多队(迁移 0025)+ 修联队团体名次 / 大屏分项成绩 / 预览算分口径;版本 dev_0.5.5
## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `0025_entry_squad.sql`:`entries` 加 `squad TEXT NOT NULL DEFAULT ''`(自由文本队名,空串=不区分队伍);
  唯一索引由 `(category_id, team_id) WHERE athlete_id IS NULL` 换成 `(category_id, team_id, squad) WHERE athlete_id IS NULL`
  —— 原来那条正是「一个单位在一个组别只能报一条团体报名」的拦路虎。
- 导入:**第 13 列「队伍」**(`squad` / 队伍 / 队名 / 队伍名,可选,放最后);老 12 列表照常导入。
- 显示:`单位名 + 队名`(不加括号),榜单不写队名(分与牌本来就归单位)。
- 新增「改队名」:`PUT /api/entries/{id}/squad` + 「成员单位与份额」弹窗里的队伍输入框(只对纯团体报名显示)。
  改队名**不需要重算**(`grep squad server/internal/domain/` = 0 处,改名前后流水/名次/榜单逐字节相同)。
- **老数据零变化**:接口层 JSON 剥掉新字段后逐字节相同;导出层 9 份导出件全单元格 750 行 dump 的 md5 相同。

## 二、修:团体名次控件没体现联队
联队的成员单位在「团体名次」里各占一行 → **合并成一行**(标签用大屏/导出一致的联队名),名次**只落主单位**;
成员单位不再进保存载荷,顺手堵掉「看 A、B 各一行,给 B 也录一个名次 → B 重复拿分」。
被并掉的成员单位原来若录过名次,区块里点名提示「已并入联队那一行,保存后清除」,不静默吞。

## 三、修:大屏「分项成绩」排名错乱
现场:第 3 栏列出的项目排名跳着显示(3、6、9、14、15、16),羽毛球单打第 1 名不显示。
**根因**:`group.rows.slice(0, 6)` 取的是**数据到达顺序**,整条渲染链**没有一处读 `rank`**。
修法:按名次升序(并列保持原先后、无名次排最后)再截断 —— 与同页「实时赛况」的既有口径一致。
复现证据:修前 `actual: [3,6,9,14,15,16]`(与现场数字一致)、`expected: [1,2,3,4,5,6]`。
顺带澄清:「只有第 3 栏出问题」不是栏的问题,是那一栏分到的数据次序不成立。

## 四、修:录入页预览算分与后端口径不一致(四处 + 一条延伸)
现场:手动录名次的项目(台球)「积分(自动)」列全是 0,而后端**有分**。
1. **预览不读 `channel_manual_ranks`、也不按通道 scope 分名次来源** → 「按单位录名次」的项目预览全 0,反过来名次填在成绩表时预览有分而服务端 0;
2. **末位归零判据不同**:预览原本「区间跨末位就整组归零」,与后端的 `startRank == lastPlace` 不符(并列第 1 时预览 0、服务端 10);
3. **`compare=none` 时预览静默降级成 manual**(后端此时直接报错)→ 改为同口径:显示**原因**而不是一个算不出来的数字;
4. **管理端保存计分通道缺守卫**:`compare=none + basis=result/aggregate` 原先只有规则包导入拦,手工能配出来、到发布才报错 → 保存前 400 + 人话。
延伸:预览的「已发布分」基线**只扣行分**,不挂报名项的分(团体名次那类)没扣 → 发布确认框「最大分差」虚高;新增 `unitPoints` 一并扣。

## 五、修:手录名次项目的「成绩」列空白
`displayResult()` 在 `resultValue`/`resultText` 都拿不到时**漏出空值**(且函数声明返回 `string`)→
新增占位 `—`、类型收紧,实时赛况与底栏对齐同一口径。

## 文档
`docs/报名名单导入规范.md`(12 → 13 列)、`docs/使用手册.md`(多队章节)、
`docs/升级说明.md`(新增 `dev_0.5.5` 一节)、`debug/使用说明/` 七份同步。

## 验证
- 全量自检两轮**全绿、`EXIT=0`**:服务端 14 包 / 前端 **883 例** 0 fail / Console 68 例 0 fail。
- 各块都先写会红的测试、留了原始输出(含现场报的那组数字与「预览全 0」)。

## 版本
`dev_0.5.4` → **`dev_0.5.5`**(含数据迁移 `0025`);安装器素材同步重生成(照例只动 `brand.bmp` 两张)。

## 下一批
`dev_0.5.6` = **团体报名队员身份**(把队员从文字名单变成人员关联,标识用**号码布**),工单见 `devdocs/20-团体报名队员身份.md`。
2026-09-14 11:45:32 +08:00

177 lines
7.7 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 { readFileSync } from 'node:fs'
import { parse } from 'vue/compiler-sfc'
import { breakdownAthleteName, breakdownRevisionLine, breakdownSourceTag, type BreakdownItem } from '@/shared'
import { resultTextOrPlaceholder } from '../results'
/**
* 大屏积分明细覆盖层的「运动员」列。
*
* 现场裁判长的原话:推到大屏的明细只有项目 / 名次 / 成绩 / 积分,看不出「谁在哪个项目拿了多少分」,
* 这种明细说服不了观众单位名字为什么升上去。数据(athleteName)服务端一直有,是覆盖层漏了这一列。
*
* 项目没有 jsdom 与组件测试工具,这里沿用仓库既有做法:`.vue` 模板用源码契约断言
* (列头顺序、每个格子绑了什么),文案内容交给 `breakdownAthleteName` 这个纯函数用例守。
*/
const OVERLAY_SOURCE = readFileSync(new URL('../components/DetailOverlay.vue', import.meta.url), 'utf8')
/** 取模板里以某个固定串开头、到下一个 </div> 为止的片段(这些行里没有嵌套 div)。 */
function block(startPattern: RegExp): string {
const found = startPattern.exec(OVERLAY_SOURCE)
assert.ok(found !== null, `模板里应有 ${startPattern}`)
const rest = OVERLAY_SOURCE.slice(found.index)
const end = rest.indexOf('</div>')
assert.ok(end > 0, '行片段应以 </div> 结束')
return rest.slice(0, end)
}
interface Cell {
cls: string
inner: string
}
function cellsOf(fragment: string): Cell[] {
return [...fragment.matchAll(/<span class="(col-[a-z]+)">([\s\S]*?)<\/span>/g)].map((match) => ({
cls: match[1] ?? '',
inner: (match[2] ?? '').replace(/\s+/g, ' ').trim(),
}))
}
const HEAD_ROW = block(/<div class="row head-row">/)
const BODY_ROW = block(/<div v-for="item in items" :key="item\.ledgerId" class="row">/)
function styleText(source: string): string {
const { descriptor } = parse(source, { filename: 'style.vue' })
return descriptor.styles[0]?.content ?? ''
}
function cssRule(style: string, selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
const match = new RegExp(`${escaped}\\s*\\{([^}]*)\\}`).exec(style)
assert.ok(match !== null, `样式里应存在 ${selector} 规则`)
return match[1] ?? ''
}
function item(over: Partial<BreakdownItem> = {}): BreakdownItem {
return {
ledgerId: 1,
sourceType: 'result',
eventId: 1,
eventCode: 'M100',
eventName: '100米跑',
categoryName: '男子',
athleteId: 7,
athleteName: '张伟',
rank: 1,
resultText: '11.58',
points: 10,
createdAt: '2026-09-13 10:00:00',
revisionCount: null,
lastRevisionAt: null,
lastRevisionReason: null,
lastRevisedBy: null,
...over,
}
}
test('列头按「项目/组别 · 运动员 · 名次 · 成绩 · 积分」排列,运动员插在项目与名次之间', () => {
const labels = [...HEAD_ROW.matchAll(/<span class="col-[a-z]+">([^<]*)<\/span>/g)].map((match) =>
(match[1] ?? '').trim(),
)
assert.deepEqual(labels, ['项目 / 组别', '运动员', '名次', '成绩', '积分'])
})
test('明细行渲染运动员格,绑定的是团队共用的降级函数', () => {
const cells = cellsOf(BODY_ROW)
assert.deepEqual(
cells.map((cell) => cell.cls),
['col-name', 'col-athlete', 'col-rank', 'col-result', 'col-points'],
)
assert.equal(cells[1]?.inner, '{{ breakdownAthleteName(item, teamName) }}')
})
test('项目 / 名次 / 成绩 / 积分四列原样保留(是加列不是改列)', () => {
const cells = cellsOf(BODY_ROW)
assert.match(cells[0]?.inner ?? '', /item\.eventName \?\? '人工调整'/)
assert.equal(cells[2]?.inner, "{{ item.rank ?? '—' }}")
// 成绩格与分项成绩页 / 实时赛况页同一个口径:`??` 只挡 null,手录名次的项目给空串就画成空白。
assert.equal(cells[3]?.inner, '{{ resultTextOrPlaceholder(item.resultText) }}')
assert.equal(cells[4]?.inner, '{{ item.points }}')
})
test('个人流水那一行大屏上看得见是谁:运动员格是姓名,四个老格子照旧', () => {
const row = item()
const rendered = [
row.eventName ?? '人工调整',
breakdownAthleteName(row, '第一分公司'),
row.rank ?? '—',
resultTextOrPlaceholder(row.resultText),
row.points,
]
assert.deepEqual(rendered, ['100米跑', '张伟', 1, '11.58', 10])
})
test('团体项目流水没有运动员名:降级成单位名,不出现空白格或 null', () => {
const row = item({ athleteName: null, athleteId: null, sourceType: 'result' })
const cell = breakdownAthleteName(row, '第一分公司')
assert.equal(cell, '第一分公司')
assert.doesNotMatch(cell, /null|undefined/)
})
test('覆盖层是 16:9 右半屏:五列加间距放得下,姓名列留得够宽', () => {
const row = cssRule(styleText(OVERLAY_SOURCE), '.row')
const tracks = /grid-template-columns:\s*([^;]+);/.exec(row)
assert.ok(tracks !== null, '.row 应声明 grid-template-columns')
const parts = (tracks[1] ?? '').trim().split(/\s+(?![^(]*\))/)
assert.equal(parts.length, 5, '五列:项目/组别、运动员、名次、成绩、积分')
const gap = Number(/gap:\s*(\d+)px/.exec(row)?.[1] ?? 0)
const fixed = parts.reduce((sum, part) => sum + Number(/(\d+)px/.exec(part)?.[1] ?? 0), 0)
// 1920 宽的 16:9 屏幕:覆盖层占右半边 960px,减去左右内边距 44px×2。
const available = 960 - 44 * 2
assert.ok(fixed + gap * (parts.length - 1) <= available, `五列 ${fixed}px + 间距会挤出右半屏`)
const nameTrack = /minmax\(0,\s*1fr\)/.test(parts[0] ?? '')
assert.ok(nameTrack, '项目 / 组别列应可伸缩')
const athleteWidth = Number(/(\d+)px/.exec(parts[1] ?? '')?.[1] ?? 0)
assert.ok(athleteWidth >= 180, '运动员列至少 180px,单位名才不至于只剩几个字')
const athlete = cssRule(styleText(OVERLAY_SOURCE), '.col-athlete')
assert.match(athlete, /text-overflow:\s*ellipsis/, '长姓名 / 单位名要省略号收尾')
assert.match(athlete, /white-space:\s*nowrap/)
})
/**
* 「这几分怎么来的、有没有被改过」——大屏不能悬停,所以:
* - 非普通成绩(破纪录加分)在项目名后面打一个来源小标签;
* - 被改过的成绩多一行整行小字 `10:42 已修订(成绩录入笔误)`。
*
* 两处都只在需要时才渲染(v-if),没改过的行连元素都没有 —— 大屏行高与加这一行之前完全一致。
*/
test('来源标签只在非普通成绩时渲染,普通成绩不打(行高与列宽都不受影响)', () => {
assert.match(BODY_ROW, /v-if="breakdownSourceTag\(item\)"/)
assert.match(BODY_ROW, /<em v-if="breakdownSourceTag\(item\)" class="col-source">/)
assert.equal(breakdownSourceTag(item()), '')
// 大屏标签用短词:项目名后面那一行放不下「破纪录加分」五个字。
assert.equal(breakdownSourceTag(item({ sourceType: 'record_bonus' })), '破纪录')
})
test('修订小字占满一整行(grid-column: 1 / -1),不与五列抢宽度', () => {
assert.match(BODY_ROW, /v-if="breakdownRevisionLine\(item\)"/)
assert.match(BODY_ROW, /class="col-revision"/)
assert.match(BODY_ROW, /\{\{ breakdownRevisionLine\(item\) \}\}/)
const css = cssRule(styleText(OVERLAY_SOURCE), '.col-revision')
assert.match(css, /grid-column:\s*1\s*\/\s*-1/, '修订小字要横跨整行,不能挤第六列')
})
test('没改过的成绩不渲染小字,渲染出来的也只有一行短句', () => {
assert.equal(breakdownRevisionLine(item()), '')
const line = breakdownRevisionLine(
item({ revisionCount: 1, lastRevisionAt: '2026-09-13 10:42:00', lastRevisionReason: '成绩录入笔误' }),
)
assert.doesNotMatch(line, /null|undefined|NaN|×1/)
assert.ok(line.length <= 30, `大屏一行小字要短:${line}`)
})