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)
This commit is contained in:
168
test/lib.test.js
Normal file
168
test/lib.test.js
Normal file
@@ -0,0 +1,168 @@
|
||||
'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), '');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user