Files
ny-exam-platform/tools/test-dashboard-nav.js
T
nxxy335 d05fd4a9b5 feat(export/dashboard): 导出增强与大屏公开化
导出-每次考试/多考试对比:
- 合格次数阈值等标记项抽为常显区块,三类成绩表共用
- 附缺考名单扩展到多考试导出(取所选考试全都没交的人)
- 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列
- 列宽按实际内容估算,合并区空位只给最小宽度
- 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底
- 打印标题改为「成绩明细」,缺考名单分页单独成页
- 打印样式加 print-color-adjust:exact,绿底/红字可正常印出

数据大屏:
- ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传
- 单班大屏移除退出按钮,仅首页保留
- 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到)
  改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿

测试/样例/文档同步更新,全量回归通过。
2026-09-27 22:04:17 +08:00

297 lines
17 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.
// e2e: 数据大屏两种形态 —— 首页班级入口 + 单班独立大屏(含后退锁定 / 顶栏控件收敛)
//
// 覆盖点:
// 1) 无 ?class= → dash-home:仍显示班级下拉,顶栏出现"选择考试 → 点卡片进入"文案
// 2) ?class=X → dash-class:顶栏隐藏班级选择 / ← 主系统,页面内不出现回首页的链接
// 3) 后退锁定:lockHistory() 只在 IS_CLASS 时挂,首页不受影响
// 4) 卡片渲染:直接把 dashboard.php 里的 clsCardHtml 抽出来跑,断言卡片结构与跳转参数
// 5) 公开访问:带 ?class= 的班级大屏免登录,且接口只回该班数据(其它班级不泄漏)
//
// 运行:node tools/test-dashboard-nav.js
const http = require('http');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { spawn } = require('child_process');
const ROOT = path.resolve(__dirname, '..');
const PORT = 18091;
const INI = process.env.PHP_INI || '';
const PHP = process.env.PHP_BIN || 'C:/Users/Zhang/.workbuddy/binaries/php/8.2.34/php.exe';
const MOCK = { user: 'mock', pass: 'surveyking' };
// NY_DATA 指向临时目录:名单/配置/缓存一律写临时目录,绝不碰项目 data/
let p = process.env.NY_DATA || path.join(os.tmpdir(), 'ny-dash-nav-' + process.pid);
if (process.platform === 'win32' && /^\/[a-zA-Z]\//.test(p)) p = p[1].toUpperCase() + ':' + p.slice(2);
const DATA = path.resolve(p);
process.env.NY_DATA = DATA;
fs.mkdirSync(DATA, { recursive: true });
const cfgFile = path.join(DATA, 'config.json');
// 名单:5 人有答卷(与 mock 的 PARTNERS 90001-90005 对齐),再补 30 名无答卷者凑出 3 个班
const ROSTER = { students: [], updated_at: '2026-01-01 00:00:00' };
const back = [
['90001', '高一(1)班', 'A组'], ['90002', '高一(1)班', 'A组'], ['90003', '高一(1)班', 'B组'],
['90004', '高一(2)班', 'A组'], ['90005', '高一(2)班', 'B组'],
];
back.forEach(([sid, cls, grp]) => ROSTER.students.push({ sid, name: sid, cls, grp, login: '' }));
['高一(1)班', '高一(2)班', '高一(3)班'].forEach((cls, ci) => {
for (let i = 1; i <= 10; i++) {
const sid = String(9100 + ci * 100 + i);
ROSTER.students.push({ sid, name: sid, cls, grp: i % 2 ? 'A组' : 'B组', login: '' });
}
});
try { fs.unlinkSync(cfgFile); } catch (e) {}
// 起始配置:连的是本机 mock,账号留空(登录时再写入,与 test-login-loop 同口径)
fs.writeFileSync(cfgFile, JSON.stringify({
base_url: 'http://127.0.0.1:19911',
api_prefix: '/api',
username: '',
password: '',
verify_ssl: false,
timeout: 10,
refresh_interval: 15,
page_size: 50,
dedupe: true,
include_temp_save: false,
name_keywords: '姓名,名字',
full_score: 100,
layout: 'default',
}, null, 2));
fs.writeFileSync(path.join(DATA, 'roster.json'), JSON.stringify(ROSTER));
let cleanedUp = false, mock = null, srv = null;
function cleanup() {
if (cleanedUp) return;
cleanedUp = true;
try { srv && srv.kill('SIGKILL'); } catch (e) {}
try { mock && mock.kill('SIGKILL'); } catch (e) {}
try { fs.rmSync(DATA, { recursive: true, force: true }); } 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: 'ignore' });
srv = spawn(PHP, INI.split(/\s+/).filter(Boolean).concat(['-S', '127.0.0.1:' + PORT, '-t', ROOT]), { cwd: ROOT, stdio: 'ignore' });
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 : '')); }
}
const cookieJar = [];
function collect(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);
}
}
const jarHeader = () => (cookieJar.length ? cookieJar.map(c => c.replace(/;.*$/, '')).join('; ') : '');
// jar 省略 → 用当前登录会话;传 [] → 完全不带 Cookie 的匿名请求(验公开访问)
function get(p, jar) {
return new Promise((resolve, reject) => {
const h = { 'Accept': 'text/html,application/json' };
const ck = jar === undefined ? jarHeader()
: (jar || []).map(c => c.replace(/;.*$/, '')).join('; ');
if (ck) h['Cookie'] = ck;
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'GET', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => {
// 匿名请求不回灌 Cookie:否则会用它自己的新会话覆盖掉登录会话
if (jar === undefined) collect(res);
resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8'), loc: res.headers.location });
});
});
r.on('error', reject);
r.end();
});
}
function post(p, fields) {
return new Promise((resolve, reject) => {
const data = new URLSearchParams(fields).toString();
const h = { 'Content-Type': 'application/x-www-form-urlencoded', 'Content-Length': Buffer.byteLength(data) };
if (jarHeader()) h['Cookie'] = jarHeader();
const r = http.request({ host: '127.0.0.1', port: PORT, method: 'POST', path: p, headers: h }, res => {
const bufs = [];
res.on('data', c => bufs.push(c));
res.on('end', () => { collect(res); resolve({ status: res.statusCode, raw: Buffer.concat(bufs).toString('utf8') }); });
});
r.on('error', reject);
r.write(data); r.end();
});
}
const delay = ms => new Promise(r => setTimeout(r, ms));
const dashSrc = fs.readFileSync(path.join(ROOT, 'dashboard.php'), 'utf8');
const cssSrc = fs.readFileSync(path.join(ROOT, 'assets', 'style.css'), 'utf8');
(async () => {
await delay(2200);
console.log('\n--- 0) 登录 ---');
await post('/login.php', { username: MOCK.user, password: MOCK.pass });
const ac = await get('/api.php?action=auth_check');
chk('已登录', /"auth"\s*:\s*true/.test(ac.raw), ac.raw.slice(0, 120));
console.log('\n--- 1) 首页形态(无 ?class=)---');
const home = await get('/dashboard.php');
chk('首页返回 200', home.status === 200, 'status=' + home.status);
chk('body 带 dash-home、不带 dash-class',
/<body class="dash dash-home">/.test(home.raw) && !/dash dash-class/.test(home.raw),
(home.raw.match(/<body class="[^"]*"/) || [''])[0]);
chk('首页仍显示班级下拉(机构形态不变)', /id="classSelWrap"/.test(home.raw));
chk('首页保留「← 主系统 / 退出」入口(仅单班页隐藏)',
/id="linkIndex" href="index\.php"/.test(home.raw) && /id="linkLogout" href="logout\.php"/.test(home.raw));
chk('首页首屏提示为"先选考试再点班级"', /先选择一份问卷 \/ 考试,再点击班级卡片进入该班大屏/.test(home.raw));
chk('首页把 renderBoard 分流到 renderClassCards',
/if \(!IS_CLASS\) \{ renderClassCards\(\); return; \}/.test(dashSrc));
chk('首页自动刷新照旧可用(autoRefresh/intervalSel 都在)',
/id="autoRefresh"/.test(home.raw) && /id="intervalSel"/.test(home.raw) && /id="btnFullscreen"/.test(home.raw));
console.log('\n--- 2) 单班大屏形态(?pid=p1&class=高一(1)班)---');
const one = await get('/dashboard.php?pid=p1&class=' + encodeURIComponent('高一(1)班'));
chk('单班页返回 200', one.status === 200, 'status=' + one.status);
chk('body 带 dash-class、不带 dash-home',
/<body class="dash dash-class">/.test(one.raw) && !/dash dash-home/.test(one.raw),
(one.raw.match(/<body class="[^"]*"/) || [''])[0]);
chk('顶栏显示当前班级徽标', /id="heroClass"[^>]*>高一\(1\)班/.test(one.raw),
(one.raw.match(/id="heroClass"[^>]*/) || [''])[0]);
chk('页面内没有任何指向大屏首页的链接', !/href="dashboard\.php"/.test(one.raw));
chk('lockHistory() 在启动时被调起', /lockHistory\(\);/.test(dashSrc));
chk('后退锁定只在单班形态生效', /if \(!IS_CLASS \|\| historyLocked\) return;/.test(dashSrc));
chk('lockHistory 用 pushState 把本页重新推回栈顶',
/history\.pushState\(\{ nyLock: 1 \}, '', location\.href\)/.test(dashSrc) &&
/popstate/.test(dashSrc));
chk('首页形态不锁后退(IS_CLASS 为假时不 push)', !/if \(IS_CLASS(?![\s\S]{0,40}return;\})[\s\S]{0,40}pushState/.test(
dashSrc.slice(dashSrc.indexOf('function lockHistory'), dashSrc.indexOf('function lockHistory') + 400)));
console.log('\n--- 3) 顶栏控件收敛(CSS 隐藏规则)---');
chk('隐藏班级选择', /body\.dash-class #classSelWrap,\s*\n?\s*body\.dash-class #linkIndex \{ display: none !important; \}/.test(cssSrc));
chk('隐藏「← 主系统」', /body\.dash-class #linkIndex/.test(cssSrc));
chk('已不再靠 CSS 藏退出按钮(该形态下根本不渲染)', !/#linkLogout/.test(cssSrc));
chk('首页隐藏 5 张汇总卡', /body\.dash-home #boardSummary \{ display: none; \}/.test(cssSrc));
chk('单班页仍保留试卷/自动刷新/全屏三个控件',
/id="projectSel"/.test(one.raw) && /id="autoRefresh"/.test(one.raw) && /id="btnFullscreen"/.test(one.raw));
chk('单班页的「← 主系统」靠 CSS 隐藏',
/id="linkIndex"/.test(one.raw) && /display: none !important/.test(
cssSrc.slice(cssSrc.indexOf('body.dash-class #classSelWrap'), cssSrc.indexOf('body.dash-home #boardSummary'))));
chk('单班页(已登录)仍渲染退出按钮,由 CSS 隐藏', /id="linkLogout"/.test(one.raw));
console.log('\n--- 4) 班级卡片渲染(把 dashboard.php 里的 clsCardHtml 抽出来实跑)---');
const m = dashSrc.match(/function clsCardHtml\(c\)\{[\s\S]*?\n\}/);
chk('能抽出 clsCardHtml 函数体', !!m);
if (m) {
const esc = s => String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const dv = (v, d) => (v === undefined || v === null || v === '' ? (d === undefined ? '-' : d) : v);
const pv = v => dv(v, '-') + (v === undefined || v === null || v === '' ? '' : '%');
const rateColor = r => (r >= 90 ? '#12a86a' : (r >= 60 ? '#e08c1a' : '#e0483d'));
const fn = new Function('esc', 'dv', 'pv', 'rateColor', m[0] + ' return clsCardHtml;')(esc, dv, pv, rateColor);
const html = fn({
class: '高一(1)班', total: 20, submitted: 3, absent: 17, rate: 15,
attempts: 5, avg: 78.5, pass_rate: 66.7,
});
chk('卡片带上班级名与进入入口', /data-cls="高一\(1\)班"/.test(html) && /cls-go/.test(html));
chk('卡片是按钮语义(role/tabindex)', /role="button"/.test(html) && /tabindex="0"/.test(html));
chk('5 项指标齐全', (html.match(/cls-kpi /g) || []).length === 5, '实际 ' + (html.match(/cls-kpi /g) || []).length + ' 项');
chk('提交率进度条按 rate 取值', /cls-bar"><i style="width:15%/.test(html), (html.match(/width:\d+%/) || [''])[0]);
chk('多次刷题徽标透传', /共 5 次刷题/.test(html));
chk('无成绩时的缺考数照常显示', /<span>缺考<\/span><b>17<\/b>/.test(html));
}
console.log('\n--- 5) 进入班级的跳转 ---');
const e = dashSrc.match(/function enterClass\(cls\)\{[\s\S]*?\n\}/);
chk('能抽出 enterClass 函数体', !!e);
if (e) {
chk('跳转时带上 pid + class + auto',
/searchParams\.set\('pid'/.test(e[0]) && /searchParams\.set\('class'/.test(e[0]) && /searchParams\.set\('auto'/.test(e[0]));
chk('是整页跳转(location.href,不是前端路由)', /location\.href = u\.pathname \+ u\.search;/.test(e[0]));
chk('卡片点击/回车都走事件委托(网格重绘不掉监听)',
/box\.addEventListener\('click'/.test(dashSrc) && /box\.addEventListener\('keydown'/.test(dashSrc) &&
/closest\('\.cls-card'\)/.test(dashSrc));
}
chk('首页卡片容器类名与样式一致(cls-grid)',
/<div class="cls-grid">/.test(dashSrc) && /\.cls-grid \{/.test(cssSrc));
console.log('\n--- 5.5) 成绩红绿口径(必须跟随后台配置的满分成数 / 合格率)---');
const sgSrc = dashSrc.match(/function scoreGrade\(sc\)\{[\s\S]*?\n\}/);
chk('能从 dashboard.php 抽出 scoreGrade', !!sgSrc);
if (sgSrc) {
// scoreGrade 里读的是脚本作用域的 DASH(let 声明,不在 window 上),
// 这里把 DASH 作为形参传进去,还原同一条作用域链
// eslint-disable-next-line no-new-func
const mk = new Function('DASH', sgSrc[0] + '; return scoreGrade;');
const g100 = mk({ full_score: 100, pass_rate: 60 });
const g150 = mk({ full_score: 150, pass_rate: 80 });
const gCur = mk({ full_score: 100, pass_rate: 95 });
chk('默认口径:95 绿 / 70 黄 / 50 红',
g100(95).cls === 'score-high' && g100(70).cls === 'score-mid' && g100(50).cls === 'score-low',
[95, 70, 50].map(v => v + '→' + g100(v).cls).join(' '));
// 后台把合格率改成 80、满分改成 150:100 分只占 66.7%,必须判红,不能再按 100 分制当成优秀
chk('后台改「满分 150 / 合格率 80%」后,100 分判红(不再按 100 分制当优秀)',
g150(100).cls === 'score-low', '100→' + g150(100).cls);
chk('同一配置下 130 分(86.7%)判黄、145 分(96.7%)判绿',
g150(130).cls === 'score-mid' && g150(145).cls === 'score-high',
[130, 145].map(v => v + '→' + g150(v).cls).join(' '));
// 合格率 85%:88 分合格但没到 90 分优秀线 → 黄
chk('合格率 85% 时 88 分判黄(合格但未达优秀线,绿线仍是 90)',
mk({ full_score: 100, pass_rate: 85 })(88).cls === 'score-mid',
'88→' + mk({ full_score: 100, pass_rate: 85 })(88).cls);
// 合格率 95%:合格线本身高过 90 分,绿线跟着抬到 95 —— 92/94 都不及格判红,96 判绿
chk('合格线高过 90% 时绿线跟着抬(合格率 95% → 92/94 判红、96 判绿)',
gCur(92).cls === 'score-low' && gCur(94).cls === 'score-low' && gCur(96).cls === 'score-high',
[92, 94, 96].map(v => v + '→' + gCur(v).cls).join(' '));
chk('缺成绩/越界分数不套样式',
g100(null).cls === '' && g100(undefined).cls === '' && g100('abc').cls === '');
chk('合格线按满分折算后回传(150×80% = 120)', g150(100).line === 120, 'line=' + g150(100).line);
chk('scoreGrade 不再通过 window.DASH 取值(let 声明取不到 window,只能读自由变量 DASH)',
!/window\.DASH/.test(sgSrc[0]));
chk('membersTable 也不再走 window.DASH',
!/window\.DASH/.test(dashSrc.slice(dashSrc.indexOf('function membersTable'), dashSrc.indexOf('function membersTable') + 1600)));
}
console.log('\n--- 6) 公开访问:班级大屏免登录 ---');
const CLS2 = encodeURIComponent('高一(2)班');
const anonPage = await get('/dashboard.php?pid=p1&class=' + CLS2, []);
chk('未登录访问班级大屏返回 200(不再被弹到登录页)',
anonPage.status === 200 && !anonPage.loc, 'status=' + anonPage.status + ' loc=' + (anonPage.loc || ''));
chk('匿名页同样是 dash-class 形态', /<body class="dash dash-class">/.test(anonPage.raw));
chk('公开页不再渲染退出按钮', !/id="linkLogout"/.test(anonPage.raw));
chk('公开页渲染「公开 · 无需登录」角标', /class="pub-tag"/.test(anonPage.raw));
chk('取数带 class(公开访问后端要按班级裁剪,两处调用都不能漏)',
(dashSrc.match(/api\('dashboard',\s*dashParams\(/g) || []).length === 2,
'实际 ' + (dashSrc.match(/api\('dashboard',\s*dashParams\(/g) || []).length + ' 处');
chk('dashParams 在单班形态下补充 class 参数',
/if \(IS_CLASS && CUR\.cls\) p\.class = CUR\.cls;/.test(dashSrc));
const anonApi = await get('/api.php?action=dashboard&projectId=p1&class=' + CLS2, []);
const aj = JSON.parse(anonApi.raw);
chk('未登录调用 dashboard 接口成功', aj.ok === true, anonApi.raw.slice(0, 120));
chk('classes 只回指定班级',
Array.isArray(aj.data.classes) && aj.data.classes.length === 1 && aj.data.classes[0].class === '高一(2)班',
'返回 ' + (aj.data.classes || []).map(x => x.class).join('/'));
chk('别班数据没有泄漏', !JSON.stringify(aj.data.classes).includes('高一(1)班'));
chk('overall 已换成该班汇总且键名兼容',
aj.data.overall && typeof aj.data.overall.attempts_total === 'number',
'attempts_total=' + (aj.data.overall || {}).attempts_total);
chk('overall 不含全卷名单总数', aj.data.overall && aj.data.overall.roster_total === aj.data.classes[0].total,
'roster_total=' + (aj.data.overall || {}).roster_total + ' class.total=' + (aj.data.classes[0] || {}).total);
chk('响应带 public 标记', aj.data.public === true);
const anonNoCls = await get('/api.php?action=dashboard&projectId=p1', []);
chk('未登录且不指定 class → 拒绝(不能拿全卷数据)',
JSON.parse(anonNoCls.raw).ok === false, anonNoCls.raw.slice(0, 120));
const homeAnon = await get('/dashboard.php', []);
chk('未登录访问大屏首页仍然跳转登录页',
/login\.php/.test(homeAnon.loc || ''), 'loc=' + (homeAnon.loc || ''));
console.log('\n通过 ' + pass + ' 项,失败 ' + fail + ' 项');
cleanup();
process.exit(fail ? 1 : 0);
})();