## 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 例 + 语法检查。
183 lines
7.0 KiB
TypeScript
183 lines
7.0 KiB
TypeScript
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, /当前为访客只读视图,团体名次只能查看,无法录入/)
|
||
})
|