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:
@@ -100,7 +100,7 @@ const server = http.createServer((req, res) => {
|
||||
Buffer.from(pwd, 'base64')
|
||||
).toString('utf8');
|
||||
} catch (e) { plain = '<<解密失败>>'; }
|
||||
if (j.username !== 'admin' || plain !== PASSWORD) {
|
||||
if (j.username !== 'mock' || plain !== PASSWORD) {
|
||||
return send(res, 200, { code: 500, data: null, msg: '账号或密码错误(收到明文=' + plain + ')' });
|
||||
}
|
||||
TOKEN = 'mock.jwt.token.' + Date.now();
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// e2e: config_save 仅连接信息变化才清 token;各试卷独立满分修复;密码不回显
|
||||
// 运行:node tools/test-config-save.js
|
||||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const PORT = 18081;
|
||||
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/php.exe';
|
||||
const INI = process.env.PHP_INI || '-d extension_dir=C:/Users/Zhang/.workbuddy/binaries/php/ext -d extension=pdo_sqlite';
|
||||
|
||||
// 1) 备份 / 准备文件存储模式
|
||||
const cfgFile = path.join(ROOT, 'data', 'config.json');
|
||||
const cfgBak = cfgFile + '.bak';
|
||||
const origCfg = fs.existsSync(cfgFile) ? fs.readFileSync(cfgFile, 'utf8') : null;
|
||||
const tokenFile = path.join(ROOT, 'data', 'cache', 'token.json');
|
||||
const tokenBak = tokenFile + '.bak';
|
||||
const hasToken = fs.existsSync(tokenFile);
|
||||
const tokenSnap = hasToken ? fs.readFileSync(tokenFile, 'utf8') : null;
|
||||
if (hasToken) fs.renameSync(tokenFile, tokenBak);
|
||||
|
||||
fs.writeFileSync(cfgFile, JSON.stringify({
|
||||
base_url: 'http://127.0.0.1:19911',
|
||||
api_prefix: '/api',
|
||||
username: 'mock',
|
||||
password: 'surveyking',
|
||||
verify_ssl: false,
|
||||
timeout: 10,
|
||||
refresh_interval: 15,
|
||||
page_size: 50,
|
||||
include_temp_save: false,
|
||||
dedupe: true,
|
||||
name_keywords: '姓名,名字',
|
||||
full_score: 100,
|
||||
full_scores: {},
|
||||
layout: 'default',
|
||||
}, null, 2));
|
||||
|
||||
// 2) 启动 mock 卷王
|
||||
const mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: 'pipe' });
|
||||
mock.stdout.on('data', d => process.stdout.write('[mock] ' + d));
|
||||
mock.stderr.on('data', d => process.stderr.write('[mock-err] ' + d));
|
||||
|
||||
// 3) 启动 PHP 内置服务器(-t 让所有请求走 index.php 路由)
|
||||
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]);
|
||||
const srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: 'pipe' });
|
||||
srv.stdout.on('data', d => process.stdout.write('[php] ' + d));
|
||||
srv.stderr.on('data', d => process.stderr.write('[php-err] ' + d));
|
||||
process.on('exit', () => { try { srv.kill('SIGKILL'); mock.kill('SIGKILL'); } catch(e){} });
|
||||
process.on('SIGINT', () => { srv.kill('SIGKILL'); mock.kill('SIGKILL'); 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 req(method, path, body, headers) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = body ? JSON.stringify(body) : '';
|
||||
const h = Object.assign({
|
||||
'Content-Type': 'application/json',
|
||||
'Content-Length': Buffer.byteLength(data),
|
||||
}, headers || {});
|
||||
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||||
const r = http.request({ host: '127.0.0.1', port: PORT, method, path, headers: h }, res => {
|
||||
let chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
let j = null;
|
||||
try { j = JSON.parse(text); } catch (e) {}
|
||||
// 收集 Set-Cookie
|
||||
if (res.headers['set-cookie']) {
|
||||
for (const sc of res.headers['set-cookie']) {
|
||||
const pair = sc.split(';')[0];
|
||||
const name = pair.split('=')[0];
|
||||
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
|
||||
}
|
||||
}
|
||||
// 跟随 302(不自动跟随 action=login 等会导致副作用的)
|
||||
if (res.statusCode === 302 && res.headers.location && !h['__followed'] && !/login\.php/.test(res.headers.location)) {
|
||||
h['__followed'] = 1;
|
||||
console.log(' [debug] follow', res.statusCode, '→', res.headers.location);
|
||||
return resolve(req('GET', res.headers.location, null, h));
|
||||
}
|
||||
resolve({ status: res.statusCode, body: j, raw: text, setCookie: res.headers['set-cookie'], loc: res.headers.location });
|
||||
});
|
||||
});
|
||||
r.on('error', reject);
|
||||
if (data) r.write(data);
|
||||
r.end();
|
||||
});
|
||||
}
|
||||
const cookieJar = [];
|
||||
|
||||
function formPost(path, fields) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const body = new URLSearchParams(fields).toString();
|
||||
const h = {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
'Content-Length': Buffer.byteLength(body),
|
||||
};
|
||||
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||||
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path, headers: h }, res => {
|
||||
let chunks = [];
|
||||
res.on('data', c => chunks.push(c));
|
||||
res.on('end', () => {
|
||||
const text = Buffer.concat(chunks).toString('utf8');
|
||||
if (res.headers['set-cookie']) {
|
||||
for (const sc of res.headers['set-cookie']) {
|
||||
const pair = sc.split(';')[0];
|
||||
const name = pair.split('=')[0];
|
||||
const idx = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||||
if (idx >= 0) cookieJar[idx] = pair; else cookieJar.push(pair);
|
||||
}
|
||||
}
|
||||
resolve({ status: res.statusCode, body: text, loc: res.headers.location });
|
||||
});
|
||||
});
|
||||
r.on('error', reject);
|
||||
r.write(body); r.end();
|
||||
});
|
||||
}
|
||||
|
||||
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
async function login() {
|
||||
// 走 login.php 的表单登录拿会话
|
||||
return await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||||
}
|
||||
|
||||
(async () => {
|
||||
// 等服务起来
|
||||
await delay(2000);
|
||||
// 用 inline 脚本快速验证 login 是否能成功(保留 srv 不退出)
|
||||
await new Promise(r => setTimeout(r, 200));
|
||||
const http = require('http');
|
||||
const testCookie = await new Promise((resolve) => {
|
||||
const body = new URLSearchParams({username:'mock',password:'surveyking'}).toString();
|
||||
http.request({host:'127.0.0.1',port:PORT,method:'POST',path:'/login.php',headers:{'Content-Type':'application/x-www-form-urlencoded','Content-Length':Buffer.byteLength(body)}},res=>{
|
||||
let c=[]; res.on('data',d=>c.push(d)); res.on('end',()=>{
|
||||
const text = Buffer.concat(c).toString();
|
||||
const cookie = (res.headers['set-cookie']||[]).map(c=>c.split(';')[0]).join('; ');
|
||||
console.log(' [inline] login status', res.statusCode, 'set-cookie', JSON.stringify(res.headers['set-cookie']));
|
||||
if (text.indexOf('登录失败') >= 0) {
|
||||
const m = text.match(/登录失败[^<]+/);
|
||||
console.log(' [inline] body 错误:', m ? m[0] : '(unknown)');
|
||||
} else {
|
||||
console.log(' [inline] body end:', text.slice(Math.max(0,text.length-200)));
|
||||
}
|
||||
// 拿同一 cookie 调 auth_check
|
||||
http.request({host:'127.0.0.1',port:PORT,method:'GET',path:'/api.php?action=auth_check',headers:{Cookie:cookie}}, rs=>{
|
||||
let cc=[]; rs.on('data',d=>cc.push(d)); rs.on('end',()=>{
|
||||
console.log(' [inline] auth_check', rs.statusCode, Buffer.concat(cc).toString());
|
||||
resolve(cookie);
|
||||
});
|
||||
}).end();
|
||||
});
|
||||
}).end(body);
|
||||
});
|
||||
if (testCookie && testCookie.indexOf('NYSESS=') >= 0) {
|
||||
// 同步 jar
|
||||
cookieJar.length = 0;
|
||||
testCookie.split('; ').forEach(c => cookieJar.push(c));
|
||||
}
|
||||
|
||||
console.log('\n--- 1) 首次登录应成功 ---');
|
||||
// 登录 formPost 不会跟随 302(直接保留 body),但要求 NYSESS cookie 出现
|
||||
const lg = await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||||
console.log(' [debug] login loc:', lg.loc, 'body:', (lg.body||'').slice(0, 200));
|
||||
chk('login.php 200/302', lg.status === 302 || lg.status === 200, 'status=' + lg.status);
|
||||
chk('session cookie 已获得', cookieJar.length > 0, 'jar=' + JSON.stringify(cookieJar));
|
||||
// 复查 auth(直接走 Session ID 路径)
|
||||
const ac = await req('GET', '/api.php?action=auth_check&_t=' + Date.now());
|
||||
console.log(' [debug] auth_check:', ac.status, ac.raw);
|
||||
chk('auth_check 通过', ac.body && ac.body.data && ac.body.data.auth === true, JSON.stringify(ac.body));
|
||||
if (!(ac.body && ac.body.data && ac.body.data.auth)) {
|
||||
console.log(' [fatal] 未登录,停止测试');
|
||||
mock.kill('SIGTERM'); srv.kill('SIGTERM');
|
||||
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg); else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
|
||||
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
|
||||
process.exit(1);
|
||||
}
|
||||
// 之后用 ping 触发 SurveyKing 登录
|
||||
const p1 = await req('GET', '/api.php?action=ping&_t=' + Date.now());
|
||||
console.log(' [debug] ping raw:', p1.status, 'loc=' + p1.loc, (p1.raw||'').slice(0, 200));
|
||||
chk('ping 200', p1.status === 200, 'status=' + p1.status + ' body=' + (p1.raw||'').slice(0, 200));
|
||||
chk('ping.ok', p1.body && p1.body.ok === true, JSON.stringify(p1.body));
|
||||
await delay(100);
|
||||
chk('token 已缓存', fs.existsSync(tokenFile));
|
||||
|
||||
console.log('\n--- 2) config_get 应返回 storage + 诊断 ---');
|
||||
const cg1 = await req('GET', '/api.php?action=config_get');
|
||||
chk('config_get 200', cg1.status === 200);
|
||||
chk('返回 storage 字段', cg1.body && cg1.body.data && typeof cg1.body.data.storage === 'string',
|
||||
'storage=' + (cg1.body && cg1.body.data && cg1.body.data.storage));
|
||||
chk('不返回明文密码', cg1.body && cg1.body.data && cg1.body.data.password === undefined);
|
||||
chk('has_password=true', cg1.body && cg1.body.data && cg1.body.data.has_password === true);
|
||||
|
||||
console.log('\n--- 3) config_save 不改连接信息:不应清 token / 不应触发 ping ---');
|
||||
const before = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||
const cs1 = await req('POST', '/api.php?action=config_save', {
|
||||
full_score: 95,
|
||||
});
|
||||
chk('config_save 200', cs1.status === 200);
|
||||
chk('conn_changed=false', cs1.body && cs1.body.data && cs1.body.data.conn_changed === false);
|
||||
chk('ping 字段不存在', cs1.body && cs1.body.data && cs1.body.data.ping === undefined);
|
||||
const after = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||
chk('token 保持未变', before === after, 'changed=' + (before !== after));
|
||||
|
||||
console.log('\n--- 4) 各试卷独立满分:仅当 apply_pid 显式指定时写 ---');
|
||||
// 不传 apply_pid,也不传 full_scores → full_scores 应保留原值(保持空映射)
|
||||
const cs2 = await req('POST', '/api.php?action=config_save', {
|
||||
full_score: 90,
|
||||
});
|
||||
const _cs2fs = cs2.body && cs2.body.data && cs2.body.data.config
|
||||
? cs2.body.data.config.full_scores : undefined;
|
||||
chk('未传 apply_pid → full_scores 保持空映射(不被覆盖为非空)',
|
||||
_cs2fs === undefined || Object.keys(_cs2fs).length === 0,
|
||||
JSON.stringify(_cs2fs));
|
||||
// 显式 apply_pid='p1', apply_score=85 → 写入
|
||||
const cs3 = await req('POST', '/api.php?action=config_save', {
|
||||
full_score: 90,
|
||||
apply_pid: 'p1',
|
||||
apply_score: 85,
|
||||
});
|
||||
chk('apply_pid=85 → full_scores.p1=85', cs3.body && cs3.body.data && cs3.body.data.config
|
||||
&& cs3.body.data.config.full_scores && cs3.body.data.config.full_scores.p1 === 85,
|
||||
JSON.stringify(cs3.body.data && cs3.body.data.config && cs3.body.data.config.full_scores));
|
||||
// apply_pid='p1', apply_score=0 → 清除
|
||||
const cs4 = await req('POST', '/api.php?action=config_save', {
|
||||
full_score: 90,
|
||||
apply_pid: 'p1',
|
||||
apply_score: 0,
|
||||
});
|
||||
chk('apply_pid=0 → 清除 p1',
|
||||
cs4.body && cs4.body.data && cs4.body.data.config
|
||||
&& (!cs4.body.data.config.full_scores || cs4.body.data.config.full_scores.p1 === undefined),
|
||||
JSON.stringify(cs4.body.data && cs4.body.data.config && cs4.body.data.config.full_scores));
|
||||
|
||||
console.log('\n--- 5) 修改连接信息:应清 token + 重新登录(ping)---');
|
||||
const tokBefore = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||
// 最先修改密码为不同值:触发 conn_changed=true + ping.ok=false
|
||||
const cs5 = await req('POST', '/api.php?action=config_save', {
|
||||
password: 'temp-wrong-password',
|
||||
});
|
||||
chk('修改密码后 conn_changed=true', cs5.body && cs5.body.data && cs5.body.data.conn_changed === true);
|
||||
chk('返回 ping.ok=false', cs5.body && cs5.body.data && cs5.body.data.ping && cs5.body.data.ping.ok === false,
|
||||
JSON.stringify(cs5.body && cs5.body.data && cs5.body.data.ping));
|
||||
await delay(200);
|
||||
const tokAfter = fs.existsSync(tokenFile) ? fs.readFileSync(tokenFile, 'utf8') : '';
|
||||
chk('修改同 token 被清', tokBefore !== '' && tokAfter === '', 'before=' + (tokBefore !== '') + ' after=' + (tokAfter !== ''));
|
||||
// 第二步:改回正确密码,应 trigger conn_changed=true + ping.ok=true
|
||||
const cs5b = await req('POST', '/api.php?action=config_save', {
|
||||
password: 'surveyking',
|
||||
});
|
||||
chk('改回正确密码同 conn_changed=true', cs5b.body && cs5b.body.data && cs5b.body.data.conn_changed === true);
|
||||
chk('改回后 ping.ok=true', cs5b.body && cs5b.body.data && cs5b.body.data.ping && cs5b.body.data.ping.ok === true,
|
||||
JSON.stringify(cs5b.body && cs5b.body.data && cs5b.body.data.ping));
|
||||
await delay(200);
|
||||
chk('登录成功同 token 已生成', fs.existsSync(tokenFile), 'token exists=' + fs.existsSync(tokenFile));
|
||||
|
||||
console.log('\n--- 6) config_get 永远不带明文密码 ---');
|
||||
const cg2 = await req('GET', '/api.php?action=config_get');
|
||||
chk('密码不存在', cg2.body && cg2.body.data && cg2.body.data.password === undefined);
|
||||
chk('has_password 标志', cg2.body && cg2.body.data && cg2.body.data.has_password === true);
|
||||
|
||||
console.log('\n--- 7) 修改为错误密码:应自动 ping 失败并保留弹窗提示 ---');
|
||||
const cs6 = await req('POST', '/api.php?action=config_save', {
|
||||
username: 'mock',
|
||||
password: 'wrong-password',
|
||||
});
|
||||
chk('conn_changed=true', cs6.body && cs6.body.data && cs6.body.data.conn_changed === true);
|
||||
chk('ping.ok=false', cs6.body && cs6.body.data && cs6.body.data.ping && cs6.body.data.ping.ok === false,
|
||||
JSON.stringify(cs6.body && cs6.body.data && cs6.body.data.ping));
|
||||
|
||||
console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败');
|
||||
|
||||
// 清理
|
||||
mock.kill('SIGTERM');
|
||||
srv.kill('SIGTERM');
|
||||
if (origCfg !== null) fs.writeFileSync(cfgFile, origCfg); else if (fs.existsSync(cfgFile)) fs.unlinkSync(cfgFile);
|
||||
if (hasToken && fs.existsSync(tokenBak)) fs.renameSync(tokenBak, tokenFile);
|
||||
setTimeout(() => process.exit(fail === 0 ? 0 : 1), 200);
|
||||
})().catch(e => { console.error('FATAL', e); try { mock.kill(); srv.kill(); } catch(z){}; process.exit(2); });
|
||||
+113
-46
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user