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 账号
This commit is contained in:
2026-09-09 13:06:47 +08:00
parent 6772aeae11
commit 6cd17b9cb2
10 changed files with 765 additions and 120 deletions
+113 -46
View File
@@ -19,79 +19,146 @@ function chk(name, cond, 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)));
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人 → 小组优先', 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)
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 q = T.allocQuotas(sizes, n);
const sum = q.reduce((a, b) => a + b, 0);
// 随机权重
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 !== expect) { fuzzOk = false; fuzzBad = `sum=${sum} expect=${expect} sizes=${sizes} n=${n}`; break; }
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
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; }
// 加权模式下"任意两组差 ≤ 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);
chk('1000 组随机 fuzz:守恒+不超容量+(均分时)均衡', fuzzOk, fuzzBad);
/* ---------- balanceKeyOf ---------- */
/* ==================== 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 ---------- */
/* ==================== 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');
const r = T.pickBalanced(pool, 10, 'grp', [12, 8]);
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);
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');
const r = T.pickBalanced(pool, 2, T.balanceKeyOf(pool) || 'grp', null);
chk('无维度也能抽取', r.picked.length === 2, JSON.stringify(r.picked.map(m => m.sid)));
}