/* 多考试打印渲染回归测试 * * 「多考试打印」的版式由 assets/app.js 的 sheetToHtml() 负责:把服务端装配好的 * 二维表 + merges(1-based 闭区间)+ cell_styles(red=最高分红字 / green=合格绿底) * 翻成带 colspan / rowspan 的 HTML。 * 本测试直接从 app.js 抽取该函数源码执行(不复制一份实现,避免两边漂移), * 用与 tests/test-multi-export.php 同一套版式校验渲染结果。 * * 运行:node tools/test-block-print.js */ const fs = require('fs'); const path = require('path'); let pass = 0, fail = 0; function chk(name, ok, extra) { if (ok) { pass++; console.log(' PASS ' + name + (extra ? ' — ' + extra : '')); } else { fail++; console.log(' FAIL ' + name + (extra ? ' — ' + extra : '')); } } /* ---------- 1) 从生产代码里抽取 sheetToHtml ---------- */ const appPath = path.join(__dirname, '..', 'assets', 'app.js'); const appSrc = fs.readFileSync(appPath, 'utf8'); const m = appSrc.match(/function sheetToHtml\(sheet\)\s*\{[\s\S]*?\n \}/); chk('能从 assets/app.js 抽到 sheetToHtml', !!m); if (!m) { console.log('\n结果:' + pass + ' 通过,' + fail + ' 失败'); process.exit(1); } const PRINT_CSS = (appSrc.match(/function printDoc\(title, bodyHtml, opts\)\s*\{[\s\S]*?\n \}/) || [''])[0]; const esc = s => String(s == null ? '' : s) .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); // eslint-disable-next-line no-new-func const sheetToHtml = new Function('esc', 'return ' + m[0])(esc); /* ---------- 2) 真实装配结果(截图版式:共享「第几次」列 + 合格次数行) ---------- 列:1 学号 | 2 姓名 | 3 第几次 | 4-5 数学 | 6-7 英语 合格线 110,阈值 1 次。 */ const FIXTURE = { header_rows: 2, rows: [ ['学号', '姓名', '第几次', '数学', '', '英语', ''], ['', '', '', '成绩', '', '成绩', ''], // 张三:数学 3 次(112/100/136),英语 1 次(112) ['356001', '张三', '1', '112', '', '112', ''], ['', '', '2', '100', '', '', ''], ['', '', '3', '136', '', '', ''], ['', '', '合格次数', '3', '', '1', ''], // 李四:数学整场未考,英语 3 次(130/129/133) ['356002', '李四', '1', '未考', '', '130', ''], ['', '', '2', '', '', '129', ''], ['', '', '3', '', '', '133', ''], ['', '', '合格次数', '', '', '3', ''], // 王五:数学 1 次(88,未达合格线),英语整场未考 ['356003', '王五', '1', '88', '', '未考', ''], ['', '', '合格次数', '0', '', '', ''], ], merges: [ [1, 1, 2, 1], [1, 2, 2, 2], [1, 3, 2, 3], [1, 4, 1, 5], [1, 6, 1, 7], // 表头 [3, 1, 6, 1], [3, 2, 6, 2], [3, 3, 5, 3], // 张三:学号/姓名并入末行,「第几次」只到末次考试 [7, 1, 10, 1], [7, 2, 10, 2], [7, 4, 10, 5], // 李四:学号/姓名并入末行;数学整场「未考」跨 2 列 × 4 行 [11, 6, 11, 7], // 王五:英语整场「未考」跨 2 列 ], cell_styles: { '5,4': 'red', // 张三 数学最高分 136(1-based 行 5) → 红字 '9,6': 'red', // 李四 英语最高分 133(1-based 行 9) → 红字 '6,4': 'green', // 张三 数学合格次数 3 → 绿底 '6,6': 'green', // 张三 英语合格次数 1 → 绿底 '10,6': 'green', // 李四 英语合格次数 3 → 绿底 }, }; const html = sheetToHtml(FIXTURE); const thead = (html.match(/([\s\S]*?)<\/thead>/) || [])[1] || ''; const tbody = (html.match(/([\s\S]*?)<\/tbody>/) || [])[1] || ''; const count = (s, needle) => s.split(needle).length - 1; /* ---------- 3) 断言 ---------- */ chk('表头 2 行进 (多页打印自动重复)', count(thead, '') === 2 && count(tbody, '') === 10, 'thead=' + count(thead, '') + ' tbody=' + count(tbody, '')); chk('学号表头纵向合并 2 行', thead.indexOf('学号') >= 0); chk('姓名表头纵向合并 2 行', thead.indexOf('姓名') >= 0); chk('第几次表头纵向合并 2 行', thead.indexOf('第几次') >= 0); chk('考试名称横跨 2 列', thead.indexOf('数学') >= 0 && thead.indexOf('英语') >= 0); chk('第 2 行表头是「成绩」小标题', thead.indexOf('成绩') >= 0); chk('张三学号纵向合并 4 行(3 次考试 + 合格次数行)', tbody.indexOf('356001') >= 0); chk('李四学号纵向合并 4 行', tbody.indexOf('356002') >= 0); chk('王五学号不合并(只有 1 次考试)', tbody.indexOf('356003') >= 0); chk('「合格次数」标签落在「第几次」列且 3 处都在', count(tbody, '合格次数') === 3, '实际 ' + count(tbody, '合格次数')); chk('未考合并 2 列 × 4 行(李四·数学)', tbody.indexOf('未考') >= 0); chk('未考合并 2 列 × 1 行(王五·英语)', tbody.indexOf('未考') >= 0); chk('最高分红字 2 处(张三 136 / 李四 133)', count(tbody, 'class="max">') === 2, '实际 ' + count(tbody, 'class="max">')); chk('合格次数绿底 3 处(张三 2 场 + 李四 1 场)', count(tbody, 'class="pass">') === 3, '实际 ' + count(tbody, 'class="pass">')); chk('王五数学 0 次未达标 → 不标绿', tbody.indexOf('>0') >= 0 && tbody.indexOf('class="pass">0') < 0); /* td 数守恒:总格位数 − 被合并吞掉的格位。合并一旦算错(重叠 / 漏格)这里就会炸 */ const covered = new Set(); FIXTURE.merges.forEach(mg => { for (let r = mg[0] - 1; r <= mg[2] - 1; r++) { for (let c = mg[1] - 1; c <= mg[3] - 1; c++) { if (r === mg[0] - 1 && c === mg[1] - 1) continue; covered.add(r + '|' + c); } } }); const capacity = FIXTURE.rows.reduce((s, row) => s + row.length, 0); const eaten = FIXTURE.rows.reduce((s, row, r) => s + row.filter((_, c) => covered.has(r + '|' + c)).length, 0); chk('td 数守恒(' + capacity + ' − ' + eaten + ' = ' + (capacity - eaten) + ')', count(html, 'a') >= 0, markHtml); chk('同行未合并格不受相邻合并影响', markHtml.indexOf('c') >= 0); chk('越界标记键被忽略', markHtml.indexOf('class="max"') < 0); chk('打印 CSS 含块表样式(居中 / 未考灰 / 最高分红 / 合格绿)', PRINT_CSS.indexOf('table.bt') >= 0 && PRINT_CSS.indexOf('td.absent') >= 0 && PRINT_CSS.indexOf('td.max') >= 0 && PRINT_CSS.indexOf('td.pass') >= 0); /* 绿底印不印得出来,取决于 print-color-adjust 有没有写上——浏览器默认关闭"背景图形" */ chk('打印 CSS 声明了 print-color-adjust:exact(否则底纹不会被打印)', /print-color-adjust:\s*exact/.test(PRINT_CSS) && PRINT_CSS.indexOf('-webkit-print-color-adjust') >= 0); chk('绿底单元格自己也声明了 print-color-adjust', /td\.pass\{[^}]*print-color-adjust:\s*exact/.test(PRINT_CSS)); /* 缺考名单等附加工作表需要另起一页,否则会挤在成绩表底部 */ chk('打印 CSS 让缺考名单(h3.grp.page-break)另起一页', /h3\.grp\.page-break\{[^}]*page-break-before:\s*always/.test(PRINT_CSS)); /* 表头渲染成 ,底色必须写 thead td,写成 th 是死规则、两行表头会变白 */ chk('表头底色命中实际渲染的 (thead td,不是死规则 th)', /table\.bt thead td\{background:/.test(PRINT_CSS)); chk('HTML 已转义', sheetToHtml({ rows: [['x']], merges: [], header_rows: 1 }).indexOf('<b>') >= 0); console.log(''); console.log('结果:' + pass + ' 通过,' + fail + ' 失败'); module.exports = { sheetToHtml: sheetToHtml, PRINT_CSS: PRINT_CSS }; process.exit(fail === 0 ? 0 : 1);