导出-每次考试/多考试对比: - 合格次数阈值等标记项抽为常显区块,三类成绩表共用 - 附缺考名单扩展到多考试导出(取所选考试全都没交的人) - 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列 - 列宽按实际内容估算,合并区空位只给最小宽度 - 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底 - 打印标题改为「成绩明细」,缺考名单分页单独成页 - 打印样式加 print-color-adjust:exact,绿底/红字可正常印出 数据大屏: - ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传 - 单班大屏移除退出按钮,仅首页保留 - 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到) 改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿 测试/样例/文档同步更新,全量回归通过。
214 lines
10 KiB
JavaScript
214 lines
10 KiB
JavaScript
// e2e: 卷内「考试白名单 / 通讯录名单」自动驱动缺/待考名单(DataService::build)
|
||
// 运行:node tools/test-whitelist.js (NY_DEBUG=1 可打印调试信息)
|
||
//
|
||
// 真实卷王模型:应考名单 = 项目「参与者(受访者)」(t_project_partner, type=RESPONDENT_SYS_USER/IMP_USER),
|
||
// 经 /api/project/partner/list 返回;不再是旧代码误读的 surveySetting.whiteLists(题目选项白名单)。
|
||
// 覆盖:
|
||
// 1) p1 配置了受访者名单(Project Partners)→ 应考名单恰好为受访者,缺考仅来自受访者(不含名单外成员)
|
||
// 2) p2 未配置受访者 → 按现状(全名单)显示,名单外成员仍在应考/缺考中
|
||
// 3) 已移除的 roster_import_whitelist 接口不再可用(按钮已删除,改为自动驱动)
|
||
// 4) 上传防护不被破坏(导入后 data/uploads 即清)
|
||
const http = require('http');
|
||
const net = require('net');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { spawn } = require('child_process');
|
||
|
||
const ROOT = path.resolve(__dirname, '..');
|
||
const PHP_PORT = 18082;
|
||
const MOCK_PORT = 19911;
|
||
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
|
||
const INI = process.env.PHP_INI || ''; // 便携版 php.ini 已开启 pdo_sqlite / zip / mbstring
|
||
const DEBUG = !!process.env.NY_DEBUG;
|
||
|
||
function resolveDataDir() {
|
||
let p = process.env.NY_DATA || path.join(ROOT, 'data');
|
||
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + p.slice(2);
|
||
p = path.resolve(p);
|
||
process.env.NY_DATA = p;
|
||
return p;
|
||
}
|
||
const DATA = resolveDataDir();
|
||
fs.mkdirSync(DATA, { recursive: true });
|
||
const UPLOADS = path.join(DATA, 'uploads');
|
||
|
||
const moved = [], copied = [];
|
||
for (const f of ['ny.db', 'cache/token.json']) {
|
||
const p = path.join(DATA, f);
|
||
if (fs.existsSync(p)) {
|
||
const bak = p + '.nybak';
|
||
try { fs.renameSync(p, bak); moved.push([p, bak]); } catch (e) {}
|
||
}
|
||
}
|
||
for (const f of ['config.json', 'roster.json', 'project_config.json']) {
|
||
const p = path.join(DATA, f);
|
||
if (fs.existsSync(p)) {
|
||
const bak = p + '.nybak';
|
||
try { fs.copyFileSync(p, bak); copied.push([p, bak]); } catch (e) {}
|
||
}
|
||
}
|
||
const cfgFile = path.join(DATA, 'config.json');
|
||
fs.writeFileSync(cfgFile, JSON.stringify({
|
||
base_url: 'http://127.0.0.1:' + MOCK_PORT,
|
||
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,
|
||
pass_rate: 60,
|
||
layout: 'default',
|
||
}, null, 2));
|
||
|
||
let mock = null, srv = null, cleanedUp = false;
|
||
function killAll() { for (const p of [srv, mock]) { try { p && p.kill('SIGKILL'); } catch (e) {} } }
|
||
function cleanup() {
|
||
if (cleanedUp) return;
|
||
cleanedUp = true;
|
||
killAll();
|
||
for (const f of ['ny.db', 'ny.db-wal', 'ny.db-shm', 'config.json', 'cache/token.json']) {
|
||
const p = path.join(DATA, f);
|
||
try { if (fs.existsSync(p)) fs.unlinkSync(p); } catch (e) {}
|
||
}
|
||
for (const [p, bak] of moved.concat(copied)) {
|
||
try { if (fs.existsSync(bak)) fs.copyFileSync(bak, p); } catch (e) {}
|
||
try { if (fs.existsSync(bak)) fs.unlinkSync(bak); } catch (e) {}
|
||
}
|
||
}
|
||
process.on('exit', cleanup);
|
||
process.on('SIGINT', () => { cleanup(); process.exit(1); });
|
||
process.on('uncaughtException', (e) => { console.error('FATAL', e); cleanup(); process.exit(2); });
|
||
|
||
mock = spawn('node', ['tools/mock-server.js'], { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||
const phpArgs = INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PHP_PORT, '-t', ROOT]);
|
||
srv = spawn(PHP, phpArgs, { cwd: ROOT, stdio: DEBUG ? 'inherit' : 'ignore' });
|
||
|
||
function waitPort(port, timeoutMs) {
|
||
const deadline = Date.now() + timeoutMs;
|
||
return new Promise((resolve) => {
|
||
(function attempt() {
|
||
const s = net.connect({ host: '127.0.0.1', port });
|
||
const done = (ok) => { s.destroy(); resolve(ok); };
|
||
s.setTimeout(800, () => done(false));
|
||
s.on('connect', () => done(true));
|
||
s.on('error', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
|
||
s.on('timeout', () => { if (Date.now() > deadline) return done(false); setTimeout(attempt, 300); });
|
||
})();
|
||
});
|
||
}
|
||
|
||
const cookieJar = [];
|
||
let CSRF = '';
|
||
function baseHeaders() {
|
||
const h = {};
|
||
if (cookieJar.length) h['Cookie'] = cookieJar.map(c => c.replace(/;.*$/, '')).join('; ');
|
||
return h;
|
||
}
|
||
function absorb(res) {
|
||
for (const sc of res.headers['set-cookie'] || []) {
|
||
const pair = sc.split(';')[0];
|
||
const name = pair.split('=')[0];
|
||
const i = cookieJar.findIndex(x => x.startsWith(name + '='));
|
||
if (i >= 0) cookieJar[i] = pair; else cookieJar.push(pair);
|
||
}
|
||
}
|
||
function request(method, urlPath, headers, bodyBuf) {
|
||
return new Promise((resolve, reject) => {
|
||
const h = Object.assign({}, headers || {});
|
||
if (bodyBuf) h['Content-Length'] = bodyBuf.length;
|
||
const r = http.request({ host: '127.0.0.1', port: PHP_PORT, method, path: urlPath, headers: h }, res => {
|
||
const chunks = [];
|
||
res.on('data', c => chunks.push(c));
|
||
res.on('end', () => { absorb(res); const raw = Buffer.concat(chunks).toString('utf8'); let j = null; try { j = JSON.parse(raw); } catch (e) {} resolve({ status: res.statusCode, body: j, raw }); });
|
||
});
|
||
r.on('error', reject);
|
||
if (bodyBuf) r.write(bodyBuf);
|
||
r.end();
|
||
});
|
||
}
|
||
const get = (p) => request('GET', p, baseHeaders());
|
||
function formPost(p, fields) {
|
||
const body = Buffer.from(new URLSearchParams(fields).toString());
|
||
return request('POST', p, Object.assign({ 'Content-Type': 'application/x-www-form-urlencoded' }, baseHeaders()), body);
|
||
}
|
||
function upload(urlPath, fields, file) {
|
||
const boundary = '----nyUpload' + Math.random().toString(16).slice(2);
|
||
const parts = [];
|
||
for (const [k, v] of Object.entries(fields)) parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="${k}"\r\n\r\n${v}\r\n`));
|
||
parts.push(Buffer.from(`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${file.filename}"\r\nContent-Type: application/octet-stream\r\n\r\n`));
|
||
parts.push(Buffer.isBuffer(file.content) ? file.content : Buffer.from(file.content));
|
||
parts.push(Buffer.from(`\r\n--${boundary}--\r\n`));
|
||
return request('POST', urlPath, Object.assign({ 'Content-Type': 'multipart/form-data; boundary=' + boundary }, baseHeaders()), Buffer.concat(parts));
|
||
}
|
||
|
||
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 : '')); }
|
||
}
|
||
|
||
(async () => {
|
||
const mockUp = await waitPort(MOCK_PORT, 15000);
|
||
const phpUp = await waitPort(PHP_PORT, 15000);
|
||
chk('mock 卷王服务已就绪', mockUp);
|
||
chk('PHP 内置服务器已就绪', phpUp);
|
||
if (!mockUp || !phpUp) { console.log(' 服务未就绪,终止测试'); cleanup(); process.exit(1); }
|
||
|
||
for (let i = 0; i < 3 && CSRF === ''; i++) {
|
||
await formPost('/login.php', { username: 'mock', password: 'surveyking' });
|
||
const ac = await get('/api.php?action=auth_check');
|
||
if (ac.body && ac.body.data && ac.body.data.auth === true) {
|
||
const cg = await get('/api.php?action=config_get');
|
||
CSRF = (cg.body && cg.body.data && cg.body.data.csrf_token) || '';
|
||
}
|
||
}
|
||
chk('已登录并取到 CSRF token', CSRF !== '');
|
||
if (CSRF === '') { console.log(' 登录失败,终止测试'); cleanup(); process.exit(1); }
|
||
|
||
console.log('\n--- 1) 先导入一份名单(含白名单内 90001-90005 + 一个白名单外 90099) ---');
|
||
const csv = '学号,姓名,班级\n90001,甲,1班\n90002,乙,1班\n90003,丙,1班\n90004,丁,1班\n90005,戊,1班\n90099,外,1班\n';
|
||
const imp = await upload('/api.php?action=roster_import', { mode: 'replace', _csrf: CSRF }, {
|
||
filename: 'roster.csv', content: Buffer.from('\xEF\xBB\xBF' + csv, 'utf8'),
|
||
});
|
||
chk('名单导入成功', !!(imp.body && imp.body.ok === true), (imp.body && (imp.body.error || imp.body.msg)) || '');
|
||
chk('导入人数为 6', !!(imp.body && imp.body.data && imp.body.data.imported === 6), imp.body ? String(imp.body.data && imp.body.data.imported) : '');
|
||
|
||
console.log('\n--- 2) p1 卷内白名单驱动应考名单 ---');
|
||
const d1 = await get('/api.php?action=data&projectId=p1');
|
||
chk('data(p1) 成功', !!(d1.body && d1.body.ok === true), (d1.body && (d1.body.error || d1.body.msg)) || d1.raw.slice(0, 120));
|
||
const d1d = (d1.body && d1.body.data) || {};
|
||
chk('p1 标记为白名单驱动', d1d.whitelist_driven === true, 'whitelist_driven=' + d1d.whitelist_driven);
|
||
chk('p1 白名单应考 5 人', d1d.whitelist_total === 5, 'whitelist_total=' + d1d.whitelist_total);
|
||
const r1 = (d1d.roster || []).map(s => String(s.sid)).sort();
|
||
chk('p1 应考名单恰好 90001-90005', JSON.stringify(r1) === JSON.stringify(['90001', '90002', '90003', '90004', '90005']), JSON.stringify(r1));
|
||
const a1 = (d1d.absent || []).map(s => String(s.sid)).sort();
|
||
chk('p1 缺考仅来自白名单(不含 90099)', !a1.includes('90099'), JSON.stringify(a1));
|
||
chk('p1 缺考是白名单子集', a1.every(s => r1.includes(s)), JSON.stringify(a1));
|
||
|
||
console.log('\n--- 3) p2 未设置白名单 → 按现状(全名单)---');
|
||
const d2 = await get('/api.php?action=data&projectId=p2');
|
||
chk('data(p2) 成功', !!(d2.body && d2.body.ok === true), (d2.body && (d2.body.error || d2.body.msg)) || '');
|
||
const d2d = (d2.body && d2.body.data) || {};
|
||
chk('p2 非白名单驱动', d2d.whitelist_driven === false, 'whitelist_driven=' + d2d.whitelist_driven);
|
||
const r2 = (d2d.roster || []).map(s => String(s.sid)).sort();
|
||
chk('p2 含白名单外成员 90099', r2.includes('90099'), JSON.stringify(r2));
|
||
|
||
console.log('\n--- 4) 已移除的 roster_import_whitelist 接口不再可用 ---');
|
||
const removed = await formPost('/api.php?action=roster_import_whitelist', { projectId: 'p1', mode: 'replace', _csrf: CSRF });
|
||
chk('roster_import_whitelist 已停用', !!(removed.body && removed.body.ok === false), (removed.body && (removed.body.error || removed.body.msg)) || '');
|
||
|
||
console.log('\n--- 5) 上传防护仍有效(导入后 data/uploads 即清) ---');
|
||
const left = fs.existsSync(UPLOADS) ? fs.readdirSync(UPLOADS).filter(f => f !== '.htaccess' && f !== 'index.html') : [];
|
||
chk('data/uploads 无残留', left.length === 0, JSON.stringify(left));
|
||
|
||
console.log('');
|
||
console.log(`结果:${pass} 通过 / ${fail} 失败(共 ${pass + fail})`);
|
||
cleanup();
|
||
process.exit(fail === 0 ? 0 : 1);
|
||
})();
|