Files
competition-website/test/lib.test.js
qubiaobiao 5392345078 feat(score): R173/R174 大题成绩 Excel 导入(三小问四列制)+ 三节点备份调度器与四项修复
成绩导入(R173 落地 → R174 改为三小问四列制)
- app/lib/bigScoreImport.js(新增 · 判据唯一真相源 · 零依赖:不 require Egg / 不连库 / 不读文件)
  · R174 删除 splitScoreAcrossSubs(均分),改为 scoreSubs 逐项取值
  · 留空 SUB_MISSING(整行拒绝,⛔ 不按 0 分)· 非数值 SUB_INVALID · 超单问上限 SUB_OUT_OF_RANGE
  · 文件级 LEGACY_TEMPLATE(旧两列模板)与 SUB_COLUMNS_MISSING(缺一小问整列)⇒ 整份拒绝
  · 定位键必须带 group:(group, team_uid),因 team_uid 跨组重号
- app/service/bigQuestion.js
  · buildScoreTemplate 改四列 + 说明书重写(删均分段,补"三列必须全填"与"旧模板不再支持")
  · previewScoreImport 回带 subRaws/subs/total;importScores 逐项写 setSubScores
  · ⛔ 不新增第二条写成绩通路:仍只走 setSubScores → admin.updateScores
- app/lib/docx.js:run 支持 color(6 位 hex);w:color 必须排在 w:sz 之前(CT_RPr sequence)
- app/controller/admin.js + app/router.js:import-template / import-preview(零写入)/ import-scores
  · 上传内存解析不落盘(app/public 对静态服务开放 ⇒ 成绩表可被猜名下载)

节点备份调度器(R168/R170)
- app/lib/nodeBackupScheduler.js(新增):考试开始前 30min / 结束后 10min / 成绩发布瞬间
  · 幂等键 group:node:fireAtISO(时间点即身份);overdue 判定用 clamp:false 的数学时刻
  · 非生产 enabled 强制 false 且 fail-safe(与 DEPLOY_ENV_LOCAL_SKIP_PREFIXES 同源)
- config/config.default.js:config.nodeBackup 四项路径全走环境变量,默认值只覆盖本地
  (生产目录布局与仓库不一致,⛔ 不许用 __dirname 推导)
- app.js:启动构造单例 + hydrate 未到期计划

缺陷修复
- app/controller/admin.js:R161 临时文件路径移到 try 外声明,否则导入抛错即跳过清理
- app/service/admin.js:R166「已答 N / N 题」恒真显示(原 totalQuestions = answeredCount)
- app/service/upload.js + app/controller/upload.js:R157/R162 删除结果计数 + @no-temp-cleanup 标注

