Files
ny-exam-platform/tests/test-multi-export.php
T
nxxy335 e87986fcc6 多考试块表:每场考试改为单列并修正第几次列合并
- ny_assemble_block_sheet() 中每场考试由 2 列改为 1 列,删除历史遗留的
  「是否最佳」空白列,消除每场右侧多余空白窄列
- 删除「第几次」列的纵向合并,改为逐行显示 1/2/3…,不再被吞成 1
- 合格次数行同步改为每场 1 列;未考合并加 $end > $start 跨行守卫,
  规避单行学生产生的非法 1x1 合并(Excel 修复提示)
- 列宽按考试名估算(CJK 记 2 半角宽),clamp 8~18
- 同步更新 test-multi-export.php(95 项含 4 条防回归断言)、
  make-samples.php(李四数学改为真正整场未考)、导出/接口/测试文档
  及样例预览文件;全量回归 404 项 0 失败
2026-10-06 12:22:16 +08:00

441 lines
23 KiB
PHP
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.
<?php
/**
* 多考试对比导出(成绩交叉矩阵)回归测试
*
* 覆盖 ny_exam_cell_score / ny_assemble_multi_exam_matrix:
* 1) 矩阵结构:第一行 = 考试名称,前两列 = 学号 / 姓名
* 2) 默认按学号升序
* 3) 最新成绩 = attempts_list 中最近一次「非暂存」提交;最高成绩 = 跨全部尝试的最高分
* 4) 草稿(temp_save)不计入"最新成绩",但计入"最高成绩"
* 5) 重考分数更低时,最新≠最高
* 6) 未考的考试列为空;学生并集(任一考试有提交即纳入)
* 7) 同一学号跨考试姓名以首个非空为准
*/
require __DIR__ . '/../lib/multi_export.php';
$fail = 0;
$pass = 0;
function check(string $name, bool $cond, string $detail = ''): void
{
global $fail, $pass;
if ($cond) {
$pass++;
echo " ✅ {$name}\n";
} else {
$fail++;
echo " ❌ {$name} {$detail}\n";
}
}
/**
* 构造一名学生的聚合行(与 DataService::aggregateByStudent 产出的结构保持一致)。
* 分数按时间升序给出(旧 -> 新),tempMarks 标记对应提交是否为草稿。
*/
function stuRow(string $sid, string $name, array $scoresAsc, array $tempMarks = [], ?float $best = null): array
{
$list = [];
$t = 1000000; // 不用数字分隔符,保证 PHP 7.3+ 都能跑回归测试
foreach ($scoresAsc as $i => $sc) {
$t += 600;
$list[] = [
'idx' => $i + 1,
'submit_ts' => $t,
'score' => $sc,
'temp_save' => !empty($tempMarks[$i]),
'duration' => 120,
];
}
return [
'sid' => $sid,
'name' => $name,
'cls' => '1班',
'grp' => 'A',
'score' => end($scoresAsc),
'best_score' => $best ?? (float)max($scoresAsc),
'attempts' => count($scoresAsc),
'attempts_list'=> $list,
];
}
/* ---------------- 场景 ---------------- */
$examMath = [
'name' => '数学期中',
'rows' => [
// 张三:第一次 90,重考降到 80(最新≠最高);草稿 95 只算最高
stuRow('2021001', '张三', [90.0, 80.0], [false, false], 90.0),
stuRow('2021002', '李四', [70.0]),
// 草稿分:time1 真实 60,time2 草稿 99 → 最新应取 60,最高取 99
stuRow('2021004', '赵六', [60.0, 99.0], [false, true], 99.0),
],
];
$examEnglish = [
'name' => '英语期中',
'rows' => [
stuRow('2021001', '张三', [85.0]),
stuRow('2021003', '王五', [60.0]),
],
];
echo "== 最新成绩矩阵 ==\n";
$m = ny_assemble_multi_exam_matrix(['math' => $examMath, 'eng' => $examEnglish], 'latest');
check('表头第一列是学号', ($m['head'][0] ?? '') === '学号', json_encode($m['head']));
check('表头第二列是姓名', ($m['head'][1] ?? '') === '姓名');
check('表头第三列起是考试名称', ($m['head'][2] ?? '') === '数学期中' && ($m['head'][3] ?? '') === '英语期中', json_encode($m['head']));
check('考试列顺序保持选择顺序', $m['exam_order'] === ['math', 'eng']);
// 学号排序:2021001,2021002,2021003,2021004
// 注意:PHP 会把数字字符串键自动转成 int('2021001' -> 2021001),排序结果本身正确,
// 这里用 strval 归一后比较排序顺序(显示层 XlsxWriter 会再转回字符串,无影响)。
$sids = array_map('strval', array_column($m['rows'], 0));
check('按学号升序', $sids === ['2021001', '2021002', '2021003', '2021004'], json_encode($sids));
// 张三:数学最新=80(重考降分,跳过草稿),英语最新=85
$zhang = $m['rows'][0];
check('张三行:姓名正确', $zhang[1] === '张三');
check('张三-数学最新=80(重考降分)', (string)$zhang[2] === '80', (string)$zhang[2]);
check('张三-英语最新=85', (string)$zhang[3] === '85', (string)$zhang[3]);
// 李四只在数学:英语为空
$li = $m['rows'][1];
check('李四-数学=70', (string)$li[2] === '70', (string)$li[2]);
check('李四-英语为空', $li[3] === '', var_export($li[3], true));
// 王五只在英语:数学为空
$wang = $m['rows'][2];
check('王五-数学为空', $wang[2] === '', var_export($wang[2], true));
check('王五-英语=60', (string)$wang[3] === '60', (string)$wang[3]);
// 赵六:数学最新跳过草稿=60
$zhao = $m['rows'][3];
check('赵六-数学最新跳过草稿=60', (string)$zhao[2] === '60', (string)$zhao[2]);
echo "== 最高成绩矩阵 ==\n";
$mh = ny_assemble_multi_exam_matrix(['math' => $examMath, 'eng' => $examEnglish], 'highest');
$zhangH = $mh['rows'][0];
check('张三-数学最高=90(含重考)', (string)$zhangH[2] === '90', (string)$zhangH[2]);
check('张三-英语最高=85', (string)$zhangH[3] === '85', (string)$zhangH[3]);
$zhaoH = $mh['rows'][3];
check('赵六-数学最高含草稿=99', (string)$zhaoH[2] === '99', (string)$zhaoH[2]);
echo "== 跨考试姓名 / 空考试 ==\n";
// 仅一场考试时仍可正常产出;姓名取首个非空
$single = ny_assemble_multi_exam_matrix(['math' => $examMath], 'latest');
check('单场考试表头正确', $single['head'] === ['学号', '姓名', '数学期中']);
check('单场考试行数=3', count($single['rows']) === 3, (string)count($single['rows']));
echo "== ny_exam_cell_score 单测 ==\n";
$r0 = stuRow('X', 'Y', [10.0, 20.0]); // latest=20, highest=20
check('latest=20', ny_exam_cell_score($r0, 'latest') === 20.0);
check('highest=20', ny_exam_cell_score($r0, 'highest') === 20.0);
$r1 = stuRow('X', 'Y', [20.0, 10.0]); // 重考降分
check('latest 取最近=10', ny_exam_cell_score($r1, 'latest') === 10.0);
check('highest 取最高=20', ny_exam_cell_score($r1, 'highest') === 20.0);
$r2 = stuRow('X', 'Y', [50.0, 88.0], [false, true]); // 草稿
check('latest 跳过草稿=50', ny_exam_cell_score($r2, 'latest') === 50.0);
check('highest 含草稿=88', ny_exam_cell_score($r2, 'highest') === 88.0);
$r3 = ['sid' => 'X', 'name' => 'Y', 'score' => 77.0, 'best_score' => 77.0]; // 无明细
check('无明细 latest 退回代表行=77', ny_exam_cell_score($r3, 'latest') === 77.0);
check('无明细 highest=77', ny_exam_cell_score($r3, 'highest') === 77.0);
/* ---------------- 「每次考试」块表(两行表头 / 合并单元格 / 未考) ---------------- */
/**
* 行内容比较:数据行是「参差不齐」的(某场次数少就没有尾巴上的格子),
* 短的一侧按空串补齐到等长再逐格比,避免因为"少几个空格子"误判。
*/
function rowEq(array $a, array $b): bool
{
$n = max(count($a), count($b));
for ($i = 0; $i < $n; $i++) {
$x = isset($a[$i]) ? (string)$a[$i] : '';
$y = isset($b[$i]) ? (string)$b[$i] : '';
if ($x !== $y) {
return false;
}
}
return true;
}
/** merges 转成 "A2:A4" 形式,便于与期望值比较(不依赖生成顺序) */function mergeRefs(array $merges): array
{
$col = static function (int $n): string {
$s = '';
while ($n > 0) {
$m = ($n - 1) % 26;
$s = chr(65 + $m) . $s;
$n = (int)(($n - $m) / 26);
}
return $s;
};
$out = [];
foreach ($merges as $m) {
$out[] = $col($m[1]) . $m[0] . ':' . $col($m[3]) . $m[2];
}
sort($out);
return $out;
}
/** 构造带班级 / 组别的学生行 */
function stuRowCG(string $sid, string $name, string $cls, string $grp, array $scoresAsc, array $tempMarks = [], ?float $best = null): array
{
$r = stuRow($sid, $name, $scoresAsc, $tempMarks, $best);
$r['cls'] = $cls;
$r['grp'] = $grp;
return $r;
}
// 版式场景完全对齐需求截图:张三 4 次数学 / 2 次英语;李四只有英语(数学「未考」)
$shotMath = ['name' => '数学', 'rows' => [
stuRow('356001', '张三', [112.0, 100.0, 136.0, 127.0], [], 136.0),
]];
$shotEng = ['name' => '英语', 'rows' => [
stuRow('356001', '张三', [112.0, 100.0], [], 112.0),
stuRow('356002', '李四', [130.0, 129.0, 133.0], [], 133.0),
]];
echo "== 每次考试块表:两行表头 / 共享第几次 / 未考(截图一版式) ==\n";
$bs = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng]);
$rows = $bs['rows'];
check('表头第 1 行:学号 / 姓名 / 第几次 / 考试名(每场 1 列)',
$rows[0] === ['学号', '姓名', '第几次', '数学', '英语'],
json_encode($rows[0], JSON_UNESCAPED_UNICODE));
check('表头第 2 行:只有「成绩」小标题',
$rows[1] === ['', '', '', '成绩', '成绩'],
json_encode($rows[1], JSON_UNESCAPED_UNICODE));
check('header_rows = 2', $bs['header_rows'] === 2);
/* 每场考试固定占 1 列(历史上每场占 2 列,第 2 列是遗留空位,
表现为"每场右边多出一根空白列");「第几次」逐行写各自次序号,不纵向合并
(合并会把 2、3 吞掉,只剩首行的 1)。 */
check('张三块首行:第几次=1,两场成绩(每场 1 列)',
rowEq($rows[2], ['356001', '张三', '1', '112', '112']),
json_encode($rows[2], JSON_UNESCAPED_UNICODE));
check('张三第 2 行:第几次=2,数学 100 / 英语 100',
rowEq($rows[3], ['', '', '2', '100', '100']),
json_encode($rows[3], JSON_UNESCAPED_UNICODE));
/* 严格校验:3 个前导列 + 每场 1 列,所有行宽必须完全一致。
rowEq 会把短的一侧按空串补齐,宽松比较测不出「多一根空白列」这种回归。 */
check('每场考试只占 1 列:所有行宽一致 = 3 + 考试数',
count($rows) === 9 && !array_filter(array_map('count', $rows), static fn($n) => $n !== 5),
'列数序列 = ' . json_encode(array_map('count', $rows)));
check('张三第 3 行:第几次=3,数学 136 / 英语空',
$rows[4] === ['', '', '3', '136', ''],
json_encode($rows[4], JSON_UNESCAPED_UNICODE));
check('张三第 4 行:第几次=4,数学 127 / 英语空',
$rows[5] === ['', '', '4', '127', ''],
json_encode($rows[5], JSON_UNESCAPED_UNICODE));
check('李四块首行:数学未考 + 英语 130',
rowEq($rows[6], ['356002', '李四', '1', '未考', '130']),
json_encode($rows[6], JSON_UNESCAPED_UNICODE));
check('李四第 2 / 3 行:第几次 2、3(未考列在合并区内留空)',
$rows[7] === ['', '', '2', '', '129'] && $rows[8] === ['', '', '3', '', '133'],
json_encode([$rows[7], $rows[8]], JSON_UNESCAPED_UNICODE));
check('「第几次」逐行递增,不是被合并压成的一串 1',
array_map(static fn($r) => (string)$r[2], array_slice($rows, 2)) === ['1', '2', '3', '4', '1', '2', '3'],
json_encode(array_map(static fn($r) => $r[2], array_slice($rows, 2)), JSON_UNESCAPED_UNICODE));
check('总行数 = 9(2 行表头 + 张三 4 + 李四 3)', count($rows) === 9, (string)count($rows));
check('total_rows / students 统计正确', $bs['total_rows'] === 7 && $bs['students'] === 2,
$bs['total_rows'] . '/' . $bs['students']);
check('未启用合格行时 pass_row=false', $bs['pass_row'] === false, var_export($bs['pass_row'], true));
$refs = mergeRefs($bs['merges']);
check('合并区间:表头纵向 + 学号姓名纵向 + 未考单列纵向',
$refs === ['A1:A2', 'A3:A6', 'A7:A9', 'B1:B2', 'B3:B6', 'B7:B9', 'C1:C2', 'D7:D9'],
json_encode($refs));
check('「第几次」列没有任何纵向合并(表头 C1:C2 除外)',
!array_filter($bs['merges'], static fn($m) => (int)$m[0] > 1 && (int)$m[1] === 3 && (int)$m[3] === 3),
json_encode($bs['merges']));
check('表头第 1 行没有任何横向合并(考试名各占 1 列)',
!array_filter($bs['merges'], static fn($m) => (int)$m[0] === 1 && (int)$m[2] === 1 && $m[1] !== $m[3]),
json_encode($bs['merges']));
/* 列宽够用就好:考试列要装得下表头考试名(中文按 2 个半角宽算)。 */
check('列宽 = 学号 10 / 姓名 9 / 第几次 10 / 每场考试 1 列(按考试名估宽)',
$bs['widths'] === [10, 9, 10, 8, 8], json_encode($bs['widths']));
echo "== 每次考试块表:最高分标红 / 合格次数标绿 ==\n";
// 合格线 110:
// 张三数学 [112,100,136,127] → 112/136/127 合格 = 3
// 张三英语 [112,100] → 112 合格 = 1
// 李四英语 [130,129,133] → 130/129/133 合格 = 3
$bp = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng], ['pass_line' => 110]);
$pp = $bp['rows'];
check('启用合格行后 header_rows 仍为 2', $bp['header_rows'] === 2);
check('pass_row=true 且 pass_line 回传', $bp['pass_row'] === true && $bp['pass_line'] === 110.0,
var_export($bp['pass_line'], true));
// 张三块:4 行成绩 + 1 行合格次数(第 7 行)
check('张三合格次数行:数学 3 次 / 英语 1 次',
$pp[6] === ['', '', '合格次数', '3', '1'],
json_encode($pp[6], JSON_UNESCAPED_UNICODE));
// 李四块:3 行成绩 + 1 行合格次数(第 11 行)
check('李四合格次数行:数学未考留空 / 英语 3 次',
$pp[10] === ['', '', '合格次数', '', '3'],
json_encode($pp[10], JSON_UNESCAPED_UNICODE));
check('总行数 = 11(2 + (4+1) + (3+1))', count($pp) === 11, (string)count($pp));
check('total_rows 含合格次数行 = 9', $bp['total_rows'] === 9, (string)$bp['total_rows']);
$pm = $bp['cell_styles'];
// 张三数学最高分 136 在第 5 行第 4 列 → 红字
check('张三数学最高分 136 标红', ($pm['5,4'] ?? '') === 'red', json_encode($pm));
// 张三英语最高分 112 在第 3 行第 5 列 → 红字
check('张三英语最高分 112 标红', ($pm['3,5'] ?? '') === 'red', json_encode($pm));
// 李四英语最高分 133 在第 10 行第 5 列 → 红字
check('李四英语最高分 133 标红', ($pm['10,5'] ?? '') === 'red', json_encode($pm));
// 张三合格次数(第 7 行):数学 3 达标绿底、英语 1 达标绿底
check('张三数学合格次数达标绿底', ($pm['7,4'] ?? '') === 'green', json_encode($pm));
check('张三英语合格次数达标绿底', ($pm['7,5'] ?? '') === 'green', json_encode($pm));
// 李四:数学未考 → 不标绿;英语 3 次达标绿底
check('李四数学未考不标绿', !isset($pm['11,4']), json_encode($pm));
check('李四英语合格次数达标绿底', ($pm['11,5'] ?? '') === 'green', json_encode($pm));
// 未达标(阈值 4 次)→ 仍显示实际次数(张三数学 3 / 英语 1),但不标绿
$bh = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng], ['pass_line' => 110, 'pass_times' => 4]);
$hp = $bh['rows'];
check('阈值 4 次时张三仍显示实际合格次数(不隐藏)',
$hp[6] === ['', '', '合格次数', '3', '1'],
json_encode($hp[6], JSON_UNESCAPED_UNICODE));
check('阈值 4 次时不达标不标绿',
!isset($bh['cell_styles']['7,4']) && !isset($bh['cell_styles']['7,5']),
json_encode($bh['cell_styles']));
echo "== 每次考试块表:草稿 / 多班级组别列 / 空考试 ==\n";
// 赵六:数学第 2 次是草稿 → 默认跳过(不占编号)
$draftMath = ['name' => '数学', 'rows' => [
stuRow('D1', '赵六', [60.0, 99.0], [false, true], 99.0),
]];
$ds = ny_assemble_block_sheet(['m' => $draftMath]);
check('默认跳过草稿:只剩 1 次',
rowEq($ds['rows'][2], ['D1', '赵六', '1', '60']),
json_encode($ds['rows'][2], JSON_UNESCAPED_UNICODE));
check('跳过草稿后无多余行', count($ds['rows']) === 3, (string)count($ds['rows']));
$di = ny_assemble_block_sheet(['m' => $draftMath], ['skip_temp' => false]);
check('保留草稿:2 次且草稿分参与',
rowEq($di['rows'][3], ['', '', '2', '99']),
json_encode($di['rows'][3], JSON_UNESCAPED_UNICODE));
// 只有草稿的学生:跳过草稿后整场无有效成绩 → 不出现在表里
$onlyDraft = ['name' => '数学', 'rows' => [
stuRow('X1', '只交草稿', [88.0], [true], 88.0),
stuRow('X2', '正常', [70.0], [], 70.0),
]];
$od = ny_assemble_block_sheet(['m' => $onlyDraft]);
check('全草稿学生被剔除(只剩正常那位)', $od['students'] === 1, (string)$od['students']);
check('全草稿学生未写「未考」占位', rowEq($od['rows'][2], ['X2', '正常', '1', '70']),
json_encode($od['rows'][2], JSON_UNESCAPED_UNICODE));
// 多班级 / 多组别:按 班级 → 组别 → 学号 排序,连续相同班级 / 组别合并
$multi = ['name' => '数学', 'rows' => [
stuRowCG('S3', '王五', '1班', 'B', [60.0]),
stuRowCG('S4', '赵六', '2班', 'B', [50.0]),
stuRowCG('S1', '张三', '1班', 'A', [90.0, 80.0]),
stuRowCG('S2', '李四', '1班', 'A', [70.0]),
]];
$mc = ny_assemble_block_sheet(['m' => $multi], ['with_cls' => true, 'with_grp' => true]);
check('多班级表头 = 班级/组别/学号/姓名/第几次/考试名',
$mc['rows'][0] === ['班级', '组别', '学号', '姓名', '第几次', '数学'],
json_encode($mc['rows'][0], JSON_UNESCAPED_UNICODE));
check('按 班级 → 组别 → 学号 排序',
[$mc['rows'][2][2], $mc['rows'][4][2], $mc['rows'][5][2], $mc['rows'][6][2]]
=== ['S1', 'S2', 'S3', 'S4'],
json_encode([$mc['rows'][2][2], $mc['rows'][4][2], $mc['rows'][5][2], $mc['rows'][6][2]]));
check('班级 / 组别只写在学生块首行',
rowEq($mc['rows'][2], ['1班', 'A', 'S1', '张三', '1', '90']),
json_encode($mc['rows'][2], JSON_UNESCAPED_UNICODE));
$mcRefs = mergeRefs($mc['merges']);
check('相同班级 / 相同组别的单元格合并(1班 S1~S3 归一格,A 组 S1~S2 归一格)',
in_array('A3:A6', $mcRefs, true) && in_array('B3:B5', $mcRefs, true),
json_encode($mcRefs));
check('班级列位移后学号 / 姓名合并列正确(C / D 列)',
in_array('C3:C4', $mcRefs, true) && in_array('D3:D4', $mcRefs, true),
json_encode($mcRefs));
// 空考试:只出两行表头
$bEmpty = ny_assemble_block_sheet(['none' => ['name' => '无人考试', 'rows' => []]]);
check('空考试只有两行表头', count($bEmpty['rows']) === 2 && $bEmpty['students'] === 0);
check('空考试仅剩表头合并(考试名不再横向合并)',
mergeRefs($bEmpty['merges']) === ['A1:A2', 'B1:B2', 'C1:C2'],
json_encode(mergeRefs($bEmpty['merges'])));
// 考试列顺序跟随传入顺序
$bOrder = ny_assemble_block_sheet(['e' => $shotEng, 'm' => $shotMath]);
check('考试列顺序跟随传入顺序(英语在前)',
$bOrder['rows'][0][3] === '英语' && $bOrder['rows'][0][4] === '数学',
json_encode($bOrder['rows'][0], JSON_UNESCAPED_UNICODE));
echo "== 单场「每次考试」宽表(截图二版式) ==\n";
$w = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110]);
check('表头 = 学号/姓名/第1次…第N次/合格次数',
$w['rows'][0] === ['学号', '姓名', '第1次', '第2次', '第3次', '合格次数'],
json_encode($w['rows'][0], JSON_UNESCAPED_UNICODE));
check('每人一行,共 2 人', count($w['rows']) === 3 && $w['students'] === 2, (string)count($w['rows']));
// 张三英语 [112,100]:只有 2 次,第 3 列留空;≥110 → 112 合格 = 1
check('张三行:112 / 100 / 空,合格 1 次',
$w['rows'][1] === ['356001', '张三', '112', '100', '', '1'],
json_encode($w['rows'][1], JSON_UNESCAPED_UNICODE));
// 李四英语 [130,129,133]:全 ≥110 = 3 次合格
check('李四行:130 / 129 / 133,合格 3 次',
$w['rows'][2] === ['356002', '李四', '130', '129', '133', '3'],
json_encode($w['rows'][2], JSON_UNESCAPED_UNICODE));
check('attempt_cols=3', $w['attempt_cols'] === 3, (string)$w['attempt_cols']);
check('最高分 112(张三第 3 列)标红', ($w['cell_styles']['2,3'] ?? '') === 'red', json_encode($w['cell_styles']));
check('最高分 133(李四第 5 列)标红', ($w['cell_styles']['3,5'] ?? '') === 'red', json_encode($w['cell_styles']));
check('张三合格次数 1 次达标绿底', ($w['cell_styles']['2,6'] ?? '') === 'green', json_encode($w['cell_styles']));
check('李四合格次数 3 次达标绿底', ($w['cell_styles']['3,6'] ?? '') === 'green', json_encode($w['cell_styles']));
// 阈值 4 次 → 都不到阈值,仍显示实际次数(1 / 3),但不标绿
$w4 = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110, 'pass_times' => 4]);
check('阈值 4 次时仍显示实际合格次数(不隐藏)',
$w4['rows'][1][5] === '1' && $w4['rows'][2][5] === '3',
json_encode([$w4['rows'][1][5], $w4['rows'][2][5]], JSON_UNESCAPED_UNICODE));
check('阈值 4 次时不标绿',
!isset($w4['cell_styles']['2,6']) && !isset($w4['cell_styles']['3,6']),
json_encode($w4['cell_styles']));
// 关闭标红 / 关掉合格行
$wOff = ny_assemble_attempt_wide_sheet($shotEng, ['pass_line' => 110, 'mark_best' => false, 'pass_row' => false]);
check('mark_best=false 不标红', $wOff['cell_styles'] === [], json_encode($wOff['cell_styles']));
check('pass_row=false 表头无合格次数', !in_array('合格次数', $wOff['rows'][0], true),
json_encode($wOff['rows'][0], JSON_UNESCAPED_UNICODE));
/** 检查合并区间两两不重叠(区间重叠会让 Excel 打开时报"需要修复") */
function overlapsOf(array $merges): array
{
$bad = [];
$n = count($merges);
for ($i = 0; $i < $n; $i++) {
for ($j = $i + 1; $j < $n; $j++) {
$a = $merges[$i];
$b = $merges[$j];
if ($a[0] <= $b[2] && $b[0] <= $a[2] && $a[1] <= $b[3] && $b[1] <= $a[3]) {
$bad[] = json_encode($a) . '×' . json_encode($b);
}
}
}
return $bad;
}
echo "== 班级 / 组别列的显示规则 ==\n";
$f1 = ny_multi_col_flags(['1班'], [], ['1班'], ['A', 'B']);
check('只选 1 个班级且未选组别 → 班级 / 组别列都不出',
$f1['with_cls'] === false && $f1['with_grp'] === false, json_encode($f1));
$f2 = ny_multi_col_flags(['1班', '2班'], ['A', 'B'], ['1班', '2班'], ['A', 'B']);
check('选了多班级 + 多组别 → 两列都出',
$f2['with_cls'] === true && $f2['with_grp'] === true, json_encode($f2));
$f3 = ny_multi_col_flags(['1班'], ['A', 'B'], ['1班'], ['A', 'B']);
check('只选 1 个班级但选了多组别 → 只出组别列',
$f3['with_cls'] === false && $f3['with_grp'] === true, json_encode($f3));
$f4 = ny_multi_col_flags([], [], ['1班'], ['A']);
check('未筛选且数据只有 1 个班 / 1 个组 → 两列都不出',
$f4['with_cls'] === false && $f4['with_grp'] === false, json_encode($f4));
$f5 = ny_multi_col_flags([], [], ['1班', '2班', '3班'], ['A', 'B']);
check('未筛选且数据跨 3 个班 → 按数据补出班级 / 组别列',
$f5['with_cls'] === true && $f5['with_grp'] === true, json_encode($f5));
$f6 = ny_multi_col_flags(['1班', '2班'], ['A'], ['1班', '2班'], ['A']);
check('多班级 + 只选 1 个组别 → 只出班级列',
$f6['with_cls'] === true && $f6['with_grp'] === false, json_encode($f6));
echo "== 合并区间不重叠(防 Excel 报修复) ==\n";
check('截图版式:无重叠', overlapsOf($bs['merges']) === [], json_encode(overlapsOf($bs['merges'])));
check('多班级多组别:无重叠', overlapsOf($mc['merges']) === [], json_encode(overlapsOf($mc['merges'])));
check('单场导出(带班级组别列):无重叠',
overlapsOf(ny_assemble_block_sheet(['m' => $multi], ['with_cls' => true])['merges']) === []);
check('草稿模式:无重叠',
overlapsOf(ny_assemble_block_sheet(['m' => $draftMath], ['skip_temp' => false])['merges']) === []);
echo "\n通过 {$pass} 项,失败 {$fail} 项\n";
exit($fail === 0 ? 0 : 1);