fix: P0 计分根因 + 奖牌结算/奖牌通道 + 身份口径改回证件号 + 19 条 P1(dev_0.1.8)
## P0:双通道项目重复发布跳分(45→85→收敛) results.rank 一列同时充当「个人名次输出」「手工名次输入」「榜单数据源」, 团体手工通道把个人名次当团体名次又按团体表发一遍分。 - 新增 channel_ranks(每个计分通道各自持久化名次,发布时全量重建) 与 channel_manual_ranks(按单位录入的团体手工名次,故意不挂通道外键, 避免规则包重导时级联删掉录入数据) - results.rank 语义收窄为「只表示个人名次」,只由 scope=athlete 通道写回; 团体手工名次的来源改为 TeamRanks 优先、团体报名项兜底, 个人报名项的名次绝不进团体通道 - writeLedgerTx 内重建 channel_ranks;buildRanked / export.loadRanked / realtime.loadRanked 三份同源 SQL 一起改读新表 - 回归:发布两次流水与名次逐字节一致(domain + httpapi 各一条,含变异验证) ## 奖牌结算开关与奖牌通道(P1(5),新功能) - events.award_medals + 通道 count_to_medals / count_to_team_medals (迁移默认 1,升级不改变既有榜单) - 单位榜入选 = 小项结算奖牌 && 通道发奖牌 && 前三名 &&(团体项目 || 个人奖牌并入单位) - 个人榜只取个人通道;金牌榜与积分榜同源同数据、按通道过滤独立计算 - 前端:小项「该项启用奖牌结算」、通道「发奖牌/个人奖牌并入单位金牌榜」、 录入页新增「团体名次」区块(含新接口 GET/PUT .../team-ranks) ## 身份口径改回证件号(P1(15),推翻第三十六轮) - 匹配顺序恢复 证件号 → 号码布 → 姓名+单位;新增 athleteByIDCard - 同表重复证件号 = 同一人报多个组别(notices 提示,不报错); 只有「同证件号 + 同小项同组别」才是真重复(conflicts[].duplicate) - 补齐后端缺失但前端/文档已在依赖的三处契约 ## 其余 P1 - 安全:会话撤销断开 ws、实时层按角色裁剪、整库快照限流(含 hello 路径, 被限流的请求挂起并由心跳兜底补发,不丢刷新) - 错误分级:手填名次非正数 / gender / 组别年龄 / 导出不存在的小项与单位 / 积分模板 awardRanks → 400 或 404,不再 500 - 算分一致性:破纪录加分看得失状态;批量退赛·恢复·移出、作废成绩、 报名导入改状态一律无条件重算推榜(不受延迟结算约束,与实时模式逐字节等价) - 配置改动触发重算:通道 / 小项参数 / 积分模板 - 升级自愈:channel_ranks 空而已有已发布成绩时,启动时一次性全量重算 (服务对外监听之前完成,避免升级后奖牌榜全 0) - 示例数据模式下破坏性操作被阻断,不再假成功 - Console:修复「只剩一块屏」后屏幕监听器被永久摘掉 - 大屏模板引擎:嵌套深度与体积安全阀,任何输入都不抛异常 ## 文档 - docs/:使用手册(奖牌开关、奖牌通道、团体名次、枚举列留空口径)、 常见问题(奖牌排查、升级后奖牌榜为 0、历史重放已知限制)、 部署指南、上线检查清单 - 版本 dev_0.1.7 → dev_0.1.8(console/package.json 同步) 自检:服务端 13 包 ×2 全绿;前端 vue-tsc + 449 例 ×2 + pnpm build; Console 35 例 + 语法检查。
This commit is contained in:
1 parent
53a0cb225c
commit
45edfbeb71
115 files changed
+10104
-755
No files matched your search
@@ -40,7 +40,7 @@ function respondWith(response: () => Response): void {
|
||||
reply = response
|
||||
}
|
||||
|
||||
const { ApiError, adminApi } = await import('../api/client')
|
||||
const { ApiError, adminApi, mockMode, SAMPLE_DATA_BLOCKED } = await import('../api/client')
|
||||
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
|
||||
|
||||
test('彻底删除走 DELETE /api/athletes/{id}?purge=1', async () => {
|
||||
@@ -164,22 +164,23 @@ test('remove 的 404 仍降级到示例数据(既有行为未受影响)', as
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
})
|
||||
|
||||
// 后端整体连不上时仍降级到示例数据;示例数据也返回完整的统计结构,页面不会拿到 undefined。
|
||||
// (示例数据里没有归属到人员的报名与成绩,所以各项是 0,用 id=1002 避开上一条已删掉的 1001。)
|
||||
test('后端不可用降级示例数据时,统计结构依然完整', async () => {
|
||||
// 后端整体连不上时不再降级示例数据:内存里「删掉」会让操作员以为人真被删了,
|
||||
// 恢复连接后这些人又会全部回来。必须给出可识别的「未提交」结果(SAMPLE_DATA_BLOCKED)。
|
||||
test('后端不可用时彻底删除被阻断:不降级示例数据、不返回假成功', async () => {
|
||||
baseDataFallback.value = false
|
||||
mockMode.value = false
|
||||
respondWith(() => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
|
||||
const result = await baseDataApi.athletes.purge(1002)
|
||||
|
||||
assert.deepEqual(result, {
|
||||
deletedEntries: 0,
|
||||
deletedResults: 0,
|
||||
deletedLedger: 0,
|
||||
deletedBreaks: 0,
|
||||
recomputedEvents: [],
|
||||
})
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
await assert.rejects(
|
||||
() => baseDataApi.athletes.purge(1002),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
|
||||
assert.match(error.message, /示例数据/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
assert.equal(baseDataFallback.value, true, '写请求撞上后端不可达后,页面应切到示例数据标记')
|
||||
})
|
||||
@@ -46,7 +46,7 @@ function requestBody(): Record<string, unknown> {
|
||||
return parsed
|
||||
}
|
||||
|
||||
const { adminApi } = await import('../api/client')
|
||||
const { ApiError, SAMPLE_DATA_BLOCKED, adminApi } = await import('../api/client')
|
||||
|
||||
const input = {
|
||||
name: '男子甲组',
|
||||
@@ -95,7 +95,7 @@ test('服务端 409 的原文要能被页面拿到(提示里那句话是现场
|
||||
)
|
||||
})
|
||||
|
||||
test('服务端不可达时,组别就地更新与运动员批量都降级到示例数据而不是抛错', async () => {
|
||||
test('服务端不可达时,组别就地更新仍降级到示例数据,运动员批量则被阻断', async () => {
|
||||
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
|
||||
const { mockBaseData } = await import('../components/baseData/mockBaseData')
|
||||
// 断网:fetch 直接抛,客户端归成 NETWORK,baseDataApi 据此降级到示例数据。
|
||||
@@ -114,9 +114,15 @@ test('服务端不可达时,组别就地更新与运动员批量都降级到
|
||||
assert.equal(categories.find((item) => item.id === category.id)?.name, '降级改名')
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
|
||||
// 批量停用是破坏性操作:示例数据模式下必须拦下,不能假装停用成功(P1(17))。
|
||||
const athlete = mockBaseData.listAthletes()[0]
|
||||
assert.ok(athlete !== undefined)
|
||||
const result = await baseDataApi.athletes.batch('disable', [athlete.id])
|
||||
assert.equal(result.succeeded, 1)
|
||||
assert.equal(result.failed, 0)
|
||||
await assert.rejects(
|
||||
() => baseDataApi.athletes.batch('disable', [athlete.id]),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,52 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
// 计分通道页的两个奖牌开关(设计 17 §八第 2 条)与小项页的「该项启用奖牌结算」。
|
||||
// 项目没有组件测试工具,按仓库既有做法(avg-mode.test.ts)读源码断言接线。
|
||||
|
||||
const CHANNELS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const EVENTS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('通道行带两个奖牌开关,缺字段按 true 归一', () => {
|
||||
assert.match(CHANNELS_SOURCE, /countToMedals: normalizeCountToMedals\(channel\.countToMedals\)/)
|
||||
assert.match(CHANNELS_SOURCE, /countToTeamMedals: normalizeCountToTeamMedals\(channel\.countToTeamMedals\)/)
|
||||
assert.match(CHANNELS_SOURCE, /发奖牌/)
|
||||
assert.match(CHANNELS_SOURCE, /个人奖牌并入单位金牌榜/)
|
||||
})
|
||||
|
||||
test('两个开关一起序列化进 ScoreChannelInput,scope=team 的值不被清掉', () => {
|
||||
// buildInputs 必须带上两个字段:PUT 是整体替换,漏掉等于把它们重置回默认(发牌)。
|
||||
assert.match(CHANNELS_SOURCE, /countToMedals: row\.countToMedals/)
|
||||
assert.match(CHANNELS_SOURCE, /countToTeamMedals: row\.countToTeamMedals/)
|
||||
// 「个人奖牌并入单位金牌榜」只在 scope=athlete 的行显示;值本身原样回传。
|
||||
assert.match(CHANNELS_SOURCE, /v-if="row\.scope === 'athlete'"/)
|
||||
assert.match(CHANNELS_SOURCE, /切换 scope 时不会被清掉/)
|
||||
})
|
||||
|
||||
test('新通道默认发奖牌并并入单位榜(与库表默认值一致)', () => {
|
||||
assert.match(CHANNELS_SOURCE, /countToMedals: true,\s*\n\s*countToTeamMedals: true,/)
|
||||
})
|
||||
|
||||
test('小项编辑有「该项启用奖牌结算」开关,默认开并说清后果', () => {
|
||||
assert.match(EVENTS_SOURCE, /awardMedals: true,/)
|
||||
assert.match(EVENTS_SOURCE, /该项启用奖牌结算/)
|
||||
assert.match(EVENTS_SOURCE, /关闭后该小项只出积分不出奖牌|关闭「该项启用奖牌结算」后,该小项只出积分不出奖牌/)
|
||||
// 编辑已有小项时按当前值回填,不能每次都变成开。
|
||||
assert.match(EVENTS_SOURCE, /awardMedals: normalizeAwardMedals\(event\.awardMedals\)/)
|
||||
// 保存时带上该字段(PUT 整体替换)。
|
||||
assert.match(EVENTS_SOURCE, /awardMedals: eventForm\.value\.awardMedals/)
|
||||
})
|
||||
|
||||
test('小项页的通道摘要给「已关闭奖牌」的通道加可见徽标', () => {
|
||||
assert.match(EVENTS_SOURCE, /!channel\.countToMedals/)
|
||||
assert.match(EVENTS_SOURCE, /不发奖牌/)
|
||||
assert.match(EVENTS_SOURCE, /!channel\.countToTeamMedals/)
|
||||
assert.match(EVENTS_SOURCE, /个人奖牌不并入单位榜/)
|
||||
})
|
||||
@@ -37,10 +37,13 @@ test('选中「个人+团体」时提示双通道口径', () => {
|
||||
assert.match(SECTION_SOURCE, /计分通道/)
|
||||
})
|
||||
|
||||
test('示例数据里的「个人+团体」小项配了个人与团体两条通道', () => {
|
||||
test('示例数据里的「个人+团体」小项配了个人与团体通道(含按单位手工录名次)', () => {
|
||||
const event = mockDb.eventDetail(108)
|
||||
assert.equal(event.eventType, 'individual_team')
|
||||
|
||||
const scopes = (event.channels ?? []).map((channel) => channel.scope)
|
||||
assert.deepEqual(scopes, ['athlete', 'team'])
|
||||
// athlete=个人成绩、team+aggregate=队内平均、team+manual=按单位手工录名次
|
||||
// (设计 17 的「团体名次」区块就靠最后这条通道才会出现在录入页)。
|
||||
assert.deepEqual(scopes, ['athlete', 'team', 'team'])
|
||||
assert.ok((event.channels ?? []).some((channel) => channel.basis === 'manual'))
|
||||
})
|
||||
@@ -1,6 +1,6 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { AthleteInput, ImportConflict } from '@/shared'
|
||||
import type { AthleteInput, ImportConflict, ImportConflictFields } from '@/shared'
|
||||
import { createApiClient } from '@/shared'
|
||||
import {
|
||||
REQUIRED_COLUMNS,
|
||||
@@ -15,13 +15,28 @@ import {
|
||||
} from '../components/baseData/entrySelection'
|
||||
import { MockBaseData } from '../components/baseData/mockBaseData'
|
||||
|
||||
/** 冲突两端可比的字段快照;证件号默认两侧相同(同一个人),号码布默认相同。 */
|
||||
function fields(overrides: Partial<ImportConflictFields> = {}): ImportConflictFields {
|
||||
return {
|
||||
name: '张三',
|
||||
gender: '男',
|
||||
age: 26,
|
||||
teamCode: 'F1',
|
||||
employeeNo: '1001',
|
||||
idCard: '110101199001010001',
|
||||
bib: '101',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function conflict(overrides: Partial<ImportConflict> & Pick<ImportConflict, 'idCard'>): ImportConflict {
|
||||
return {
|
||||
row: 12,
|
||||
bib: '',
|
||||
athleteId: 42,
|
||||
existing: { name: '张三', gender: '男', age: 26, teamCode: 'F1', employeeNo: '1001', bib: '101' },
|
||||
incoming: { name: '张三', gender: '男', age: 27, teamCode: 'F2', employeeNo: null, bib: '102' },
|
||||
duplicate: false,
|
||||
existing: fields(),
|
||||
incoming: fields({ age: 27, teamCode: 'F2', employeeNo: null, bib: '102' }),
|
||||
changedFields: ['age', 'teamCode', 'bib'],
|
||||
...overrides,
|
||||
}
|
||||
@@ -32,38 +47,71 @@ function csv(rows: string[][]): File {
|
||||
}
|
||||
|
||||
/** event_code / category / team_code / athlete_name / id_card / gender / age / bib / seed / heat / status / member_names */
|
||||
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
|
||||
return ['M100', '男子组', teamCode, name, idCard, gender, age, bib, '', '', 'registered', '']
|
||||
function rowAt(
|
||||
eventCode: string,
|
||||
category: string,
|
||||
name: string,
|
||||
idCard: string,
|
||||
teamCode = 'F1',
|
||||
gender = '',
|
||||
bib = '',
|
||||
age = '',
|
||||
): string[] {
|
||||
return [eventCode, category, teamCode, name, idCard, gender, age, bib, '', '', 'registered', '']
|
||||
}
|
||||
|
||||
test('conflictDiffs 展开两侧字段并标出差异字段(含年龄 / 号码布)', () => {
|
||||
/** 默认小项与组别(M100 / 男子组)的一行。 */
|
||||
function row(name: string, idCard: string, teamCode = 'F1', gender = '', bib = '', age = ''): string[] {
|
||||
return rowAt('M100', '男子组', name, idCard, teamCode, gender, bib, age)
|
||||
}
|
||||
|
||||
test('conflictDiffs 展开两侧字段并标出差异字段(含证件号 / 年龄 / 号码布)', () => {
|
||||
const diffs = conflictDiffs(conflict({ idCard: '110101199001011234' }))
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => diff.field),
|
||||
['name', 'gender', 'age', 'teamCode', 'employeeNo', 'bib'],
|
||||
['name', 'gender', 'age', 'teamCode', 'employeeNo', 'idCard', 'bib'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => diff.label),
|
||||
['姓名', '性别', '年龄', '单位', '工号 / 学号', '号码布'],
|
||||
['姓名', '性别', '年龄', '单位', '工号 / 学号', '证件号', '号码布'],
|
||||
)
|
||||
assert.deepEqual(
|
||||
diffs.map((diff) => diff.changed),
|
||||
[false, false, true, true, false, true],
|
||||
[false, false, true, true, false, false, true],
|
||||
'只高亮 changedFields 里的字段,employeeNo 差异不参与冲突判定',
|
||||
)
|
||||
assert.equal(diffs[2]?.existing, '26', 'number 类型的年龄转成文本展示')
|
||||
assert.equal(diffs[2]?.incoming, '27')
|
||||
assert.equal(diffs[4]?.incoming, '—', 'null 展示为占位符')
|
||||
assert.equal(diffs[5]?.existing, '101')
|
||||
assert.equal(diffs[5]?.incoming, '102')
|
||||
assert.equal(diffs[6]?.existing, '101')
|
||||
assert.equal(diffs[6]?.incoming, '102')
|
||||
})
|
||||
|
||||
// P1(16):服务端只差证件号时回的是 changedFields:["idCard"],前端必须有对应标签并高亮,
|
||||
// 否则冲突卡片上一个差异都不亮,用户无从判断该保留哪一侧。
|
||||
test('changedFields 含 idCard 时证件号一行有标签并高亮', () => {
|
||||
const diffs = conflictDiffs(
|
||||
conflict({
|
||||
idCard: 'C2',
|
||||
existing: fields({ idCard: 'C1' }),
|
||||
incoming: fields({ idCard: 'C2' }),
|
||||
changedFields: ['idCard'],
|
||||
}),
|
||||
)
|
||||
const idCard = diffs.find((diff) => diff.field === 'idCard')
|
||||
assert.deepEqual([idCard?.label, idCard?.existing, idCard?.incoming, idCard?.changed], ['证件号', 'C1', 'C2', true])
|
||||
assert.deepEqual(
|
||||
diffs.filter((diff) => diff.changed).map((diff) => diff.field),
|
||||
['idCard'],
|
||||
)
|
||||
})
|
||||
|
||||
test('conflictDiffs 年龄为空的一侧显示占位符,不算差异', () => {
|
||||
const diffs = conflictDiffs(
|
||||
conflict({
|
||||
idCard: '110101199001011234',
|
||||
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
|
||||
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: '101' },
|
||||
existing: fields({ age: null }),
|
||||
incoming: fields({ age: null }),
|
||||
changedFields: [],
|
||||
}),
|
||||
)
|
||||
@@ -99,7 +147,7 @@ test('pruneDecisions / decisionsPayload / summarizeDecisions 按冲突列表收
|
||||
test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
|
||||
const conflicts = [conflict({ row: 2, idCard: '', bib: '101' }), conflict({ row: 3, idCard: '', bib: '102' })]
|
||||
|
||||
// 只勾选第一条:另一条必须仍是未决策,回退成按证件号作键时这里会被 '' 覆盖成空集。
|
||||
// 只勾选第一条:另一条必须仍是未决策,按证件号作键时两条都会被空串键带偏。
|
||||
const first = recordDecisions({}, conflicts, ['101'], 'keep-existing')
|
||||
assert.deepEqual(first, { 101: 'keep-existing' })
|
||||
assert.deepEqual(summarizeDecisions(first, conflicts), { keepExisting: 1, keepNew: 0, undecided: 1 })
|
||||
@@ -120,26 +168,33 @@ test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('决策键号码布优先:共用一个证件号的两个人不再共用一行决策', () => {
|
||||
// 决策键的优先级:证件号 → 号码布 → 姓名 + 单位,与服务端 athleteImportRow.decisionKey() 逐字一致。
|
||||
test('决策键证件号优先:同证件号的两行是同一个人,共用一条决策', () => {
|
||||
const shared = '110101199001010001'
|
||||
// 证件号不参与匹配后,同一份表里两个不同的人可以共用一个证件号;键回到号码布才不会串。
|
||||
// 同一证件号 = 同一个人(多半是报了多个组别),号码布不同也只做一次决策。
|
||||
const first = conflict({ row: 2, idCard: shared, bib: '101' })
|
||||
const second = conflict({ row: 3, idCard: shared, bib: '102' })
|
||||
assert.equal(decisionKeyOf(first), '101')
|
||||
assert.equal(decisionKeyOf(second), '102')
|
||||
assert.equal(decisionKeyOf(first), shared)
|
||||
assert.equal(decisionKeyOf(second), shared)
|
||||
|
||||
// 勾其中一行时另一行必须还是未决策(按证件号作键时两条会互相覆盖)。
|
||||
const decisions = recordDecisions({}, [first, second], [decisionKeyOf(first)], 'keep-new')
|
||||
assert.deepEqual(decisions, { 101: 'keep-new' })
|
||||
assert.deepEqual(summarizeDecisions(decisions, [first, second]), { keepExisting: 0, keepNew: 1, undecided: 1 })
|
||||
assert.deepEqual(decisionsPayload(decisions, [first, second]), [
|
||||
{ idCard: shared, bib: '101', action: 'keep-new' },
|
||||
])
|
||||
const decisions = recordDecisions({}, [first, second], [shared], 'keep-new')
|
||||
assert.deepEqual(decisions, { [shared]: 'keep-new' })
|
||||
assert.deepEqual(summarizeDecisions(decisions, [first, second]), { keepExisting: 0, keepNew: 2, undecided: 0 })
|
||||
assert.deepEqual(
|
||||
decisionsPayload(decisions, [first, second]),
|
||||
[
|
||||
{ idCard: shared, bib: '101', action: 'keep-new' },
|
||||
{ idCard: shared, bib: '102', action: 'keep-new' },
|
||||
],
|
||||
'同一个人的两条都带上证件号,服务端按证件号重算键后一次决策覆盖两行',
|
||||
)
|
||||
})
|
||||
|
||||
test('决策键回落:没有号码布用证件号,两样都没有用姓名 + 单位', () => {
|
||||
const idCard = '110101199001010002'
|
||||
assert.equal(decisionKeyOf(conflict({ idCard, bib: '' })), idCard)
|
||||
// 证件号优先于号码布:库里那条的号码布与表里一致、证件号不同时,键也必须取证件号。
|
||||
test('决策键证件号优先于号码布', () => {
|
||||
const idCard = '110101199001010003'
|
||||
assert.equal(decisionKeyOf(conflict({ idCard, bib: '101' })), idCard)
|
||||
assert.equal(decisionKeyOf(conflict({ idCard: '', bib: '101' })), '101', '没有证件号才退到号码布')
|
||||
assert.equal(decisionKeyOf(conflict({ idCard: '', bib: '' })), '张三·F2', '两样都没有时仍要有一行唯一的键')
|
||||
})
|
||||
|
||||
@@ -161,25 +216,62 @@ test('导入:新建人员带证件号,姓名 + 单位命中且证件号为
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010002')
|
||||
})
|
||||
|
||||
test('导入:有姓名的行缺证件号 / 号码布都按行报错;重复证件号不再拦截', async () => {
|
||||
test('导入:有姓名的行缺证件号 / 号码布按行报错', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const missing = await mock.importEntries(csv([row('无证人员', '')]))
|
||||
assert.equal(missing.skipped, 1)
|
||||
assert.match(missing.errors[0] ?? '', /必须填证件号或号码布/)
|
||||
|
||||
// 同一份表里两行共用证件号不再报错:证件号只是可选档案信息,人员按号码布 / 姓名 + 单位认,
|
||||
// 两行都落在同一个人身上(第一行按号码布命中并回填证件号,第二行按姓名 + 单位命中)。
|
||||
const before = mock.listAthletes().length
|
||||
const duplicated = await mock.importEntries(
|
||||
csv([row('刘洋', '110101199001010003', 'F1', '男', '201'), row('刘洋', '110101199001010003', 'F1', '男')]),
|
||||
)
|
||||
assert.deepEqual(duplicated.errors, [], '重复证件号不再拦截')
|
||||
assert.equal(duplicated.created, 2, '两行都落库')
|
||||
assert.equal(mock.listAthletes().length, before, '两行都命中已有人员,不新增档案')
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.name === '刘洋')?.idCard, '110101199001010003')
|
||||
})
|
||||
|
||||
test('导入:证件号撞别人按行报错,复用同一个人时不算差异', async () => {
|
||||
test('导入:同一证件号报多个组别不报错,逐行落库并给出提示', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const idCard = '110101199001010003'
|
||||
const before = mock.listAthletes().length
|
||||
// 同一个人报两个小项:号码布也可以照抄(号码布不再是唯一标识)。
|
||||
const result = await mock.importEntries(
|
||||
csv([
|
||||
rowAt('M100', '男子组', '多组别甲', idCard, 'F1', '男', '9901'),
|
||||
rowAt('M800', '男子组', '多组别甲', idCard, 'F1', '男', '9901'),
|
||||
]),
|
||||
)
|
||||
assert.deepEqual(result.errors, [], '同一证件号重复出现不再拦截')
|
||||
assert.equal(result.created, 2, '同一人的两条报名项都落库')
|
||||
assert.deepEqual(result.notices, [`证件号 ${idCard} 在本表中出现 2 次(同一人报名多个组别)`])
|
||||
assert.deepEqual(result.conflicts, [], '不同小项不是真重复')
|
||||
assert.equal(mock.listAthletes().length, before + 1, '同一个人只建一份档案')
|
||||
})
|
||||
|
||||
test('导入:同证件号 + 同一个小项同一个组别才是真重复,等用户决策', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const idCard = '110101199001010004'
|
||||
const body = [row('重复甲', idCard, 'F1', '男', '9401'), row('重复甲', idCard, 'F1', '男', '9402')]
|
||||
|
||||
const conflicted = await mock.importEntries(csv(body))
|
||||
assert.equal(conflicted.created, 1, '首行落库')
|
||||
assert.equal(conflicted.skipped, 0, '真重复行不进「跳过」,要等决策')
|
||||
assert.equal(conflicted.conflicts.length, 1)
|
||||
const [conflict] = conflicted.conflicts
|
||||
assert.equal(conflict?.duplicate, true, '标记为真重复行,面板才能说明为什么没有差异高亮')
|
||||
assert.equal(conflict?.row, 3)
|
||||
assert.deepEqual(conflict?.changedFields, ['bib'])
|
||||
assert.deepEqual(conflicted.notices, [`证件号 ${idCard} 在本表中出现 2 次(同一人报名多个组别)`])
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.bib, '9401', '未决策前保持首行的值')
|
||||
|
||||
// 保留已有:真重复行不落库,同一个人一个字段都不动。
|
||||
const keptExisting = await mock.resolveImportConflicts(csv(body), [
|
||||
{ idCard, bib: '9402', action: 'keep-existing' },
|
||||
])
|
||||
assert.equal(keptExisting.skipped, 1)
|
||||
assert.deepEqual(keptExisting.conflicts, [])
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.bib, '9401')
|
||||
|
||||
// 使用新数据:用真重复行的字段覆盖同一个人。
|
||||
const keptNew = await mock.resolveImportConflicts(csv(body), [{ idCard, bib: '9402', action: 'keep-new' }])
|
||||
assert.deepEqual(keptNew.conflicts, [])
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.bib, '9402')
|
||||
})
|
||||
|
||||
test('导入:证件号优先命中,复用同一个人时不算差异', async () => {
|
||||
const idCard = '110101199001010004'
|
||||
const mock = new MockBaseData()
|
||||
// 库里没有「张三」:第一行按新人员建,证件号写进档案。
|
||||
@@ -188,21 +280,17 @@ test('导入:证件号撞别人按行报错,复用同一个人时不算差
|
||||
assert.deepEqual(first.conflicts, [])
|
||||
assert.ok(mock.listAthletes().some((athlete) => athlete.idCard === idCard))
|
||||
|
||||
// 同名同单位再来一行:姓名 + 单位命中同一个人,证件号相同不是差异。
|
||||
const again = await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
|
||||
// 同一证件号换一个小项再来一行:按证件号命中同一个人,不算冲突。
|
||||
const again = await mock.importEntries(csv([rowAt('M800', '男子组', '张三', idCard, 'F1', '男')]))
|
||||
assert.equal(again.created, 1)
|
||||
assert.deepEqual(again.conflicts, [])
|
||||
|
||||
// 同名但单位不同:姓名 + 单位找不到人,只能新建,而证件号已被 F1 的张三占用 → 行级报错。
|
||||
const taken = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
|
||||
assert.equal(taken.created, 0)
|
||||
assert.equal(taken.skipped, 1)
|
||||
assert.deepEqual(taken.conflicts, [])
|
||||
assert.equal(
|
||||
taken.errors[0],
|
||||
`第 2 行:证件号 ${idCard} 已被另一个人占用(姓名 张三,号码布 未填)`,
|
||||
'与服务端 idCardTakenError 同一句中文文案',
|
||||
)
|
||||
// 别的人拿着这个证件号来:按证件号命中张三,姓名 / 单位对不上 → 冲突面板,不是行级报错。
|
||||
const taken = await mock.importEntries(csv([row('李四', idCard, 'F2', '男')]))
|
||||
assert.equal(taken.skipped, 0)
|
||||
assert.deepEqual(taken.errors, [], '证件号命中那个人就不再报「已被占用」')
|
||||
assert.equal(taken.conflicts.length, 1)
|
||||
assert.deepEqual(taken.conflicts[0]?.changedFields, ['name', 'teamCode'])
|
||||
assert.equal(mock.listAthletes().filter((athlete) => athlete.name === '张三').length, 1)
|
||||
})
|
||||
|
||||
@@ -319,7 +407,14 @@ test('导入:号码布不同 → 按「同名的另一个人」新建并报告
|
||||
assert.equal(result.created, 1, '行本身不拦截:新建了同名的另一个人')
|
||||
assert.deepEqual(result.conflicts, [], '号码布不同不是冲突')
|
||||
assert.deepEqual(result.sameNameNewPersons, [
|
||||
{ name: '张伟', teamCode: 'JG', newBib: '999', existingBib: '101' },
|
||||
{
|
||||
name: '张伟',
|
||||
teamCode: 'JG',
|
||||
newIdCard: '110101199001010007',
|
||||
existingIdCard: '',
|
||||
newBib: '999',
|
||||
existingBib: '101',
|
||||
},
|
||||
])
|
||||
assert.equal(mock.listAthletes().length, before + 1)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '999')?.idCard, '110101199001010007')
|
||||
@@ -328,6 +423,49 @@ test('导入:号码布不同 → 按「同名的另一个人」新建并报告
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId, 1)
|
||||
})
|
||||
|
||||
// 证件号是唯一标识:号码布写错不影响认人,只把差异交给冲突决策。
|
||||
test('导入:证件号优先命中,号码布对不上也只是冲突', async () => {
|
||||
const mock = new MockBaseData()
|
||||
// 种子数据里 JG 的「张伟」号码布为 101;先给他登记证件号。
|
||||
const zhangWei = mock.listAthletes().find((athlete) => athlete.bib === '101')
|
||||
assert.ok(zhangWei !== undefined)
|
||||
const { id: athleteId, ...base } = zhangWei
|
||||
mock.updateAthlete(athleteId, { ...base, idCard: 'C30003' })
|
||||
|
||||
const before = mock.listAthletes().length
|
||||
const result = await mock.importEntries(csv([row('张伟', 'C30003', 'JG', '男', '7777')]))
|
||||
assert.equal(result.created, 0, '冲突行不落库')
|
||||
assert.equal(mock.listAthletes().length, before, '证件号命中那个人,不新建档案')
|
||||
assert.equal(result.conflicts.length, 1)
|
||||
assert.equal(result.conflicts[0]?.athleteId, athleteId)
|
||||
assert.deepEqual(result.conflicts[0]?.changedFields, ['bib'])
|
||||
})
|
||||
|
||||
// 姓名 + 单位相同、证件号不同 → 同名的另一个人:新建并报告(与号码布不同同一条路径)。
|
||||
test('导入:姓名 + 单位相同但证件号不同 → 按同名的另一个人新建并报告', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const first = await mock.importEntries(csv([rowAt('M100', '男子组', '同名甲', 'C10001', 'F1', '男')]))
|
||||
assert.equal(first.created, 1)
|
||||
assert.deepEqual(first.sameNameNewPersons, [])
|
||||
|
||||
const before = mock.listAthletes().length
|
||||
const result = await mock.importEntries(csv([rowAt('M800', '男子组', '同名甲', 'C20002', 'F1', '男')]))
|
||||
assert.equal(result.created, 1, '行本身不拦截:新建了同名的另一个人')
|
||||
assert.deepEqual(result.conflicts, [], '证件号不同不是冲突')
|
||||
assert.deepEqual(result.sameNameNewPersons, [
|
||||
{
|
||||
name: '同名甲',
|
||||
teamCode: 'F1',
|
||||
newIdCard: 'C20002',
|
||||
existingIdCard: 'C10001',
|
||||
newBib: '',
|
||||
existingBib: '',
|
||||
},
|
||||
])
|
||||
assert.equal(mock.listAthletes().length, before + 1)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.idCard === 'C10001')?.idCard, 'C10001', '原档案证件号不动')
|
||||
})
|
||||
|
||||
test('导入:同名同单位有多个人时一律进冲突面板,以第一条候选为代表', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const firstId = mock.listAthletes().find((athlete) => athlete.bib === '101')?.id
|
||||
|
||||
@@ -50,6 +50,7 @@ test('示例数据的组别就地更新:重名与不存在给与服务端一
|
||||
compare: seed.compare,
|
||||
recordEligible: seed.recordEligible,
|
||||
scored: seed.scored,
|
||||
awardMedals: seed.awardMedals,
|
||||
multiplier: seed.multiplier,
|
||||
status: seed.status,
|
||||
scheduledAt: null,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
||||
/**
|
||||
* 示例数据模式(后端不可达 / mock 生效)下破坏性写操作的统一拦截(P1(17))。
|
||||
*
|
||||
* 以前「批量停用 / 批量彻底删除」会照常走 mock 分支:内存里把人删掉、还提示
|
||||
* 「已彻底删除 N 人」,操作员以为真删了。这里钉住三件事——
|
||||
* 示例数据模式下不发写请求、返回可识别的「未提交」结果;连上服务端时行为不变;
|
||||
* 拦截文案存在且页面入口会触发它。
|
||||
*/
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
/** 记录每一次真实发出的请求;破坏性操作在示例数据模式下应当一条都不产生。 */
|
||||
let calls: string[] = []
|
||||
let reply: () => Response = () => jsonResponse({ ok: true, data: null })
|
||||
|
||||
// 必须在导入被测模块之前装好:ApiClient 构造时就把当时的 fetch 绑进实例。
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
calls.push(`${init?.method ?? 'GET'} ${String(input)}`)
|
||||
return reply()
|
||||
}) as typeof fetch
|
||||
|
||||
const {
|
||||
ApiError,
|
||||
SAMPLE_DATA_BLOCKED,
|
||||
isSampleData,
|
||||
mockMode,
|
||||
mockReason,
|
||||
sampleDataBlockedError,
|
||||
sampleDataBlockedNotice,
|
||||
} = await import('../api/client')
|
||||
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
|
||||
|
||||
function blocked(error: unknown): boolean {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
|
||||
return true
|
||||
}
|
||||
|
||||
/** 切到「后端不可达 / mock 生效」这一态:全局降级为真。 */
|
||||
function enterSampleData(): void {
|
||||
mockMode.value = true
|
||||
mockReason.value = '测试:后端未就绪'
|
||||
baseDataFallback.value = false
|
||||
calls = []
|
||||
}
|
||||
|
||||
/** 切回「连得上服务端」这一态。 */
|
||||
function leaveSampleData(): void {
|
||||
mockMode.value = false
|
||||
mockReason.value = null
|
||||
baseDataFallback.value = false
|
||||
calls = []
|
||||
}
|
||||
|
||||
test('示例数据模式下批量停用被阻断:不发写请求,返回可识别的「未提交」结果', async () => {
|
||||
enterSampleData()
|
||||
|
||||
await assert.rejects(() => baseDataApi.athletes.batch('disable', [1, 2]), blocked)
|
||||
assert.deepEqual(calls, [], '示例数据模式下不应发出任何写请求')
|
||||
})
|
||||
|
||||
test('示例数据模式下批量彻底删除(预览与提交)都被阻断且不发请求', async () => {
|
||||
enterSampleData()
|
||||
|
||||
// dryRun 预览与真删是同一流程的两步,示例数据里连预览都不该发。
|
||||
await assert.rejects(() => baseDataApi.athletes.batch('purge', [1], true), blocked)
|
||||
await assert.rejects(() => baseDataApi.athletes.batch('purge', [1]), blocked)
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('示例数据模式下单条彻底删除(人员 / 单位)也被阻断', async () => {
|
||||
enterSampleData()
|
||||
|
||||
await assert.rejects(() => baseDataApi.athletes.purge(1), blocked)
|
||||
await assert.rejects(() => baseDataApi.teams.purge(1), blocked)
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('基础数据接口降级(仅页面级)同样算示例数据模式', async () => {
|
||||
// 服务端整体正常、只有基础数据接口未实现:mockMode 为 false,页面却仍在用示例数据。
|
||||
mockMode.value = false
|
||||
baseDataFallback.value = true
|
||||
calls = []
|
||||
|
||||
assert.equal(isSampleData(), true, '统一判定要同时看全局与页面级降级')
|
||||
await assert.rejects(() => baseDataApi.athletes.batch('disable', [1]), blocked)
|
||||
assert.deepEqual(calls, [])
|
||||
})
|
||||
|
||||
test('连上服务端时行为不变:真发请求并原样带回服务端结果', async () => {
|
||||
leaveSampleData()
|
||||
reply = () => jsonResponse({ ok: true, data: { action: 'purge', items: [{ id: 1, ok: true }] } })
|
||||
|
||||
const result = await baseDataApi.athletes.batch('purge', [1])
|
||||
|
||||
assert.equal(result.succeeded, 1)
|
||||
assert.deepEqual(calls, ['POST /api/athletes/batch'])
|
||||
assert.equal(mockMode.value, false)
|
||||
})
|
||||
|
||||
test('拦截错误与提示文案可识别:说清是示例数据、未连接服务端', () => {
|
||||
assert.equal(SAMPLE_DATA_BLOCKED, 'SAMPLE_DATA_BLOCKED')
|
||||
|
||||
const error = sampleDataBlockedError('批量彻底删除')
|
||||
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
|
||||
assert.match(error.message, /示例数据/)
|
||||
assert.match(error.message, /未连接服务端/)
|
||||
assert.match(error.message, /批量彻底删除/)
|
||||
|
||||
const notice = sampleDataBlockedNotice('批量彻底删除')
|
||||
assert.equal(notice.title, '示例数据模式不能批量彻底删除')
|
||||
assert.match(notice.detail, /未连接服务端/)
|
||||
})
|
||||
|
||||
// 项目没有组件测试工具(无 @vue/test-utils / jsdom),界面部分按仓库既有做法读源码断言。
|
||||
const ATHLETES_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/AthletesSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const TEAMS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/TeamsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const SPORTS_EVENTS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('运动员页的批量停用 / 批量彻底删除 / 单条彻底删除都先过统一拦截', () => {
|
||||
assert.match(ATHLETES_SOURCE, /blockDestructiveWrite\('批量停用'\)/)
|
||||
assert.match(ATHLETES_SOURCE, /blockDestructiveWrite\('批量彻底删除'\)/)
|
||||
assert.match(ATHLETES_SOURCE, /blockDestructiveWrite\('彻底删除'\)/)
|
||||
})
|
||||
|
||||
test('单位与大小项页的破坏性入口同样先过统一拦截', () => {
|
||||
assert.match(TEAMS_SOURCE, /blockDestructiveWrite\('彻底删除'\)/)
|
||||
assert.match(SPORTS_EVENTS_SOURCE, /blockDestructiveWrite\('删除大项'\)/)
|
||||
assert.match(SPORTS_EVENTS_SOURCE, /blockDestructiveWrite\('删除小项'\)/)
|
||||
})
|
||||
@@ -18,6 +18,7 @@ function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
compare: 'asc',
|
||||
recordEligible: false,
|
||||
scored: true,
|
||||
awardMedals: true,
|
||||
multiplier: 1,
|
||||
status: 'pending',
|
||||
scheduledAt: null,
|
||||
|
||||
@@ -40,7 +40,7 @@ function respondWith(response: () => Response): void {
|
||||
reply = response
|
||||
}
|
||||
|
||||
const { ApiError, adminApi } = await import('../api/client')
|
||||
const { ApiError, adminApi, mockMode, SAMPLE_DATA_BLOCKED } = await import('../api/client')
|
||||
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
|
||||
|
||||
test('彻底删除走 DELETE /api/teams/{id}?purge=1', async () => {
|
||||
@@ -117,16 +117,23 @@ test('teams 其它接口的 404 仍降级到示例数据(既有行为未受影
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
})
|
||||
|
||||
// 后端整体连不上时仍降级到示例数据;假删除没有统计对象,示例数据也不能凭空造一个出来。
|
||||
// 用 id=2 避开上一条已经删掉的 1(示例数据是模块级单例)。
|
||||
test('后端不可用降级示例数据时,假删除照常生效且不返回统计', async () => {
|
||||
// 后端整体连不上时不再降级示例数据:内存里「删掉」只是假象,后端恢复后单位又会回来,
|
||||
// 操作员却以为已经彻底删了。必须给出可识别的「未提交」结果(SAMPLE_DATA_BLOCKED)。
|
||||
test('后端不可用时彻底删除单位被阻断:不降级示例数据、不假装删掉了', async () => {
|
||||
baseDataFallback.value = false
|
||||
mockMode.value = false
|
||||
respondWith(() => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
|
||||
const result = await baseDataApi.teams.purge(2)
|
||||
|
||||
assert.equal(result, undefined)
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
await assert.rejects(
|
||||
() => baseDataApi.teams.purge(2),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, SAMPLE_DATA_BLOCKED)
|
||||
assert.match(error.message, /示例数据/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
assert.equal(baseDataFallback.value, true, '写请求撞上后端不可达后,页面应切到示例数据标记')
|
||||
})
|
||||
@@ -0,0 +1,182 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import type { ScoreChannel, Team, TeamRanksView } from '@/shared'
|
||||
import {
|
||||
hasTeamManualChannel,
|
||||
parseTeamRankInput,
|
||||
teamRankFlaggedTeams,
|
||||
teamRankRowsOf,
|
||||
teamRankWarnings,
|
||||
teamRanksDirty,
|
||||
teamRanksPayload,
|
||||
} from '../pages/teamRanks'
|
||||
import { mockDb } from '../api/mock'
|
||||
|
||||
// 录入页「团体名次」区块(设计 17 §八第 3 条):纯逻辑在 teamRanks.ts,
|
||||
// 界面部分与仓库既有做法一致——没有组件测试工具,读源码断言关键接线。
|
||||
|
||||
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||
|
||||
function makeChannel(overrides: Partial<ScoreChannel> = {}): ScoreChannel {
|
||||
return { id: 1, eventId: 108, scope: 'team', basis: 'manual', profileId: 2, sortOrder: 1, countToTeam: true, ...overrides }
|
||||
}
|
||||
|
||||
function makeTeam(id: number, overrides: Partial<Team> = {}): Team {
|
||||
return {
|
||||
id,
|
||||
code: `T${id}`,
|
||||
name: `单位${id}`,
|
||||
shortName: `单位${id}`,
|
||||
logoPath: null,
|
||||
color: null,
|
||||
sortOrder: id,
|
||||
active: true,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeView(overrides: Partial<TeamRanksView> = {}): TeamRanksView {
|
||||
return {
|
||||
channels: [{ channelId: 3, scope: 'team', basis: 'manual', profileId: 2 }],
|
||||
teams: [
|
||||
{ teamId: 1, code: 'JG', name: '机关本部', shortName: '机关' },
|
||||
{ teamId: 2, code: 'F1', name: '第一分公司', shortName: '一分公司' },
|
||||
],
|
||||
ranks: [{ teamId: 1, rank: 2 }],
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test('只有 scope=team + basis=manual 的通道才需要「团体名次」区块', () => {
|
||||
assert.equal(hasTeamManualChannel([makeChannel()]), true)
|
||||
assert.equal(hasTeamManualChannel([makeChannel({ basis: 'aggregate' })]), false)
|
||||
assert.equal(hasTeamManualChannel([makeChannel({ scope: 'athlete' })]), false)
|
||||
assert.equal(
|
||||
hasTeamManualChannel([makeChannel({ scope: 'athlete', basis: 'result' }), makeChannel()]),
|
||||
true,
|
||||
)
|
||||
assert.equal(hasTeamManualChannel([]), false)
|
||||
assert.equal(hasTeamManualChannel(null), false)
|
||||
assert.equal(hasTeamManualChannel(undefined), false)
|
||||
})
|
||||
|
||||
test('名次输入:空 / 0 / 负数 / 非整数一律按清空处理', () => {
|
||||
assert.equal(parseTeamRankInput('3'), 3)
|
||||
assert.equal(parseTeamRankInput(' 4 '), 4)
|
||||
// 留空 = 清空该单位,服务端口径是 rank 0。
|
||||
assert.equal(parseTeamRankInput(''), 0)
|
||||
assert.equal(parseTeamRankInput(' '), 0)
|
||||
assert.equal(parseTeamRankInput('0'), 0)
|
||||
assert.equal(parseTeamRankInput('-2'), 0)
|
||||
assert.equal(parseTeamRankInput('2.5'), 0)
|
||||
assert.equal(parseTeamRankInput('abc'), 0)
|
||||
})
|
||||
|
||||
test('表格行:单位来自接口,已有名次回填,未填留空', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
assert.deepEqual(
|
||||
rows.map((row) => [row.teamId, row.code, row.input, row.original]),
|
||||
[
|
||||
[1, 'JG', '2', '2'],
|
||||
[2, 'F1', '', ''],
|
||||
],
|
||||
)
|
||||
assert.equal(teamRanksDirty(rows), false)
|
||||
})
|
||||
|
||||
test('接口没带单位时退回全局单位表,且不列停用 / 假删除的单位', () => {
|
||||
const rows = teamRankRowsOf(makeView({ teams: [], ranks: [] }), [
|
||||
makeTeam(1),
|
||||
makeTeam(2, { active: false }),
|
||||
makeTeam(3, { deletedAt: '2026-01-01' }),
|
||||
])
|
||||
assert.deepEqual(rows.map((row) => row.teamId), [1])
|
||||
})
|
||||
|
||||
test('rank=0(清空)在表格里显示为未填,而不是 0', () => {
|
||||
const rows = teamRankRowsOf(makeView({ ranks: [{ teamId: 1, rank: 0 }] }), [])
|
||||
assert.equal(rows[0]?.input, '')
|
||||
assert.equal(rows[0]?.original, '')
|
||||
})
|
||||
|
||||
test('改动检测与保存载荷:整组回传,清空发 0', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
rows[0]!.input = '1'
|
||||
rows[1]!.input = ''
|
||||
assert.equal(teamRanksDirty(rows), true)
|
||||
assert.deepEqual(teamRanksPayload(rows), {
|
||||
scope: 'team',
|
||||
basis: 'manual',
|
||||
ranks: [
|
||||
{ teamId: 1, rank: 1 },
|
||||
{ teamId: 2, rank: 0 },
|
||||
],
|
||||
})
|
||||
})
|
||||
|
||||
test('名次重复只提示不拦截,超出单位数同样只是提醒', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
rows[0]!.input = '1'
|
||||
rows[1]!.input = '1'
|
||||
const warnings = teamRankWarnings(rows)
|
||||
assert.equal(warnings.length, 1)
|
||||
assert.match(warnings[0] ?? '', /名次 1 重复 2 次,允许并列/)
|
||||
assert.deepEqual([...teamRankFlaggedTeams(rows)], [1, 2])
|
||||
|
||||
// 超出参赛单位数(两个单位却填了 5)同样不抛错,只多一条提示。
|
||||
rows[1]!.input = '5'
|
||||
assert.match(teamRankWarnings(rows).join(';'), /超出参赛单位数 2/)
|
||||
assert.deepEqual([...teamRankFlaggedTeams(rows)], [2])
|
||||
})
|
||||
|
||||
test('没问题的输入不产生提示', () => {
|
||||
const rows = teamRankRowsOf(makeView(), [])
|
||||
rows[0]!.input = '1'
|
||||
rows[1]!.input = '2'
|
||||
assert.deepEqual(teamRankWarnings(rows), [])
|
||||
assert.equal(teamRankFlaggedTeams(rows).size, 0)
|
||||
})
|
||||
|
||||
test('示例数据:录入接口随行下发团体名次,保存后能原样读回', () => {
|
||||
const event = mockDb.eventDetail(108)
|
||||
assert.equal(hasTeamManualChannel(event.channels), true)
|
||||
|
||||
const board = mockDb.entryBoard(108)
|
||||
const view = board.teamRanks.find((item) => item.channels.length > 0)
|
||||
assert.ok(view !== undefined, 'TPULL 应随行下发团体名次视图')
|
||||
assert.equal(view.categoryId, 108 * 10 + 1)
|
||||
|
||||
const rows = teamRankRowsOf(view, [])
|
||||
assert.equal(rows.length, 7, '启用单位全部列出')
|
||||
// 示例数据里预填了前两个单位,用来演示「已有值回填」。
|
||||
assert.deepEqual(rows.slice(0, 2).map((row) => row.input), ['1', '2'])
|
||||
|
||||
// 改一个、清一个,保存后读回的口径与提交的一致。
|
||||
rows[0]!.input = '3'
|
||||
rows[1]!.input = ''
|
||||
const saved = mockDb.saveTeamRanks(108, view.categoryId ?? 0, teamRanksPayload(rows))
|
||||
const after = teamRankRowsOf(saved, [])
|
||||
assert.equal(after[0]?.input, '3')
|
||||
assert.equal(after[1]?.input, '', '空值 = 清空该单位')
|
||||
assert.equal(mockDb.teamRanks(108, view.categoryId ?? 0).ranks.length, 1)
|
||||
})
|
||||
|
||||
test('录入页按契约接线:区块可见性、随行数据优先、保存与 guest 只读', () => {
|
||||
// 区块出现条件 = 该组别有团体手工通道。
|
||||
assert.match(ENTRY_SOURCE, /hasTeamManualChannel\(channels\.value\)/)
|
||||
assert.match(ENTRY_SOURCE, /v-if="showTeamRanks"/)
|
||||
// 数据来源:录入接口随行的 teamRanks 优先,拿不到才退到 GET team-ranks。
|
||||
assert.match(ENTRY_SOURCE, /adminApi\.entryBoard\(/)
|
||||
assert.match(ENTRY_SOURCE, /adminApi\.teamRanks\(/)
|
||||
// 保存走 PUT,并且是整组替换的载荷。
|
||||
assert.match(ENTRY_SOURCE, /adminApi\.saveTeamRanks\(eventId, categoryId, teamRanksPayload\(/)
|
||||
// 保存后整页刷新(名次 / 积分 / 榜单都会变)。
|
||||
assert.match(ENTRY_SOURCE, /await reloadAfterSettlementChange\(\)/)
|
||||
// 说明文案必须点明「按单位录入」且不影响个人名次。
|
||||
assert.match(ENTRY_SOURCE, /按<strong>单位<\/strong>录入的团体名次/)
|
||||
assert.match(ENTRY_SOURCE, /不影响个人名次/)
|
||||
// guest / 断线只读:整块置灰 + 提示条。
|
||||
assert.match(ENTRY_SOURCE, /'is-readonly': !canWrite/)
|
||||
assert.match(ENTRY_SOURCE, /当前为访客只读视图,团体名次只能查看,无法录入/)
|
||||
})
|
||||
Reference in new issue
Block a user