## 一、新能力:团体项目多队
现场:羽毛球双打,一个单位可能派两个队,一个单位两个队各占一个名次。
- 迁移 `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`。
355 lines
13 KiB
TypeScript
355 lines
13 KiB
TypeScript
import { test } from 'node:test'
|
||
import assert from 'node:assert/strict'
|
||
import type { Event, EventCategory, ScoreChannel, ScoringProfile } from '@/shared'
|
||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||
|
||
// 逐条对齐服务端 internal/domain/scoring.go 的 ScoreEvent 口径。
|
||
// 运行:node --test --import ./src/shared/tests/register.mjs src/admin/tests/
|
||
|
||
function makeEvent(overrides: Partial<Event> = {}): Event {
|
||
return {
|
||
id: 1,
|
||
sportId: 1,
|
||
code: 'M100',
|
||
name: '100 米',
|
||
eventType: 'individual',
|
||
resultMode: 'auto',
|
||
resultType: 'time',
|
||
compare: 'asc',
|
||
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: 'athlete', basis: 'result', profileId: 1, sortOrder: 1, countToTeam: true, ...overrides }
|
||
}
|
||
|
||
function makeRow(entryId: number, resultValue: number | null, teamId: number, rank: number | null = null): ScorableRow {
|
||
return { entryId, teamId, athleteId: entryId, resultValue, rank, status: 'ok', isTie: false }
|
||
}
|
||
|
||
function makeContext(overrides: Partial<ScoreContext> = {}): ScoreContext {
|
||
return { event: makeEvent(), category: null, channels: [], profiles: [], weight: 1, ...overrides }
|
||
}
|
||
|
||
function close(actual: number, expected: number): void {
|
||
assert.ok(Math.abs(actual - expected) < 1e-9, `${actual} != ${expected}`)
|
||
}
|
||
|
||
test('awardRanks 之外的名次一律 0 分', () => {
|
||
const profile = makeProfile({
|
||
awardRanks: 2,
|
||
rules: [
|
||
{ rank: 1, points: 9 },
|
||
{ rank: 2, points: 6 },
|
||
{ rank: 3, points: 3 },
|
||
],
|
||
})
|
||
const result = computeEventScore(
|
||
[makeRow(1, 10, 100), makeRow(2, 11, 101), makeRow(3, 12, 102)],
|
||
makeContext({ channels: [makeChannel()], profiles: [profile] }),
|
||
)
|
||
close(result.rowPoints.get(1) ?? 0, 9)
|
||
close(result.rowPoints.get(2) ?? 0, 6)
|
||
close(result.rowPoints.get(3) ?? 0, 0)
|
||
close(result.teamPoints.get(102) ?? 0, 0)
|
||
})
|
||
|
||
test('lastPlaceZero 让末位整组 0 分', () => {
|
||
const profile = makeProfile({
|
||
lastPlaceZero: true,
|
||
rules: [
|
||
{ rank: 1, points: 9 },
|
||
{ rank: 2, points: 6 },
|
||
{ rank: 3, points: 3 },
|
||
],
|
||
})
|
||
const result = computeEventScore(
|
||
[makeRow(1, 10, 100), makeRow(2, 11, 101), makeRow(3, 12, 102)],
|
||
makeContext({ channels: [makeChannel()], profiles: [profile] }),
|
||
)
|
||
close(result.rowPoints.get(1) ?? 0, 9)
|
||
close(result.rowPoints.get(2) ?? 0, 6)
|
||
close(result.rowPoints.get(3) ?? 0, 0)
|
||
})
|
||
|
||
test('跨末位的并列组不整组归零:只有起始名次正好落在末位的那一组才 0 分', () => {
|
||
// 与服务端 domain/scoring.go groupPoints 逐字同口径(Go 用例「lastPlaceZero 不牵连跨末位的并列组」):
|
||
// lastPlaceZero 的判据是 startRank == lastPlace。3 人里两人并列第 2(占 2、3),末位档 = 3,
|
||
// 起始名次 2 != 3 → 第 2 名的 6 分照发;整组归零会连带抹掉本可拿分的第 2 名。
|
||
const profile = makeProfile({
|
||
lastPlaceZero: true,
|
||
rules: [
|
||
{ rank: 1, points: 9 },
|
||
{ rank: 2, points: 6 },
|
||
{ rank: 3, points: 3 },
|
||
],
|
||
})
|
||
const result = computeEventScore(
|
||
[makeRow(1, 10, 100), makeRow(2, 11, 101), makeRow(3, 11, 102)],
|
||
makeContext({ channels: [makeChannel()], profiles: [profile] }),
|
||
)
|
||
close(result.rowPoints.get(1) ?? 0, 9)
|
||
close(result.rowPoints.get(2) ?? 0, 6)
|
||
close(result.rowPoints.get(3) ?? 0, 6)
|
||
})
|
||
|
||
test('scored=false 只排名不计分', () => {
|
||
const profile = makeProfile({
|
||
rules: [
|
||
{ rank: 1, points: 9 },
|
||
{ rank: 2, points: 6 },
|
||
],
|
||
})
|
||
const result = computeEventScore(
|
||
[makeRow(1, 10, 100), makeRow(2, 11, 101)],
|
||
makeContext({ event: makeEvent({ scored: false }), channels: [makeChannel()], profiles: [profile] }),
|
||
)
|
||
assert.equal(result.ranks.get(1), 1)
|
||
assert.equal(result.ranks.get(2), 2)
|
||
close(result.rowPoints.get(1) ?? 0, 0)
|
||
close(result.teamPoints.get(100) ?? 0, 0)
|
||
})
|
||
|
||
test('多通道叠加,个人通道分按权重折算进单位总分', () => {
|
||
const athleteProfile = makeProfile({ id: 1, rules: [{ rank: 1, points: 10 }] })
|
||
const teamProfile = makeProfile({ id: 2, rules: [{ rank: 1, points: 4 }] })
|
||
const result = computeEventScore(
|
||
[makeRow(1, 10, 100)],
|
||
makeContext({
|
||
channels: [
|
||
makeChannel({ id: 1, scope: 'athlete', profileId: 1 }),
|
||
makeChannel({ id: 2, scope: 'team', profileId: 2 }),
|
||
],
|
||
profiles: [athleteProfile, teamProfile],
|
||
weight: 2,
|
||
}),
|
||
)
|
||
// 行分是两条通道的原始分之和;折算到单位时只有个人通道乘系数。
|
||
close(result.rowPoints.get(1) ?? 0, 14)
|
||
close(result.rowTeamPoints.get(1) ?? 0, 24)
|
||
close(result.teamPoints.get(100) ?? 0, 24)
|
||
})
|
||
|
||
test('并列 average 取占用名次区间的均值', () => {
|
||
const profile = makeProfile({
|
||
tieMode: 'average',
|
||
rules: [
|
||
{ rank: 1, points: 10 },
|
||
{ rank: 2, points: 6 },
|
||
{ rank: 3, points: 3 },
|
||
{ rank: 4, points: 1 },
|
||
],
|
||
})
|
||
const result = computeEventScore(
|
||
[makeRow(1, 10, 100), makeRow(2, 10, 101), makeRow(3, 10, 102)],
|
||
makeContext({ channels: [makeChannel()], profiles: [profile] }),
|
||
)
|
||
close(result.rowPoints.get(1) ?? 0, (10 + 6 + 3) / 3)
|
||
close(result.rowPoints.get(2) ?? 0, (10 + 6 + 3) / 3)
|
||
close(result.rowPoints.get(3) ?? 0, (10 + 6 + 3) / 3)
|
||
assert.equal(result.ranks.get(1), 1)
|
||
})
|
||
|
||
test('并列 higher 取区间最高档', () => {
|
||
const profile = makeProfile({
|
||
tieMode: 'higher',
|
||
rules: [
|
||
{ rank: 1, points: 10 },
|
||
{ rank: 2, points: 6 },
|
||
{ rank: 3, points: 3 },
|
||
],
|
||
})
|
||
const result = computeEventScore(
|
||
[makeRow(1, 10, 100), makeRow(2, 10, 101), makeRow(3, 10, 102)],
|
||
makeContext({ channels: [makeChannel()], profiles: [profile] }),
|
||
)
|
||
close(result.rowPoints.get(1) ?? 0, 10)
|
||
close(result.rowPoints.get(2) ?? 0, 10)
|
||
close(result.rowPoints.get(3) ?? 0, 10)
|
||
})
|
||
|
||
test('没有计分通道时算出名次但一律 0 分', () => {
|
||
const result = computeEventScore(
|
||
[makeRow(1, 10, 100), makeRow(2, 11, 101)],
|
||
makeContext({ channels: [], profiles: [] }),
|
||
)
|
||
assert.equal(result.ranks.get(1), 1)
|
||
assert.equal(result.ranks.get(2), 2)
|
||
close(result.rowPoints.get(1) ?? 0, 0)
|
||
close(result.teamPoints.get(100) ?? 0, 0)
|
||
})
|
||
|
||
test('manual 通道使用录入名次,不做顺推改写', () => {
|
||
const profile = makeProfile({
|
||
rules: [
|
||
{ rank: 1, points: 10 },
|
||
{ rank: 2, points: 6 },
|
||
],
|
||
})
|
||
const result = computeEventScore(
|
||
[makeRow(1, null, 100, 2), makeRow(2, null, 101, 1)],
|
||
makeContext({
|
||
event: makeEvent({ resultMode: 'manual', compare: 'none' }),
|
||
channels: [makeChannel({ basis: 'manual' })],
|
||
profiles: [profile],
|
||
}),
|
||
)
|
||
close(result.rowPoints.get(1) ?? 0, 6)
|
||
close(result.rowPoints.get(2) ?? 0, 10)
|
||
assert.equal(result.ranks.get(1), 2)
|
||
assert.equal(result.ranks.get(2), 1)
|
||
})
|
||
|
||
test('项目与组别权重相乘', () => {
|
||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }] })
|
||
const category: EventCategory = {
|
||
id: 7,
|
||
eventId: 1,
|
||
name: '甲组',
|
||
multiplier: 3,
|
||
gender: null,
|
||
ageMin: null,
|
||
ageMax: null,
|
||
sortOrder: 1,
|
||
}
|
||
const result = computeEventScore(
|
||
[makeRow(1, 10, 100)],
|
||
makeContext({ event: makeEvent({ multiplier: 2 }), category, channels: [makeChannel()], profiles: [profile] }),
|
||
)
|
||
close(result.rowPoints.get(1) ?? 0, 60)
|
||
})
|
||
|
||
// 团体行的 athleteId 是 null(服务端 entries.athlete_id 为空,0 队员的「只报单位」报名也是这种行),
|
||
// 团体通道的排名主体是单位,不能因为没有运动员就把行丢掉。
|
||
test('团体行(athleteId 为 null)按单位通道正常拿名次与积分', () => {
|
||
const profile = makeProfile({
|
||
rules: [
|
||
{ rank: 1, points: 9 },
|
||
{ rank: 2, points: 6 },
|
||
],
|
||
})
|
||
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 result = computeEventScore(
|
||
rows,
|
||
makeContext({
|
||
event: makeEvent({ eventType: 'team', resultMode: 'manual', compare: 'none', resultType: 'none' }),
|
||
channels: [makeChannel({ scope: 'team', basis: 'manual' })],
|
||
profiles: [profile],
|
||
}),
|
||
)
|
||
assert.equal(result.ranks.get(1), 1)
|
||
assert.equal(result.ranks.get(2), 2)
|
||
close(result.rowPoints.get(1) ?? 0, 9)
|
||
close(result.rowTeamPoints.get(1) ?? 0, 9)
|
||
close(result.teamPoints.get(100) ?? 0, 9)
|
||
close(result.teamPoints.get(101) ?? 0, 6)
|
||
})
|
||
|
||
test('countToTeam=false 的通道只算个人分,不进单位总分', () => {
|
||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }] })
|
||
const result = computeEventScore(
|
||
[makeRow(1, 10, 100)],
|
||
makeContext({
|
||
channels: [makeChannel({ basis: 'result', countToTeam: false })],
|
||
profiles: [profile],
|
||
weight: 2,
|
||
}),
|
||
)
|
||
close(result.rowPoints.get(1) ?? 0, 10)
|
||
close(result.rowTeamPoints.get(1) ?? 0, 0)
|
||
close(result.teamPoints.get(100) ?? 0, 0)
|
||
// 名次照算,只是分不落到单位头上。
|
||
assert.equal(result.ranks.get(1), 1)
|
||
})
|
||
|
||
test('「个人+团体」:个人通道不进单位总分,团体通道按队内平均记分', () => {
|
||
const athleteProfile = makeProfile({ id: 1, rules: [{ rank: 1, points: 10 }, { rank: 2, points: 8 }] })
|
||
const teamProfile = makeProfile({ id: 2, rules: [{ rank: 1, points: 15 }, { rank: 2, points: 8 }] })
|
||
const rows = [makeRow(1, 18, 100), makeRow(2, 20, 100), makeRow(3, 16, 101)]
|
||
const result = computeEventScore(
|
||
rows,
|
||
makeContext({
|
||
event: makeEvent({ eventType: 'individual_team', resultType: 'count', compare: 'desc' }),
|
||
channels: [
|
||
makeChannel({ id: 1, scope: 'athlete', basis: 'result', profileId: 1, countToTeam: false }),
|
||
makeChannel({ id: 2, scope: 'team', basis: 'aggregate', profileId: 2, countToTeam: true }),
|
||
],
|
||
profiles: [athleteProfile, teamProfile],
|
||
}),
|
||
)
|
||
// 个人分照给:20 排第 1、18 排第 2、16 排第 3(录取前 2 名)。
|
||
close(result.rowPoints.get(2) ?? 0, 10)
|
||
close(result.rowPoints.get(1) ?? 0, 8)
|
||
// 单位总分只来自团体通道:100 队平均 19 排第 1,101 队平均 16 排第 2。
|
||
// 个人通道若也计入,100 队会多出个人第 1 名的 10 分。
|
||
close(result.teamPoints.get(100) ?? 0, 15)
|
||
close(result.teamPoints.get(101) ?? 0, 8)
|
||
})
|
||
|
||
test('aggregate 通道按队内平均排名(valid 只算有效成绩)', () => {
|
||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }, { rank: 2, points: 5 }] })
|
||
const result = computeEventScore(
|
||
[makeRow(1, 10, 100), makeRow(2, 20, 100), makeRow(3, 16, 101)],
|
||
makeContext({
|
||
event: makeEvent({ eventType: 'team', resultType: 'count', compare: 'desc' }),
|
||
channels: [makeChannel({ scope: 'team', basis: 'aggregate' })],
|
||
profiles: [profile],
|
||
}),
|
||
)
|
||
// 100 队平均 15 < 101 队平均 16,desc 下 101 队第一。
|
||
close(result.teamPoints.get(101) ?? 0, 10)
|
||
close(result.teamPoints.get(100) ?? 0, 5)
|
||
})
|
||
|
||
test('avgMode=all 把没成绩的报名项按 0 计入平均分母', () => {
|
||
const profile = makeProfile({ rules: [{ rank: 1, points: 10 }, { rank: 2, points: 5 }] })
|
||
const makeRows = (): ScorableRow[] => [makeRow(1, 20, 100), makeRow(2, 20, 100), makeRow(3, null, 100), makeRow(4, 16, 101)]
|
||
const context = (avgMode: Event['avgMode']): ScoreContext =>
|
||
makeContext({
|
||
event: makeEvent({ eventType: 'team', resultType: 'count', compare: 'desc', avgMode }),
|
||
channels: [makeChannel({ scope: 'team', basis: 'aggregate' })],
|
||
profiles: [profile],
|
||
})
|
||
|
||
// valid:100 队只按两条有效成绩平均(20)→ 第一。
|
||
const valid = computeEventScore(makeRows(), context('valid'))
|
||
close(valid.teamPoints.get(100) ?? 0, 10)
|
||
close(valid.teamPoints.get(101) ?? 0, 5)
|
||
|
||
// all:第 3 条没成绩按 0 计入,100 队平均 40/3 → 落到第二。
|
||
const all = computeEventScore(makeRows(), context('all'))
|
||
close(all.teamPoints.get(101) ?? 0, 10)
|
||
close(all.teamPoints.get(100) ?? 0, 5)
|
||
})
|
||
|
||
test('团体行只配了录入名次(无计分通道)时也能算出名次', () => {
|
||
const rows: ScorableRow[] = [
|
||
{ entryId: 1, teamId: 100, athleteId: null, resultValue: null, rank: 2, status: 'ok', isTie: false },
|
||
{ entryId: 2, teamId: 101, athleteId: null, resultValue: null, rank: 1, status: 'ok', isTie: false },
|
||
]
|
||
const result = computeEventScore(
|
||
rows,
|
||
makeContext({ event: makeEvent({ eventType: 'team', resultMode: 'manual', compare: 'none', resultType: 'none' }) }),
|
||
)
|
||
assert.equal(result.ranks.get(1), 2)
|
||
assert.equal(result.ranks.get(2), 1)
|
||
})
|