## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `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`。
290 lines
11 KiB
TypeScript
290 lines
11 KiB
TypeScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import { readFileSync } from 'node:fs'
|
||
import type { Event, ScoreChannel, ScoringProfile, TeamRankEntry } from '@/shared'
|
||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||
|
||
/**
|
||
* 「按单位录名次」(scope=team + basis=manual)的预览口径,逐条对齐服务端
|
||
* domain/scoring.go 的 collectTeamManualCandidates 与 groupPoints。
|
||
*
|
||
* 现场报的 bug:台球按单位在录入页底部的「团体名次」卡片里录名次(channel_manual_ranks),
|
||
* 服务端算对了(六个单位合计 40 分),录入页的实时预览却每个单位都是 0——预览只读行上的
|
||
* rank,从不读 channel_manual_ranks。而那张预览又是发布确认框里「最大分差」的来源。
|
||
*/
|
||
|
||
function makeEvent(overrides: Partial<Event> = {}): Event {
|
||
return {
|
||
id: 1,
|
||
sportId: 1,
|
||
code: 'BALL',
|
||
name: '台球',
|
||
eventType: 'team',
|
||
resultMode: 'manual',
|
||
resultType: 'none',
|
||
compare: 'none',
|
||
recordEligible: false,
|
||
scored: true,
|
||
awardMedals: true,
|
||
multiplier: 1,
|
||
status: 'pending',
|
||
scheduledAt: null,
|
||
venue: null,
|
||
sortOrder: 1,
|
||
remark: null,
|
||
avgMode: 'valid',
|
||
...overrides,
|
||
}
|
||
}
|
||
|
||
function makeProfile(overrides: Partial<ScoringProfile> = {}): ScoringProfile {
|
||
return { id: 1, code: 'p', name: 'p', tieMode: 'skip', recordBonusMode: 'max', rules: [], ...overrides }
|
||
}
|
||
|
||
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
|
||
return { id: 1, eventId: 1, scope: 'team', basis: 'manual', profileId: 1, sortOrder: 1, countToTeam: true, ...overrides }
|
||
}
|
||
|
||
function makeContext(overrides: Partial<ScoreContext> = {}): ScoreContext {
|
||
return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides }
|
||
}
|
||
|
||
/** 团体名次分表:与规则包 team_top6 同一张表,第 1–6 名 15/10/8/4/2/1(六个单位合计 40 分,正是现场那组数)。 */
|
||
const TEAM_PROFILE = makeProfile({
|
||
rules: [
|
||
{ rank: 1, points: 15 },
|
||
{ rank: 2, points: 10 },
|
||
{ rank: 3, points: 8 },
|
||
{ rank: 4, points: 4 },
|
||
{ rank: 5, points: 2 },
|
||
{ rank: 6, points: 1 },
|
||
],
|
||
})
|
||
|
||
const SIX_UNITS: TeamRankEntry[] = [
|
||
{ teamId: 100, rank: 1 },
|
||
{ teamId: 101, rank: 2 },
|
||
{ teamId: 102, rank: 3 },
|
||
{ teamId: 103, rank: 4 },
|
||
{ teamId: 104, rank: 5 },
|
||
{ teamId: 105, rank: 6 },
|
||
]
|
||
|
||
function close(actual: number, expected: number): void {
|
||
assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`)
|
||
}
|
||
|
||
test('名次只填在「团体名次」卡片(channel_manual_ranks):六个单位合计 40 分', () => {
|
||
// 与服务端 httpapi 侧 team_manual_ranks_score_test.go 同输入同期望:同一份 channel_manual_ranks,
|
||
// 预览与引擎必须给出同一组数字。
|
||
// 团体名次不挂在任何报名项上:六个单位各录一个名次,成绩表里一行都没有 rank。
|
||
const rows: ScorableRow[] = [100, 101, 102, 103, 104, 105].map((teamId, index) => ({
|
||
entryId: index + 1,
|
||
teamId,
|
||
athleteId: index + 1,
|
||
resultValue: null,
|
||
rank: null,
|
||
status: 'ok',
|
||
isTie: false,
|
||
}))
|
||
const result = computeEventScore(
|
||
rows,
|
||
makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: SIX_UNITS }),
|
||
)
|
||
const expected = [15, 10, 8, 4, 2, 1]
|
||
let total = 0
|
||
for (const [index, teamId] of [100, 101, 102, 103, 104, 105].entries()) {
|
||
const points = result.teamPoints.get(teamId) ?? 0
|
||
close(points, expected[index] ?? 0)
|
||
total += points
|
||
}
|
||
close(total, 40)
|
||
})
|
||
|
||
test('团体手工通道只认按单位录的名次:填在个人行上的名次一律不进团体通道', () => {
|
||
// 服务端 collectTeamManualCandidates 只收 athlete_id == 0 的团体行;
|
||
// 预览若照旧读行上的 rank,就会出现「预览有分(10/9/8…)、服务端 0」。
|
||
const rows: ScorableRow[] = [10, 9, 8, 6, 4, 3].map((rank, index) => ({
|
||
entryId: index + 1,
|
||
teamId: 100 + index,
|
||
athleteId: index + 1,
|
||
resultValue: null,
|
||
rank,
|
||
status: 'ok',
|
||
isTie: false,
|
||
}))
|
||
const result = computeEventScore(rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE] }))
|
||
assert.equal(result.teamPoints.size, 0)
|
||
assert.equal(result.rowPoints.size, 0)
|
||
})
|
||
|
||
test('两个团体行的名次照旧生效,且被卡片里的名次覆盖', () => {
|
||
// 纯团体项目(篮球等)的成绩表兜底:团体行(athleteId 为 null)上录的名次。
|
||
const rows: ScorableRow[] = [
|
||
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false },
|
||
{ entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
|
||
]
|
||
const fromRows = computeEventScore(rows, makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE] }))
|
||
close(fromRows.teamPoints.get(100) ?? 0, 15)
|
||
close(fromRows.teamPoints.get(101) ?? 0, 10)
|
||
assert.equal(fromRows.ranks.get(1), 1)
|
||
|
||
// 同一单位两个来源都有时以 channel_manual_ranks 为准(服务端 overrideOf)。
|
||
const overridden = computeEventScore(
|
||
rows,
|
||
makeContext({
|
||
channels: [makeChannel()],
|
||
profiles: [TEAM_PROFILE],
|
||
teamRanks: [
|
||
{ teamId: 100, rank: 2 },
|
||
{ teamId: 101, rank: 1 },
|
||
],
|
||
}),
|
||
)
|
||
close(overridden.teamPoints.get(100) ?? 0, 10)
|
||
close(overridden.teamPoints.get(101) ?? 0, 15)
|
||
assert.equal(overridden.ranks.get(1), 2)
|
||
})
|
||
|
||
test('联队:按单位录的名次按份额拆给各成员单位(与服务端 jointTeamEntry + ledgerFromRanked 同口径)', () => {
|
||
// 服务端 joint_team_test.go 的场景:联队报名(A、B 各半)+ TeamRanks{主单位 A 第 1 名}、15 分表
|
||
// → A、B 各 7.5。名次挂在联队那条报名上(entryId 1),所以行分是整份 15,单位分才拆。
|
||
const profile = makeProfile({ rules: [{ rank: 1, points: 15 }] })
|
||
const result = computeEventScore(
|
||
[
|
||
{
|
||
entryId: 1,
|
||
teamId: 100,
|
||
athleteId: 0,
|
||
resultValue: null,
|
||
rank: null,
|
||
status: 'ok',
|
||
isTie: false,
|
||
shares: [
|
||
{ teamId: 100, weight: 1 },
|
||
{ teamId: 200, weight: 1 },
|
||
],
|
||
},
|
||
],
|
||
makeContext({ channels: [makeChannel()], profiles: [profile], teamRanks: [{ teamId: 100, rank: 1 }] }),
|
||
)
|
||
close(result.teamPoints.get(100) ?? 0, 7.5)
|
||
close(result.teamPoints.get(200) ?? 0, 7.5)
|
||
close(result.rowPoints.get(1) ?? 0, 15)
|
||
})
|
||
|
||
test('lastPlaceZero 只抹「起始名次正好落在末位」的那一组:两人并列第 1 不归零', () => {
|
||
// 服务端判据是 startRank == lastPlace(scoring.go groupPoints)。两人并列第 1 时
|
||
// 占位名次是 1、2,末位档 = 2,起始名次 1 != 2 → 照发第 1 名的分。
|
||
const profile = makeProfile({
|
||
lastPlaceZero: true,
|
||
rules: [
|
||
{ rank: 1, points: 10 },
|
||
{ rank: 2, points: 6 },
|
||
{ rank: 3, points: 3 },
|
||
],
|
||
})
|
||
const result = computeEventScore(
|
||
[makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 1)],
|
||
makeContext({
|
||
event: makeEvent({ eventType: 'individual', resultType: 'none' }),
|
||
channels: [makeChannel({ scope: 'athlete', basis: 'manual' })],
|
||
profiles: [profile],
|
||
}),
|
||
)
|
||
close(result.rowPoints.get(1) ?? 0, 10)
|
||
close(result.rowPoints.get(2) ?? 0, 10)
|
||
close(result.teamPoints.get(100) ?? 0, 10)
|
||
close(result.teamPoints.get(101) ?? 0, 10)
|
||
})
|
||
|
||
test('lastPlaceZero 仍要抹掉真正的末位那一组', () => {
|
||
const profile = makeProfile({
|
||
lastPlaceZero: true,
|
||
rules: [
|
||
{ rank: 1, points: 10 },
|
||
{ rank: 2, points: 6 },
|
||
{ rank: 3, points: 3 },
|
||
],
|
||
})
|
||
const result = computeEventScore(
|
||
[makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 2), makeRowOf(3, null, 102, 3)],
|
||
makeContext({
|
||
event: makeEvent({ eventType: 'individual', resultType: 'none' }),
|
||
channels: [makeChannel({ scope: 'athlete', basis: 'manual' })],
|
||
profiles: [profile],
|
||
}),
|
||
)
|
||
close(result.rowPoints.get(1) ?? 0, 10)
|
||
close(result.rowPoints.get(2) ?? 0, 6)
|
||
close(result.rowPoints.get(3) ?? 0, 0)
|
||
})
|
||
|
||
test('compare=none 配 basis=result:预览报错,不静默按录入名次兜底', () => {
|
||
const profile = makeProfile({
|
||
rules: [
|
||
{ rank: 1, points: 10 },
|
||
{ rank: 2, points: 6 },
|
||
],
|
||
})
|
||
const result = computeEventScore(
|
||
[makeRowOf(1, null, 100, 1), makeRowOf(2, null, 101, 2)],
|
||
makeContext({
|
||
event: makeEvent({ eventType: 'individual', resultMode: 'manual' }),
|
||
channels: [makeChannel({ scope: 'athlete', basis: 'result' })],
|
||
profiles: [profile],
|
||
}),
|
||
)
|
||
assert.ok(result.error, '服务端 domain.ScoreEvent 对这个配置返回 ErrCompareRequired(发布 409),预览必须同样报错')
|
||
assert.match(result.error.message, /比较方向/)
|
||
assert.equal(result.error.channelLabel, '个人名次 / 按成绩自动排名')
|
||
// 报错时不再产出任何分:预览不该给一个服务端算不出来的配置算分。
|
||
assert.equal(result.teamPoints.size, 0)
|
||
assert.equal(result.rowPoints.size, 0)
|
||
})
|
||
|
||
test('compare=none 配 basis=aggregate:同样报错', () => {
|
||
const result = computeEventScore(
|
||
[makeRowOf(1, 20, 100, null), makeRowOf(2, 16, 101, null)],
|
||
makeContext({
|
||
event: makeEvent({ eventType: 'team', resultType: 'count' }),
|
||
channels: [makeChannel({ basis: 'aggregate' })],
|
||
profiles: [TEAM_PROFILE],
|
||
}),
|
||
)
|
||
assert.ok(result.error, '聚合通道同样要先按成绩排名,compare=none 时服务端返回 ErrCompareRequired')
|
||
assert.match(result.error.message, /比较方向/)
|
||
})
|
||
|
||
test('compare=none 配 basis=manual:照常算分,不报错', () => {
|
||
const result = computeEventScore(
|
||
[],
|
||
makeContext({ channels: [makeChannel()], profiles: [TEAM_PROFILE], teamRanks: SIX_UNITS }),
|
||
)
|
||
assert.equal(result.error, null)
|
||
close(result.teamPoints.get(100) ?? 0, 15)
|
||
})
|
||
|
||
function makeRowOf(entryId: number, resultValue: number | null, teamId: number, rank: number | null): ScorableRow {
|
||
return { entryId, teamId, athleteId: entryId, resultValue, rank, status: 'ok', isTie: false }
|
||
}
|
||
|
||
// 录入页没有组件测试工具,接线部分按仓库既有做法(avg-mode.test.ts)读源码断言:
|
||
// 纯函数再有分,页面没把「团体名次」卡片喂进去,现场看到的还是 0。
|
||
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||
|
||
test('录入页把「团体名次」卡片喂进预览算分(接线掉了就等于没修)', () => {
|
||
assert.match(
|
||
ENTRY_SOURCE,
|
||
/const previewTeamRanks = computed<TeamRankEntry\[\]>\(\(\) => teamRanksPayload\(teamRankRows\.value\)\.ranks\)/,
|
||
)
|
||
assert.match(ENTRY_SOURCE, /const score = computed\(\(\) => scoreWith\(visibleRows\.value, previewTeamRanks\.value\)\)/)
|
||
assert.match(ENTRY_SOURCE, /const savedTeamRanks = computed<TeamRankEntry\[\]>\(\(\) => \{/)
|
||
assert.match(ENTRY_SOURCE, /teamRanks,\n \}\)/)
|
||
})
|
||
|
||
test('录入页把「不挂在行上的单位分」从已发布基线里扣掉,差值才算得对', () => {
|
||
assert.match(ENTRY_SOURCE, /for \(const \[teamId, points\] of published\?\.unitPoints \?\? \[\]\)/)
|
||
assert.match(ENTRY_SOURCE, /const scoreConfigError = computed\(\(\) => score\.value\?\.error \?\? null\)/)
|
||
})
|