// 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', /
/.test(home.raw) && !/dash dash-class/.test(home.raw), (home.raw.match(//.test(one.raw) && !/dash dash-home/.test(one.raw), (one.raw.match(/]*>高一\(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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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">/.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 形态', //.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); })();