测试
- .gitignore + test/lib.test.js:R134 候选 ㊴ —— test/* + !test/*.test.js,
  使第 26 步裸 mocha 在干净检出上可复现(改前 rc=1 ↔ 改后 23 passing)
2026-10-19 13:03:48 +08:00

169 lines
7.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* backend 最小可跑单测集(P1 ㉚a)
* ================================================================
* 背景:`backend/package.json` 声明了 `test-local: egg-bin test`,但 `backend/test/`
* 长期是**空目录** ⇒ 「配置存在、用例为零」——跑 `npm run test-local` 恒绿(0 个用例),
* 属 R95 根因①「最危险假绿族」。本文件填充**真实、无外部依赖(无 DB / 无网络 /
* 无 egg 启动)、有判别力**的最小单测集,使该 script 不再空转。
*
* 取材原则(为什么选这三个纯函数模块):
* ① 业务核心:aggregationKey = 铁律13/14 归集键 SoT;idCard = 导入通道判据 SoT;
* filename = R108g 上传落盘名清洗。
* ② **判别力**:三者的关键判据都有「退化后必然出错」的形态——
* · aggregationKey 若退回「无条件双键归集」(历史 bug)⇒ 跨编号空间重叠用例必红;
* · idCard 若校验位算法改动 ⇒ 已知公开有效号 `11010519491231002X` 用例必红;
* · filename 若漏控制字符 / 路径分隔符清洗 ⇒ 穿越与 ENOENT 用例必红。
* ③ 零副作用:全部为纯函数,不触盘、不连库。
*
* ⚠️ 运行:`cd backend && node_modules/.bin/egg-bin test`(或裸 mocha 直跑本文件)。
*/
const assert = require('assert');
const aggKey = require('../app/lib/aggregationKey');
const idCard = require('../app/lib/idCard');
const { sanitizeUploadFileName } = require('../app/utils/filename');
describe('app/lib/aggregationKey —— 归集键 SoT(铁律13/14)', () => {
it('考试组:主键 = contestant.id,兜底键 = 真实聚合 uid', () => {
assert.strictEqual(aggKey.primaryKeyOf('gaozhi', 5, 9), '5');
assert.strictEqual(aggKey.primaryKeyOf('zhongzhi', 5, 9), '5');
assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, 9), '9');
});
it('考试组:缺真实聚合 uid ⇒ 无兜底键(null),而非拼一个假键', () => {
assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, null), null);
assert.strictEqual(aggKey.fallbackKeyOf('gaozhi', 5, ''), null);
});
it('作品组:主键 = 真实聚合 uid,兜底键 = cid', () => {
assert.strictEqual(aggKey.primaryKeyOf('benke', 5, 9), '9');
assert.strictEqual(aggKey.fallbackKeyOf('benke', 5, 9), '5');
});
it('作品组:缺真实聚合 uid ⇒ 主键退化为 cid(不再引入第二个键)', () => {
assert.strictEqual(aggKey.primaryKeyOf('benke', 5, null), '5');
assert.strictEqual(aggKey.primaryKeyOf('benke', 5, ''), '5');
});
it('isExamGroup 只认 zhongzhi / gaozhi', () => {
assert.strictEqual(aggKey.isExamGroup('zhongzhi'), true);
assert.strictEqual(aggKey.isExamGroup('gaozhi'), true);
assert.strictEqual(aggKey.isExamGroup('benke'), false);
assert.strictEqual(aggKey.isExamGroup('jiaoshi'), false);
});
// ---- 判别力核心:跨编号空间重叠 ----
// 生产实测(2026-10-08)283 对碰撞的根因就是「无条件双键归集」。
it('buildAggregationKeyMap:兜底键撞上同组他人主键 ⇒ 必须被丢弃', () => {
// 考试组:cid=1 的真实聚合 uid = 2,而 cid=2 的主键恰为 '2' ⇒ 二者重叠
const contestants = [{ id: 1 }, { id: 2 }];
const realAgg = { 1: 2 };
const map = aggKey.buildAggregationKeyMap('gaozhi', contestants, realAgg);
assert.deepStrictEqual(map['1'], ['1'], 'cid=1 的兜底键 2 会命中 cid=2 ⇒ 必须丢弃');
assert.deepStrictEqual(map['2'], ['2']);
});
it('buildAggregationKeyMap:安全兜底键须保留(不因噎废食)', () => {
const contestants = [{ id: 1 }, { id: 2 }];
const realAgg = { 1: 7 }; // 7 未被任何人认领主键
const map = aggKey.buildAggregationKeyMap('gaozhi', contestants, realAgg);
assert.deepStrictEqual(map['1'], ['1', '7']);
assert.deepStrictEqual(map['2'], ['2']);
});
it('buildAggregationKeyMap:空输入 / 非数组不抛错', () => {
assert.deepStrictEqual(aggKey.buildAggregationKeyMap('gaozhi', null, null), {});
assert.deepStrictEqual(aggKey.buildAggregationKeyMap('gaozhi', [], {}), {});
});
it('buildAggregationKeyMap:首元素恒为主键且键不重复', () => {
const contestants = [{ id: 3 }];
const map = aggKey.buildAggregationKeyMap('benke', contestants, { 3: 3 }); // 兜底=cid=主键
assert.deepStrictEqual(map['3'], ['3'], '主键与兜底相等时不得重复');
});
});
describe('app/lib/idCard —— 导入通道判据 SoT', () => {
it('normalizeIdNumber:trim + 末位 x 归一为大写 X', () => {
assert.strictEqual(idCard.normalizeIdNumber(' 11010519491231002x '), '11010519491231002X');
assert.strictEqual(idCard.normalizeIdNumber(null), '');
assert.strictEqual(idCard.normalizeIdNumber(undefined), '');
});
it('expectedCheckCode:对 GB11643 公开样例号算出正确校验位(独立已知答案)', () => {
// `11010519491231002X` 为公开引用的有效样例;校验位算法若被改动此处必红。
assert.strictEqual(idCard.expectedCheckCode('11010519491231002'), 'X');
// 入参不足 17 位或含非数字 ⇒ 返回 ''
assert.strictEqual(idCard.expectedCheckCode('123'), '');
assert.strictEqual(idCard.expectedCheckCode('1101051949123100A'), '');
});
it('inspectIdNumber:合法号 ⇒ importable=true 且 level=null', () => {
const r = idCard.inspectIdNumber('11010519491231002X');
assert.strictEqual(r.importable, true);
assert.strictEqual(r.level, null);
});
it('inspectIdNumber:空 ⇒ error 且不可导入', () => {
const r = idCard.inspectIdNumber('');
assert.strictEqual(r.importable, false);
assert.strictEqual(r.level, 'error');
});
it('inspectIdNumber:长度≠18 ⇒ error 且不可导入(模型层硬约束前置为可读错误)', () => {
const r = idCard.inspectIdNumber('11010519491231002'); // 17 位
assert.strictEqual(r.importable, false);
assert.strictEqual(r.level, 'error');
assert.ok(r.message.includes('18'));
});
it('inspectIdNumber:18 位但校验位不符 ⇒ warn(不阻断,防误伤在册考生)', () => {
const r = idCard.inspectIdNumber('110105194912310020'); // 末位应为 X
assert.strictEqual(r.importable, true);
assert.strictEqual(r.level, 'warn');
assert.ok(r.message.includes('校验位'));
});
it('inspectIdNumber:18 位但含非法字符 ⇒ warn(模型只查长度)', () => {
const r = idCard.inspectIdNumber('1101051949123100AX');
assert.strictEqual(r.importable, true);
assert.strictEqual(r.level, 'warn');
});
});
describe('app/utils/filename —— 上传落盘名清洗(R108g)', () => {
it('去除控制字符(防 stat 失败 / ENOENT)', () => {
assert.strictEqual(sanitizeUploadFileName('a\x00b\x1fc.txt'), 'abc.txt');
});
it('路径分隔符替换为下划线(防目录穿越)', () => {
assert.strictEqual(sanitizeUploadFileName('a/b\\c.txt'), 'a_b_c.txt');
});
it('折叠多余空白并 trim(修「前导空格 + 乱码」类 ENOENT)', () => {
assert.strictEqual(sanitizeUploadFileName(' a b .txt'), 'a b .txt');
});
it('去除首尾点(防 .name / .. 类)', () => {
assert.strictEqual(sanitizeUploadFileName('.hidden'), 'hidden');
});
it('超长名保留扩展名并截断到上限(120)', () => {
const out = sanitizeUploadFileName('a'.repeat(200) + '.txt');
assert.strictEqual(out.length, 120);
assert.ok(out.endsWith('.txt'), '扩展名必须保留');
});
it('客户端预编码(%XX)先解码', () => {
assert.strictEqual(sanitizeUploadFileName('%E5%89%8D.txt'), '前.txt');
});
it('空值 / 非字符串 ⇒ 返回空串(调用方回退时间戳名)', () => {
assert.strictEqual(sanitizeUploadFileName(''), '');
assert.strictEqual(sanitizeUploadFileName(null), '');
assert.strictEqual(sanitizeUploadFileName(123), '');
});
});