Files
ny-exam-platform/tools/test-draw-alloc.js
T
nxxy335 6772aeae11 feat: 抽取页分组均衡5:5 / 重置清累计 / 历史记录 / 排除管理 / 登陆账号字段 / side等高
随机抽取页:
- 分组均衡抽取:有组别(≥2个)时默认按名额均分到组(12/8抽10→5/5),
  其次按班级均分,否则整池随机;可勾选关闭
- 名额均分算法 allocQuotas:floor 均分 + 余数小组优先补齐 +
  超容量退回 + 差额给剩余容量大者
- 重置结果:同时清零累计已抽取数(历史记录仍保留)
- 历史记录:每批次入 localStorage ny_draw_log(上限50),
  重置不清,支持查看详情/恢复结果/删除/清空
- 抽取排除:排除管理弹窗(搜索/清空),结果行可"移出并加入排除"
- 白名单列表下载:登陆账号优先,缺失回退学号

名单登陆账号:
- 模板解析识别"登陆/登录账号/账号/手机号"列,手机号去 .0 尾巴
- Db ny_roster 增 login 列 + 旧表自动升级(三方言)
- roster_save 透传 login;draw_export 使用 login
- 登陆账号仅用于白名单列表下载,其余界面不展示

布局:side 模式下成绩明细/缺考名单 panel 改 flex 撑满,
.table-scroll 取消 320px 限高,两侧等高不再留白

测试:tools/test-draw-alloc.js 18/18 通过(含 1000 组随机 fuzz)
2026-09-09 12:29:49 +08:00

