feat: 8 条功能 + 身份口径改为号码布为准(含批量退赛/移出、基础数据导入导出、登录有效期)
用户给出的 8 条功能 + 一条口径变更,全部带真机/真浏览器验证:
① 录入页姓名后直接显示号码布(原「号码」列保留)
② 录入页批次进度条:`第 2 批 已录 5/8 · 3 条有错`,录完变绿;已录判据复用
entryGuards.judgeEntryRow(与发布防呆同源,不会出现"进度条说录完、发布说缺 8 条")
⑦ 小项列表双击 → 跳到该小项的组别(编辑/删除按钮不误触)
③ 赛事开关 `standings.live_on_publish`(默认开=保持原行为):关闭后分批发布只上屏成绩,
名次/流水/榜单一起等该组录完再结算 —— 代理实读代码后纠正了我的前提:
奖牌榜读的是 results.rank(buildRanked)而非流水汇总,只延迟流水会出现
「有金牌但 0 分」。另提供 POST /api/results/settle 人工兜底 + GET /api/results/settlement 未结算清单
④ 组别「就地更新」PUT /api/events/{id}/categories/{categoryId}:有报名数据时也能改名/改年龄/
改性别/改权重(旧路径是整表替换,一改就被 409 拦下);改名不动报名/成绩/流水(真机逐字节比对);
权重真的变了才触发该组重算(容差 1e-9)
④辅 运动员列表多选+全选,POST /api/athletes/batch 一次请求完成整批停用/彻底删除(逐条回报,
dryRun 预览走同一段 SQL 但不提交、不写审计)——这才是绕开限流的正解
新增 报名项批量:POST /api/entries/batch(scratch/restore/remove)。退赛只动 registered、
跳过 dq/dns 并说明原因;移出是物理删除,必须显式清 score_ledger(该表 entry_id 是
ON DELETE SET NULL,不显式删会留下"人没了分还在"的孤儿流水,验证基线 orphan=0);
退赛同时清 results.rank(否则出现"0 分还挂着金牌")
⑤ GET /api/export/entries.xlsx 报名名单导出(13 列含批次,状态中文与录入页逐字一致)
⑥ 基础数据包:GET /api/export/basedata.xlsx + POST /api/export/basedata/import
(四张表、表头只有一份定义、表头行自动定位、列缺席≠写空、按编码 upsert、绝不删除、
行级中文错误带真实 Excel 行号、枚举走既有校验、新增单位一定是启用)
⑧ 登录有效期面板可配:系统设置新页签,1–720 小时,默认 12h → 24h;滑动续期 +
Cookie 同步刷新(只服务端续期是假的,浏览器侧的 Max-Age 到点仍会丢 Cookie)
身份口径:号码布为唯一标识、证件号退出匹配(号码布不同=同名的两个人,新建并单独报告);
同一份表重复证件号不再报错;两个不同的人共用证件号仍报行级错误(唯一索引保留)
其它对齐:分项成绩表草稿行不再消失(成绩列「分数未录入」、名次/积分 `—`,没成绩的行同占位);
大屏实时赛况单栏铺满(留白 370→78px)、字号上限 56→96px、≥7 行自动拆左右两块;
删除报名项/退赛/权重等改动都会重算并推榜单。
测试:服务端 13 包(连跑两遍)、前端 335 例、vue-tsc + build、Console 18 文件 —— 全绿。
文档:README、docs/ 四份(含 debug/使用说明 副本)、devdocs 05/08/04/13。
版本号 dev_0.1.5 → dev_0.1.6。
This commit is contained in:
1 parent
8dd599bd97
commit
631c8dd8e1
80 files changed
+12468
-519
No files matched your search
@@ -0,0 +1,135 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
/**
|
||||
* 运动员批量动作的前端契约。
|
||||
*
|
||||
* 这是绕开写限流的正解:逐个 DELETE 是「一人一条请求」,现场清理几十个人就会撞上配额;
|
||||
* 因此这里钉住三件事——一个请求完成整批、逐条结果按顺序保留、缺失的逐条失败不能变成整批成功。
|
||||
*/
|
||||
|
||||
interface Captured {
|
||||
url?: string
|
||||
method?: string
|
||||
body?: string
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
let reply: () => Response = () => jsonResponse({ ok: true, data: { items: [] } })
|
||||
let lastRequest: Captured = {}
|
||||
|
||||
// 必须在导入被测模块之前装好:ApiClient 构造时就把当时的 fetch 绑进实例。
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
lastRequest = {
|
||||
url: String(input),
|
||||
method: init?.method ?? 'GET',
|
||||
body: typeof init?.body === 'string' ? init.body : undefined,
|
||||
}
|
||||
return reply()
|
||||
}) as typeof fetch
|
||||
|
||||
function respondWith(response: () => Response): void {
|
||||
lastRequest = {}
|
||||
reply = response
|
||||
}
|
||||
|
||||
/** 写请求体里总会被注入 requestId(幂等回放用),比较字段时先摘掉它。 */
|
||||
function requestBody(): Record<string, unknown> {
|
||||
const parsed: Record<string, unknown> = JSON.parse(lastRequest.body ?? '{}')
|
||||
delete parsed['requestId']
|
||||
return parsed
|
||||
}
|
||||
|
||||
const { adminApi } = await import('../api/client')
|
||||
|
||||
function item(id: number, ok = true, extra: Record<string, unknown> = {}): Record<string, unknown> {
|
||||
return { id, ok, ...extra }
|
||||
}
|
||||
|
||||
test('批量停用是一个请求:POST /api/athletes/batch,body 带 action 与 ids', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: { action: 'disable', items: [item(1), item(2)] } }))
|
||||
|
||||
await adminApi.client.athletes.batch('disable', [1, 2])
|
||||
|
||||
assert.equal(lastRequest.url, '/api/athletes/batch')
|
||||
assert.equal(lastRequest.method, 'POST')
|
||||
assert.deepEqual(requestBody(), { action: 'disable', ids: [1, 2] })
|
||||
})
|
||||
|
||||
test('dryRun 才显式带 dryRun: true,普通请求不带(老服务端不能被静默降级成预览)', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: { action: 'purge', dryRun: true, items: [item(1)] } }))
|
||||
|
||||
const preview = await adminApi.client.athletes.batch('purge', [1], true)
|
||||
assert.deepEqual(requestBody(), { action: 'purge', ids: [1], dryRun: true })
|
||||
assert.equal(preview.dryRun, true)
|
||||
|
||||
respondWith(() => jsonResponse({ ok: true, data: { action: 'purge', items: [item(1)] } }))
|
||||
await adminApi.client.athletes.batch('purge', [1])
|
||||
assert.deepEqual(requestBody(), { action: 'purge', ids: [1] })
|
||||
})
|
||||
|
||||
test('逐条成败与合计按 items 还原,purge 统计原样带出', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
data: {
|
||||
action: 'purge',
|
||||
requested: 3,
|
||||
succeeded: 2,
|
||||
failed: 1,
|
||||
items: [item(1), item(2), item(99, false, { code: 'NOT_FOUND', message: '运动员不存在' })],
|
||||
purge: {
|
||||
deletedEntries: 2,
|
||||
deletedResults: 2,
|
||||
deletedLedger: 3,
|
||||
deletedBreaks: 0,
|
||||
recomputedEvents: [5],
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await adminApi.client.athletes.batch('purge', [1, 2, 99])
|
||||
|
||||
assert.equal(result.requested, 3)
|
||||
assert.equal(result.succeeded, 2)
|
||||
assert.equal(result.failed, 1)
|
||||
assert.deepEqual(result.items[2], { id: 99, ok: false, code: 'NOT_FOUND', message: '运动员不存在' })
|
||||
assert.deepEqual(result.purge, {
|
||||
deletedEntries: 2,
|
||||
deletedResults: 2,
|
||||
deletedLedger: 3,
|
||||
deletedBreaks: 0,
|
||||
recomputedEvents: [5],
|
||||
})
|
||||
})
|
||||
|
||||
test('逐条结果缺失时按失败处理:删不掉不能报成删掉了', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: { action: 'purge' } }))
|
||||
|
||||
const result = await adminApi.client.athletes.batch('purge', [1, 2])
|
||||
|
||||
assert.deepEqual(result.items, [])
|
||||
assert.equal(result.succeeded, 0)
|
||||
assert.equal(result.failed, 0)
|
||||
assert.equal(result.purge, undefined)
|
||||
})
|
||||
|
||||
test('响应里没有 ok: true 的条目按失败处理,不猜成成功', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({ ok: true, data: { action: 'disable', items: [{ id: 1 }, { id: 2, ok: true }] } }),
|
||||
)
|
||||
|
||||
const result = await adminApi.client.athletes.batch('disable', [1, 2])
|
||||
|
||||
assert.equal(result.succeeded, 1)
|
||||
assert.equal(result.failed, 1)
|
||||
assert.equal(result.items[0]?.ok, false)
|
||||
assert.equal(result.items[1]?.ok, true)
|
||||
})
|
||||
@@ -0,0 +1,202 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { ApiError } from '@/shared'
|
||||
import {
|
||||
downloadBaseDataXLSX,
|
||||
downloadEntriesXLSX,
|
||||
entriesExportPath,
|
||||
importBaseDataXLSX,
|
||||
} from '@/shared/basedata'
|
||||
|
||||
/**
|
||||
* 报名名单导出与基础数据包导入的前端契约:路径、筛选参数、dryRun 标志、解析出的差异。
|
||||
*
|
||||
* 路径或参数写错时现场拿到的是 404 或「筛了却导出全量」;dryRun 标志丢了则会
|
||||
* 让「预览差异」直接写库。这里用 stub fetch 钉住真正发出去的请求。
|
||||
*/
|
||||
|
||||
interface Captured {
|
||||
url?: string
|
||||
method?: string
|
||||
body?: unknown
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function xlsxResponse(): Response {
|
||||
return new Response(new Blob([new Uint8Array([0x50, 0x4b, 0x03, 0x04])]), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' },
|
||||
})
|
||||
}
|
||||
|
||||
let reply: () => Response = () => xlsxResponse()
|
||||
let lastRequest: Captured = {}
|
||||
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
lastRequest = { url: String(input), method: init?.method ?? 'GET', body: init?.body }
|
||||
return reply()
|
||||
}) as typeof fetch
|
||||
|
||||
function respondWith(next: () => Response): void {
|
||||
lastRequest = {}
|
||||
reply = next
|
||||
}
|
||||
|
||||
function formValue(body: unknown, key: string): FormDataEntryValue | null {
|
||||
assert.ok(body instanceof FormData, '上传必须是 multipart FormData')
|
||||
return body.get(key)
|
||||
}
|
||||
|
||||
test('报名名单导出路径按筛选条件拼 query,留空的维度不出现', () => {
|
||||
assert.equal(entriesExportPath(), '/export/entries.xlsx')
|
||||
assert.equal(entriesExportPath({ eventId: 3 }), '/export/entries.xlsx?eventId=3')
|
||||
assert.equal(
|
||||
entriesExportPath({ eventId: 3, categoryId: 9, teamId: 1, heat: 2 }),
|
||||
'/export/entries.xlsx?eventId=3&categoryId=9&teamId=1&heat=2',
|
||||
)
|
||||
})
|
||||
|
||||
test('导出报名名单走 GET /api/export/entries.xlsx,文件名是「报名名单.xlsx」', async () => {
|
||||
respondWith(() => xlsxResponse())
|
||||
|
||||
const result = await downloadEntriesXLSX({ eventId: 3, heat: 2 })
|
||||
|
||||
assert.equal(lastRequest.url, '/api/export/entries.xlsx?eventId=3&heat=2')
|
||||
assert.equal(lastRequest.method, 'GET')
|
||||
assert.equal(result.filename, '报名名单.xlsx')
|
||||
assert.equal(result.blob.size, 4)
|
||||
})
|
||||
|
||||
test('导出基础数据包走 GET /api/export/basedata.xlsx,文件名是「基础数据包.xlsx」', async () => {
|
||||
respondWith(() => xlsxResponse())
|
||||
|
||||
const result = await downloadBaseDataXLSX()
|
||||
|
||||
assert.equal(lastRequest.url, '/api/export/basedata.xlsx')
|
||||
assert.equal(result.filename, '基础数据包.xlsx')
|
||||
})
|
||||
|
||||
test('预览差异带 dryRun=1,结果里的三类计数与逐行差异都解析出来', async () => {
|
||||
respondWith(() => jsonResponse({
|
||||
ok: true,
|
||||
data: {
|
||||
dryRun: true,
|
||||
added: 1,
|
||||
updated: 2,
|
||||
unchanged: 5,
|
||||
errorCount: 1,
|
||||
errors: ['单位 第 3 行:单位编码必填'],
|
||||
changes: [
|
||||
{
|
||||
sheet: '单位',
|
||||
line: 2,
|
||||
action: 'updated',
|
||||
key: 'T1',
|
||||
fields: [{ field: '单位名称', from: '第一分公司', to: '第一分公司(改)' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
}))
|
||||
|
||||
const file = new File(['x'], 'basedata.xlsx')
|
||||
const result = await importBaseDataXLSX(file, { dryRun: true })
|
||||
|
||||
assert.equal(lastRequest.url, '/api/export/basedata/import')
|
||||
assert.equal(lastRequest.method, 'POST')
|
||||
assert.equal(formValue(lastRequest.body, 'dryRun'), '1')
|
||||
assert.equal(formValue(lastRequest.body, 'file'), file)
|
||||
assert.equal(result.dryRun, true)
|
||||
assert.equal(result.added, 1)
|
||||
assert.equal(result.updated, 2)
|
||||
assert.equal(result.unchanged, 5)
|
||||
assert.equal(result.errorCount, 1)
|
||||
assert.deepEqual(result.errors, ['单位 第 3 行:单位编码必填'])
|
||||
assert.equal(result.changes.length, 1)
|
||||
assert.equal(result.changes[0].sheet, '单位')
|
||||
assert.equal(result.changes[0].line, 2)
|
||||
assert.equal(result.changes[0].fields[0].field, '单位名称')
|
||||
assert.equal(result.changes[0].fields[0].from, '第一分公司')
|
||||
assert.equal(result.changes[0].fields[0].to, '第一分公司(改)')
|
||||
})
|
||||
|
||||
test('确认写入不带 dryRun,避免预览之后又走一次只读分支', async () => {
|
||||
respondWith(() => jsonResponse({
|
||||
ok: true,
|
||||
data: { dryRun: false, added: 3, updated: 0, unchanged: 0, errorCount: 0, errors: [], changes: [] },
|
||||
}))
|
||||
|
||||
const result = await importBaseDataXLSX(new File(['x'], 'basedata.xlsx'), { dryRun: false })
|
||||
|
||||
assert.equal(formValue(lastRequest.body, 'dryRun'), null)
|
||||
assert.equal(result.dryRun, false)
|
||||
assert.equal(result.added, 3)
|
||||
})
|
||||
|
||||
test('服务端 400 的行级错误原样抛出,页面能拿到那句中文', async () => {
|
||||
respondWith(() => jsonResponse({
|
||||
ok: false,
|
||||
error: { code: 'VALIDATION_FAILED', message: '导出链接参数不对' },
|
||||
}, 400))
|
||||
|
||||
await assert.rejects(
|
||||
() => downloadEntriesXLSX({ heat: 0 }),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.status, 400)
|
||||
assert.equal(error.code, 'VALIDATION_FAILED')
|
||||
assert.equal(error.message, '导出链接参数不对')
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('导入 403 / 500 原样抛出,不降级成示例数据', async () => {
|
||||
respondWith(() => jsonResponse({
|
||||
ok: false,
|
||||
error: { code: 'FORBIDDEN', message: '只有管理员可以导入基础数据' },
|
||||
}, 403))
|
||||
|
||||
await assert.rejects(
|
||||
() => importBaseDataXLSX(new File(['x'], 'basedata.xlsx')),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, 'FORBIDDEN')
|
||||
assert.equal(error.message, '只有管理员可以导入基础数据')
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('响应不是 {ok,data} 结构时报解析错误,而不是假装导入成功', async () => {
|
||||
respondWith(() => jsonResponse({ hello: 'world' }))
|
||||
|
||||
await assert.rejects(
|
||||
() => importBaseDataXLSX(new File(['x'], 'basedata.xlsx')),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, 'INTERNAL')
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('连不上服务端时报 NETWORK,页面据此区分「后端未就绪」', async () => {
|
||||
respondWith(() => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
|
||||
await assert.rejects(
|
||||
() => downloadEntriesXLSX(),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, 'NETWORK')
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
/**
|
||||
* 组别「就地更新」的前端契约:方法 / 路径 / 请求体,以及响应是否按组别列表回传。
|
||||
*
|
||||
* 这条路径是有报名数据的小项唯一能改名的通道,路径拼错(比如漏掉 categoryId)
|
||||
* 会静默走回整表替换的 409,现场只会看到「已有报名数据不能增删组别」。
|
||||
*/
|
||||
|
||||
interface Captured {
|
||||
url?: string
|
||||
method?: string
|
||||
body?: string
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
let reply: () => Response = () => jsonResponse({ ok: true, data: [] })
|
||||
let lastRequest: Captured = {}
|
||||
|
||||
// 必须在导入被测模块之前装好:ApiClient 构造时就把当时的 fetch 绑进实例。
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
lastRequest = {
|
||||
url: String(input),
|
||||
method: init?.method ?? 'GET',
|
||||
body: typeof init?.body === 'string' ? init.body : undefined,
|
||||
}
|
||||
return reply()
|
||||
}) as typeof fetch
|
||||
|
||||
function respondWith(response: () => Response): void {
|
||||
lastRequest = {}
|
||||
reply = response
|
||||
}
|
||||
|
||||
/** 写请求体里总会被注入 requestId(幂等回放用),比较字段时先摘掉它。 */
|
||||
function requestBody(): Record<string, unknown> {
|
||||
const parsed: Record<string, unknown> = JSON.parse(lastRequest.body ?? '{}')
|
||||
delete parsed['requestId']
|
||||
return parsed
|
||||
}
|
||||
|
||||
const { adminApi } = await import('../api/client')
|
||||
|
||||
const input = {
|
||||
name: '男子甲组',
|
||||
multiplier: 2,
|
||||
gender: '男' as const,
|
||||
ageMin: 18,
|
||||
ageMax: 35,
|
||||
sortOrder: 1,
|
||||
}
|
||||
|
||||
test('就地更新走 PUT /api/events/{id}/categories/{categoryId},请求体是单个组别', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: [] }))
|
||||
|
||||
await adminApi.client.events.updateCategory(7, 12, input)
|
||||
|
||||
assert.equal(lastRequest.url, '/api/events/7/categories/12')
|
||||
assert.equal(lastRequest.method, 'PUT')
|
||||
assert.deepEqual(requestBody(), input)
|
||||
})
|
||||
|
||||
test('响应按组别列表回传,直接拿到更新后的名字', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
data: [
|
||||
{ id: 12, eventId: 7, name: '男子甲组', multiplier: 2, gender: '男', ageMin: 18, ageMax: 35, sortOrder: 1 },
|
||||
],
|
||||
}),
|
||||
)
|
||||
|
||||
const categories = await adminApi.client.events.updateCategory(7, 12, input)
|
||||
|
||||
assert.equal(categories.length, 1)
|
||||
assert.equal(categories[0]?.name, '男子甲组')
|
||||
assert.equal(categories[0]?.multiplier, 2)
|
||||
})
|
||||
|
||||
test('服务端 409 的原文要能被页面拿到(提示里那句话是现场唯一的线索)', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({ ok: false, error: { code: 'CONFLICT', message: '组别名称重复:男子' } }, 409),
|
||||
)
|
||||
|
||||
await assert.rejects(
|
||||
() => adminApi.client.events.updateCategory(7, 12, input),
|
||||
(error: unknown) => (error as { message?: string }).message === '组别名称重复:男子',
|
||||
)
|
||||
})
|
||||
|
||||
test('服务端不可达时,组别就地更新与运动员批量都降级到示例数据而不是抛错', async () => {
|
||||
const { baseDataApi, baseDataFallback } = await import('../components/baseData/baseDataApi')
|
||||
const { mockBaseData } = await import('../components/baseData/mockBaseData')
|
||||
// 断网:fetch 直接抛,客户端归成 NETWORK,baseDataApi 据此降级到示例数据。
|
||||
respondWith(() => {
|
||||
throw new TypeError('Failed to fetch')
|
||||
})
|
||||
|
||||
const event = mockBaseData.listEvents()[0]
|
||||
assert.ok(event !== undefined)
|
||||
const category = (mockBaseData.getEvent(event.id).categories ?? [])[0]
|
||||
assert.ok(category !== undefined)
|
||||
const categories = await baseDataApi.events.updateCategory(event.id, category.id, {
|
||||
...input,
|
||||
name: '降级改名',
|
||||
})
|
||||
assert.equal(categories.find((item) => item.id === category.id)?.name, '降级改名')
|
||||
assert.equal(baseDataFallback.value, true)
|
||||
|
||||
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)
|
||||
})
|
||||
@@ -0,0 +1,149 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { ApiError } from '@/shared'
|
||||
import { ENTRY_BATCH_PATH, submitEntryBatch } from '../pages/entryBatch'
|
||||
|
||||
/**
|
||||
* 报名项批量操作的请求契约:方法 / 路径 / 请求体字段必须与服务端
|
||||
* internal/httpapi/entries_batch.go 的 entryBatchPayload 一致;
|
||||
* 写请求漏掉 X-CSRF-Token 会被中间件 403,现场只会看到「删不掉」。
|
||||
*/
|
||||
|
||||
interface Captured {
|
||||
url?: string
|
||||
method?: string
|
||||
headers?: Record<string, string>
|
||||
body?: unknown
|
||||
credentials?: string
|
||||
}
|
||||
|
||||
let lastRequest: Captured = {}
|
||||
let reply: () => Response = () => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } })
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function emptySummary() {
|
||||
return {
|
||||
ok: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
changed: 0,
|
||||
removedEntries: 0,
|
||||
removedResults: 0,
|
||||
removedLedger: 0,
|
||||
removedBreaks: 0,
|
||||
recomputedEvents: [],
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
test('批量操作走 POST /api/entries/batch,body 带 action + ids', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
data: {
|
||||
items: [{ id: 1, ok: true }],
|
||||
summary: { ...emptySummary(), ok: 1, changed: 1, recomputedEvents: [3] },
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
const result = await submitEntryBatch({ action: 'scratch', ids: [1, 1, 2] })
|
||||
|
||||
assert.equal(lastRequest.url, ENTRY_BATCH_PATH)
|
||||
assert.equal(lastRequest.method, 'POST')
|
||||
assert.equal(lastRequest.credentials, 'include')
|
||||
assert.equal((lastRequest.body as { action: string }).action, 'scratch')
|
||||
assert.deepEqual((lastRequest.body as { ids: number[] }).ids, [1, 1, 2])
|
||||
// 写请求必须有 requestId(幂等)与 JSON 内容类型。
|
||||
assert.equal(typeof (lastRequest.body as { requestId?: unknown }).requestId, 'string')
|
||||
assert.equal(lastRequest.headers?.['Content-Type'], 'application/json; charset=utf-8')
|
||||
assert.deepEqual(result.summary.recomputedEvents, [3])
|
||||
})
|
||||
|
||||
// 已登录写请求必须带 CSRF:漏掉就是 403,退赛 / 移出都不会生效。
|
||||
test('带 csrf_token Cookie 时补上 X-CSRF-Token', async () => {
|
||||
const original = (globalThis as { document?: unknown }).document
|
||||
;(globalThis as { document?: unknown }).document = { cookie: 'a=1; csrf_token=tok-123; b=2' }
|
||||
try {
|
||||
respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }))
|
||||
await submitEntryBatch({ action: 'restore', ids: [2] })
|
||||
assert.equal(lastRequest.headers?.['X-CSRF-Token'], 'tok-123')
|
||||
} finally {
|
||||
if (original === undefined) delete (globalThis as { document?: unknown }).document
|
||||
else (globalThis as { document?: unknown }).document = original
|
||||
}
|
||||
})
|
||||
|
||||
test('没有 document(非浏览器环境)时不带 CSRF,也不报错', async () => {
|
||||
respondWith(() => jsonResponse({ ok: true, data: { items: [], summary: emptySummary() } }))
|
||||
await submitEntryBatch({ action: 'remove', ids: [3] })
|
||||
assert.equal(lastRequest.headers?.['X-CSRF-Token'], undefined)
|
||||
})
|
||||
|
||||
// 服务端的中文原文要原样传给页面;批量里的逐条失败也在 data 里,不能被当成请求失败。
|
||||
test('服务端报错时原样抛出 code 与中文 message', async () => {
|
||||
respondWith(() => jsonResponse({ ok: false, error: { code: 'VALIDATION_FAILED', message: '一次最多处理 500 条报名项,请分批提交' } }, 400))
|
||||
await assert.rejects(
|
||||
() => submitEntryBatch({ action: 'remove', ids: [1] }),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, 'VALIDATION_FAILED')
|
||||
assert.match(error.message, /分批提交/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
test('逐条失败(ok:false)仍算成功响应,由页面逐条提示', async () => {
|
||||
respondWith(() =>
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
data: {
|
||||
items: [
|
||||
{ id: 1, ok: true },
|
||||
{ id: 999999, ok: false, error: '报名项不存在' },
|
||||
],
|
||||
summary: { ...emptySummary(), ok: 1, failed: 1 },
|
||||
},
|
||||
}),
|
||||
)
|
||||
const result = await submitEntryBatch({ action: 'remove', ids: [1, 999999] })
|
||||
assert.equal(result.summary.failed, 1)
|
||||
assert.equal(result.items[1]?.error, '报名项不存在')
|
||||
})
|
||||
|
||||
test('网络不可达时报「没有生效」,不能让操作员以为已经删掉了', async () => {
|
||||
respondWith(() => {
|
||||
throw new TypeError('fetch failed')
|
||||
})
|
||||
await assert.rejects(
|
||||
() => submitEntryBatch({ action: 'remove', ids: [1] }),
|
||||
(error: unknown) => {
|
||||
assert.ok(error instanceof ApiError)
|
||||
assert.equal(error.code, 'NETWORK')
|
||||
assert.match(error.message, /没有生效/)
|
||||
return true
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,117 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import {
|
||||
MAX_ENTRY_BATCH,
|
||||
dedupeEntryIds,
|
||||
entryBatchOutcome,
|
||||
entryBatchSizeError,
|
||||
entryBatchSkipNote,
|
||||
planEntryBatch,
|
||||
type EntryBatchRow,
|
||||
} from '../pages/entryBatch'
|
||||
|
||||
function row(overrides: Partial<EntryBatchRow> & { entryId: number }): EntryBatchRow {
|
||||
return {
|
||||
name: '张三',
|
||||
bib: '1042',
|
||||
team: '第一分公司',
|
||||
categoryId: 7,
|
||||
entryStatus: 'registered',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
test('dedupeEntryIds:去重、丢非法 id,保持勾选顺序', () => {
|
||||
assert.deepEqual(dedupeEntryIds([3, 1, 3, 0, -2, 1, 2.5, 2]), [3, 1, 2])
|
||||
assert.deepEqual(dedupeEntryIds([]), [])
|
||||
})
|
||||
|
||||
test('entryBatchSizeError:空选与超上限都给出中文提示', () => {
|
||||
assert.equal(entryBatchSizeError(0), '请先勾选要操作的报名项')
|
||||
assert.equal(entryBatchSizeError(3), null)
|
||||
assert.equal(entryBatchSizeError(MAX_ENTRY_BATCH), null)
|
||||
assert.match(entryBatchSizeError(MAX_ENTRY_BATCH + 1) ?? '', /分批提交/)
|
||||
})
|
||||
|
||||
// dq / dns 是别人的判定:退赛不能覆盖,跳过时必须说清原因。
|
||||
test('entryBatchSkipNote:只动 registered / scratched,其余跳过并说明', () => {
|
||||
assert.equal(entryBatchSkipNote('scratch', 'registered'), null)
|
||||
assert.equal(entryBatchSkipNote('scratch', 'scratched'), '已是退赛状态,跳过')
|
||||
assert.equal(entryBatchSkipNote('scratch', 'dq'), '已被判罚取消资格(dq),跳过,不覆盖原判定')
|
||||
assert.equal(entryBatchSkipNote('scratch', 'dns'), '已标记未出场(dns),跳过,不覆盖原判定')
|
||||
assert.equal(entryBatchSkipNote('restore', 'scratched'), null)
|
||||
assert.equal(entryBatchSkipNote('restore', 'registered'), '当前正常参赛,无需恢复')
|
||||
// 移出报名不看状态:报名项本身就是要被物理删除的。
|
||||
assert.equal(entryBatchSkipNote('remove', 'dq'), null)
|
||||
})
|
||||
|
||||
test('planEntryBatch:退赛只改 registered,跳过的人在确认框里列出来', () => {
|
||||
const plan = planEntryBatch('scratch', [
|
||||
row({ entryId: 1 }),
|
||||
row({ entryId: 2, entryStatus: 'dq', name: '李四' }),
|
||||
row({ entryId: 1 }),
|
||||
])
|
||||
// 请求仍带上全部勾选 id:跳过与否由服务端逐条回报,前端不替它过滤。
|
||||
assert.deepEqual(plan.ids, [1, 2])
|
||||
assert.equal(plan.changed, 1)
|
||||
assert.equal(plan.scopeCount, 1)
|
||||
assert.deepEqual(plan.skipped, [{ name: '李四', note: '已被判罚取消资格(dq),跳过,不覆盖原判定' }])
|
||||
assert.ok(plan.details.some((line) => line.includes('李四')))
|
||||
assert.match(plan.message, /0 分且不占名次/)
|
||||
})
|
||||
|
||||
test('planEntryBatch:恢复只改已退赛的人', () => {
|
||||
const plan = planEntryBatch('restore', [
|
||||
row({ entryId: 1, entryStatus: 'scratched' }),
|
||||
row({ entryId: 2, entryStatus: 'registered', name: '王五' }),
|
||||
])
|
||||
assert.deepEqual(plan.ids, [1, 2])
|
||||
assert.equal(plan.changed, 1)
|
||||
assert.deepEqual(plan.skipped, [{ name: '王五', note: '当前正常参赛,无需恢复' }])
|
||||
})
|
||||
|
||||
// 移出报名必须让操作员看到「删谁」与「重算几个组别」,规模大时还要明说别人的名次会变。
|
||||
test('planEntryBatch:移出报名列出被删的人与重算范围', () => {
|
||||
const plan = planEntryBatch('remove', [
|
||||
row({ entryId: 1, name: '张三', bib: '1042', team: '第一分公司' }),
|
||||
row({ entryId: 2, name: '李四', bib: null, team: '第二分公司', categoryId: 8 }),
|
||||
])
|
||||
assert.deepEqual(plan.ids, [1, 2])
|
||||
assert.equal(plan.changed, 2)
|
||||
assert.equal(plan.scopeCount, 2)
|
||||
assert.deepEqual(plan.skipped, [])
|
||||
assert.ok(plan.details.some((line) => line.includes('张三|号码 1042|第一分公司')))
|
||||
assert.ok(plan.details.some((line) => line.includes('李四|号码 —|第二分公司')))
|
||||
assert.ok(plan.details.some((line) => line.includes('会重算 1 个小项(当前项目)、2 个组别')))
|
||||
assert.match(plan.message, /不可恢复/)
|
||||
})
|
||||
|
||||
test('planEntryBatch:超过 20 人时只列前 20 并追加「别人的名次会变」', () => {
|
||||
const rows = Array.from({ length: 25 }, (_, index) => row({ entryId: index + 1, name: `选手${index + 1}` }))
|
||||
const plan = planEntryBatch('remove', rows)
|
||||
assert.equal(plan.changed, 25)
|
||||
assert.ok(plan.details.some((line) => line.includes('还有 5 人未列出')))
|
||||
assert.match(plan.message, /别人的名次会变/)
|
||||
})
|
||||
|
||||
test('entryBatchOutcome:汇总逐条成败与删除规模', () => {
|
||||
const text = entryBatchOutcome({
|
||||
items: [],
|
||||
summary: {
|
||||
ok: 2,
|
||||
failed: 1,
|
||||
skipped: 1,
|
||||
changed: 1,
|
||||
removedEntries: 1,
|
||||
removedResults: 1,
|
||||
removedLedger: 2,
|
||||
removedBreaks: 0,
|
||||
recomputedEvents: [3],
|
||||
},
|
||||
})
|
||||
assert.match(text, /成功 2 条(跳过 1 条)/)
|
||||
assert.match(text, /失败 1 条/)
|
||||
assert.match(text, /删除报名 1 条/)
|
||||
assert.match(text, /删除流水 2 条/)
|
||||
assert.match(text, /重算 1 个小项/)
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { entryProgress, progressScopeLabel, type ProgressRow } from '../pages/entryProgress'
|
||||
import { judgeEntryRow } from '../pages/entryGuards'
|
||||
|
||||
function row(heat: number | null, resultText: string, over: Partial<ProgressRow> = {}): ProgressRow {
|
||||
return { heat, resultText, rank: null, status: 'ok', ...over }
|
||||
}
|
||||
|
||||
test('分母是当前筛选范围:选中某一批时只数这一批', () => {
|
||||
const rows = [row(1, '66'), row(1, ''), row(2, '70.1'), row(2, '71.2')]
|
||||
|
||||
const heat2 = entryProgress({ rows, filter: 2, unit: 's', needsRank: false })
|
||||
assert.equal(heat2.total, 2)
|
||||
assert.equal(heat2.filled, 2)
|
||||
assert.equal(heat2.done, true)
|
||||
assert.equal(heat2.text, '第 2 批 已录完 2/2')
|
||||
assert.equal(heat2.percent, 100)
|
||||
|
||||
const heat1 = entryProgress({ rows, filter: 1, unit: 's', needsRank: false })
|
||||
assert.equal(heat1.total, 2)
|
||||
assert.equal(heat1.filled, 1)
|
||||
assert.equal(heat1.missing, 1)
|
||||
assert.equal(heat1.text, '第 1 批 已录 1/2')
|
||||
|
||||
const all = entryProgress({ rows, filter: 'all', unit: 's', needsRank: false })
|
||||
assert.equal(all.total, 4)
|
||||
assert.equal(all.filled, 3)
|
||||
assert.equal(all.text, '全部 已录 3/4')
|
||||
assert.equal(all.percent, 75)
|
||||
assert.equal(all.done, false)
|
||||
})
|
||||
|
||||
test('筛选「未分批」只数没有批次号的行', () => {
|
||||
const rows = [row(null, '66'), row(null, ''), row(1, '70')]
|
||||
const progress = entryProgress({ rows, filter: 'none', unit: 's', needsRank: false })
|
||||
assert.equal(progress.total, 2)
|
||||
assert.equal(progress.filled, 1)
|
||||
assert.equal(progress.text, '未分批 已录 1/2')
|
||||
})
|
||||
|
||||
// 关键约束:进度条的「已录」必须与发布防呆 / 行徽标完全同源,否则会出现
|
||||
// 「进度条说录完了、发布说缺 8 条」。这里直接拿 judgeEntryRow 的结果对账。
|
||||
test('已录判据与 entryGuards 的 judgeEntryRow 同源(逐行对账)', () => {
|
||||
const rows = [
|
||||
row(1, '66.33'),
|
||||
row(1, ''),
|
||||
row(1, '1:70'),
|
||||
row(1, '', { status: 'dns' }),
|
||||
row(1, '0'),
|
||||
row(1, '', { rank: 1 }),
|
||||
row(1, ' '),
|
||||
]
|
||||
for (const needsRank of [false, true]) {
|
||||
const expected = rows.filter((item, index) => {
|
||||
const judged = judgeEntryRow({
|
||||
label: `第 ${index + 1} 行`,
|
||||
resultText: item.resultText,
|
||||
unit: 's',
|
||||
status: item.status,
|
||||
rank: item.rank,
|
||||
needsRank,
|
||||
})
|
||||
return judged.state === 'filled'
|
||||
}).length
|
||||
const progress = entryProgress({ rows, filter: 'all', unit: 's', needsRank })
|
||||
assert.equal(progress.filled, expected, `needsRank=${needsRank}`)
|
||||
}
|
||||
|
||||
// DNS 这类现场结果没有数值也是「已录」,手动名次项目只填名次也算「已录」。
|
||||
const progress = entryProgress({ rows: [row(1, '', { status: 'dns' })], filter: 'all', unit: 's', needsRank: false })
|
||||
assert.equal(progress.filled, 1)
|
||||
const rankOnly = entryProgress({ rows: [row(1, '', { rank: 1 })], filter: 'all', unit: 's', needsRank: true })
|
||||
assert.equal(rankOnly.filled, 1)
|
||||
})
|
||||
|
||||
test('有错的行不计入已录,并在文案里报数', () => {
|
||||
const rows = [
|
||||
row(2, '70.1'),
|
||||
row(2, '70.2'),
|
||||
row(2, '70.3'),
|
||||
row(2, '70.4'),
|
||||
row(2, '70.5'),
|
||||
row(2, '1:70'),
|
||||
row(2, '约1分06秒'),
|
||||
row(2, '快点跑'),
|
||||
]
|
||||
const progress = entryProgress({ rows, filter: 2, unit: 's', needsRank: false })
|
||||
assert.equal(progress.total, 8)
|
||||
assert.equal(progress.filled, 5)
|
||||
assert.equal(progress.error, 3)
|
||||
assert.equal(progress.missing, 0)
|
||||
assert.equal(progress.done, false, '有错时永远不是完成态')
|
||||
assert.equal(progress.text, '第 2 批 已录 5/8 · 3 条有错')
|
||||
assert.equal(progress.percent, 63)
|
||||
})
|
||||
|
||||
test('全部录完是完成态:文案改成已录完、进度条满格、变绿由 done 驱动', () => {
|
||||
const rows = [row(3, '70.1'), row(3, '', { status: 'dq' }), row(3, '1:06')]
|
||||
const progress = entryProgress({ rows, filter: 3, unit: 's', needsRank: false })
|
||||
assert.equal(progress.done, true)
|
||||
assert.equal(progress.filled, 3)
|
||||
assert.ok(progress.text.includes('已录完'), progress.text)
|
||||
assert.equal(progress.percent, 100)
|
||||
|
||||
const all = entryProgress({ rows, filter: 'all', unit: 's', needsRank: false })
|
||||
assert.equal(all.text, '全部 已录完 3/3')
|
||||
})
|
||||
|
||||
test('筛选范围内没有行时不算完成,也不会出现 NaN 百分比', () => {
|
||||
const progress = entryProgress({ rows: [row(1, '66')], filter: 'none', unit: 's', needsRank: false })
|
||||
assert.equal(progress.total, 0)
|
||||
assert.equal(progress.percent, 0)
|
||||
assert.equal(progress.done, false)
|
||||
assert.equal(progress.text, '未分批 已录 0/0')
|
||||
})
|
||||
|
||||
test('作用域称谓:全部 / 第 N 批 / 未分批', () => {
|
||||
assert.equal(progressScopeLabel('all'), '全部')
|
||||
assert.equal(progressScopeLabel(2), '第 2 批')
|
||||
assert.equal(progressScopeLabel('none'), '未分批')
|
||||
})
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { athleteCellName, inlineBibLabel } from '../pages/entryRowDisplay'
|
||||
|
||||
// 姓名后重复一次号码布:录入时不用在「号码」列与姓名列之间来回跳。
|
||||
test('有号码布:原样展示(去掉首尾空白)', () => {
|
||||
assert.equal(inlineBibLabel('1042'), '1042')
|
||||
assert.equal(inlineBibLabel(' 1042 '), '1042')
|
||||
assert.equal(inlineBibLabel('A-07'), 'A-07')
|
||||
})
|
||||
|
||||
// 号码布为空时模板必须不渲染那个 span,否则这一列会被一段空白推歪。
|
||||
test('没有号码布(null / 空串 / 只有空白):返回空串,不占位', () => {
|
||||
assert.equal(inlineBibLabel(null), '')
|
||||
assert.equal(inlineBibLabel(undefined), '')
|
||||
assert.equal(inlineBibLabel(''), '')
|
||||
assert.equal(inlineBibLabel(' '), '')
|
||||
})
|
||||
|
||||
test('姓名主文本:个人行用姓名,团体 / 接力行回落队员名单,都没有时占位', () => {
|
||||
assert.equal(athleteCellName({ athleteName: '张三', memberNames: null }), '张三')
|
||||
assert.equal(athleteCellName({ athleteName: '', memberNames: ['李四', '王五'] }), '李四、王五')
|
||||
assert.equal(athleteCellName({ athleteName: '', memberNames: null }), '—')
|
||||
assert.equal(athleteCellName({ athleteName: '', memberNames: [] }), '—')
|
||||
})
|
||||
|
||||
// 模板契约:号码布必须紧跟在姓名后面、且带 v-if;团体行(athleteName 为空)也要显示。
|
||||
const ENTRY_SOURCE = readFileSync(new URL('../pages/EntryPage.vue', import.meta.url), 'utf8')
|
||||
|
||||
test('录入页姓名单元格紧随号码布,且用 v-if 保证空号码不占位', () => {
|
||||
const cell = ENTRY_SOURCE.match(/<td>\{\{ athleteCellName\(row\) \}\}([\s\S]*?)<\/td>/)?.[1]
|
||||
assert.ok(cell !== undefined, '找不到姓名单元格')
|
||||
assert.match(cell, /<span v-if="inlineBibLabel\(row\.bib\)"/, '号码布必须带 v-if,空值不渲染')
|
||||
assert.match(cell, /class="bib-inline mono"/, '号码布要小号、弱化、等宽')
|
||||
})
|
||||
|
||||
test('原来的「号码」列保留,没有被这次改动删掉', () => {
|
||||
assert.match(ENTRY_SOURCE, /<td class="mono">\{\{ row\.bib \?\? '—' \}\}<\/td>/)
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { EVENT_ROW_DBLCLICK_TITLE, openEventCategories } from '../components/baseData/eventRowActions'
|
||||
import type { BaseDataTabKey } from '../components/baseData/tabs'
|
||||
|
||||
// 双击小项条目 → 跳到该小项的组别列表。
|
||||
// 浏览器在 dblclick 前必然先派发两次 click,这里模拟「先点过别的行、再双击目标行」,
|
||||
// 确认最终选中项是被双击的那一项,而不是上一次单击留下的那一项。
|
||||
test('双击:最终选中被双击的那一项,并跳到 categories', () => {
|
||||
const calls: string[] = []
|
||||
let selected: number | null = null
|
||||
const deps = {
|
||||
selectEvent: (id: number) => {
|
||||
selected = id
|
||||
calls.push(`select:${id}`)
|
||||
},
|
||||
goTab: (tab: BaseDataTabKey) => {
|
||||
calls.push(`goTab:${tab}`)
|
||||
},
|
||||
}
|
||||
|
||||
deps.selectEvent(1)
|
||||
openEventCategories(7, deps)
|
||||
|
||||
assert.equal(selected, 7)
|
||||
assert.deepEqual(calls.slice(-2), ['select:7', 'goTab:categories'])
|
||||
})
|
||||
|
||||
// 必须先选中再跳分区:反过来会在分区切换(可能重建列表)之后再改选中,跳过去的小项就不对。
|
||||
test('双击:先选中、后跳转,顺序不能反', () => {
|
||||
const calls: string[] = []
|
||||
openEventCategories(3, {
|
||||
selectEvent: (id) => calls.push(`select:${id}`),
|
||||
goTab: (tab) => calls.push(`goTab:${tab}`),
|
||||
})
|
||||
assert.deepEqual(calls, ['select:3', 'goTab:categories'])
|
||||
})
|
||||
|
||||
// 模板契约:条目要绑 @dblclick,且 title 写明可发现性提示;
|
||||
// 行内的小按钮(编辑 / 删除)用 @dblclick.stop 挡住,双击按钮不会误跳转。
|
||||
const SECTION_SOURCE = readFileSync(
|
||||
new URL('../components/baseData/SportsEventsSection.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('小项条目绑定双击跳转与 title 提示', () => {
|
||||
assert.match(SECTION_SOURCE, /@dblclick="openCategories\(event\.id\)"/)
|
||||
assert.match(SECTION_SOURCE, /:title="EVENT_ROW_DBLCLICK_TITLE"/)
|
||||
assert.equal(EVENT_ROW_DBLCLICK_TITLE, '双击查看该小项的组别')
|
||||
})
|
||||
|
||||
test('条目里的小按钮不会被双击误触发跳转', () => {
|
||||
assert.match(SECTION_SOURCE, /<div class="bd-actions" @dblclick\.stop>/)
|
||||
})
|
||||
|
||||
// 单击仍然只做选中:不能为了双击而给单击加延时(会让人以为界面卡了)。
|
||||
test('单击没有被加延时,仍是直接选中', () => {
|
||||
assert.match(SECTION_SOURCE, /@click="data\.selectEvent\(event\.id\)"/)
|
||||
assert.ok(!/setTimeout/.test(SECTION_SOURCE), '双击实现不应引入延时')
|
||||
})
|
||||
@@ -5,6 +5,7 @@ import { createApiClient } from '@/shared'
|
||||
import {
|
||||
REQUIRED_COLUMNS,
|
||||
conflictDiffs,
|
||||
decisionKeyOf,
|
||||
decisionsPayload,
|
||||
pruneDecisions,
|
||||
recordDecisions,
|
||||
@@ -119,6 +120,29 @@ test('只有号码布的冲突按号码布分键,不再互相覆盖', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
// 勾其中一行时另一行必须还是未决策(按证件号作键时两条会互相覆盖)。
|
||||
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' },
|
||||
])
|
||||
})
|
||||
|
||||
test('决策键回落:没有号码布用证件号,两样都没有用姓名 + 单位', () => {
|
||||
const idCard = '110101199001010002'
|
||||
assert.equal(decisionKeyOf(conflict({ idCard, bib: '' })), idCard)
|
||||
assert.equal(decisionKeyOf(conflict({ idCard: '', bib: '' })), '张三·F2', '两样都没有时仍要有一行唯一的键')
|
||||
})
|
||||
|
||||
test('导入:新建人员带证件号,姓名 + 单位命中且证件号为空时回填', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const before = mock.listAthletes().length
|
||||
@@ -137,57 +161,87 @@ 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'), row('重号乙', '110101199001010003', 'F2')]),
|
||||
csv([row('刘洋', '110101199001010003', 'F1', '男', '201'), row('刘洋', '110101199001010003', 'F1', '男')]),
|
||||
)
|
||||
assert.equal(duplicated.created, 1, '第一行仍导入')
|
||||
assert.equal(duplicated.skipped, 1)
|
||||
assert.match(duplicated.errors[0] ?? '', /重复出现/)
|
||||
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 idCard = '110101199001010004'
|
||||
const mock = new MockBaseData()
|
||||
await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
|
||||
const athleteId = mock.listAthletes().find((athlete) => athlete.idCard === idCard)?.id
|
||||
// 库里没有「张三」:第一行按新人员建,证件号写进档案。
|
||||
const first = await mock.importEntries(csv([row('张三', idCard, 'F1', '男')]))
|
||||
assert.equal(first.created, 1)
|
||||
assert.deepEqual(first.conflicts, [])
|
||||
assert.ok(mock.listAthletes().some((athlete) => athlete.idCard === idCard))
|
||||
|
||||
const conflicted = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
|
||||
// 同名同单位再来一行:姓名 + 单位命中同一个人,证件号相同不是差异。
|
||||
const again = await mock.importEntries(csv([row('张三', 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 同一句中文文案',
|
||||
)
|
||||
assert.equal(mock.listAthletes().filter((athlete) => athlete.name === '张三').length, 1)
|
||||
})
|
||||
|
||||
test('导入:号码布命中同一人但单位不同记为冲突,决策后按所选一侧处理', async () => {
|
||||
const mock = new MockBaseData()
|
||||
// 种子数据里 JG 的「张伟」号码布为 101;这一行按号码布命中他,但单位写成 F2。
|
||||
const conflicted = await mock.importEntries(csv([row('张伟', '', 'F2', '男', '101')]))
|
||||
assert.equal(conflicted.created, 0, '冲突行不建报名')
|
||||
assert.equal(conflicted.conflicts.length, 1)
|
||||
assert.deepEqual(conflicted.conflicts[0], {
|
||||
row: 2,
|
||||
idCard,
|
||||
bib: '',
|
||||
athleteId,
|
||||
existing: { name: '张三', gender: '男', age: null, teamCode: 'F1', employeeNo: null, bib: null },
|
||||
incoming: { name: '张三', gender: '男', age: null, teamCode: 'F2', employeeNo: null, bib: null },
|
||||
changedFields: ['teamCode'],
|
||||
})
|
||||
const [conflict] = conflicted.conflicts
|
||||
assert.equal(conflict?.idCard, '')
|
||||
assert.equal(conflict?.bib, '101')
|
||||
assert.deepEqual(conflict?.changedFields, ['teamCode'])
|
||||
|
||||
// 未决策时再次导入,冲突仍然可见,不会静默丢弃。
|
||||
const again = await mock.importEntries(csv([row('张三', idCard, 'F2', '男')]))
|
||||
const again = await mock.importEntries(csv([row('张伟', '', 'F2', '男', '101')]))
|
||||
assert.equal(again.conflicts.length, 1)
|
||||
|
||||
const keptExisting = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
|
||||
{ idCard, bib: '', action: 'keep-existing' },
|
||||
// 证件号为空时决策键就是号码布(与服务端 decisionKey() 同序)。
|
||||
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', '', 'F2', '男', '101')]), [
|
||||
{ idCard: '', bib: '101', action: 'keep-existing' },
|
||||
])
|
||||
assert.equal(keptExisting.conflicts.length, 0)
|
||||
assert.equal(keptExisting.created, 1)
|
||||
const afterExisting = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
|
||||
assert.equal(afterExisting?.teamId, 2, '保留已有数据时人员单位不变(F1 的 id 为 2)')
|
||||
assert.equal(
|
||||
mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId,
|
||||
1,
|
||||
'保留已有数据时人员单位不变(JG 的 id 为 1)',
|
||||
)
|
||||
|
||||
const keptNew = await mock.resolveImportConflicts(csv([row('张三', idCard, 'F2', '男')]), [
|
||||
{ idCard, bib: '', action: 'keep-new' },
|
||||
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', '', 'F2', '男', '101')]), [
|
||||
{ idCard: '', bib: '101', action: 'keep-new' },
|
||||
])
|
||||
assert.equal(keptNew.conflicts.length, 0)
|
||||
const afterNew = mock.listAthletes().find((athlete) => athlete.idCard === idCard)
|
||||
assert.equal(afterNew?.teamId, 3, '使用新数据时人员单位更新为 F2 的 id')
|
||||
assert.equal(
|
||||
mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId,
|
||||
3,
|
||||
'使用新数据时人员单位更新为 F2 的 id',
|
||||
)
|
||||
})
|
||||
|
||||
test('resolveImport 走 /entries/import/resolve 并带上 decisions JSON', async () => {
|
||||
@@ -256,34 +310,43 @@ test('导入:姓名 + 单位命中且证件号为空时回填证件号并保
|
||||
assert.equal(after?.bib, '202', '表里没给号码布时保留已有值')
|
||||
})
|
||||
|
||||
test('导入:号码布与库里不一致记为冲突,差异含 bib,决策后按所选一侧处理', async () => {
|
||||
const idCard = '110101199001010007'
|
||||
test('导入:号码布不同 → 按「同名的另一个人」新建并报告', async () => {
|
||||
const mock = new MockBaseData()
|
||||
// JG 的「张伟」证件号为空、号码布为 101,先用姓名 + 单位回填证件号。
|
||||
await mock.importEntries(csv([row('张伟', idCard, 'JG', '男')]))
|
||||
const athleteId = mock.listAthletes().find((item) => item.idCard === idCard)?.id
|
||||
assert.ok(athleteId !== undefined)
|
||||
// 种子数据里 JG 的「张伟」号码布为 101;这一行给了别的号码布 → 同名的另一个人。
|
||||
const before = mock.listAthletes().length
|
||||
const result = await mock.importEntries(csv([row('张伟', '110101199001010007', 'JG', '男', '999')]))
|
||||
|
||||
const conflicted = await mock.importEntries(csv([row('张伟', idCard, 'JG', '男', '999')]))
|
||||
assert.equal(conflicted.created, 0, '冲突行不建报名')
|
||||
assert.equal(result.created, 1, '行本身不拦截:新建了同名的另一个人')
|
||||
assert.deepEqual(result.conflicts, [], '号码布不同不是冲突')
|
||||
assert.deepEqual(result.sameNameNewPersons, [
|
||||
{ name: '张伟', teamCode: 'JG', newBib: '999', existingBib: '101' },
|
||||
])
|
||||
assert.equal(mock.listAthletes().length, before + 1)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '999')?.idCard, '110101199001010007')
|
||||
// 库里那个人一个字段都没被改:同名的另一个人是新档案。
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '101')?.idCard, null)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '101')?.teamId, 1)
|
||||
})
|
||||
|
||||
test('导入:同名同单位有多个人时一律进冲突面板,以第一条候选为代表', async () => {
|
||||
const mock = new MockBaseData()
|
||||
const firstId = mock.listAthletes().find((athlete) => athlete.bib === '101')?.id
|
||||
// 先造出第二个「张伟」(都在 JG):号码布不同 → 同名的另一个人。
|
||||
await mock.importEntries(csv([row('张伟', '', 'JG', '男', '999')]))
|
||||
|
||||
// 第三行只给了号码布 777,两个候选都对不上号码布:认不出是哪一个人,不落库。
|
||||
const conflicted = await mock.importEntries(csv([row('张伟', '', 'JG', '男', '777')]))
|
||||
assert.equal(conflicted.created, 0)
|
||||
assert.equal(conflicted.conflicts.length, 1)
|
||||
const [conflict] = conflicted.conflicts
|
||||
assert.equal(conflict?.idCard, idCard)
|
||||
assert.equal(conflict?.existing.bib, '101')
|
||||
assert.equal(conflict?.incoming.bib, '999')
|
||||
assert.deepEqual(conflict?.changedFields, ['bib'])
|
||||
assert.equal(conflicted.conflicts[0]?.athleteId, firstId, '以第一条候选为代表')
|
||||
|
||||
const keptExisting = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
|
||||
{ idCard, bib: '999', action: 'keep-existing' },
|
||||
const decided = await mock.resolveImportConflicts(csv([row('张伟', '', 'JG', '男', '777')]), [
|
||||
{ idCard: '', bib: '777', action: 'keep-new' },
|
||||
])
|
||||
assert.equal(keptExisting.created, 1)
|
||||
assert.equal(mock.listAthletes().find((item) => item.id === athleteId)?.bib, '101')
|
||||
|
||||
const keptNew = await mock.resolveImportConflicts(csv([row('张伟', idCard, 'JG', '男', '999')]), [
|
||||
{ idCard, bib: '999', action: 'keep-new' },
|
||||
])
|
||||
assert.equal(keptNew.created, 1)
|
||||
assert.equal(mock.listAthletes().find((item) => item.id === athleteId)?.bib, '999')
|
||||
assert.equal(decided.created, 1)
|
||||
assert.equal(decided.conflicts.length, 0)
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.id === firstId)?.bib, '777')
|
||||
assert.equal(mock.listAthletes().find((athlete) => athlete.bib === '999')?.bib, '999', '另一个候选不被动')
|
||||
})
|
||||
|
||||
test('导入:同一文件里号码布重复按行报错', async () => {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import { MockBaseData } from '../components/baseData/mockBaseData'
|
||||
|
||||
/**
|
||||
* 示例数据模式(服务端连不上)下,组别就地更新与运动员批量的降级行为。
|
||||
*
|
||||
* 这一层不接上的话,页面上这两个按钮在示例数据里会直接抛「无法连接服务端」——
|
||||
* 而示例数据存在的意义就是让每个分区都能走通完整交互。
|
||||
*/
|
||||
|
||||
test('示例数据的组别就地更新保留组别 id,不重建', () => {
|
||||
const mock = new MockBaseData()
|
||||
const event = mock.listEvents()[0]
|
||||
assert.ok(event !== undefined)
|
||||
const before = mock.getEvent(event.id).categories ?? []
|
||||
const target = before[0]
|
||||
assert.ok(target !== undefined)
|
||||
|
||||
const after = mock.updateCategory(event.id, target.id, {
|
||||
name: '男子甲组',
|
||||
multiplier: 2,
|
||||
gender: '男',
|
||||
ageMin: 18,
|
||||
ageMax: 35,
|
||||
sortOrder: target.sortOrder,
|
||||
})
|
||||
|
||||
const updated = after.find((category) => category.id === target.id)
|
||||
assert.ok(updated !== undefined)
|
||||
assert.equal(updated.name, '男子甲组')
|
||||
assert.equal(updated.multiplier, 2)
|
||||
// 其余组别一条不少:就地更新不是整表替换。
|
||||
assert.equal(after.length, before.length)
|
||||
})
|
||||
|
||||
test('示例数据的组别就地更新:重名与不存在给与服务端一致的文案', () => {
|
||||
const mock = new MockBaseData()
|
||||
// 用新建的小项拿两个组别:种子事件各自只有一个组别,没法构造重名。
|
||||
const seed = mock.listEvents()[0]
|
||||
assert.ok(seed !== undefined)
|
||||
const fresh = mock.createEvent({
|
||||
sportId: seed.sportId,
|
||||
code: 'DUP_CHECK',
|
||||
name: '重名检查',
|
||||
eventType: seed.eventType,
|
||||
resultMode: seed.resultMode,
|
||||
resultType: seed.resultType,
|
||||
compare: seed.compare,
|
||||
recordEligible: seed.recordEligible,
|
||||
scored: seed.scored,
|
||||
multiplier: seed.multiplier,
|
||||
status: seed.status,
|
||||
scheduledAt: null,
|
||||
venue: null,
|
||||
sortOrder: 99,
|
||||
remark: null,
|
||||
avgMode: seed.avgMode,
|
||||
})
|
||||
const categories = mock.saveCategories(fresh.id, [
|
||||
{ name: '男子', multiplier: 1, gender: '男', ageMin: null, ageMax: null, sortOrder: 1 },
|
||||
{ name: '女子', multiplier: 1, gender: '女', ageMin: null, ageMax: null, sortOrder: 2 },
|
||||
])
|
||||
const [first, second] = categories
|
||||
assert.ok(first !== undefined && second !== undefined)
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
mock.updateCategory(fresh.id, second.id, {
|
||||
name: first.name,
|
||||
multiplier: 1,
|
||||
gender: null,
|
||||
ageMin: null,
|
||||
ageMax: null,
|
||||
sortOrder: second.sortOrder,
|
||||
}),
|
||||
/组别名称重复/,
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
mock.updateCategory(fresh.id, 999999, {
|
||||
name: '男子',
|
||||
multiplier: 1,
|
||||
gender: null,
|
||||
ageMin: null,
|
||||
ageMax: null,
|
||||
sortOrder: 1,
|
||||
}),
|
||||
/组别不存在/,
|
||||
)
|
||||
})
|
||||
|
||||
test('示例数据有报名数据时仍拒绝增删组别,但文案说清是「不能增删」', () => {
|
||||
const mock = new MockBaseData()
|
||||
const catsOf = (eventId: number) => mock.getEvent(eventId).categories ?? []
|
||||
|
||||
// 找一个小项:它在示例数据里带报名条数(增删会被拒),改名仍然放行。
|
||||
const blocked = mock.listEvents().filter((event) => {
|
||||
const categories = catsOf(event.id)
|
||||
if (categories.length === 0) return false
|
||||
try {
|
||||
mock.saveCategories(event.id, categories.map((category) => ({ ...category })))
|
||||
return false
|
||||
} catch (error) {
|
||||
assert.match(String(error), /不能新增或删除组别/)
|
||||
return true
|
||||
}
|
||||
})
|
||||
assert.ok(blocked.length > 0, '示例数据里应有带报名条数的小项')
|
||||
|
||||
const event = blocked[0]
|
||||
assert.ok(event !== undefined)
|
||||
const first = catsOf(event.id)[0]
|
||||
assert.ok(first !== undefined)
|
||||
const after = mock.updateCategory(event.id, first.id, {
|
||||
name: '改个名字',
|
||||
multiplier: 1,
|
||||
gender: first.gender,
|
||||
ageMin: null,
|
||||
ageMax: null,
|
||||
sortOrder: first.sortOrder,
|
||||
})
|
||||
assert.equal(after.find((category) => category.id === first.id)?.name, '改个名字')
|
||||
})
|
||||
|
||||
test('示例数据批量停用:逐条回报,缺失 id 不整批失败,数据真被停用', () => {
|
||||
const mock = new MockBaseData()
|
||||
const athletes = mock.listAthletes()
|
||||
const [a, b] = athletes
|
||||
assert.ok(a !== undefined && b !== undefined)
|
||||
|
||||
const result = mock.batchAthletes('disable', [a.id, b.id, 999999])
|
||||
|
||||
assert.equal(result.action, 'disable')
|
||||
assert.equal(result.requested, 3)
|
||||
assert.equal(result.succeeded, 2)
|
||||
assert.equal(result.failed, 1)
|
||||
assert.deepEqual(result.items[2], { id: 999999, ok: false, code: 'NOT_FOUND', message: '运动员不存在' })
|
||||
assert.equal(result.purge, undefined)
|
||||
|
||||
const after = mock.listAthletes()
|
||||
assert.equal(after.find((athlete) => athlete.id === a.id)?.active, false)
|
||||
assert.equal(after.length, athletes.length)
|
||||
})
|
||||
|
||||
test('示例数据批量彻底删除:dryRun 只回报不改数据,真删才把人移出名单', () => {
|
||||
const mock = new MockBaseData()
|
||||
const athletes = mock.listAthletes()
|
||||
const target = athletes[0]
|
||||
assert.ok(target !== undefined)
|
||||
|
||||
const preview = mock.batchAthletes('purge', [target.id], true)
|
||||
assert.equal(preview.dryRun, true)
|
||||
assert.equal(preview.succeeded, 1)
|
||||
assert.deepEqual(preview.purge, {
|
||||
deletedEntries: 0,
|
||||
deletedResults: 0,
|
||||
deletedLedger: 0,
|
||||
deletedBreaks: 0,
|
||||
recomputedEvents: [],
|
||||
})
|
||||
assert.equal(mock.listAthletes().length, athletes.length)
|
||||
|
||||
const real = mock.batchAthletes('purge', [target.id])
|
||||
assert.equal(real.succeeded, 1)
|
||||
assert.equal(mock.listAthletes().length, athletes.length - 1)
|
||||
assert.equal(mock.listAthletes().some((athlete) => athlete.id === target.id), false)
|
||||
})
|
||||
@@ -0,0 +1,167 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
/**
|
||||
* 登录有效期(系统级设置)的契约:
|
||||
* 前端校验必须与服务端一致(1~720 整数小时),到期时间按本机时区渲染,
|
||||
* 会话心跳要真的打到 GET /api/auth/me(服务端的滑动续期与 Cookie 同步都靠它)。
|
||||
*
|
||||
* adminApi 的 http 实例在模块初始化时就把 globalThis.fetch 绑死了,所以这里在文件顶部
|
||||
* 装一个按 URL 分发的假 fetch,各用例只改 reply;用例内部再动态 import 被测模块。
|
||||
*/
|
||||
|
||||
interface Captured {
|
||||
url: string
|
||||
method: string
|
||||
body: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const captured: Captured = { url: '', method: '', body: null }
|
||||
let reply: (url: string, method: string) => unknown = () => ({ ok: true, data: null })
|
||||
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
const method = init?.method ?? 'GET'
|
||||
captured.url = url
|
||||
captured.method = method
|
||||
captured.body = init?.body === undefined ? null : (JSON.parse(String(init.body)) as Record<string, unknown>)
|
||||
const payload = reply(url, method)
|
||||
if (payload instanceof Response) return payload
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}) as typeof fetch
|
||||
|
||||
/** systemApi 的 send() 用 window.setTimeout 做超时控制,node 里没有 window。 */
|
||||
;(globalThis as { window?: unknown }).window = {
|
||||
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
|
||||
clearTimeout: (id: number) => clearTimeout(id),
|
||||
}
|
||||
|
||||
const ME_DATA = {
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
displayName: '裁判长',
|
||||
role: 'admin',
|
||||
mustChangePassword: false,
|
||||
lastLoginAt: null,
|
||||
sessionExpiresAt: '2026-09-12T00:31:00Z',
|
||||
}
|
||||
|
||||
const BOUNDS = { minHours: 1, maxHours: 720 }
|
||||
|
||||
test('validateSessionHours 只接受 1~720 的整数小时', async () => {
|
||||
const { validateSessionHours } = await import('../components/system/sessionSettings')
|
||||
|
||||
for (const [raw, want] of [
|
||||
['1', 1],
|
||||
['24', 24],
|
||||
['720', 720],
|
||||
[' 48 ', 48],
|
||||
[72, 72],
|
||||
] as const) {
|
||||
const got = validateSessionHours(raw, BOUNDS)
|
||||
assert.equal(got.ok, true, `应接受 ${String(raw)}`)
|
||||
assert.equal(got.ok ? got.hours : 0, want)
|
||||
}
|
||||
|
||||
for (const raw of ['', ' ', '0', '721', '-1', '24.5', 'abc']) {
|
||||
const got = validateSessionHours(raw, BOUNDS)
|
||||
assert.equal(got.ok, false, `应拒绝 ${JSON.stringify(raw)}`)
|
||||
if (!got.ok) assert.match(got.message, /登录有效期/)
|
||||
}
|
||||
})
|
||||
|
||||
test('describeSessionLifetime 用小时与天数说明「登录后能待多久」', async () => {
|
||||
const { describeSessionLifetime } = await import('../components/system/sessionSettings')
|
||||
|
||||
const day = describeSessionLifetime(24)
|
||||
assert.match(day, /24 小时/)
|
||||
assert.match(day, /1 天/)
|
||||
assert.match(day, /顺延/)
|
||||
|
||||
assert.match(describeSessionLifetime(36), /1\.5 天/)
|
||||
assert.match(describeSessionLifetime(0), /未设置/)
|
||||
})
|
||||
|
||||
test('readSessionExpiresAt 只认带时区的合法时间串', async () => {
|
||||
const { readSessionExpiresAt } = await import('../components/system/sessionSettings')
|
||||
|
||||
assert.equal(readSessionExpiresAt(ME_DATA), '2026-09-12T00:31:00Z')
|
||||
// 老服务端不下发这个字段、访客、以及字段被写坏的情况,都要当成「没有」。
|
||||
assert.equal(readSessionExpiresAt({ ...ME_DATA, sessionExpiresAt: undefined }), null)
|
||||
assert.equal(readSessionExpiresAt({ ...ME_DATA, sessionExpiresAt: '不是时间' }), null)
|
||||
assert.equal(readSessionExpiresAt(null), null)
|
||||
})
|
||||
|
||||
test('formatSessionExpiry 渲染成本机时间的「当前会话将于 … 过期」', async () => {
|
||||
const { formatSessionExpiry } = await import('../components/system/sessionSettings')
|
||||
|
||||
assert.match(formatSessionExpiry('2026-09-12T00:31:00Z'), /^当前会话将于 \d{4}-\d{2}-\d{2} \d{2}:\d{2} 过期$/)
|
||||
assert.equal(formatSessionExpiry(null), '当前会话的到期时间未知')
|
||||
assert.equal(formatSessionExpiry(''), '当前会话的到期时间未知')
|
||||
assert.equal(formatSessionExpiry('2026-13-45'), '当前会话的到期时间未知')
|
||||
})
|
||||
|
||||
test('会话心跳重新拉 /api/auth/me 并刷新到期时间', async () => {
|
||||
reply = () => ({ ok: true, data: ME_DATA })
|
||||
const { useAuthStore } = await import('../stores/auth')
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
await auth.load()
|
||||
assert.equal(captured.url, '/api/auth/me')
|
||||
assert.equal(auth.sessionExpiresAt, '2026-09-12T00:31:00Z')
|
||||
|
||||
// 服务端续期后下发新的到期时间,心跳要跟上(用户菜单与设置页显示的就是它)。
|
||||
captured.url = ''
|
||||
reply = () => ({ ok: true, data: { ...ME_DATA, sessionExpiresAt: '2026-09-13T00:31:00Z' } })
|
||||
await auth.refreshSession()
|
||||
assert.equal(captured.url, '/api/auth/me')
|
||||
assert.equal(auth.sessionExpiresAt, '2026-09-13T00:31:00Z')
|
||||
})
|
||||
|
||||
test('访客不发起心跳', async () => {
|
||||
reply = () => ({ ok: true, data: { ...ME_DATA, role: 'guest', sessionExpiresAt: null } })
|
||||
const { useAuthStore } = await import('../stores/auth')
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const auth = useAuthStore()
|
||||
await auth.load()
|
||||
assert.equal(auth.isGuest, true)
|
||||
assert.equal(auth.sessionExpiresAt, null)
|
||||
|
||||
captured.url = ''
|
||||
await auth.refreshSession()
|
||||
assert.equal(captured.url, '', '访客不该发起心跳')
|
||||
})
|
||||
|
||||
test('sessionSettingsApi 走 /api/system/session 读写登录有效期', async () => {
|
||||
reply = () => ({
|
||||
ok: true,
|
||||
data: { sessionHours: 72, defaultSessionHours: 24, minHours: 1, maxHours: 720, expiresAt: null },
|
||||
})
|
||||
const { sessionSettingsApi } = await import('../components/system/systemApi')
|
||||
|
||||
const loaded = await sessionSettingsApi.load()
|
||||
assert.equal(captured.url, '/api/system/session')
|
||||
assert.equal(captured.method, 'GET')
|
||||
assert.equal(loaded.sessionHours, 72)
|
||||
assert.equal(loaded.minHours, 1)
|
||||
assert.equal(loaded.maxHours, 720)
|
||||
|
||||
reply = () => ({
|
||||
ok: true,
|
||||
data: { sessionHours: 1, defaultSessionHours: 24, minHours: 1, maxHours: 720, expiresAt: '2026-09-11T12:00:00Z' },
|
||||
})
|
||||
const saved = await sessionSettingsApi.save(1)
|
||||
assert.equal(captured.url, '/api/system/session')
|
||||
assert.equal(captured.method, 'PUT')
|
||||
assert.equal(captured.body?.sessionHours, 1)
|
||||
// 写请求带幂等用的 requestId(服务端据此防重复提交)。
|
||||
assert.equal(typeof captured.body?.requestId, 'string')
|
||||
assert.equal(saved.sessionHours, 1)
|
||||
assert.equal(saved.expiresAt, '2026-09-11T12:00:00Z')
|
||||
})
|
||||
@@ -0,0 +1,171 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import type { EventSettings } from '@/shared'
|
||||
|
||||
/**
|
||||
* 分批结算的前端契约:设置读取的默认值、未结算清单的路径与字段名、立即结算的请求体。
|
||||
*
|
||||
* 路径或字段名写错时现场看到的是「提示不出现 / 结算按错组生效」这类静默故障,
|
||||
* 所以这里用 stub fetch 钉住真正发出去的请求。
|
||||
*
|
||||
* ⚠️ 共享 ApiClient 在模块初始化时就把 globalThis.fetch 绑死了,所以这里:
|
||||
* 1. 不在文件顶部静态导入任何会构造 client 的模块(本文件的 import 都是类型或无副作用模块);
|
||||
* 2. 桩在模块体里先装好,再用动态 import 拿被测模块;
|
||||
* 3. 桩的响应由每个用例改的 responder 决定,多个用例共用一个桩。
|
||||
*/
|
||||
|
||||
interface Captured {
|
||||
url: string
|
||||
method: string
|
||||
headers: Record<string, string>
|
||||
body: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
const captured: Captured = { url: '', method: '', headers: {}, body: null }
|
||||
let responder: (request: Captured) => Response = () =>
|
||||
new Response(JSON.stringify({ ok: true, data: null }), {
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
globalThis.fetch = (async (input: unknown, init?: RequestInit) => {
|
||||
captured.url = String(input)
|
||||
captured.method = init?.method ?? 'GET'
|
||||
captured.headers = (init?.headers ?? {}) as Record<string, string>
|
||||
captured.body = init?.body === undefined ? null : (JSON.parse(String(init.body)) as Record<string, unknown>)
|
||||
return responder(captured)
|
||||
}) as typeof fetch
|
||||
// systemApi 的 send() 用 window.setTimeout 做超时控制,node 里没有 window。
|
||||
;(globalThis as { window?: unknown }).window = {
|
||||
setTimeout: (fn: () => void, ms?: number) => setTimeout(fn, ms),
|
||||
clearTimeout: (id: number) => clearTimeout(id),
|
||||
}
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
const SETTINGS = {
|
||||
event: { name: '测试赛事', subtitle: null, host: null, logoPath: null, primaryColor: null },
|
||||
display: { background: null, logo: null, title: '测试赛事', subtitle: null, primaryColor: null, rotateSeconds: 15 },
|
||||
standings: {
|
||||
individualToTeamWeight: 1,
|
||||
rankingMode: 'points',
|
||||
showIndividualBoard: true,
|
||||
showIndividualMedals: true,
|
||||
},
|
||||
defaultProfileId: null,
|
||||
requireApproval: false,
|
||||
}
|
||||
|
||||
test('liveOnPublishOf:缺字段 / 旧服务端一律按「实时结算」处理', async () => {
|
||||
const { liveOnPublishOf } = await import('../components/system/systemApi')
|
||||
|
||||
assert.equal(liveOnPublishOf(null), true)
|
||||
assert.equal(liveOnPublishOf(undefined), true)
|
||||
assert.equal(liveOnPublishOf(SETTINGS as unknown as EventSettings), true)
|
||||
assert.equal(
|
||||
liveOnPublishOf({
|
||||
...SETTINGS,
|
||||
standings: { ...SETTINGS.standings, liveOnPublish: true },
|
||||
} as unknown as EventSettings),
|
||||
true,
|
||||
)
|
||||
assert.equal(
|
||||
liveOnPublishOf({
|
||||
...SETTINGS,
|
||||
standings: { ...SETTINGS.standings, liveOnPublish: false },
|
||||
} as unknown as EventSettings),
|
||||
false,
|
||||
)
|
||||
})
|
||||
|
||||
test('settlementApi.status 读 GET /api/results/settlement 并归一未结算清单', async () => {
|
||||
const { settlementApi } = await import('../components/system/systemApi')
|
||||
responder = () =>
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
data: {
|
||||
liveOnPublish: false,
|
||||
groups: [
|
||||
{ eventId: 3, categoryId: 7, categoryName: '男子甲组', required: 8, published: 4 },
|
||||
{ eventId: 3, categoryId: 8, categoryName: '女子甲组', required: 6, published: 6 },
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
const status = await settlementApi.status(3, 7)
|
||||
|
||||
assert.equal(captured.url, '/api/results/settlement?eventId=3&categoryId=7')
|
||||
assert.equal(captured.method, 'GET')
|
||||
assert.equal(status.liveOnPublish, false)
|
||||
assert.deepEqual(status.groups[0], {
|
||||
eventId: 3,
|
||||
categoryId: 7,
|
||||
categoryName: '男子甲组',
|
||||
required: 8,
|
||||
published: 4,
|
||||
})
|
||||
assert.equal(status.groups.length, 2)
|
||||
})
|
||||
|
||||
test('settlementApi.status 不带组别时只带 eventId', async () => {
|
||||
const { settlementApi } = await import('../components/system/systemApi')
|
||||
responder = () => jsonResponse({ ok: true, data: { liveOnPublish: true, groups: [] } })
|
||||
|
||||
const status = await settlementApi.status(9)
|
||||
|
||||
assert.equal(captured.url, '/api/results/settlement?eventId=9')
|
||||
assert.equal(status.liveOnPublish, true)
|
||||
assert.deepEqual(status.groups, [])
|
||||
})
|
||||
|
||||
test('settlementApi.settle 发 POST /api/results/settle 并带 requestId', async () => {
|
||||
const { settlementApi } = await import('../components/system/systemApi')
|
||||
responder = () => jsonResponse({ ok: true, data: [] })
|
||||
|
||||
await settlementApi.settle(3, 7)
|
||||
|
||||
assert.equal(captured.url, '/api/results/settle')
|
||||
assert.equal(captured.method, 'POST')
|
||||
assert.equal(captured.body?.eventId, 3)
|
||||
assert.equal(captured.body?.categoryId, 7)
|
||||
// 服务端按 requestId 幂等:不带就会被当成重复提交。
|
||||
assert.equal(typeof captured.headers['X-Request-Id'], 'string')
|
||||
})
|
||||
|
||||
test('保存结算方式 PUT /api/settings,提交完整 standings 并回写 event store', async () => {
|
||||
responder = () =>
|
||||
jsonResponse({
|
||||
ok: true,
|
||||
data: { ...SETTINGS, standings: { ...SETTINGS.standings, liveOnPublish: false } },
|
||||
})
|
||||
const { liveOnPublishOf } = await import('../components/system/systemApi')
|
||||
const { useEventStore } = await import('../stores/event')
|
||||
const { useBaseData } = await import('../components/baseData/useBaseData')
|
||||
|
||||
setActivePinia(createPinia())
|
||||
const event = useEventStore()
|
||||
event.settings = { ...SETTINGS } as unknown as EventSettings
|
||||
const data = useBaseData()
|
||||
|
||||
const saved = await data.saveLiveOnPublish(false)
|
||||
|
||||
assert.equal(captured.url, '/api/settings')
|
||||
assert.equal(captured.method, 'PUT')
|
||||
const standings = captured.body?.standings as Record<string, unknown>
|
||||
assert.equal(standings.liveOnPublish, false)
|
||||
// 只提交一个字段会把其余口径留在服务端旧值上,所以整个 standings 子对象原样回传。
|
||||
assert.equal(standings.individualToTeamWeight, 1)
|
||||
assert.equal(standings.rankingMode, 'points')
|
||||
assert.equal(standings.showIndividualBoard, true)
|
||||
|
||||
assert.equal(liveOnPublishOf(saved), false)
|
||||
// 录入页提示直接读 store 里的设置,不回写就等于「保存成功但提示没变」。
|
||||
assert.equal(liveOnPublishOf(event.settings), false)
|
||||
assert.equal(liveOnPublishOf(data.settings.value), false)
|
||||
})
|
||||
Reference in new issue
Block a user