// 测试随机抽取页的均衡分配算法(通过 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 : '')); } } function sum(a) { return a.reduce((x, y) => x + y, 0); } function eqArr(a, b) { return JSON.stringify(a) === JSON.stringify(b); } /* ==================== 均分模式(weights = null) ==================== */ // A/B = 12/8 抽 10 → 各 5 chk('均分 12/8 抽10 → 5/5', eqArr(T.allocQuotas([12, 8], 10, null), [5, 5]), JSON.stringify(T.allocQuotas([12, 8], 10, null))); // 抽 1 人:余数小组优先 chk('均分 抽1人 → 小组优先', eqArr(T.allocQuotas([12, 8], 1, null), [0, 1]), JSON.stringify(T.allocQuotas([12, 8], 1, null))); // 12/8 抽 3 → 1/2 chk('均分 12/8 抽3 → 1/2', eqArr(T.allocQuotas([12, 8], 3, null), [1, 2]), JSON.stringify(T.allocQuotas([12, 8], 3, null))); // 容量不足:A=1, B=19, 抽 10 → 1/9 chk('均分 容量不足差额再分配', eqArr(T.allocQuotas([1, 19], 10, null), [1, 9]), JSON.stringify(T.allocQuotas([1, 19], 10, null))); // 全抽 chk('均分 全部抽取 = 各组全量', eqArr(T.allocQuotas([12, 8], 20, null), [12, 8]), JSON.stringify(T.allocQuotas([12, 8], 20, null))); // 超额 chk('均分 超额取全量', eqArr(T.allocQuotas([3, 4], 99, null), [3, 4]), JSON.stringify(T.allocQuotas([3, 4], 99, null))); // 20/20 抽 10 chk('均分 20/20 抽10 → 5/5', eqArr(T.allocQuotas([20, 20], 10, null), [5, 5]), JSON.stringify(T.allocQuotas([20, 20], 10, null))); // 三组 10/10/10 抽 8 chk('均分 三组 10/10/10 抽8', (() => { const q = T.allocQuotas([10, 10, 10], 8, null); return sum(q) === 8 && Math.max(...q) - Math.min(...q) <= 1; })(), JSON.stringify(T.allocQuotas([10, 10, 10], 8, null))); // 空数组 chk('空数组返回空', eqArr(T.allocQuotas([], 5, null), [])); /* ==================== 加权模式(按人数权重) ==================== */ // 12/8 抽 10 → 6/4(按人数加权) chk('加权 12/8 抽10 → 6/4', eqArr(T.allocQuotas([12, 8], 10, [12, 8]), [6, 4]), JSON.stringify(T.allocQuotas([12, 8], 10, [12, 8]))); // 12/8 抽 3 → 2/1(raw=[1.8,1.2], base=[1,1], 余数 .8 → A) chk('加权 12/8 抽3 → 2/1', eqArr(T.allocQuotas([12, 8], 3, [12, 8]), [2, 1]), JSON.stringify(T.allocQuotas([12, 8], 3, [12, 8]))); // 加权抽 1 → 按权重 chk('加权 12/8 抽1 → 1/0', eqArr(T.allocQuotas([12, 8], 1, [12, 8]), [1, 0]), JSON.stringify(T.allocQuotas([12, 8], 1, [12, 8]))); // 自定义比例 6:4 chk('自定义 6:4 抽10 → 6/4', eqArr(T.allocQuotas([12, 8], 10, [6, 4]), [6, 4]), JSON.stringify(T.allocQuotas([12, 8], 10, [6, 4]))); // 自定义 1:1 等价均分 chk('自定义 1:1 = 均分', eqArr(T.allocQuotas([12, 8], 10, [1, 1]), [5, 5]), JSON.stringify(T.allocQuotas([12, 8], 10, [1, 1]))); // 极端比例 9:1 抽 5 chk('自定义 9:1 抽5', (() => { const q = T.allocQuotas([12, 8], 5, [9, 1]); return sum(q) === 5 && q[0] >= 4 && q[1] >= 0 && q[0] <= 12 && q[1] <= 8; })(), JSON.stringify(T.allocQuotas([12, 8], 5, [9, 1]))); // 容量约束 [1,19] 抽 10:按权重 1:19 → raw=[0.5,9.5] → base=[0,9] + frac 相同时 // 给剩余容量大的组补余数 → A=0, B=10(A 容量只有 1,本来也只可能 0 或 1) chk('加权 容量约束 [1,19] 抽10', (() => { const q = T.allocQuotas([1, 19], 10, [1, 19]); return sum(q) === 10 && q[0] === 0 && q[1] === 10; })(), JSON.stringify(T.allocQuotas([1, 19], 10, [1, 19]))); // 全 0 权重 → 退化为均分 chk('全0权重 → 均分', eqArr(T.allocQuotas([12, 8], 10, [0, 0]), [5, 5]), JSON.stringify(T.allocQuotas([12, 8], 10, [0, 0]))); // null 权重 → 均分 chk('null权重 → 均分', eqArr(T.allocQuotas([12, 8], 10, null), [5, 5]), JSON.stringify(T.allocQuotas([12, 8], 10, null))); /* ==================== 守恒 + 不超容量 + 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 weights = Array.from({ length: g }, () => Math.random() * 10); // 50% 概率用 null 走均分 const w = Math.random() < 0.5 ? weights : null; const q = T.allocQuotas(sizes, n, w); const total = sizes.reduce((a, b) => a + b, 0); const expect = Math.min(n, total); if (sum(q) !== expect) { fuzzOk = false; fuzzBad = `sum=${sum(q)} expect=${expect} sizes=${sizes} n=${n} w=${w}`; break; } let bad = false; for (let i = 0; i < g; i++) if (q[i] < 0 || q[i] > sizes[i]) bad = true; // 加权模式下"任意两组差 ≤ 1"不一定成立(按权重 9:1 抽 5 应是 5/0) // 但均分模式下应成立 if (w === null) { 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=${sizes} n=${n} w=${w} q=${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 ==================== */ { // 12A + 8B,加权(weights=sizes)抽 10 → 应 6A/4B 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', [12, 8]); const byGrp = { A: 0, B: 0 }; r.picked.forEach(m => byGrp[m.grp]++); chk('加权 pickBalanced 12A/8B 抽10 → 6A/4B', byGrp.A === 6 && byGrp.B === 4, JSON.stringify(byGrp) + ' detail=' + r.detail.join(',')); chk('pickBalanced 详情文案', r.detail.join('/') === 'A 6人/B 4人', r.detail.join('/')); chk('pickBalanced 结果不重复', new Set(r.picked.map(m => m.sid)).size === 10); } { // 均分(weights=null)抽 10 → 5A/5B const pool = []; for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, grp: 'A', cls: 'C' }); for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, grp: 'B', cls: 'C' }); const r = T.pickBalanced(pool, 10, 'grp', null); const byGrp = { A: 0, B: 0 }; r.picked.forEach(m => byGrp[m.grp]++); chk('均分 pickBalanced 12A/8B 抽10 → 5A/5B', byGrp.A === 5 && byGrp.B === 5, JSON.stringify(byGrp) + ' detail=' + r.detail.join(',')); } { // 自定义比例 9:1 抽 5 const pool = []; for (let i = 1; i <= 12; i++) pool.push({ sid: 'A' + i, grp: 'A' }); for (let i = 1; i <= 8; i++) pool.push({ sid: 'B' + i, grp: 'B' }); const r = T.pickBalanced(pool, 5, 'grp', [9, 1]); const byGrp = { A: 0, B: 0 }; r.picked.forEach(m => byGrp[m.grp]++); chk('自定义 9:1 抽5 → 全部 A', byGrp.A === 5 && byGrp.B === 0, JSON.stringify(byGrp)); } { // 无维度(key='')走不到 const pool = [{ sid: 'x1', grp: '', cls: '' }, { sid: 'x2', grp: '', cls: '' }]; const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp', null); chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid))); } console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败'); process.exit(fail === 0 ? 0 : 1);