/* 多考试打印渲染回归测试
*
* 「多考试打印」的版式由 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);
|