100 lines
5.1 KiB
JavaScript
Raw 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.
// 测试随机抽取页的均衡分配算法(通过 stub 环境加载 assets/draw.js)
// 运行:node tools/test-draw-alloc.js
const fs = require('fs');
const path = require('path');
// stub 浏览器环境:draw.js 顶部只定义函数,尾部 document.addEventListener
global.window = {};
global.document = { addEventListener() {} };
global.localStorage = { getItem: () => null, setItem: () => {} };
global.location = {};
eval(fs.readFileSync(path.join(__dirname, '..', 'assets', 'draw.js'), 'utf8'));
const T = global.window.__nyDrawTest;
if (!T) { console.error('FAIL 未找到 __nyDrawTest 测试钩子'); process.exit(1); }
let pass = 0, fail = 0;
function chk(name, cond, extra) {
if (cond) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); }
else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); }
}
/* ---------- allocQuotas:名额均分(5:5 语义) ---------- */
// A/B = 12/8 抽 10 → 各 5(名额均分到组,与组人数无关)
chk('12/8 抽10 → 5/5', JSON.stringify(T.allocQuotas([12, 8], 10)) === '[5,5]',
JSON.stringify(T.allocQuotas([12, 8], 10)));
// 抽 1 人:余数小组优先
chk('抽1人 → 小组优先', JSON.stringify(T.allocQuotas([12, 8], 1)) === '[0,1]',
JSON.stringify(T.allocQuotas([12, 8], 1)));
// 12/8 抽 3 → 均分 1/1 + 余数给小组 → 1/2
chk('12/8 抽3 → 1/2', JSON.stringify(T.allocQuotas([12, 8], 3)) === '[1,2]',
JSON.stringify(T.allocQuotas([12, 8], 3)));
// 组容量不足:A 只有 1 人,B 有 19 人,抽 10 → A 1 + B 9
chk('容量不足差额再分配', JSON.stringify(T.allocQuotas([1, 19], 10)) === '[1,9]',
JSON.stringify(T.allocQuotas([1, 19], 10)));
// 全抽 → 每组全量
chk('全部抽取 = 各组全量', JSON.stringify(T.allocQuotas([12, 8], 20)) === '[12,8]',
JSON.stringify(T.allocQuotas([12, 8], 20)));
// n 超过总人数 → 全量
chk('超额抽取取全量', (() => {
const q = T.allocQuotas([3, 4], 99);
return JSON.stringify(q) === '[3,4]';
})(), JSON.stringify(T.allocQuotas([3, 4], 99)));
// 5:5 经典:A 20 / B 20 抽 10 → 5/5
chk('20/20 抽10 → 5/5', JSON.stringify(T.allocQuotas([20, 20], 10)) === '[5,5]',
JSON.stringify(T.allocQuotas([20, 20], 10)));
// 三组 10/10/10 抽 8 → 3/3/2(余数并列时稳定分配)
chk('三组 10/10/10 抽8', (() => {
const q = T.allocQuotas([10, 10, 10], 8);
return q.reduce((a, b) => a + b, 0) === 8 && Math.max(...q) - Math.min(...q) <= 1;
})(), JSON.stringify(T.allocQuotas([10, 10, 10], 8)));
// 空组 / 0
chk('空数组返回空', JSON.stringify(T.allocQuotas([], 5)) === '[]');
// 名额总数守恒 + 不超容量 + 均衡(随机 fuzz)
let fuzzOk = true;
let fuzzBad = '';
for (let t = 0; t < 1000; t++) {
const g = 1 + Math.floor(Math.random() * 5);
const sizes = Array.from({ length: g }, () => Math.floor(Math.random() * 30));
const n = Math.floor(Math.random() * 60);
const q = T.allocQuotas(sizes, n);
const sum = q.reduce((a, b) => a + b, 0);
const total = sizes.reduce((a, b) => a + b, 0);
const expect = Math.min(n, total);
if (sum !== expect) { fuzzOk = false; fuzzBad = `sum=${sum} expect=${expect} sizes=${sizes} n=${n}`; break; }
let bad = false;
for (let i = 0; i < g; i++) if (q[i] < 0 || q[i] > sizes[i]) bad = true;
// 均衡性:所有组均未满时,任意两组名额差 ≤ 1
const allNotFull = q.every((v, i) => v < sizes[i]);
if (allNotFull && Math.max(...q) - Math.min(...q) > 1) bad = true;
if (bad) { fuzzOk = false; fuzzBad = `sizes=${JSON.stringify(sizes)} n=${n} q=${JSON.stringify(q)}`; break; }
}
chk('1000 组随机 fuzz:守恒+不超容量+均衡', fuzzOk, fuzzBad);
/* ---------- balanceKeyOf ---------- */
chk('有 A/B 组别 → 按 grp', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'B', cls: 'C1' }]) === 'grp');
chk('单组别 → 按班级', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C2' }]) === 'cls');
chk('无组别多班级 → 按班级', T.balanceKeyOf([{ grp: '', cls: 'C1' }, { grp: '', cls: 'C2' }]) === 'cls');
chk('都相同 → 不均衡', T.balanceKeyOf([{ grp: 'A', cls: 'C1' }, { grp: 'A', cls: 'C1' }]) === '');
/* ---------- pickBalanced ---------- */
{
const pool = [];
for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, name: 'A' + i, grp: 'A', cls: 'C' });
for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, name: 'B' + i, grp: 'B', cls: 'C' });
const r = T.pickBalanced(pool, 10, 'grp');
const byGrp = { A: 0, B: 0 };
r.picked.forEach(m => byGrp[m.grp]++);
chk('均衡抽取 12A/8B 抽10 → 5A/5B', byGrp.A === 5 && byGrp.B === 5, JSON.stringify(byGrp) + ' detail=' + r.detail.join(','));
chk('detail 文案', r.detail.join('/') === 'A 5人/B 5人', r.detail.join('/'));
chk('结果不重复', new Set(r.picked.map(m => m.sid)).size === 10);
}
{
const pool = [{ sid: 'x1', grp: '', cls: '' }, { sid: 'x2', grp: '', cls: '' }];
const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp');
chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid)));
}
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
process.exit(fail === 0 ? 0 : 1);