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`。
This commit is contained in:
1 parent
9663d5bfb0
commit
7a3e4ad365
67 files changed
+4658
-228
No files matched your search
@@ -2,6 +2,7 @@ import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { AVG_MODE_OPTIONS, loadAvgMode, selectAvgMode, settleAvgMode } from '../pages/avgMode'
|
||||
import { CHANNEL_BASIS_LABELS, CHANNEL_SCOPE_LABELS } from '../api/scoring'
|
||||
|
||||
// 「平均值口径」只在录入页改;项目没有组件测试工具,界面部分按仓库既有做法读源码断言。
|
||||
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||
@@ -69,7 +70,11 @@ test('录入页只在存在 aggregate 通道时显示口径选择,并整体回
|
||||
})
|
||||
|
||||
test('计分通道页支持「按队内平均」与「计入单位总分」开关', () => {
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'aggregate', label: '按队内平均' \}/)
|
||||
// 选项文案与预览报错里点名的通道称谓共用一份(api/scoring.ts 的 CHANNEL_*_LABELS),
|
||||
// 所以这里同时钉住两件事:页面用的是这份常量,常量就是现场认识的那句话。
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'aggregate', label: CHANNEL_BASIS_LABELS\.aggregate \}/)
|
||||
assert.equal(CHANNEL_BASIS_LABELS.aggregate, '按队内平均')
|
||||
assert.equal(CHANNEL_SCOPE_LABELS.team, '团体名次')
|
||||
assert.match(CHANNELS_SOURCE, /团体成绩 = 该单位成员成绩的平均值/)
|
||||
assert.match(CHANNELS_SOURCE, /计入单位总分/)
|
||||
assert.match(CHANNELS_SOURCE, /只记在个人头上/)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { compareConflictMessage } from '../components/baseData/channelRows'
|
||||
|
||||
/**
|
||||
* 管理端保存计分通道前的守卫:compare=none 的小项不得配 basis=result / aggregate。
|
||||
*
|
||||
* 规则包导入侧早有这条(server/internal/rules/diff.go checkCompareCompatible),服务端保存通道侧
|
||||
* 见 server/internal/httpapi/repo_events.go——手工在管理端配一直没人拦,一直配到发布才 409。
|
||||
*/
|
||||
|
||||
const MANUAL_EVENT = { name: '台球', compare: 'none' as const }
|
||||
|
||||
test('compare=none 配 basis=result / aggregate:给出人话原因并点名小项', () => {
|
||||
for (const basis of ['result', 'aggregate'] as const) {
|
||||
const message = compareConflictMessage(MANUAL_EVENT, basis)
|
||||
assert.ok(message, `${basis} 必须被拦下`)
|
||||
assert.match(message, /台球/)
|
||||
assert.match(message, /不比较/)
|
||||
assert.match(message, /手动录名次/)
|
||||
}
|
||||
})
|
||||
|
||||
test('compare=none 配 basis=manual:放行', () => {
|
||||
assert.equal(compareConflictMessage(MANUAL_EVENT, 'manual'), null)
|
||||
})
|
||||
|
||||
test('有比较方向的小项:三种 basis 一律放行', () => {
|
||||
for (const compare of ['asc', 'desc'] as const) {
|
||||
for (const basis of ['result', 'manual', 'aggregate'] as const) {
|
||||
assert.equal(compareConflictMessage({ name: '100 米', compare }, basis), null)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('还没选小项时不拦(页面显示的是空态)', () => {
|
||||
assert.equal(compareConflictMessage(null, 'result'), null)
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
MANDATORY_COLUMNS,
|
||||
OPTIONAL_COLUMNS,
|
||||
REQUIRED_COLUMNS,
|
||||
SQUAD_COLUMN,
|
||||
buildSelectionRows,
|
||||
buildTeamSelectionRows,
|
||||
buildTemplateCsv,
|
||||
@@ -17,9 +18,11 @@ import {
|
||||
missingColumnMessage,
|
||||
registeredAthleteIds,
|
||||
registeredTeamIds,
|
||||
registeredTeamSquads,
|
||||
selectableTeamIds,
|
||||
selectionAllowsSubmit,
|
||||
setPickedTeams,
|
||||
setTeamPickSquad,
|
||||
teamSelectionAllowsSubmit,
|
||||
templateFileName,
|
||||
templateHeader,
|
||||
@@ -37,24 +40,26 @@ test('toCsv 按 RFC 4180 转义逗号 / 引号 / 换行', () => {
|
||||
assert.equal(toCsv([['a,"b"', 'c']]), '"a,""b""",c')
|
||||
})
|
||||
|
||||
test('表头含证件号 / 性别 / 年龄 / 批次可选列,必填列不含这四列', () => {
|
||||
test('表头含证件号 / 性别 / 年龄 / 批次 / 队伍可选列,必填列不含这五列', () => {
|
||||
assert.deepEqual(
|
||||
[...REQUIRED_COLUMNS],
|
||||
[
|
||||
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age',
|
||||
'bib', 'seed', 'heat', 'status', 'member_names',
|
||||
'bib', 'seed', 'heat', 'status', 'member_names', 'squad',
|
||||
],
|
||||
)
|
||||
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(REQUIRED_COLUMNS.at(-1), SQUAD_COLUMN, '队伍列在最后:老表格一列都不会错位')
|
||||
assert.deepEqual([...OPTIONAL_COLUMNS], ['id_card', 'gender', 'age', 'heat', 'squad'])
|
||||
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('squad'), false, '队伍列可缺省,缺省即不区分队伍')
|
||||
assert.equal(MANDATORY_COLUMNS.includes('athlete_name'), true)
|
||||
})
|
||||
|
||||
test('buildSelectionRows 个人项目:一人一行,共 12 列并带上证件号 / 号码布', () => {
|
||||
test('buildSelectionRows 个人项目:一人一行,共 13 列并带上证件号 / 号码布', () => {
|
||||
const rows = buildSelectionRows({
|
||||
eventCode: 'M100',
|
||||
categoryName: '男子组',
|
||||
@@ -68,13 +73,14 @@ test('buildSelectionRows 个人项目:一人一行,共 12 列并带上证件
|
||||
})
|
||||
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', ''])
|
||||
assert.equal(rows[1]?.length, 13)
|
||||
// 勾选报名的人员已在库里,年龄列留空表示「不修改」;批次列留空 = 未分批(到录入页再编排);
|
||||
// 队伍列留空 = 个人报名没有「第几支队」这回事。
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '张伟', '110101199001011234', '', '', '101', '', '', 'registered', '', ''])
|
||||
// 没有证件号时号码布进 bib 列,服务端按号码布匹配人员并同时写入报名。
|
||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[2], ['M100', '男子组', 'F1', '李强', '', '', '', '102', '', '', 'registered', '', ''])
|
||||
// 只有证件号时 bib 列留空。
|
||||
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[3], ['M100', '男子组', 'F1', '王芳', '110101199001011235', '', '', '', '', '', 'registered', '', ''])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺序拼接', () => {
|
||||
@@ -86,8 +92,8 @@ test('buildSelectionRows 团体项目:整体一条,member_names 按列表顺
|
||||
athletes: [{ name: '王芳' }, { name: '刘洋' }, { name: '陈晨' }],
|
||||
})
|
||||
assert.equal(rows.length, 2, '表头 + 1 条团体报名')
|
||||
assert.equal(rows[1]?.length, 12)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨'])
|
||||
assert.equal(rows[1]?.length, 13)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '王芳/刘洋/陈晨', ''])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序', () => {
|
||||
@@ -100,7 +106,7 @@ test('buildSelectionRows 团体项目可用 memberNames 覆盖名单与顺序',
|
||||
athletes: [{ name: '王芳' }],
|
||||
memberNames: ['刘洋', '王芳'],
|
||||
})
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳'])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'scratched', '刘洋/王芳', ''])
|
||||
})
|
||||
|
||||
test('buildSelectionRows 个人项目:证件号与号码布都为空时两列留空,由导入通道报错', () => {
|
||||
@@ -111,7 +117,7 @@ test('buildSelectionRows 个人项目:证件号与号码布都为空时两列
|
||||
mode: 'individual',
|
||||
athletes: [{ name: '无标识' }],
|
||||
})
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[1], ['M100', '男子组', 'F1', '无标识', '', '', '', '', '', '', 'registered', '', ''])
|
||||
})
|
||||
|
||||
// 团体项目「只报单位」:一个人都没勾也要能提交,member_names 留空(服务端 athlete_id 为 NULL 的报名合法)。
|
||||
@@ -124,7 +130,7 @@ test('buildSelectionRows 团体项目:0 名队员时 member_names 留空', ()
|
||||
athletes: [],
|
||||
})
|
||||
assert.equal(rows.length, 2, '表头 + 1 条只挂单位的报名')
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', ''])
|
||||
})
|
||||
|
||||
test('团体 / 对抗项目 0 勾选可提交,个人项目必须勾人', () => {
|
||||
@@ -147,13 +153,14 @@ test('buildTeamSelectionRows 多个单位各一行,队员名单按 / 拼接,
|
||||
})
|
||||
assert.equal(rows.length, 4, '表头 + 3 个单位')
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '第一行复用导入表头')
|
||||
assert.equal(rows[1]?.length, 12)
|
||||
assert.equal(rows[1]?.length, 13)
|
||||
for (const row of rows.slice(1)) {
|
||||
assert.equal(row[3], '', '团体行的 athlete_name 必须为空,服务端才按单位 upsert')
|
||||
assert.equal(row[12], '', '不填队名时队伍列留空 = 不区分队伍')
|
||||
}
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋'])
|
||||
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', ''])
|
||||
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟'])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋', ''])
|
||||
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', ''])
|
||||
assert.deepEqual(rows[3], ['BB', '混合组', 'F3', '', '', '', '', '', '', '', 'registered', '张伟', ''])
|
||||
})
|
||||
|
||||
test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可覆盖', () => {
|
||||
@@ -165,7 +172,7 @@ test('buildTeamSelectionRows:一个单位都没勾时只有表头,status 可
|
||||
status: 'scratched',
|
||||
teams: [{ teamCode: 'F1', memberNames: [] }],
|
||||
})
|
||||
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', ''])
|
||||
assert.deepEqual(scratched[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'scratched', '', ''])
|
||||
})
|
||||
|
||||
test('toggleTeamPick 勾选 / 取消单位,取消不清队员名单', () => {
|
||||
@@ -283,8 +290,8 @@ test('勾选状态转成 CSV 后表头与导入表一致,每个单位一行',
|
||||
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 留空')
|
||||
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,"张,伟/李强",', '带逗号的姓名按 RFC 4180 转义')
|
||||
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,,', '只报单位时 member_names 留空')
|
||||
})
|
||||
|
||||
test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只认具体运动员', () => {
|
||||
@@ -303,6 +310,135 @@ test('registeredTeamIds 按组别取已报名单位;registeredAthleteIds 只
|
||||
assert.deepEqual([...registeredAthleteIds(entries, undefined, 1)], [])
|
||||
})
|
||||
|
||||
// ── 队伍(迁移 0025):同一个单位在一个组别可以派多个队 ────────────────────────
|
||||
|
||||
test('不填队名时报名行为与改造前完全一致:队伍列留空、其余列一个字都没变', () => {
|
||||
const rows = buildTeamSelectionRows({
|
||||
eventCode: 'BB',
|
||||
categoryName: '混合组',
|
||||
teams: [
|
||||
{ teamCode: 'F1', memberNames: ['王芳'], squad: '' },
|
||||
{ teamCode: 'F2', memberNames: [] },
|
||||
],
|
||||
})
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS], '表头就是导入表本身(队伍列在最后、可空)')
|
||||
assert.equal(rows[1]?.length, REQUIRED_COLUMNS.length)
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳', ''])
|
||||
assert.deepEqual(rows[2], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '', ''])
|
||||
assert.equal(SQUAD_COLUMN, 'squad', '列名是服务端 importColumns 里的规范名')
|
||||
assert.equal(lookupImportColumn('队伍'), SQUAD_COLUMN, '中文表头「队伍」也认')
|
||||
assert.equal(lookupImportColumn('队名'), SQUAD_COLUMN)
|
||||
})
|
||||
|
||||
test('填了队名:队名进最后一列,其它列一个字不动', () => {
|
||||
const rows = buildTeamSelectionRows({
|
||||
eventCode: 'BB',
|
||||
categoryName: '混合组',
|
||||
teams: [
|
||||
{ teamCode: 'F1', memberNames: ['王芳', '刘洋'], squad: '一队' },
|
||||
// 同一个单位换个队名 = 这个单位的第二支队(服务端按 组别 + 单位 + 队伍 upsert)。
|
||||
{ teamCode: 'F1', memberNames: [], squad: '二队' },
|
||||
// 没填队名的单位照样一行,队名格留空。
|
||||
{ teamCode: 'F2', memberNames: ['张伟'] },
|
||||
],
|
||||
})
|
||||
assert.deepEqual(rows[0], [...REQUIRED_COLUMNS])
|
||||
assert.deepEqual(rows[1], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '王芳/刘洋', '一队'])
|
||||
assert.deepEqual(rows[2], ['BB', '混合组', 'F1', '', '', '', '', '', '', '', 'registered', '', '二队'])
|
||||
assert.deepEqual(rows[3], ['BB', '混合组', 'F2', '', '', '', '', '', '', '', 'registered', '张伟', ''])
|
||||
for (const line of toCsv(rows).split('\n')) {
|
||||
assert.equal(line.split(',').length, REQUIRED_COLUMNS.length)
|
||||
}
|
||||
})
|
||||
|
||||
test('队名两侧空白去掉再上表:服务端存的就是去空白后的文本', () => {
|
||||
const rows = buildTeamSelectionRows({
|
||||
eventCode: 'BB',
|
||||
categoryName: '混合组',
|
||||
teams: [{ teamCode: 'F1', memberNames: [], squad: ' 二队 ' }],
|
||||
})
|
||||
assert.equal(rows[1]?.[REQUIRED_COLUMNS.indexOf(SQUAD_COLUMN)], '二队')
|
||||
})
|
||||
|
||||
test('setTeamPickSquad 只改这一个单位的队名,逐个字输入都保留', () => {
|
||||
let state = toggleTeamPick(emptyTeamPick(), 1, true)
|
||||
state = toggleTeamPick(state, 2, true)
|
||||
state = setTeamPickSquad(state, 1, '一')
|
||||
state = setTeamPickSquad(state, 1, '一队')
|
||||
assert.equal(state.squads?.[1], '一队')
|
||||
assert.equal(state.squads?.[2], undefined, '另一个单位没填')
|
||||
assert.deepEqual(state.teamIds, [1, 2], '填队名不动勾选')
|
||||
// 清空输入框 = 改回不区分队伍,与从没填过一样。
|
||||
assert.equal(setTeamPickSquad(state, 1, '').squads?.[1], '')
|
||||
})
|
||||
|
||||
test('buildTeamSelections:填了队名的单位带上 squad,没填的与改造前逐字段相同', () => {
|
||||
const teams = [
|
||||
{ id: 1, code: 'F1' },
|
||||
{ id: 2, code: 'F2' },
|
||||
]
|
||||
const athletes = [{ id: 11, teamId: 1, name: '张伟', active: true }]
|
||||
let state = toggleTeamPick(emptyTeamPick(), 1, true)
|
||||
state = toggleTeamPick(state, 2, true)
|
||||
state = toggleMemberPick(state, 1, 11, true)
|
||||
assert.deepEqual(buildTeamSelections(state, teams, athletes), [
|
||||
{ teamCode: 'F1', memberNames: ['张伟'] },
|
||||
{ teamCode: 'F2', memberNames: [] },
|
||||
])
|
||||
|
||||
const withSquad = setTeamPickSquad(state, 1, ' 二队 ')
|
||||
assert.deepEqual(buildTeamSelections(withSquad, teams, athletes), [
|
||||
{ teamCode: 'F1', memberNames: ['张伟'], squad: '二队' },
|
||||
{ teamCode: 'F2', memberNames: [] },
|
||||
])
|
||||
})
|
||||
|
||||
test('勾选状态 → CSV 端到端:一个单位两个队就是两行,队名在最后一列', () => {
|
||||
const teams = [
|
||||
{ id: 1, code: 'F1' },
|
||||
{ id: 2, code: 'F2' },
|
||||
]
|
||||
let state = toggleTeamPick(emptyTeamPick(), 1, true)
|
||||
state = toggleTeamPick(state, 2, true)
|
||||
state = setTeamPickSquad(state, 1, '一队')
|
||||
state = setTeamPickSquad(state, 2, '青年队')
|
||||
|
||||
const rows = buildTeamSelectionRows({
|
||||
eventCode: 'BB',
|
||||
categoryName: '混合组',
|
||||
teams: buildTeamSelections(state, teams, []),
|
||||
})
|
||||
const lines = toCsv(rows).split('\n')
|
||||
assert.equal(lines[0], REQUIRED_COLUMNS.join(','))
|
||||
assert.equal(lines[1], 'BB,混合组,F1,,,,,,,,registered,,一队')
|
||||
assert.equal(lines[2], 'BB,混合组,F2,,,,,,,,registered,,青年队')
|
||||
})
|
||||
|
||||
test('registeredTeamSquads:按组别列出各单位已报名的队名,老报名(空串)不进表', () => {
|
||||
const entries = [
|
||||
{ categoryId: 7, teamId: 1, squad: '一队' },
|
||||
{ categoryId: 7, teamId: 1, squad: '二队' },
|
||||
{ categoryId: 7, teamId: 1, squad: '一队' },
|
||||
{ categoryId: 7, teamId: 2, squad: '' },
|
||||
{ categoryId: 7, teamId: 3 },
|
||||
{ categoryId: 8, teamId: 4, squad: '一队' },
|
||||
]
|
||||
const squads = registeredTeamSquads(entries, 7)
|
||||
assert.deepEqual(squads.get(1), ['一队', '二队'], '按报名表出现顺序、去重')
|
||||
assert.equal(squads.has(2), false, '不区分队伍的老报名不进表,徽标仍只说「已报名」')
|
||||
assert.equal(squads.has(3), false, '缺字段(老服务端)同样当空串')
|
||||
assert.equal(squads.has(4), false, '别的组别不算')
|
||||
assert.deepEqual([...registeredTeamSquads(entries, undefined)], [])
|
||||
})
|
||||
|
||||
test('entryRowLabel 团体行带上队名:同一单位的两个队在报错文案里也分得开', () => {
|
||||
assert.equal(entryRowLabel({ teamShort: '一司', squad: '二队' }), '一司二队')
|
||||
assert.equal(entryRowLabel({ teamShort: '一司', teamName: '第一分公司', squad: '' }), '一司', '没队名时不变')
|
||||
assert.equal(entryRowLabel({ teamName: '第一分公司', squad: '青年队' }), '第一分公司青年队')
|
||||
assert.equal(entryRowLabel({ athleteName: '张伟', teamShort: '一司', squad: '二队' }), '张伟', '个人行只写姓名')
|
||||
assert.equal(entryRowLabel({ memberNames: ['王芳'], teamShort: '一司', squad: '二队' }), '王芳')
|
||||
})
|
||||
|
||||
// 团体 / 接力行服务端不下发 athleteName(omitempty),回落到队员 / 单位名,
|
||||
// 否则防呆校验的文案会变成「:缺少成绩」。
|
||||
test('entryRowLabel 团体行回落到队员首人 / 单位', () => {
|
||||
@@ -321,8 +457,9 @@ test('两种语言的模板表头逐列对应:列数、顺序一致,只有
|
||||
assert.deepEqual(en, [...REQUIRED_COLUMNS], '英文模板就是英文规范名')
|
||||
assert.equal(zh.length, en.length, '两种模板列数必须一样')
|
||||
assert.deepEqual(zh, [
|
||||
'项目编码', '组别', '单位编码', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单',
|
||||
'项目编码', '组别', '单位编码', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', '队伍',
|
||||
])
|
||||
assert.equal(zh.at(-1), '队伍', '队伍列也列在模板最后,与服务端 importColumns 同序')
|
||||
// 每一列的中文写法必须与服务端 handlers_config.go 的别名逐字一致,否则中文模板传回去会报缺列。
|
||||
assert.deepEqual(
|
||||
zh,
|
||||
@@ -340,7 +477,7 @@ test('模板 CSV:只有表头语言不同,示例行与列数完全相同', (
|
||||
assert.equal(en[0], templateHeader('en').join(','))
|
||||
assert.deepEqual(zh.slice(1), en.slice(1), '示例行共用,只有表头换语言')
|
||||
for (const line of zh) {
|
||||
assert.equal(line.split(',').length, REQUIRED_COLUMNS.length, '每行都是 12 列')
|
||||
assert.equal(line.split(',').length, REQUIRED_COLUMNS.length, '每行都是 13 列')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -357,6 +494,7 @@ test('lookupImportColumn 认英文名、中文别名,并容错全角 / BOM /
|
||||
assert.equal(lookupImportColumn('项目编码'), 'event_code')
|
||||
assert.equal(lookupImportColumn('小项编码'), 'event_code')
|
||||
assert.equal(lookupImportColumn('队员名单'), 'member_names')
|
||||
assert.equal(lookupImportColumn('队伍'), 'squad', '队伍列的中文表头')
|
||||
assert.equal(lookupImportColumn('EVENT_CODE'), 'event_code', '大小写不敏感')
|
||||
assert.equal(lookupImportColumn(' event_code '), 'event_code', '首尾空格')
|
||||
assert.equal(lookupImportColumn('\uFEFF项目编码'), 'event_code', 'BOM 粘在第一格列名上')
|
||||
@@ -368,13 +506,21 @@ test('lookupImportColumn 认英文名、中文别名,并容错全角 / BOM /
|
||||
|
||||
test('importColumnIndex 中英混用每列各自认;同一列写两遍直接拦下', () => {
|
||||
const index = importColumnIndex([
|
||||
'event_code', '组别', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单',
|
||||
'event_code', '组别', 'team_code', '姓名', '证件号', '性别', '年龄', '号码布', '种子', '批次', '状态', '队员名单', '队伍',
|
||||
])
|
||||
assert.equal(index.get('event_code'), 0)
|
||||
assert.equal(index.get('category'), 1)
|
||||
assert.equal(index.get('member_names'), 11, '下标按混用后的实际列序取')
|
||||
assert.equal(index.get('squad'), 12, '队伍列在最后')
|
||||
assert.equal(index.size, REQUIRED_COLUMNS.length)
|
||||
|
||||
// 老表格(12 列、没有队伍列)照样认得:队伍是可选列,缺列 = 不区分队伍(老流程一个字不用改)。
|
||||
const legacy = importColumnIndex([
|
||||
'event_code', 'category', 'team_code', 'athlete_name', 'id_card', 'gender', 'age', 'bib', 'seed', 'heat', 'status', 'member_names',
|
||||
])
|
||||
assert.equal(legacy.size, REQUIRED_COLUMNS.length - 1)
|
||||
assert.equal(legacy.has('squad'), false)
|
||||
|
||||
for (const header of [
|
||||
['event_code', '项目编码', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'],
|
||||
['event_code', 'event_code', 'category', 'team_code', 'athlete_name', 'bib', 'seed', 'status', 'member_names'],
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { ApiError, teamSquadLabel } from '@/shared'
|
||||
import {
|
||||
entrySquadEditable,
|
||||
entrySquadPath,
|
||||
squadDraftDirty,
|
||||
squadDraftOf,
|
||||
submitEntrySquad,
|
||||
} from '../pages/entrySquad'
|
||||
|
||||
/**
|
||||
* 管理端的队伍展示、录入与改名(迁移 0025)。
|
||||
*
|
||||
* SFC 挂不进 `node --test`,所以这里用源码契约钉住接线;口径本身(拼名字、空串不变)在
|
||||
* shared/tests/squad.test.ts 与大屏 joint-teams.test.ts 里逐条测。要守住的是四件事:
|
||||
* 1. 录入页 / 复核页的「单位」列显示 **单位名 + 队名**(不加括号);
|
||||
* 2. 队名为空时那几个表达式与改造前逐字相同(老数据与单队单位一个像素都不变);
|
||||
* 3. 队名是在补报名(团体勾选)里填的,**勾上单位之后**才出现那个输入框,
|
||||
* 不勾(常规流程)时界面上根本看不到它;
|
||||
* 4. 已经报好的队名在「成员单位与份额」弹窗里能改(PUT /api/entries/{id}/squad),
|
||||
* 但只对纯团体报名出现,改哪个才发哪个请求,服务端的中文 400 原样上屏。
|
||||
*/
|
||||
|
||||
const ENTRY = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||
const REVIEW = readFileSync(new URL('../pages/ReviewPage.vue', import.meta.url), 'utf8')
|
||||
const PICKER = readFileSync(new URL('../components/baseData/EntryPicker.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('录入页:单位列显示「单位名 + 队名」,队名来自报名行', () => {
|
||||
assert.match(
|
||||
ENTRY,
|
||||
/function teamCellText\(row: EditableRow\): string \{\s*return teamSquadLabel\(row\.teamShort \|\| row\.teamName, row\.squad\)/,
|
||||
'拼名字只有 shared/squad.ts 一份实现',
|
||||
)
|
||||
assert.match(ENTRY, /\{\{ teamCellText\(row\) \}\}/, '单位格用带队名的文案')
|
||||
assert.doesNotMatch(ENTRY, /\{\{ row\.teamShort \|\| row\.teamName \}\}/, '老写法不能再留在单位格上')
|
||||
assert.match(ENTRY, /squad: normalizeSquad\(entry\.squad\)/, '老服务端不下发 squad 时按空串')
|
||||
})
|
||||
|
||||
test('录入页:批量确认框里的单位也带上队名(同一个单位的两个队要分得清删的是哪条)', () => {
|
||||
assert.match(ENTRY, /team: teamCellText\(row\),/)
|
||||
})
|
||||
|
||||
test('录入页:联队那一格仍按份额逐行列,队名单独一行写出来', () => {
|
||||
assert.match(ENTRY, /teamSquadLabel\(jointTeamLabel\(row\.teamShares\), row\.squad\)/, '联队的悬停文案带队名')
|
||||
assert.match(ENTRY, /v-if="row\.squad !== ''"[\s\S]{0,80}队伍 \{\{ row\.squad \}\}/)
|
||||
})
|
||||
|
||||
test('复核页:单位列同样显示「单位名 + 队名」', () => {
|
||||
assert.match(
|
||||
REVIEW,
|
||||
/function teamCellText\(row: DraftRow\): string \{\s*return teamSquadLabel\(row\.teamShort \|\| row\.teamName, row\.squad\)/,
|
||||
)
|
||||
assert.match(REVIEW, /\{\{ teamCellText\(row\) \}\}/)
|
||||
assert.doesNotMatch(REVIEW, /\{\{ row\.teamShort \|\| row\.teamName \}\}/)
|
||||
assert.match(REVIEW, /squad: normalizeSquad\(entry\.squad\)/)
|
||||
assert.match(REVIEW, /row\.memberNames\?\.join\('、'\) \|\| team \|\| `#\$\{row\.entryId\}`/, '报错文案里的行名带队名')
|
||||
})
|
||||
|
||||
test('补报名:队伍输入框只在勾上单位后出现,不勾时界面与过去完全一样', () => {
|
||||
assert.match(PICKER, /v-if="isTeamPicked\(team\.id\)"/, '输入框跟着勾选出现')
|
||||
assert.match(PICKER, /:value="squadTextOf\(team\.id\)"/, '回显输入框原文')
|
||||
assert.match(PICKER, /@input="onSquadInput\(team\.id, \$event\)"/)
|
||||
assert.match(PICKER, /teamPick\.value = setTeamPickSquad\(teamPick\.value, teamId, input\.value\)/)
|
||||
assert.match(PICKER, /placeholder="队伍(留空=不区分)"/, '留空 = 不区分队伍,要写出来')
|
||||
// 输入框是普通文本框:不进 type="number" 那条「v-model 把值转成 number」的坑。
|
||||
assert.doesNotMatch(PICKER, /team-squad-input[\s\S]{0,200}type="number"/)
|
||||
})
|
||||
|
||||
test('补报名:已报名徽标写清已报过哪些队名,避免把二队又写成一队', () => {
|
||||
assert.match(PICKER, /registeredTeamSquads\(entries\.value, currentCategory\.value\?\.id\)/)
|
||||
assert.match(PICKER, /registeredSquadLabel\(team\.id\)/)
|
||||
})
|
||||
|
||||
// ── 改队名:PUT /api/entries/{id}/squad(入口在「成员单位与份额」弹窗里) ──────────────
|
||||
|
||||
interface Captured {
|
||||
url?: string
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
credentials?: string
|
||||
}
|
||||
|
||||
let lastRequest: Captured = {}
|
||||
let reply: () => Response = () => jsonResponse({ ok: true, data: {} })
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
lastRequest = {
|
||||
url: String(input),
|
||||
method: init?.method ?? 'GET',
|
||||
headers: (init?.headers ?? {}) as Record<string, string>,
|
||||
body: init?.body === undefined ? undefined : JSON.parse(String(init.body)),
|
||||
credentials: init?.credentials,
|
||||
}
|
||||
return reply()
|
||||
}) as typeof fetch
|
||||
|
||||
function respondWith(response: () => Response): void {
|
||||
lastRequest = {}
|
||||
reply = response
|
||||
}
|
||||
|
||||
function withCsrfCookie(cookie: string, run: () => Promise<void>): Promise<void> {
|
||||
const original = (globalThis as { document?: unknown }).document
|
||||
;(globalThis as { document?: unknown }).document = { cookie }
|
||||
return run().finally(() => {
|
||||
if (original === undefined) delete (globalThis as { document?: unknown }).document
|
||||
else (globalThis as { document?: unknown }).document = original
|
||||
})
|
||||
}
|
||||
|
||||
test('改队名只对纯团体报名开放:个人报名(有运动员)连输入框都不出现', () => {
|
||||
assert.equal(entrySquadEditable({ athleteId: null }), true, '纯团体行才能改队名')
|
||||
assert.equal(entrySquadEditable({ athleteId: 42 }), false, '个人报名按人报,服务端也会 400')
|
||||
// 弹窗里的那一块跟着这个判断走:个人报名整块不渲染,不会让人白点一次保存。
|
||||
assert.match(ENTRY, /v-if="shareEditor\.teamEntry" class="share-squad"/)
|
||||
assert.match(ENTRY, /placeholder="队伍(留空=不区分)"/)
|
||||
assert.match(ENTRY, /teamEntry: entrySquadEditable\(row\),/)
|
||||
assert.match(ENTRY, /squad: squadDraftOf\(row\),/)
|
||||
assert.match(ENTRY, /originalSquad: squadDraftOf\(row\),/)
|
||||
// 输入框是普通文本框:不进 type="number" 那条「v-model 把值转成 number」的坑。
|
||||
assert.doesNotMatch(ENTRY, /squad-input[\s\S]{0,300}type="number"/)
|
||||
})
|
||||
|
||||
test('队名留空 = 不区分队伍:老数据读出来是空串,写回去也是空串(不是不发这个字段)', async () => {
|
||||
assert.equal(squadDraftOf({ squad: undefined }), '', '老服务端不下发 squad')
|
||||
assert.equal(squadDraftOf({ squad: null }), '')
|
||||
assert.equal(squadDraftOf({ squad: ' ' }), '', '只有空白等于没填')
|
||||
assert.equal(squadDraftOf({ squad: ' 青年队 ' }), '青年队')
|
||||
// 留空时单位列的文案与改造前逐字一样(队名一个字符都不多)。
|
||||
assert.equal(teamSquadLabel('第一分公司', ''), '第一分公司')
|
||||
|
||||
respondWith(() => jsonResponse({ ok: true, data: { id: 7, teamId: 1, squad: '' } }))
|
||||
const outcome = await submitEntrySquad(7, '')
|
||||
const body = lastRequest.body as { squad?: unknown }
|
||||
assert.equal(body.squad, '', '空串是合法值:字段要在,不能被省略成 undefined')
|
||||
assert.equal(outcome.squad, '', '服务端回显空串时本地也按不区分队伍写回')
|
||||
})
|
||||
|
||||
test('队名没改动时不发请求:比的是归一后的值,多一个空格不算改', () => {
|
||||
assert.equal(squadDraftDirty('', ''), false, '两边都留空 = 没动过')
|
||||
assert.equal(squadDraftDirty('一队', '一队'), false)
|
||||
assert.equal(squadDraftDirty(' 一队 ', '一队'), false, '首尾空白服务端也会 trim,不该算成一次改动')
|
||||
assert.equal(squadDraftDirty(' ', ''), false)
|
||||
assert.equal(squadDraftDirty('一队', ''), true)
|
||||
assert.equal(squadDraftDirty('', '一队'), true, '清空队名也是一次改动(改回不区分队伍)')
|
||||
assert.equal(squadDraftDirty('二队', '一队'), true)
|
||||
|
||||
// 保存按钮只在「份额或队名任一改了」时才亮(没改动就点不动,与既有做法一致)。
|
||||
assert.match(ENTRY, /const shareEditorDirty = computed\(\(\) => shareChanged\.value \|\| squadChanged\.value\)/)
|
||||
assert.match(ENTRY, /if \(editor === null \|\| !canSaveShares\.value\) return/)
|
||||
assert.match(ENTRY, /shareEditorDirty\.value &&\s*shareIssues\.value\.length === 0/)
|
||||
// 改哪一块发哪一个:只改队名时不会顺手把没动过的份额整组重发一遍。
|
||||
assert.match(ENTRY, /const sharesDirty = shareChanged\.value\s*const squadDirty = squadChanged\.value/)
|
||||
assert.match(ENTRY, /if \(sharesDirty\) \{[\s\S]{0,400}submitEntryTeamShares\(/)
|
||||
assert.match(ENTRY, /if \(squadDirty\) \{[\s\S]{0,400}submitEntrySquad\(/)
|
||||
})
|
||||
|
||||
test('保存走 PUT /api/entries/{id}/squad,队名 trim 后发出,带 CSRF 与同一个幂等键', async () => {
|
||||
await withCsrfCookie('a=1; csrf_token=tok-9; b=2', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: { id: 7, teamId: 1, squad: '青年队' } }))
|
||||
|
||||
const outcome = await submitEntrySquad(7, ' 青年队 ')
|
||||
|
||||
assert.equal(lastRequest.url, entrySquadPath(7))
|
||||
assert.equal(lastRequest.url, '/api/entries/7/squad')
|
||||
assert.equal(lastRequest.method, 'PUT')
|
||||
assert.equal(lastRequest.credentials, 'include')
|
||||
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-9')
|
||||
assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8')
|
||||
assert.deepEqual(lastRequest.body, {
|
||||
squad: '青年队',
|
||||
requestId: lastRequest.headers?.['X-Request-Id'],
|
||||
})
|
||||
assert.equal(outcome.entry?.id, 7)
|
||||
assert.equal(outcome.squad, '青年队')
|
||||
})
|
||||
})
|
||||
|
||||
test('幂等键由调用方给定时 header 与 body 用同一把(超时重试不会落两遍)', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: { id: 7, squad: '二队' } }))
|
||||
await submitEntrySquad(7, '二队', 'req-fixed-1')
|
||||
assert.equal(lastRequest.headers?.['X-Request-Id'], 'req-fixed-1')
|
||||
assert.equal((lastRequest.body as { requestId?: unknown }).requestId, 'req-fixed-1')
|
||||
})
|
||||
|
||||
test('回显里没有 squad 字段(老服务端)时交给调用方退回草稿,不把队名抹成空', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: { id: 7, teamId: 1 } }))
|
||||
const outcome = await submitEntrySquad(7, '青年队')
|
||||
assert.equal(outcome.squad, null, 'null 而不是空串:空串是「用户清空了队名」这个合法值')
|
||||
assert.match(ENTRY, /const saved = outcome\.squad \?\? editor\.squad/)
|
||||
})
|
||||
|
||||
test('保存后当场改本地那一行:单位列文案从「第一分公司」变成「第一分公司青年队」', () => {
|
||||
assert.match(ENTRY, /if \(row !== undefined\) row\.squad = saved/)
|
||||
assert.match(ENTRY, /editor\.squad = saved\s*editor\.originalSquad = saved/, '保存过的值成为新的初值')
|
||||
// 单位列的文案只认这一份实现:row.squad 一改,表格与大屏同时跟着变。
|
||||
assert.match(ENTRY, /return teamSquadLabel\(row\.teamShort \|\| row\.teamName, row\.squad\)/)
|
||||
// 输入框旁边先念一遍改完长什么样(空串时就是单位名本身)。
|
||||
assert.match(ENTRY, /return editor === null \? '' : teamSquadLabel\(editor\.teamCellBase, editor\.squad\)/)
|
||||
assert.match(ENTRY, /teamCellBase: row\.teamShort \|\| row\.teamName,/)
|
||||
assert.equal(teamSquadLabel('第一分公司', '青年队'), '第一分公司青年队')
|
||||
assert.equal(teamSquadLabel('第一分公司', ''), '第一分公司')
|
||||
})
|
||||
|
||||
test('服务端 400 的中文原文原样抛出(尤其「队名重复」那条,界面直接展示这句话)', async () => {
|
||||
const message = '组别「男子双打」下单位「第一分公司」已经有「一队」了:同一个单位在同一组别里队名不能重复'
|
||||
respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message } }, 400))
|
||||
await assert.rejects(
|
||||
() => submitEntrySquad(7, '一队'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, 'VALIDATION_FAILED')
|
||||
assert.equal(error.message, message, '一个字都不能被前端改写')
|
||||
return true
|
||||
},
|
||||
)
|
||||
// 弹窗直接把这句话放到错误条里,不换成自己的措辞。
|
||||
assert.match(ENTRY, /editor\.error = caught instanceof Error \? caught\.message/)
|
||||
assert.match(ENTRY, /\{\{ shareEditor\.error \}\}/)
|
||||
})
|
||||
|
||||
test('报名不存在 / 网络不可达都说清「队名没有改成功」,不让人以为已经落库', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({ ok: false, error: { code: 'CONFLICT', message: '报名不存在或已被移出' } }, 404),
|
||||
)
|
||||
await assert.rejects(
|
||||
() => submitEntrySquad(999, '一队'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.message, '报名不存在或已被移出')
|
||||
return true
|
||||
},
|
||||
)
|
||||
|
||||
respondWith(() => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
await assert.rejects(
|
||||
() => submitEntrySquad(7, '一队'),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, 'NETWORK')
|
||||
assert.match(error.message, /队名没有改成功/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,289 @@
|
||||
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\)/)
|
||||
})
|
||||
@@ -87,7 +87,10 @@ test('lastPlaceZero 让末位整组 0 分', () => {
|
||||
close(result.rowPoints.get(3) ?? 0, 0)
|
||||
})
|
||||
|
||||
test('末位并列整组都按最后一名处理', () => {
|
||||
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: [
|
||||
@@ -101,8 +104,8 @@ test('末位并列整组都按最后一名处理', () => {
|
||||
makeContext({ channels: [makeChannel()], profiles: [profile] }),
|
||||
)
|
||||
close(result.rowPoints.get(1) ?? 0, 9)
|
||||
close(result.rowPoints.get(2) ?? 0, 0)
|
||||
close(result.rowPoints.get(3) ?? 0, 0)
|
||||
close(result.rowPoints.get(2) ?? 0, 6)
|
||||
close(result.rowPoints.get(3) ?? 0, 6)
|
||||
})
|
||||
|
||||
test('scored=false 只排名不计分', () => {
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { ScoreChannel, Team, TeamRanksView } from '@/shared'
|
||||
import { normalizeTeamShares, type ScoreChannel, type Team, type TeamRanksView } from '@/shared'
|
||||
import {
|
||||
hasTeamManualChannel,
|
||||
mergeJointTeamRankRows,
|
||||
mergedAwayRankNames,
|
||||
parseTeamRankInput,
|
||||
teamRankFlaggedTeams,
|
||||
teamRankRowsOf,
|
||||
teamRankWarnings,
|
||||
teamRanksDirty,
|
||||
teamRanksPayload,
|
||||
type TeamRankEntryRef,
|
||||
} from '../pages/teamRanks'
|
||||
import { mockDb } from '../api/mock'
|
||||
|
||||
@@ -48,6 +51,130 @@ function makeView(overrides: Partial<TeamRanksView> = {}): TeamRanksView {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 联队(devdocs/19):一条报名挂多个单位时,团体名次区块要把成员单位合并成一行 ──────
|
||||
|
||||
/** 一个成员单位的名字与编码(拼联队名 / 编码用)。 */
|
||||
interface Member {
|
||||
id: number
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
const JG: Member = { id: 1, code: 'JG', name: '机关本部' }
|
||||
const F1: Member = { id: 2, code: 'F1', name: '第一分公司' }
|
||||
const F3: Member = { id: 3, code: 'F3', name: '第三分公司' }
|
||||
|
||||
/** 一条报名:teamId 是主单位(entries.team_id),members 是份额里的成员单位(第一条即主单位)。 */
|
||||
function makeEntry(teamId: number, members: readonly Member[]): TeamRankEntryRef {
|
||||
return {
|
||||
teamId,
|
||||
teamShares: normalizeTeamShares(
|
||||
members.map((member) => ({ teamId: member.id, weight: 1, teamCode: member.code, teamName: member.name })),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
test('联队:成员单位合并成一行,标签用大屏 / 导出同一口径的联队名', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
|
||||
|
||||
assert.deepEqual(merged.map((row) => row.teamId), [1], '成员单位不再单独占一行')
|
||||
assert.equal(merged[0]?.name, '机关本部+第一分公司联队')
|
||||
assert.equal(merged[0]?.shortName, null)
|
||||
assert.equal(merged[0]?.code, 'JG+F1', '编码用导入 / 导出的 A+B 写法')
|
||||
assert.deepEqual(merged[0]?.members.map((member) => member.teamId), [1, 2])
|
||||
})
|
||||
|
||||
test('联队:名次落在主单位那一行,成员单位不再进保存载荷(重复计分就此堵死)', () => {
|
||||
const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 2 }, { teamId: 2, rank: 3 }] }), [])
|
||||
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
|
||||
|
||||
assert.equal(merged.length, 1)
|
||||
assert.equal(merged[0]?.input, '2', '主单位那一行原来录的名次回填到联队行')
|
||||
assert.equal(teamRanksDirty(merged), false)
|
||||
// 成员单位 2 库里那条名次 3 不再出现在载荷里:它不会被当成第二个名次主体。
|
||||
assert.deepEqual(teamRanksPayload(merged), {
|
||||
scope: 'team',
|
||||
basis: 'manual',
|
||||
ranks: [{ teamId: 1, rank: 2 }],
|
||||
})
|
||||
})
|
||||
|
||||
test('联队:输入框写回主单位那一行(副本会把草稿吞掉,保存按钮也不会亮)', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
|
||||
|
||||
merged[0]!.input = 4
|
||||
assert.equal(rows[0]?.input, 4, '联队行的输入写回主单位那一行')
|
||||
assert.equal(teamRanksDirty(merged), true)
|
||||
assert.deepEqual(teamRanksPayload(merged).ranks, [{ teamId: 1, rank: 4 }])
|
||||
})
|
||||
|
||||
test('组别里没有联队报名时,行与现在一字不差', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
|
||||
// 单单位报名(份额只有一条)不是联队:连对象都不该换一个。
|
||||
assert.deepEqual(mergeJointTeamRankRows(rows, [makeEntry(1, [JG])]), rows)
|
||||
assert.equal(mergeJointTeamRankRows(rows, [makeEntry(1, [JG])])[0], rows[0])
|
||||
// 没有报名时同理。
|
||||
assert.deepEqual(mergeJointTeamRankRows(rows, []), rows)
|
||||
})
|
||||
|
||||
test('成员单位自己也有报名时两条都列出来,且分得清', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1]), makeEntry(2, [F1])])
|
||||
|
||||
assert.deepEqual(merged.map((row) => row.teamId), [1, 2], '别静默吞掉第一条')
|
||||
assert.equal(merged[0]?.name, '机关本部+第一分公司联队')
|
||||
assert.equal(merged[1]?.name, '第一分公司')
|
||||
assert.deepEqual(merged[1]?.members, [], '它自己那条不是联队')
|
||||
assert.equal(merged[1], rows[1], '普通行原样透传')
|
||||
})
|
||||
|
||||
test('一个单位同时是两个联队的成员单位时,两个联队各占一行', () => {
|
||||
const rows = teamRankRowsOf(
|
||||
makeView({
|
||||
teams: [
|
||||
{ teamId: 1, code: 'JG', name: '机关本部', shortName: '机关' },
|
||||
{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' },
|
||||
{ teamId: 3, code: 'F3', name: '第三分公司', shortName: '三分公司' },
|
||||
],
|
||||
}),
|
||||
[],
|
||||
)
|
||||
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1]), makeEntry(2, [F1, F3])])
|
||||
|
||||
assert.deepEqual(merged.map((row) => row.teamId), [1, 2])
|
||||
assert.equal(merged[0]?.name, '机关本部+第一分公司联队')
|
||||
assert.equal(merged[1]?.name, '第一分公司+第三分公司联队')
|
||||
})
|
||||
|
||||
test('主单位那一行不在列表里(单位停用 / 假删除)时不合并不吞', () => {
|
||||
const rows = teamRankRowsOf(
|
||||
makeView({ teams: [{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' }] }),
|
||||
[],
|
||||
)
|
||||
assert.deepEqual(mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])]), rows)
|
||||
})
|
||||
|
||||
test('参赛单位数按合并后的行数算', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
const merged = mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])
|
||||
merged[0]!.input = '2'
|
||||
|
||||
assert.match(teamRankWarnings(merged).join(';'), /超出参赛单位数 1/)
|
||||
assert.deepEqual([...teamRankFlaggedTeams(merged)], [1])
|
||||
})
|
||||
|
||||
test('被并掉的成员单位原来录过的名次要点名(保存后会清掉,不静默改数)', () => {
|
||||
const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 2 }, { teamId: 2, rank: 3 }] }), [])
|
||||
assert.deepEqual(mergedAwayRankNames(rows, mergeJointTeamRankRows(rows, [makeEntry(1, [JG, F1])])), ['一分公司'])
|
||||
|
||||
// 没录过名次的成员单位不点名。
|
||||
const clean = teamRankRowsOf(makeView(), [])
|
||||
assert.deepEqual(mergedAwayRankNames(clean, mergeJointTeamRankRows(clean, [makeEntry(1, [JG, F1])])), [])
|
||||
})
|
||||
|
||||
test('只有 scope=team + basis=manual 的通道才需要「团体名次」区块', () => {
|
||||
assert.equal(hasTeamManualChannel([makeChannel()]), true)
|
||||
assert.equal(hasTeamManualChannel([makeChannel({ basis: 'aggregate' })]), false)
|
||||
@@ -219,3 +346,14 @@ test('录入页按契约接线:区块可见性、随行数据优先、保存
|
||||
assert.match(ENTRY_SOURCE, /'is-readonly': !canWrite/)
|
||||
assert.match(ENTRY_SOURCE, /当前为访客只读视图,团体名次只能查看,无法录入/)
|
||||
})
|
||||
|
||||
test('录入页按契约接线:联队成员合并成一行、名次写到主单位', () => {
|
||||
// 表格行由本组别的报名行(teamShares 现成)合并而来,不为它多开一个接口。
|
||||
assert.match(ENTRY_SOURCE, /mergeJointTeamRankRows\(teamRanksByCategory\.value\.get\(categoryId\) \?\? \[\], categoryRows\.value\)/)
|
||||
// 联队行要能一眼认出来:徽标 + 成员单位与份额(与大屏 / 成绩表同一读法)。
|
||||
assert.match(ENTRY_SOURCE, /v-if="row\.members\.length > 0" class="mx-badge mx-badge-info">联队</)
|
||||
assert.match(ENTRY_SOURCE, /shareText\(member\)/)
|
||||
// 被并掉的成员单位原来录过的名次要点名,别静默清掉。
|
||||
assert.match(ENTRY_SOURCE, /mergedAwayRankNames\(/)
|
||||
assert.match(ENTRY_SOURCE, /teamRankMergedAwayNames/)
|
||||
})
|
||||
Reference in new issue
Block a user