feat(0.5.1): 业务逻辑审查 9 条 P0 全修 + 自绘安装器 + 现场反馈四项
## 算分正确性(6 份只读审查 → 收敛成 5 个病根,均先复现失败再修) - 「人工名次」与「自动名次回写」互斥:原来同一份成绩**发布两次分数不同** (团体报名项 dual 通道 10/8/6 → 25/18/14),现在连发多次逐字节一致 - 删小项时在同一事务作废旧流水并推榜:原来分数**永久残留**在总榜、任何重算都打不到 - 规则包导入整体事务化:原来中途失败会清空名次表且补不回(奖牌全丢); 另加守卫「compare=none 的小项不得配 basis=result/aggregate」 - 末位归零只抹真正落在末位的那一档:原来跨末位的并列组会被整组抹 0 (7 人三人并列第 5 时第 5、6 名的分也一起没了) - 模板「分值表档位超出录取名次」现在拒绝保存:原来静默截断成 0 分 ## 现场反馈(主人实测报的) - 已发布成绩可改:行内「修改本条」+ 成绩格/状态格可点(退回草稿→改→重新发布本组), 与「作废」语义分开;取消路径不产生任何副作用 - 单位积分明细补「运动员 / 来源」列,并显示「已修订 ×N(时间·原因)」: 管理端、大屏覆盖层、xlsx 备注列三处同源 - 单位奖牌榜改为按奖牌独立排名(金→银→铜→单位编码),与积分榜解耦 - 历史孤儿积分流水清理(迁移 0022,只动 result/record_bonus,人工调整分不受影响) ## 新功能 - 团体项目聚合口径二选一:平均(默认)/ 总和(迁移 0023,通道编辑器可切) ## 安装器 - 自绘五页安装界面:无边框、自绘标题栏、DPI 自适应(4K@200% 实测)、 安装/卸载逻辑复用 electron-builder 宏不变;默认「为所有用户安装」并自动放行防火墙 19540 - 构建期校验:安装器素材里的版本号必须与 VERSION 一致,否则构建失败 自检:服务端 14 包 ×2 / 前端 vue-tsc + build + 742 例 ×2 / Console 20 文件 + 68 例 ×2,全绿。 端到端对拍:修前 vs 修后,纯团体 / 双计 / 普通个人三形态**逐字节一致**,仅出 bug 的双通道形态按要求改变。
This commit is contained in:
1 parent
78b14efaa9
commit
877ff15823
181 files changed
+8747
-371
No files matched your search
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { DEFAULT_AGGREGATE_MODE, normalizeAggregateMode } from '@/shared'
|
||||
import { newChannelRow } from '../components/baseData/channelRows'
|
||||
|
||||
// 聚合口径(basis=aggregate 的通道按成员平均还是按成员总和排名)的前端接线。
|
||||
// 纯逻辑在 shared/channels.ts 与 channelRows.ts,界面接线按仓库既有做法读源码断言
|
||||
// (项目没有组件测试工具,见 channels-count-to-team.test.ts)。
|
||||
|
||||
const CHANNELS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
const CHANNEL_ROWS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/channelRows.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('聚合口径缺字段 / 认不出的取值一律按 avg(老数据的口径不变)', () => {
|
||||
assert.equal(DEFAULT_AGGREGATE_MODE, 'avg')
|
||||
assert.equal(normalizeAggregateMode('sum'), 'sum')
|
||||
assert.equal(normalizeAggregateMode('avg'), 'avg')
|
||||
// 老服务端不下发该字段(缺省值不下发),旧缓存里的脏值同样按缺省处理。
|
||||
assert.equal(normalizeAggregateMode(undefined), 'avg')
|
||||
assert.equal(normalizeAggregateMode(null), 'avg')
|
||||
assert.equal(normalizeAggregateMode('SUM'), 'avg')
|
||||
assert.equal(normalizeAggregateMode('bogus'), 'avg')
|
||||
})
|
||||
|
||||
test('新增通道行默认按成员平均(avg),要用总和时现场切', () => {
|
||||
assert.equal(newChannelRow(1, 'individual_team', '3', 1).aggregateMode, 'avg')
|
||||
assert.match(CHANNEL_ROWS_SOURCE, /aggregateMode: DEFAULT_AGGREGATE_MODE/)
|
||||
})
|
||||
|
||||
test('通道编辑器读写聚合口径:存库值回填、保存时随行回传', () => {
|
||||
// 读:已存库的通道按原值回填(含「缺字段按 avg」的归一化),不被新建默认值改写。
|
||||
assert.match(CHANNELS_SOURCE, /aggregateMode: normalizeAggregateMode\(channel\.aggregateMode\)/)
|
||||
// 写:PUT 是整体替换,聚合口径必须随行回传,否则会被重置成缺省。
|
||||
assert.match(CHANNELS_SOURCE, /aggregateMode: row\.aggregateMode/)
|
||||
})
|
||||
|
||||
test('聚合口径是「一点即切」的显眼控件,且只在聚合通道上出现', () => {
|
||||
// 两个口径做成按钮组(不是下拉),现场一眼看到当前口径、一下点过去。
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'avg', label: '平均', hint: /)
|
||||
assert.match(CHANNELS_SOURCE, /\{ value: 'sum', label: '总和', hint: /)
|
||||
assert.match(CHANNELS_SOURCE, /class="agg-mode-btn"/)
|
||||
assert.match(CHANNELS_SOURCE, /:class="\{ 'is-active': row\.aggregateMode === option\.value \}"/)
|
||||
assert.match(CHANNELS_SOURCE, /@click="row\.aggregateMode = option\.value"/)
|
||||
// 只有 basis=aggregate 的行才显示该控件:别的通道没有汇总口径可言。
|
||||
assert.match(CHANNELS_SOURCE, /<div v-if="row\.basis === 'aggregate'" class="agg-mode"/)
|
||||
// 表头要让人找到它。
|
||||
assert.match(CHANNELS_SOURCE, /<th class="bd-select">聚合口径<\/th>/)
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { defaultCountToTeam, newChannelRow } from '../components/baseData/channelRows'
|
||||
|
||||
// 工单 A-3:通道编辑器给 scope=athlete 的行默认「计入单位总分」,手工给「个人+团体」小项
|
||||
// 配一次通道就会「个人分 + 团体分」同时进单位总分(双计)。
|
||||
// 纯逻辑在 channelRows.ts,界面接线按仓库既有做法读源码断言(项目没有组件测试工具)。
|
||||
|
||||
const CHANNELS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('A-3:新增的个人通道在「个人+团体」小项默认不计入单位总分', () => {
|
||||
assert.equal(newChannelRow(1, 'individual_team', '3', 1).countToTeam, false)
|
||||
assert.equal(defaultCountToTeam('individual_team', 'athlete'), false)
|
||||
})
|
||||
|
||||
test('默认值与规则包的通道模板逐条一致:只有双计项目的个人通道默认不计入', () => {
|
||||
// channelTemplates:individual 的个人通道、team / match 的团体通道都没写 countToTeam(缺省计入)。
|
||||
assert.equal(defaultCountToTeam('individual', 'athlete'), true)
|
||||
assert.equal(defaultCountToTeam('team', 'athlete'), true)
|
||||
assert.equal(defaultCountToTeam('match', 'athlete'), true)
|
||||
// 普通个人项目的个人分正是单位总分的主要来源,默认不计入会让单位总分静默归零。
|
||||
assert.equal(newChannelRow(1, 'individual', '1', 1).countToTeam, true)
|
||||
assert.equal(newChannelRow(1, 'team', '1', 1).countToTeam, true)
|
||||
// 团体名次本来就记在单位头上,团体通道一律默认计入。
|
||||
assert.equal(defaultCountToTeam('individual_team', 'team'), true)
|
||||
})
|
||||
|
||||
test('新增行只带默认值:名次范围 / 来源、排序、聚合口径、两个奖牌开关', () => {
|
||||
assert.deepEqual(newChannelRow(7, 'individual_team', '5', 4), {
|
||||
key: 7,
|
||||
scope: 'athlete',
|
||||
basis: 'result',
|
||||
profileId: '5',
|
||||
sortOrder: '4',
|
||||
// 聚合口径的默认值:缺省就是「按成员平均」,与库表默认值一致。
|
||||
aggregateMode: 'avg',
|
||||
countToTeam: false,
|
||||
countToMedals: true,
|
||||
countToTeamMedals: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('默认值只作用于新增行:已存库的通道按原值回填', () => {
|
||||
// toRow 必须走通道自身的值(含「留空按 true」的归一化),不能被新建默认值改写。
|
||||
assert.match(CHANNELS_SOURCE, /countToTeam: normalizeCountToTeam\(channel\.countToTeam\)/)
|
||||
assert.match(CHANNELS_SOURCE, /countToMedals: normalizeCountToMedals\(channel\.countToMedals\)/)
|
||||
assert.match(CHANNELS_SOURCE, /countToTeamMedals: normalizeCountToTeamMedals\(channel\.countToTeamMedals\)/)
|
||||
// addRow 必须把当前小项的类型交给 newChannelRow,否则默认值拿不到小项类型。
|
||||
assert.match(CHANNELS_SOURCE, /newChannelRow\(\(nextKey \+= 1\), eventType, profileId, rows\.value\.length \+ 1\)/)
|
||||
assert.match(CHANNELS_SOURCE, /const eventType = selectedEvent\.value\?\.eventType \?\? 'individual'/)
|
||||
})
|
||||
@@ -9,6 +9,11 @@ const CHANNELS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/ChannelsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
// 新增行的默认值(含「计入单位总分」按小项类型取值)在 channelRows.ts,见 channels-count-to-team.test.ts。
|
||||
const CHANNEL_ROWS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/channelRows.ts', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const EVENTS_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
@@ -31,7 +36,7 @@ test('两个开关一起序列化进 ScoreChannelInput,scope=team 的值不被
|
||||
})
|
||||
|
||||
test('新通道默认发奖牌并并入单位榜(与库表默认值一致)', () => {
|
||||
assert.match(CHANNELS_SOURCE, /countToMedals: true,\s*\n\s*countToTeamMedals: true,/)
|
||||
assert.match(CHANNEL_ROWS_SOURCE, /countToMedals: true,\s*\n\s*countToTeamMedals: true,/)
|
||||
})
|
||||
|
||||
test('小项编辑有「该项启用奖牌结算」开关,默认开并说清后果', () => {
|
||||
|
||||
@@ -8,14 +8,21 @@ import {
|
||||
RESULT_ROW_ACTION_TITLE,
|
||||
RESULT_ROW_ACTION_WORD,
|
||||
resultActionTarget,
|
||||
resultRowAction,
|
||||
resultActionTargets,
|
||||
resultCellAction,
|
||||
resultRowActions,
|
||||
} from '../pages/entryResultActions'
|
||||
|
||||
/**
|
||||
* 成绩录入页「作废 / 重新录入」(P2 A16)。
|
||||
* 成绩录入页「修改 / 作废 / 重新录入」(P2 A16)。
|
||||
*
|
||||
* 后端早有 `POST /api/results/{id}/void`,管理端却没有任何入口:现场改一个人填错的成绩,
|
||||
* 只能「整组撤销发布 → 改 → 重发」,同组其它人的名次与积分会被整体回退再重算。
|
||||
* 已发布的行由服务端锁着(直接改会让「榜单是旧数、表格是新数」),所以每行要有明确的退回入口。
|
||||
* 「填错了要改」与「这条不算了」是两件事:已发布的行并排给「修改本条」与「作废本条」,
|
||||
* 前者退回草稿(`POST /api/results/{id}/reopen`)改完重新发布,后者才是作废(`.../void`);
|
||||
* 已作废的行只给「重新录入」。想改一条已发布的成绩不必先作废。
|
||||
*
|
||||
* 现场裁判长的第一直觉是**去点那一格**(成绩格 / 状态格),所以这两格也从「彻底禁用」改成可点:
|
||||
* 点一下弹的是与「修改本条」同一个确认框,确认后才退回草稿——取消则一个接口都不发。
|
||||
*
|
||||
* 项目没有组件测试工具(无 @vue/test-utils / jsdom),判定逻辑抽在 entryResultActions 里直接测,
|
||||
* 界面接线按仓库既有做法读源码断言。
|
||||
@@ -31,78 +38,123 @@ function section(source: string, start: string, end: string): string {
|
||||
return source.slice(from, to)
|
||||
}
|
||||
|
||||
// ── 哪一行显示哪个按钮 ──────────────────────────────────────────────────────
|
||||
// ── 哪一行显示哪些按钮 ──────────────────────────────────────────────────────
|
||||
|
||||
test('已发布且未作废的行显示「作废本条」', () => {
|
||||
assert.equal(resultRowAction({ resultState: 'published', status: 'ok' }), 'void')
|
||||
assert.equal(resultRowAction({ resultState: 'published', status: 'dq' }), 'void')
|
||||
assert.equal(resultRowAction({ resultState: 'published', status: 'dns' }), 'void')
|
||||
test('已发布且未作废的行同时给「修改本条」与「作废本条」', () => {
|
||||
assert.deepEqual(resultRowActions({ resultState: 'published', status: 'ok' }), ['modify', 'void'])
|
||||
assert.deepEqual(resultRowActions({ resultState: 'published', status: 'dq' }), ['modify', 'void'])
|
||||
assert.deepEqual(resultRowActions({ resultState: 'published', status: 'dns' }), ['modify', 'void'])
|
||||
})
|
||||
|
||||
test('已作废(published + withdrawn)的行显示「重新录入」', () => {
|
||||
test('已作废(published + withdrawn)的行只给「重新录入」', () => {
|
||||
// 服务端 void 只改 status、不动 state,所以「已作废」等于「已发布 + withdrawn」。
|
||||
assert.equal(resultRowAction({ resultState: 'published', status: 'withdrawn' }), 'reopen')
|
||||
assert.deepEqual(resultRowActions({ resultState: 'published', status: 'withdrawn' }), ['reopen'])
|
||||
})
|
||||
|
||||
test('草稿行(含退回草稿后的行)不显示任何行操作:本来就是可编辑的', () => {
|
||||
assert.equal(resultRowAction({ resultState: 'draft', status: 'withdrawn' }), null)
|
||||
assert.equal(resultRowAction({ resultState: 'draft', status: 'ok' }), null)
|
||||
assert.equal(resultRowAction({ resultState: null, status: 'ok' }), null)
|
||||
assert.deepEqual(resultRowActions({ resultState: 'draft', status: 'withdrawn' }), [])
|
||||
assert.deepEqual(resultRowActions({ resultState: 'draft', status: 'ok' }), [])
|
||||
assert.deepEqual(resultRowActions({ resultState: null, status: 'ok' }), [])
|
||||
})
|
||||
|
||||
test('按钮文案逐字:作废本条 / 重新录入', () => {
|
||||
test('按钮文案逐字:修改本条 / 作废本条 / 重新录入,且「修改」说清不是作废', () => {
|
||||
assert.equal(RESULT_ROW_ACTION_LABEL.modify, '修改本条')
|
||||
assert.equal(RESULT_ROW_ACTION_LABEL.void, '作废本条')
|
||||
assert.equal(RESULT_ROW_ACTION_LABEL.reopen, '重新录入')
|
||||
assert.match(RESULT_ROW_ACTION_TITLE.modify, /不是作废/)
|
||||
assert.match(RESULT_ROW_ACTION_TITLE.void, /不再计入名次与积分/)
|
||||
assert.match(RESULT_ROW_ACTION_WORD.void, /作废/)
|
||||
assert.match(RESULT_ROW_ACTION_WORD.modify, /修改/)
|
||||
})
|
||||
|
||||
// ── 动的是哪一条成绩 ────────────────────────────────────────────────────────
|
||||
|
||||
test('操作目标是成绩 id(接口按它定位),不是报名项 id', () => {
|
||||
const target = resultActionTarget({
|
||||
const row = {
|
||||
entryId: 7001,
|
||||
resultId: 9001,
|
||||
resultState: 'published',
|
||||
status: 'ok',
|
||||
resultState: 'published' as const,
|
||||
status: 'ok' as const,
|
||||
resultText: '11.58',
|
||||
}
|
||||
assert.deepEqual(resultActionTarget(row, 'modify'), {
|
||||
action: 'modify',
|
||||
resultId: 9001,
|
||||
entryId: 7001,
|
||||
resultText: '11.58',
|
||||
})
|
||||
assert.deepEqual(target, {
|
||||
assert.deepEqual(resultActionTarget(row, 'void'), {
|
||||
action: 'void',
|
||||
resultId: 9001,
|
||||
entryId: 7001,
|
||||
resultText: '11.58',
|
||||
})
|
||||
// 一行的两个按钮都打在这一条成绩上(同一 resultId),点哪个都不会动到别人的成绩。
|
||||
assert.deepEqual(
|
||||
resultActionTargets(row).map((target) => [target.action, target.resultId]),
|
||||
[
|
||||
['modify', 9001],
|
||||
['void', 9001],
|
||||
],
|
||||
)
|
||||
})
|
||||
|
||||
test('没有成绩 id 或不是已发布行时,不产生可提交的目标', () => {
|
||||
test('该动作在这一行不成立时不产生可提交的目标', () => {
|
||||
const base = { entryId: 7001, resultState: 'published' as const, status: 'ok' as const, resultText: '11.58' }
|
||||
assert.equal(resultActionTarget({ ...base, resultId: null }), null)
|
||||
assert.equal(resultActionTarget({ ...base, resultId: 9001, resultState: 'draft' }), null)
|
||||
// 草稿行不该能作废 / 退回。
|
||||
assert.equal(resultActionTarget({ ...base, resultId: 9001, resultState: 'draft' }, 'void'), null)
|
||||
// 未作废的行没有「重新录入」这个动作(它的退回入口叫「修改本条」)。
|
||||
assert.equal(resultActionTarget({ ...base, resultId: 9001 }, 'reopen'), null)
|
||||
// 没有成绩 id 就定位不到接口目标。
|
||||
assert.equal(resultActionTarget({ ...base, resultId: null }, 'modify'), null)
|
||||
assert.deepEqual(resultActionTargets({ ...base, resultId: null }), [])
|
||||
// 已作废的行只剩「重新录入」,拿不到作废入口(作废两次没有意义)。
|
||||
assert.deepEqual(resultActionTargets({ ...base, resultId: 9001, status: 'withdrawn' }), [
|
||||
{ action: 'reopen', resultId: 9001, entryId: 7001, resultText: '11.58' },
|
||||
])
|
||||
})
|
||||
|
||||
// ── 界面接线 ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('确认框文案写清了后果,且作废原因必填', () => {
|
||||
test('确认框文案写清了后果,「修改」与「作废」一眼能分开,且原因必填', () => {
|
||||
assert.equal(RESULT_ACTION_CONFIRM.void.title, '确认作废本条成绩?')
|
||||
assert.equal(
|
||||
RESULT_ACTION_CONFIRM.void.message,
|
||||
'作废后该成绩不再计入名次与积分,同组其它人重新排名,大屏立即更新。',
|
||||
)
|
||||
assert.equal(RESULT_ACTION_CONFIRM.modify.title, '确认修改本条已发布的成绩?')
|
||||
assert.match(RESULT_ACTION_CONFIRM.modify.message, /退回草稿/)
|
||||
// 点格子与点按钮共用这一份文案:已发布 / 退回草稿 / 重新发布本组 / 榜单先退下来,四点都要说清。
|
||||
assert.match(RESULT_ACTION_CONFIRM.modify.message, /积分榜与大屏先把它退下来/)
|
||||
assert.match(RESULT_ACTION_CONFIRM.modify.message, /重新发布本组/)
|
||||
assert.match(RESULT_ACTION_CONFIRM.modify.message, /不是作废|确实不算了/)
|
||||
assert.notEqual(RESULT_ACTION_CONFIRM.modify.message, RESULT_ACTION_CONFIRM.void.message)
|
||||
assert.equal(RESULT_ACTION_CONFIRM.modify.reasonLabel, '修改原因')
|
||||
assert.equal(RESULT_ACTION_CONFIRM.void.reasonLabel, '作废原因')
|
||||
assert.equal(RESULT_ACTION_CONFIRM.reopen.title, '确认重新录入本条成绩?')
|
||||
assert.match(RESULT_ACTION_CONFIRM.reopen.message, /退回草稿(可编辑)/)
|
||||
assert.match(ENTRY_SOURCE, /:reason-required="true"/)
|
||||
assert.match(ENTRY_SOURCE, /@confirm="confirmResultAction"/)
|
||||
})
|
||||
|
||||
test('作废调用 void、退回草稿调用 reopen,提交的都是成绩 id', () => {
|
||||
test('「修改」走 reopen 接口、「作废」走 void 接口,提交的都是成绩 id', () => {
|
||||
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
||||
assert.match(confirm, /await adminApi\.voidResult\(target\.resultId, \{ reason \}\)/)
|
||||
assert.match(confirm, /await adminApi\.reopenResult\(target\.resultId, \{ reason \}\)/)
|
||||
assert.doesNotMatch(confirm, /(voidResult|reopenResult)\(target\.entryId/)
|
||||
})
|
||||
|
||||
test('作废 / 退回成功后刷新本页数据与榜单', () => {
|
||||
test('按钮按纯函数给出的顺序渲染,一个按钮一个动作', () => {
|
||||
const cell = section(ENTRY_SOURCE, 'class="center action-cell"', '</tr>')
|
||||
assert.match(cell, /v-for="target in rowActionsOf\(row\)"/)
|
||||
assert.match(cell, /:key="target\.action"/)
|
||||
assert.match(cell, /\{\{ RESULT_ROW_ACTION_LABEL\[target\.action\] \}\}/)
|
||||
assert.match(cell, /@click="requestResultAction\(target\)"/)
|
||||
assert.match(cell, /:disabled="!canWrite \|\| resultActionBusy"/)
|
||||
assert.match(cell, /v-if="rowActionsOf\(row\)\.length === 0"/)
|
||||
})
|
||||
|
||||
test('修改 / 作废 / 退回成功后刷新本页数据与榜单', () => {
|
||||
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
||||
// 名次与积分由服务端重算:必须整表重拉 + 刷新榜单,不能只在本地行上打补丁。
|
||||
assert.match(confirm, /await reloadAfterSettlementChange\(\)/)
|
||||
@@ -119,8 +171,9 @@ test('成功后给 toast,失败时原样报错', () => {
|
||||
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
||||
assert.match(confirm, /const success = RESULT_ACTION_SUCCESS\[target\.action\]/)
|
||||
assert.match(confirm, /toast\.success\(success\.title, success\.detail\)/)
|
||||
assert.match(confirm, /toast\.error\(target\.action === 'void' \? '作废失败' : '退回草稿失败'/)
|
||||
assert.match(confirm, /toast\.error\(`\$\{RESULT_ROW_ACTION_WORD\[target\.action\]\}失败`/)
|
||||
assert.match(RESULT_ACTION_SUCCESS.void.title, /名次与积分已重算/)
|
||||
assert.match(RESULT_ACTION_SUCCESS.modify.title, /可以改了/)
|
||||
})
|
||||
|
||||
test('示例数据模式在发请求前拦下,提示与既有口径一致', () => {
|
||||
@@ -133,13 +186,6 @@ test('示例数据模式在发请求前拦下,提示与既有口径一致', ()
|
||||
)
|
||||
})
|
||||
|
||||
test('访客只读时按钮保留但置灰(与既有 data.canWrite 同一口径)', () => {
|
||||
const cell = section(ENTRY_SOURCE, 'class="center action-cell"', '</tr>')
|
||||
assert.match(cell, /:disabled="!canWrite \|\| resultActionBusy"/)
|
||||
assert.match(cell, /\{\{ rowActionLabel\(row\) \}\}/)
|
||||
assert.match(cell, /@click="requestResultAction\(row\)"/)
|
||||
})
|
||||
|
||||
test('行能不能改只看服务端返回的 state:退回草稿后必须重新可编辑', () => {
|
||||
// 退回草稿(reopen)由服务端把 state 改回 draft;页面只有按 state 判定锁,行才会重新可编辑。
|
||||
assert.match(
|
||||
@@ -148,6 +194,26 @@ test('行能不能改只看服务端返回的 state:退回草稿后必须重
|
||||
)
|
||||
assert.match(ENTRY_SOURCE, /resultState: result\.state \?\? null|resultState: result\?\.state \?\? null/)
|
||||
assert.match(ENTRY_SOURCE, /:disabled="!canWrite \|\| rowLocked\(row\)"/)
|
||||
// 成绩格用 readonly 锁可点的已发布行:disabled 的表单控件收不到点击,也就弹不出确认框。
|
||||
assert.match(ENTRY_SOURCE, /:readonly="cellAction\(row\) !== null"/)
|
||||
assert.match(ENTRY_SOURCE, /@click="requestCellAction\(row\)"/)
|
||||
})
|
||||
|
||||
test('已发布行的悬停提示指向「点这一格 / 修改本条」这条出路,不再只说撤销发布', () => {
|
||||
const hint = section(ENTRY_SOURCE, 'function lockedHint(', '/**\n * 每行的行操作')
|
||||
// 点得动的格子(成绩格 / 状态格)说「点击这一格」。
|
||||
assert.match(
|
||||
hint,
|
||||
/'已发布:点击这一格可退回草稿修改,改完需重新发布本组;也可以点右侧「修改」或整组「撤销发布」'/,
|
||||
)
|
||||
// 名次 / 备注格点不动,提示只能指路,不能承诺「点这一格」。
|
||||
assert.match(hint, /'已发布:点成绩格或右侧「修改」退回草稿后可改,改完需重新发布本组'/)
|
||||
assert.match(hint, /重新录入/)
|
||||
assert.doesNotMatch(hint, /已发布,请先撤销发布/)
|
||||
// 成绩格的 title 走 resultTitle → lockedHint(row, true);名次 / 状态下拉 / 备注走不带 clickable 的那份。
|
||||
assert.match(ENTRY_SOURCE, /:title="resultTitle\(row\)"/)
|
||||
assert.equal(ENTRY_SOURCE.match(/:title="lockedHint\(row\)"/g)?.length, 3)
|
||||
assert.equal(ENTRY_SOURCE.match(/:title="lockedHint\(row, true\)"/g)?.length, 1)
|
||||
})
|
||||
|
||||
test('已作废的行有独立徽标,不会被显示成「已发布」', () => {
|
||||
@@ -164,6 +230,104 @@ test('表格多了一列「操作」,空状态行的 colspan 跟着改', () =>
|
||||
assert.doesNotMatch(ENTRY_SOURCE, /<td colspan="11">/)
|
||||
})
|
||||
|
||||
// ── 点格子改成绩:有反应,但没有静默副作用 ──────────────────────────────────
|
||||
|
||||
test('已发布行点格子只走「修改本条」:改错字的入口,绝不作废', () => {
|
||||
assert.equal(resultCellAction({ resultState: 'published', status: 'ok' }), 'modify')
|
||||
assert.equal(resultCellAction({ resultState: 'published', status: 'dnf' }), 'modify')
|
||||
// 「作废本条」是独立动作:格子点一万次也点不出 void,要作废只能点那个按钮。
|
||||
for (const status of ['ok', 'dns', 'dq', 'dnf', 'withdrawn'] as const) {
|
||||
assert.notEqual(resultCellAction({ resultState: 'published', status }), 'void')
|
||||
}
|
||||
})
|
||||
|
||||
test('草稿行与已作废行的格子点不动:各走各的路', () => {
|
||||
// 草稿行本来就能直接改,点格子不该弹窗。
|
||||
assert.equal(resultCellAction({ resultState: 'draft', status: 'ok' }), null)
|
||||
assert.equal(resultCellAction({ resultState: null, status: 'ok' }), null)
|
||||
// 已作废的行保持现状:出路是按钮上的「重新录入」。
|
||||
assert.equal(resultCellAction({ resultState: 'published', status: 'withdrawn' }), null)
|
||||
})
|
||||
|
||||
test('点格子先弹确认框:不直接改成绩,也不直接调接口', () => {
|
||||
const request = section(ENTRY_SOURCE, 'function requestCellAction(', 'async function confirmResultAction(')
|
||||
// 复用按钮那条路:确认框(含原因必填)、示例数据 / 只读 / 未保存拦截都跟着一起生效。
|
||||
assert.match(request, /const action = cellAction\(row\)/)
|
||||
assert.match(request, /resultActionTarget\(row, action\)/)
|
||||
assert.match(request, /requestResultAction\(target\)/)
|
||||
assert.doesNotMatch(request, /adminApi\./)
|
||||
assert.doesNotMatch(request, /resultText\s*=/)
|
||||
})
|
||||
|
||||
test('确认之后才真的调 reopen:点击路径里一次写接口都没有', () => {
|
||||
const request = section(ENTRY_SOURCE, 'function requestCellAction(', 'async function confirmResultAction(')
|
||||
assert.doesNotMatch(request, /reopenResult|voidResult/)
|
||||
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
||||
assert.match(confirm, /await adminApi\.reopenResult\(target\.resultId, \{ reason \}\)/)
|
||||
})
|
||||
|
||||
test('取消确认框不调接口、行状态一个字节都不动', () => {
|
||||
// 取消只清掉待办动作:退回草稿的接口绝不能提前发出去,否则「点一下格子又点取消」
|
||||
// 也会把一条已发布成绩悄悄从榜上退下来。整个函数体就这么两行。
|
||||
assert.match(
|
||||
ENTRY_SOURCE,
|
||||
/function cancelResultAction\(\): void \{\n if \(resultActionBusy\.value\) return\n resultAction\.value = null\n\}/,
|
||||
)
|
||||
const cancel = section(ENTRY_SOURCE, 'function cancelResultAction(', '/**\n * 把焦点落到')
|
||||
assert.doesNotMatch(cancel, /adminApi\.|allRows|resultState|reload|resultText/)
|
||||
})
|
||||
|
||||
test('退回草稿成功后把焦点落回那一格(作废不聚焦)', () => {
|
||||
const confirm = section(ENTRY_SOURCE, 'async function confirmResultAction(', 'function cancelResultAction(')
|
||||
assert.match(confirm, /if \(target\.action !== 'void'\) await focusResultCell\(target\.entryId\)/)
|
||||
const focus = section(ENTRY_SOURCE, 'async function focusResultCell(', 'function requestSplit(')
|
||||
// 整表刚被 reload 换过一轮,必须等 nextTick 才查得到新那一格的节点。
|
||||
assert.match(focus, /await nextTick\(\)/)
|
||||
assert.match(focus, /\[data-result-cell="\$\{entryId\}"\]/)
|
||||
assert.match(focus, /input\.focus\(\)/)
|
||||
assert.match(ENTRY_SOURCE, /:data-result-cell="row\.entryId"/)
|
||||
})
|
||||
|
||||
test('成绩格 / 状态格各有一个点击入口,并给出「点得动」的视觉暗示', () => {
|
||||
assert.equal(ENTRY_SOURCE.match(/@click="requestCellAction\(row\)"/g)?.length, 2)
|
||||
// 成绩格:readonly 收点击 + 动态挂可点样式;状态格:可点时直接换成带该类名的按钮。
|
||||
assert.match(ENTRY_SOURCE, /:readonly="cellAction\(row\) !== null"/)
|
||||
assert.match(ENTRY_SOURCE, /'is-locked-cell': cellAction\(row\) !== null/)
|
||||
assert.match(ENTRY_SOURCE, /class="mx-cell-input status-select is-locked-cell"/)
|
||||
// 光标 + 与草稿格(--mx-s3)不同的底色与描边:一眼看出这一格点得动。
|
||||
const style = section(ENTRY_SOURCE, '.is-locked-cell {', '.rank-auto {')
|
||||
assert.match(style, /cursor: pointer/)
|
||||
assert.match(style, /background: var\(--mx-s2\)/)
|
||||
assert.match(style, /border-color: var\(--mx-border\)/)
|
||||
})
|
||||
|
||||
test('已发布行的状态格是收得到点击的按钮;已作废行仍是那个点不动的下拉', () => {
|
||||
const cell = section(ENTRY_SOURCE, '<td class="status-cell">', '</td>')
|
||||
assert.match(cell, /v-if="cellAction\(row\) !== null"/)
|
||||
assert.match(cell, /<button[\s\S]*?@click="requestCellAction\(row\)"/)
|
||||
assert.match(cell, /STATUS_LABEL\[row\.status\]/)
|
||||
// v-else 分支照旧:草稿行的下拉可编辑,已作废行的下拉仍 disabled(格子点不动)。
|
||||
assert.match(cell, /<select[\s\S]*?v-model="row\.status"/)
|
||||
assert.match(cell, /<select[\s\S]*?:disabled="!canWrite \|\| rowLocked\(row\)"/)
|
||||
// 状态文案与下拉选项同源,不在两处各写一份。
|
||||
assert.match(ENTRY_SOURCE, /const STATUS_LABEL = Object\.fromEntries\(STATUS_OPTIONS\.map/)
|
||||
})
|
||||
|
||||
test('已作废行的成绩格保持原来的禁用,可点样式也不挂上去', () => {
|
||||
// cellDisabled = 锁着且点不动(已作废)→ 仍旧 disabled;可点的已发布行才走 readonly。
|
||||
assert.match(
|
||||
ENTRY_SOURCE,
|
||||
/function cellDisabled\(row: EditableRow\): boolean \{\n\s*return rowLocked\(row\) && cellAction\(row\) === null\n\}/,
|
||||
)
|
||||
assert.match(ENTRY_SOURCE, /:disabled="!canWrite \|\| cellDisabled\(row\)"/)
|
||||
})
|
||||
|
||||
test('点得动的格子不给成绩留静默后门:粘贴落在已发布行上直接返回', () => {
|
||||
// readonly 的格子照样会派发 paste 事件(只是不插入文本),放过去就会绕过确认框改掉这一条。
|
||||
const paste = section(ENTRY_SOURCE, 'function onPaste(', 'function moveFocusBy(')
|
||||
assert.match(paste, /if \(start === undefined \|\| rowLocked\(start\)\) return/)
|
||||
})
|
||||
|
||||
// ── ConfirmDialog 的必填原因 ────────────────────────────────────────────────
|
||||
|
||||
test('原因为空时不提交(emit confirm),只亮出提示', () => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import type { AggregateMode, Event, ScoreChannel, ScoringProfile } from '@/shared'
|
||||
import { computeEventScore, type ScorableRow, type ScoreContext } from '../api/scoring'
|
||||
|
||||
// 聚合通道的汇总口径(avg 平均 / sum 总和)在录入页预览引擎里的口径,
|
||||
// 逐条对齐服务端 internal/domain/scoring.go 的 scoreByAggregate(同款用例在 domain 包里也有)。
|
||||
|
||||
function makeEvent(overrides: Partial<Event> = {}): Event {
|
||||
return {
|
||||
id: 1,
|
||||
sportId: 1,
|
||||
code: 'TEAM_PULLUP',
|
||||
name: '团体引体向上',
|
||||
eventType: 'team',
|
||||
resultMode: 'auto',
|
||||
resultType: 'count',
|
||||
compare: 'desc',
|
||||
recordEligible: false,
|
||||
scored: true,
|
||||
awardMedals: true,
|
||||
multiplier: 1,
|
||||
status: 'pending',
|
||||
scheduledAt: null,
|
||||
venue: null,
|
||||
sortOrder: 1,
|
||||
remark: null,
|
||||
avgMode: 'valid',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function makeProfile(): ScoringProfile {
|
||||
return {
|
||||
id: 2,
|
||||
code: 'team_top6',
|
||||
name: '团体项目前六名',
|
||||
tieMode: 'skip',
|
||||
recordBonusMode: 'max',
|
||||
rules: [
|
||||
{ rank: 1, points: 15 },
|
||||
{ rank: 2, points: 10 },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
function makeChannel(aggregateMode: AggregateMode | undefined): ScoreChannel {
|
||||
return {
|
||||
id: 3,
|
||||
eventId: 1,
|
||||
scope: 'team',
|
||||
basis: 'aggregate',
|
||||
profileId: 2,
|
||||
sortOrder: 1,
|
||||
aggregateMode,
|
||||
countToTeam: true,
|
||||
}
|
||||
}
|
||||
|
||||
function makeRow(entryId: number, resultValue: number | null, teamId: number): ScorableRow {
|
||||
return { entryId, teamId, athleteId: entryId, resultValue, rank: null, status: 'ok', isTie: false }
|
||||
}
|
||||
|
||||
function context(
|
||||
aggregateMode: AggregateMode | undefined,
|
||||
avgMode: Event['avgMode'] = 'valid',
|
||||
): ScoreContext {
|
||||
return {
|
||||
event: makeEvent({ avgMode }),
|
||||
category: null,
|
||||
channels: [makeChannel(aggregateMode)],
|
||||
profiles: [makeProfile()],
|
||||
weight: 1,
|
||||
}
|
||||
}
|
||||
|
||||
/** 某个单位在这次计分里拿到的团体分(单位总分只来自这条聚合通道),按单位号排序后返回。 */
|
||||
function teamPoints(rows: ScorableRow[], aggregateMode: AggregateMode | undefined, avgMode: Event['avgMode'] = 'valid'): Array<[number, number]> {
|
||||
const points = computeEventScore(rows, context(aggregateMode, avgMode)).teamPoints
|
||||
return [...points.entries()].sort((a, b) => a[0] - b[0])
|
||||
}
|
||||
|
||||
test('各队人数相同时,avg 与 sum 的排名完全一致', () => {
|
||||
// 三队各 2 人:总和与平均只差一个共同的倍数,排名必然相同。
|
||||
const rows = [
|
||||
makeRow(1, 18, 100),
|
||||
makeRow(2, 20, 100),
|
||||
makeRow(3, 16, 101),
|
||||
makeRow(4, 14, 101),
|
||||
makeRow(5, 12, 102),
|
||||
makeRow(6, 10, 102),
|
||||
]
|
||||
// 100 队(平均 19 / 总和 38)第一,101 队(15 / 30)第二,102 队(11 / 22)第三(只录取前 2)。
|
||||
assert.deepEqual(teamPoints(rows, 'avg'), [
|
||||
[100, 15],
|
||||
[101, 10],
|
||||
[102, 0],
|
||||
])
|
||||
assert.deepEqual(teamPoints(rows, 'sum'), teamPoints(rows, 'avg'))
|
||||
})
|
||||
|
||||
test('各队人数不同时,avg 与 sum 的排名可能不同(现场必须明确选哪种)', () => {
|
||||
// 100 队 2 人(10 + 1,平均 5.5 / 总和 11),101 队 1 人(9,平均 9 / 总和 9)。
|
||||
const rows = [makeRow(1, 10, 100), makeRow(2, 1, 100), makeRow(3, 9, 101)]
|
||||
// 平均:101 队第一;总和:100 队第一。名次互换。
|
||||
assert.deepEqual(teamPoints(rows, 'avg'), [
|
||||
[100, 10],
|
||||
[101, 15],
|
||||
])
|
||||
assert.deepEqual(teamPoints(rows, 'sum'), [
|
||||
[100, 15],
|
||||
[101, 10],
|
||||
])
|
||||
})
|
||||
|
||||
test('缺省(不带 aggregateMode)就是 avg:老数据的口径一字不变', () => {
|
||||
const rows = [makeRow(1, 10, 100), makeRow(2, 1, 100), makeRow(3, 9, 101)]
|
||||
assert.deepEqual(teamPoints(rows, undefined), teamPoints(rows, 'avg'))
|
||||
// 与 sum 明显不同,证明确实落在 avg 一侧,而不是「怎么算都行」。
|
||||
assert.notDeepEqual(teamPoints(rows, undefined), teamPoints(rows, 'sum'))
|
||||
})
|
||||
|
||||
test('sum 只累加有效成绩:没成绩的人按 0 计,avgMode 只决定零成绩单位参不参与排名', () => {
|
||||
const rows = [makeRow(1, 10, 100), makeRow(2, 1, 100), makeRow(3, 9, 101)]
|
||||
for (const avgMode of ['valid', 'all'] as const) {
|
||||
// 100 队总和 11 第一,101 队总和 9 第二:加不加 0 都不改变和。
|
||||
assert.deepEqual(teamPoints(rows, 'sum', avgMode), [
|
||||
[100, 15],
|
||||
[101, 10],
|
||||
])
|
||||
}
|
||||
|
||||
// 一个人都没有效成绩的单位:valid 不参与排名(榜单上根本没有这一队)、all 按 0 参与——
|
||||
// 与平均口径同一条规则。
|
||||
const noScore = [makeRow(1, null, 100), makeRow(2, 9, 101)]
|
||||
assert.deepEqual(teamPoints(noScore, 'sum', 'valid'), [[101, 15]])
|
||||
assert.deepEqual(teamPoints(noScore, 'sum', 'all'), [
|
||||
[100, 10],
|
||||
[101, 15],
|
||||
])
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { parse } from 'vue/compiler-sfc'
|
||||
import {
|
||||
breakdownAthleteName,
|
||||
breakdownCategoryName,
|
||||
breakdownRevisionBadge,
|
||||
breakdownRevisionTitle,
|
||||
breakdownSourceLabel,
|
||||
type BreakdownItem,
|
||||
} from '@/shared'
|
||||
|
||||
/**
|
||||
* 管理端积分榜下钻明细的「运动员」列(与推送到大屏的覆盖层同一套口径)。
|
||||
*
|
||||
* 明细的意义是回答「单位这一分是谁在哪个项目挣的」:只有项目 / 名次 / 成绩 / 积分时,
|
||||
* 现场没法解释单位名字为什么升上去。数据服务端一直下发(BreakdownItem.athleteName),
|
||||
* 这里钉住页面上确实把它显示成一列,且团体流水(athleteName 为空)降级成单位名。
|
||||
*
|
||||
* `.vue` 跑不进 node --test,沿用仓库做法:模板源码契约 + 纯函数用例。
|
||||
*/
|
||||
|
||||
const PAGE_SOURCE = readFileSync(new URL('../pages/StandingsPage.vue', import.meta.url), 'utf8')
|
||||
|
||||
/** 取模板里以某个固定串开头、到下一个 </div> 或 </li> 为止的片段(这些行里没有嵌套块)。 */
|
||||
function block(startPattern: RegExp, endTag: string): string {
|
||||
const found = startPattern.exec(PAGE_SOURCE)
|
||||
assert.ok(found !== null, `模板里应有 ${startPattern}`)
|
||||
const rest = PAGE_SOURCE.slice(found.index)
|
||||
const end = rest.indexOf(endTag)
|
||||
assert.ok(end > 0, `行片段应以 ${endTag} 结束`)
|
||||
return rest.slice(0, end)
|
||||
}
|
||||
|
||||
const HEAD_ROW = block(/<div v-if="activeBreakdown\.items\.length > 0" class="detail-item detail-head">/, '</div>')
|
||||
const BODY_ROW = block(/<li v-for="item in activeBreakdown\.items" :key="item\.ledgerId" class="detail-item">/, '</li>')
|
||||
|
||||
function item(over: Partial<BreakdownItem> = {}): BreakdownItem {
|
||||
return {
|
||||
ledgerId: 1,
|
||||
sourceType: 'result',
|
||||
eventId: 1,
|
||||
eventCode: 'M100',
|
||||
eventName: '100米跑',
|
||||
categoryName: '男子',
|
||||
athleteId: 7,
|
||||
athleteName: '张伟',
|
||||
rank: 1,
|
||||
resultText: '11.58',
|
||||
points: 10,
|
||||
createdAt: '2026-09-13 10:00:00',
|
||||
revisionCount: null,
|
||||
lastRevisionAt: null,
|
||||
lastRevisionReason: null,
|
||||
lastRevisedBy: null,
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
function styleText(source: string): string {
|
||||
const { descriptor } = parse(source, { filename: 'style.vue' })
|
||||
return descriptor.styles[0]?.content ?? ''
|
||||
}
|
||||
|
||||
test('明细表有列头,依次是项目/组别 · 运动员 · 来源 · 成绩 · 名次 · 积分', () => {
|
||||
const labels = [...HEAD_ROW.matchAll(/<span>([^<]*)<\/span>/g)].map((match) => (match[1] ?? '').trim())
|
||||
assert.deepEqual(labels, ['项目 / 组别', '运动员', '来源', '成绩', '名次', '积分'])
|
||||
})
|
||||
|
||||
test('明细行渲染运动员格,绑定团队共用的降级函数', () => {
|
||||
assert.match(BODY_ROW, /<span class="detail-athlete"[^>]*>[\s\S]*breakdownAthleteName\(item, activeBreakdown\.teamName\)/)
|
||||
// 悬停能看到被省略号截掉的完整姓名 / 单位名。
|
||||
assert.match(BODY_ROW, /:title="breakdownAthleteName\(item, activeBreakdown\.teamName\)"/)
|
||||
})
|
||||
|
||||
test('项目 / 组别 / 成绩 / 名次 / 积分照旧,只是旁边多了一列', () => {
|
||||
assert.match(BODY_ROW, /\{\{ item\.eventName \?\? '—' \}\}/)
|
||||
assert.match(BODY_ROW, /breakdownCategoryName\(item\)/)
|
||||
assert.match(BODY_ROW, /\{\{ item\.resultText \?\? '—' \}\}/)
|
||||
assert.match(BODY_ROW, /第 \{\{ item\.rank \?\? '—' \}\} 名/)
|
||||
assert.match(BODY_ROW, /\+{{ formatPoints\(item\.points\) \}\}/)
|
||||
})
|
||||
|
||||
test('个人流水那一行看得到是谁;四个老格子照旧', () => {
|
||||
const row = item()
|
||||
const rendered = [
|
||||
row.eventName ?? '—',
|
||||
breakdownCategoryName(row),
|
||||
breakdownAthleteName(row, '第一分公司'),
|
||||
row.resultText ?? '—',
|
||||
row.rank === null ? '—' : `第 ${row.rank} 名`,
|
||||
row.points,
|
||||
]
|
||||
assert.deepEqual(rendered, ['100米跑', '男子', '张伟', '11.58', '第 1 名', 10])
|
||||
})
|
||||
|
||||
test('团体项目流水没有运动员名:降级成单位名,不出现空白格或 null', () => {
|
||||
const cell = breakdownAthleteName(item({ athleteName: null, athleteId: null }), '第一分公司')
|
||||
assert.equal(cell, '第一分公司')
|
||||
assert.doesNotMatch(cell, /null|undefined/)
|
||||
})
|
||||
|
||||
test('明细卡放得下五列:加列之后侧栏放宽到 ≥ 400px', () => {
|
||||
const grid = /grid-template-columns:\s*minmax\(0, 1fr\) (\d+)px/.exec(styleText(PAGE_SOURCE))
|
||||
assert.ok(grid !== null, '.standings-grid 应声明「主表 + 明细侧栏」两列')
|
||||
assert.ok(Number(grid[1]) >= 400, `明细侧栏 ${grid[1]}px 放不下五列`)
|
||||
})
|
||||
|
||||
/**
|
||||
* 「来源」列与「已修订」角标:现场裁判长问的第二件事是「这几分怎么来的、裁判是不是改过」。
|
||||
*
|
||||
* 来源列与导出的「来源」列同一套词;角标只在真被改过时出现,时间 / 原因 / 操作人塞进悬停。
|
||||
*/
|
||||
test('明细行有来源列,绑的是与导出同一套词的 breakdownSourceLabel', () => {
|
||||
assert.match(BODY_ROW, /class="detail-source"[\s\S]*breakdownSourceLabel\(item\)/)
|
||||
assert.equal(breakdownSourceLabel(item()), '成绩')
|
||||
assert.equal(breakdownSourceLabel(item({ sourceType: 'adjustment', eventName: null })), '人工调整')
|
||||
assert.equal(breakdownSourceLabel(item({ sourceType: 'record_bonus' })), '破纪录加分')
|
||||
})
|
||||
|
||||
test('改过的成绩行尾有角标,悬停给出时间 + 原因 + 操作人', () => {
|
||||
assert.match(BODY_ROW, /v-if="breakdownRevisionBadge\(item\)"/)
|
||||
assert.match(BODY_ROW, /class="detail-revision"/)
|
||||
assert.match(BODY_ROW, /:title="breakdownRevisionTitle\(item\)"/)
|
||||
})
|
||||
|
||||
test('角标只在 >0 时渲染;没改过 / 追不出来时整行连元素都没有', () => {
|
||||
// v-if 的判据是空串:0 与 null 都渲染不出角标,也就不会出现「其实没改过却标了已修订」。
|
||||
assert.equal(breakdownRevisionBadge(item()), '')
|
||||
assert.equal(breakdownRevisionBadge(item({ revisionCount: 0 })), '')
|
||||
assert.equal(breakdownRevisionBadge(item({ revisionCount: null })), '')
|
||||
assert.equal(breakdownRevisionBadge(item({ revisionCount: 2 })), '已修订 ×2')
|
||||
assert.equal(
|
||||
breakdownRevisionTitle(
|
||||
item({ revisionCount: 2, lastRevisionAt: '2026-09-13 10:42:00', lastRevisionReason: '成绩录入笔误' }),
|
||||
).includes('已修订 ×2(成绩录入笔误)'),
|
||||
true,
|
||||
)
|
||||
assert.equal(breakdownRevisionTitle(item()), '')
|
||||
})
|
||||
|
||||
test('明细卡七条轨道都放得下:侧栏不放宽到 400 以下', () => {
|
||||
const grid = /grid-template-columns:\s*minmax\(0, 1fr\) (\d+)px/.exec(styleText(PAGE_SOURCE))
|
||||
assert.ok(grid !== null, '.standings-grid 应声明「主表 + 明细侧栏」两列')
|
||||
assert.ok(Number(grid[1]) >= 400, `明细侧栏 ${grid[1]}px 放不下明细列`)
|
||||
// 行内栅格:一条轨道对应一列(项目/组别、运动员、来源、成绩、名次、积分、修订角标)。
|
||||
const tracks = /grid-template-columns:\s*(minmax\(0,\s*1\.1fr\)[^;]+);/.exec(styleText(PAGE_SOURCE))
|
||||
assert.ok(tracks !== null, '.detail-item 应声明行内栅格')
|
||||
const parts = (tracks[1] ?? '').trim().split(/\s+(?![^(]*\))/)
|
||||
assert.equal(parts.length, 7, `行内轨道数应为 7:${tracks[1]}`)
|
||||
})
|
||||
Reference in new issue
Block a user