随机抽取页: - 分组均衡抽取:有组别(≥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)
100 lines
5.1 KiB
JavaScript
100 lines
5.1 KiB
JavaScript
// 测试随机抽取页的均衡分配算法(通过 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);
|