Files
ny-exam-platform/tools/test-draw-alloc.js
T
nxxy335 6cd17b9cb2 fix: 修复保存满分后清登录;配置双写;设置显示账号/密码;抽取比例改按人数加权
- 修复:仅当连接信息(base_url/api_prefix/username/password/verify_ssl)真变化时才清 token + 重连;
  保存满分/布局等不应触发登录卷王(bug:之前一律 unlink token.json,导致保存后所有用户都被踢)
- 修复:默认满分不再误写当前卷;改为显式 apply_pid + apply_score 才会改 full_scores
- 配置双写(DB+文件):避免 DB 不可用时静默回退到出厂默认 admin/123456;
  ny_save_config 同时写文件+DB;ny_load_config 在 DB 可用但无记录时从文件自动迁回
- 设置弹窗新增:登录账号可编辑、密码 type=password 不回显、留空表示不修改;
  保存后根据 ping 自动给出成功/失败反馈(错误密码不再静默)
- 抽取比例改为按组人数加权(默认)+ 各组均分 + 自定义比例 + 不分组,最大余额法守恒分配
- 抽出比例 UI:删除 5:5 复选框、改 #balanceMode 下拉 + 按组渲染比例输入框
- Db::lastError() 暴露 DB 错误用于诊断(config_get 返回 db_driver / db_connected / db_error)
- e2e:test-draw-alloc 18→29 测试用例(加权/自定义/守恒 fuzz);
  新增 test-config-save e2e 27 用例全过(连接信息变化检测 + 自动 ping + 密码不回显 + apply_pid)
- mock-server:账户校验从 'admin' 改为 'mock' 接受 e2e 账号
2026-09-09 13:06:47 +08:00

167 lines
8.2 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 : '')); }
}
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);