Files
Scoreboard/web/src/display/tests/joint-teams.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

256 lines
10 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 type { Team } from '@/shared'
import {
entryShareIndex,
entrySquadIndex,
readEntrySquad,
readEntryTeamShares,
rowLogoCount,
rowTeamLogos,
rowTeamText,
tickerRowText,
} from '../jointTeams'
/**
* 大屏的联队与队伍渲染(devdocs/19、迁移 0025)。要钉住的是:
* 1. **单单位(普通报名)时逐字/逐图不变**:文案还是 `shortName ?? teamName`,logo 还是刚那一张,
* 联队成员表与队伍表里根本没有这条报名的位置;
* 2. 联队时写「A+B联队」(两个单位的名字都写全),单位列两个 logo 都画;
* 3. 填了队名时写「第一分公司二队」(不加括号),没填时一个字不变——一个单位两个队
* 各自占一个名次,两行必须分得开;
* 4. 成员单位的 logo 只能从快照的单位表里取(entries 接口不下发 logoPath),
* 取不到时那个位置是 null,TeamLogo 不渲染,不会留空洞。
*/
function team(id: number, over: Partial<Team> = {}): Team {
return {
id,
code: `T${id}`,
name: `第${id}分公司`,
shortName: null,
logoPath: null,
color: null,
sortOrder: id,
active: true,
deletedAt: null,
...over,
}
}
const TEAMS: Team[] = [
team(1, { code: 'A', name: '第一分公司', shortName: '一司', logoPath: '/uploads/a.png' }),
team(2, { code: 'B', name: '第二分公司', shortName: '二司', logoPath: '/uploads/b.png' }),
team(3, { code: 'C', name: '第三分公司', shortName: '三司', logoPath: null }),
]
test('teamShares 解析:缺字段 / 类型不对一律当「没有配置」,不抛异常', () => {
assert.deepEqual(readEntryTeamShares({}), [])
assert.deepEqual(readEntryTeamShares({ teamShares: null }), [])
assert.deepEqual(readEntryTeamShares({ teamShares: 'A+B' }), [])
assert.deepEqual(readEntryTeamShares(null), [])
assert.equal(readEntryTeamShares({ teamShares: [{ teamId: 1, weight: 1 }] }).length, 1)
})
test('联队进表:成员单位带上各自的 logo(名字与 logo 都来自两处不同的接口)', () => {
const index = entryShareIndex(
[
{
id: 11,
teamId: 1,
teamCode: 'A',
teamName: '第一分公司',
teamShares: [
{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamCode: 'B', teamName: '第二分公司' },
],
},
],
TEAMS,
)
const members = index.get(11)
assert.equal(members?.length, 2)
assert.equal(members?.[1]?.logoPath, '/uploads/b.png')
assert.equal(members?.[1]?.percent, 50)
})
test('普通报名(单单位 / 老服务端没有 teamShares)不进表 = 屏上按原来那样渲染', () => {
const index = entryShareIndex(
[
{ id: 11, teamId: 1, teamCode: 'A', teamName: '第一分公司' },
{
id: 12,
teamId: 1,
teamCode: 'A',
teamName: '第一分公司',
teamShares: [{ teamId: 1, weight: 1, teamCode: 'A', teamName: '第一分公司' }],
},
],
TEAMS,
)
assert.equal(index.size, 0)
assert.equal(index.get(11), undefined)
})
test('单位列文案:单单位与改造前逐字一致,联队写「A+B联队」,填了队名写「第一分公司二队」', () => {
const row = { shortName: '一司', teamName: '第一分公司' }
assert.equal(rowTeamText(row, null), '一司')
assert.equal(rowTeamText({ shortName: null, teamName: '第一分公司' }, null), '第一分公司')
// 老服务端不下发 squad(null)与空串都走同一条路:一个字符都不多。
assert.equal(rowTeamText(row, null, ''), '一司')
assert.equal(rowTeamText(row, null, null), '一司')
// 队名直接拼在单位名后面,不加括号(大屏单位列宽紧张)。
assert.equal(rowTeamText(row, null, '二队'), '一司二队')
assert.equal(rowTeamText({ shortName: null, teamName: '第一分公司' }, null, '青年队'), '第一分公司青年队')
const members = entryShareIndex(
[
{
id: 11,
teamId: 1,
teamName: '第一分公司',
teamShares: [
{ teamId: 1, weight: 1, teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamName: '第二分公司' },
],
},
],
TEAMS,
).get(11)
assert.ok(members !== null && members !== undefined)
// 联队写全称:两个单位名一个不少(简称在别的单位的人眼里认不出来)。
assert.equal(rowTeamText(row, members), '第一分公司+第二分公司联队')
assert.equal(rowTeamText(row, members, '二队'), '第一分公司+第二分公司联队二队')
})
test('队名索引:只收填了队名的报名,空串 / 缺字段 / 脏数据一律不进表', () => {
const index = entrySquadIndex([
{ id: 11, squad: '一队' },
{ id: 12, squad: '' },
{ id: 13 },
{ id: 14, squad: ' 二队 ' },
{ id: 15, squad: 2 },
{ id: 0, squad: '一队' },
null,
])
assert.equal(index.get(11), '一队')
assert.equal(index.get(14), '二队', '两侧空白去掉后再上屏')
assert.equal(index.size, 2, '不填队名的报名不在表里 = 按原来那样渲染')
assert.equal(index.has(0), false, '没有合法 id 的报名项丢掉')
assert.deepEqual(entrySquadIndex([]), new Map())
assert.equal(readEntrySquad({ squad: '一队' }), '一队')
assert.equal(readEntrySquad({ squad: null }), '')
assert.equal(readEntrySquad({}), '')
assert.equal(readEntrySquad(null), '')
})
test('底栏一行:有运动员写运动员;团体行的联队写「A+B联队」,带队伍时队名也在', () => {
const members = entryShareIndex(
[
{
id: 11,
teamId: 1,
teamName: '第一分公司',
teamShares: [
{ teamId: 1, weight: 1, teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamName: '第二分公司' },
],
},
],
TEAMS,
).get(11)
assert.ok(members !== null && members !== undefined)
assert.equal(
tickerRowText({ athleteName: '张三', shortName: '一司', teamName: '第一分公司' }, members),
'张三',
)
assert.equal(
tickerRowText({ athleteName: null, shortName: '一司', teamName: '第一分公司' }, members),
'第一分公司+第二分公司联队',
)
assert.equal(
tickerRowText({ athleteName: null, shortName: '一司', teamName: '第一分公司' }, null),
'一司',
)
// 底栏一行只差队名时,队名就是唯一的分辨依据(同一个单位的两个队都进了名次)。
assert.equal(
tickerRowText({ athleteName: null, shortName: '一司', teamName: '第一分公司' }, null, '一队'),
'一司一队',
)
assert.equal(
tickerRowText({ athleteName: '', shortName: '一司', teamName: '第一分公司' }, null, '二队'),
'一司二队',
'athleteName 为空串(团体行)时同样带上队名',
)
})
test('单位列的 logo:普通报名只有行的那一张,联队把两个单位都放上', () => {
const row = { teamId: 1, logoPath: '/uploads/a.png' }
assert.deepEqual(rowTeamLogos(row, null), ['/uploads/a.png'])
assert.equal(rowLogoCount(row, null), 1)
const members = entryShareIndex(
[
{
id: 11,
teamId: 1,
teamName: '第一分公司',
teamShares: [
{ teamId: 1, weight: 1, teamName: '第一分公司' },
{ teamId: 2, weight: 1, teamName: '第二分公司' },
],
},
],
TEAMS,
).get(11)
assert.ok(members !== null && members !== undefined)
// 主单位的 logo 来自行本身(服务端读侧口径),成员单位的 logo 来自快照单位表,不重复画。
assert.deepEqual(rowTeamLogos(row, members), ['/uploads/a.png', '/uploads/b.png'])
assert.equal(rowLogoCount(row, members), 2)
// 成员没有 logo 时是 null(TeamLogo 不渲染),不影响另一个 logo 与名字。
const noLogo = entryShareIndex(
[
{
id: 12,
teamId: 1,
teamName: '第一分公司',
teamShares: [
{ teamId: 1, weight: 1, teamName: '第一分公司' },
{ teamId: 3, weight: 1, teamName: '第三分公司' },
],
},
],
TEAMS,
).get(12)
assert.ok(noLogo !== null && noLogo !== undefined)
assert.deepEqual(rowTeamLogos(row, noLogo), ['/uploads/a.png', null])
assert.equal(rowLogoCount(row, noLogo), 1, '没有 logo 的那一份不占 logo 宽度')
})
test('大屏为屏上的小项读报名表:读失败静默(屏上仍是主单位,不清屏)', () => {
const source = readFileSync(new URL('../useScoreboard.ts', import.meta.url), 'utf8')
assert.match(source, /api\.events\.entries\(eventId\)/, '成员单位只能来自报名表')
assert.match(source, /entryShareIndex\(entries, teams\.value\)/, 'logo 要从快照单位表里补')
assert.match(source, /entrySquadIndex\(entries\)/, '队名与成员单位同一次请求,不为它多发一个请求')
assert.match(source, /catch \{/, '拿不到报名表时按单单位渲染,不能弹错或清屏')
assert.match(source, /jointTeamOf/, '页面通过 jointTeamOf 取成员单位')
assert.match(source, /squadOf/, '页面通过 squadOf 取队名')
})
// 三处渲染点都要把队名接上:少接一处,那一页上同一个单位的两个队就分不开了。
test('分项成绩 / 实时赛况 / 底栏三处都把队名传进单位列文案', () => {
const call = /rowTeamText\(row, jointTeamOf\(row\.entryId\), squadOf\(row\.entryId\)\)/
const pages: [string, RegExp][] = [
['../components/pages/EventResultsPage.vue', call],
['../components/pages/LivePage.vue', call],
['../components/TickerBar.vue', /tickerRowText\(row, jointTeamOf\(row\.entryId\), squadOf\(row\.entryId\)\)/],
]
for (const [file, pattern] of pages) {
const source = readFileSync(new URL(file, import.meta.url), 'utf8')
assert.match(source, pattern, `${file} 没把队名接进单位列`)
assert.match(source, /squadOf/, `${file} 要从 useScoreboard 取 squadOf`)
}
})