多考试块表:每场考试改为单列并修正第几次列合并

- 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 失败
This commit is contained in:
2026-10-06 12:22:16 +08:00
parent d05fd4a9b5
commit e87986fcc6
11 changed files with 161 additions and 205 deletions
+50 -31
View File
@@ -190,43 +190,60 @@ $shotEng = ['name' => '英语', 'rows' => [
echo "== 每次考试块表:两行表头 / 共享第几次 / 未考(截图一版式) ==\n";
$bs = ny_assemble_block_sheet(['m' => $shotMath, 'e' => $shotEng]);
$rows = $bs['rows'];
check('表头第 1 行:学号 / 姓名 / 第几次 / 考试名(各占 1 格 + 1 空格)',
$rows[0] === ['学号', '姓名', '第几次', '数学', '', '英语', ''],
check('表头第 1 行:学号 / 姓名 / 第几次 / 考试名(每场 1 列)',
$rows[0] === ['学号', '姓名', '第几次', '数学', '英语'],
json_encode($rows[0], JSON_UNESCAPED_UNICODE));
check('表头第 2 行:只有「成绩」小标题',
$rows[1] === ['', '', '', '成绩', '', '成绩', ''],
$rows[1] === ['', '', '', '成绩', '成绩'],
json_encode($rows[1], JSON_UNESCAPED_UNICODE));
check('header_rows = 2', $bs['header_rows'] === 2);
/* 每场考试固定占 2 列:第 1 列成绩,第 2 列是「成绩列与相邻列合并」落下的空位。
当初漏推这个空串,整行会左移一格,表现就是成绩串列、表头合并区间对不上。 */
check('张三块首行:第几次=1,两场成绩(每场 2 列)',
rowEq($rows[2], ['356001', '张三', '1', '112', '', '112', '']),
/* 每场考试固定占 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 列(成绩 + 落位空列),行宽齐整',
count($rows) === 9 && !array_filter(array_map('count', $rows), static fn($n) => $n !== 7),
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 / 英语空',
rowEq($rows[4], ['', '', '3', '136', '']),
$rows[4] === ['', '', '3', '136', ''],
json_encode($rows[4], JSON_UNESCAPED_UNICODE));
check('张三第 4 行:第几次=4,英语留空',
rowEq($rows[5], ['', '', '4', '127', '']),
check('张三第 4 行:第几次=4,数学 127 / 英语空',
$rows[5] === ['', '', '4', '127', ''],
json_encode($rows[5], JSON_UNESCAPED_UNICODE));
check('李四块首行:数学未考 + 英语 130',
rowEq($rows[6], ['356002', '李四', '1', '未考', '', '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('合并区间:表头 C 列纵向 + 考试名横向 + 学号姓名第几次纵向 + 未考跨 2 列',
$refs === ['A1:A2', 'A3:A6', 'A7:A9', 'B1:B2', 'B3:B6', 'B7:B9', 'C1:C2', 'C3:C6', 'C7:C9', 'D1:E1', 'D7:E9', 'F1:G1'],
check('合并区间:表头纵向 + 学号姓名纵向 + 未考单列纵向',
$refs === ['A1:A2', 'A3:A6', 'A7:A9', 'B1:B2', 'B3:B6', 'B7:B9', 'C1:C2', 'D7:D9'],
json_encode($refs));
/* 列宽够用就好:空落位列给 2.5,宽度让给真正显示成绩的列,
否则一屏只放得下两场考试。 */
check('列宽 = 学号/姓名/第几次 + 成绩 10 + 空落位 2.5',
$bs['widths'] === [10, 9, 9, 10, 2.5, 10, 2.5], json_encode($bs['widths']));
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:
@@ -240,11 +257,11 @@ check('pass_row=true 且 pass_line 回传', $bp['pass_row'] === true && $bp['pas
var_export($bp['pass_line'], true));
// 张三块:4 行成绩 + 1 行合格次数(第 7 行)
check('张三合格次数行:数学 3 次 / 英语 1 次',
rowEq($pp[6], ['', '', '合格次数', '3', '', '1']),
$pp[6] === ['', '', '合格次数', '3', '1'],
json_encode($pp[6], JSON_UNESCAPED_UNICODE));
// 李四块:3 行成绩 + 1 行合格次数(第 11 行)
check('李四合格次数行:数学未考留空 / 英语 3 次',
rowEq($pp[10], ['', '', '合格次数', '', '', '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']);
@@ -252,23 +269,25 @@ check('total_rows 含合格次数行 = 9', $bp['total_rows'] === 9, (string)$bp[
$pm = $bp['cell_styles'];
// 张三数学最高分 136 在第 5 行第 4 列 → 红字
check('张三数学最高分 136 标红', ($pm['5,4'] ?? '') === 'red', json_encode($pm));
// 张三英语最高分 112 在第 3 行第 6 列 → 红字
check('张三英语最高分 112 标红', ($pm['3,6'] ?? '') === '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,6'] ?? '') === 'green', json_encode($pm));
check('张三英语合格次数达标绿底', ($pm['7,5'] ?? '') === 'green', json_encode($pm));
// 李四:数学未考 → 不标绿;英语 3 次达标绿底
check('李四数学未考不标绿', !isset($pm['11,4']), json_encode($pm));
check('李四英语合格次数达标绿底', ($pm['11,6'] ?? '') === 'green', 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 次时张三仍显示实际合格次数(不隐藏)',
rowEq($hp[6], ['', '', '合格次数', '3', '', '1']),
$hp[6] === ['', '', '合格次数', '3', '1'],
json_encode($hp[6], JSON_UNESCAPED_UNICODE));
check('阈值 4 次时不达标不标绿',
!isset($bh['cell_styles']['7,4']) && !isset($bh['cell_styles']['7,6']),
!isset($bh['cell_styles']['7,4']) && !isset($bh['cell_styles']['7,5']),
json_encode($bh['cell_styles']));
echo "== 每次考试块表:草稿 / 多班级组别列 / 空考试 ==\n";
@@ -306,7 +325,7 @@ $multi = ['name' => '数学', 'rows' => [
]];
$mc = ny_assemble_block_sheet(['m' => $multi], ['with_cls' => true, 'with_grp' => true]);
check('多班级表头 = 班级/组别/学号/姓名/第几次/考试名',
$mc['rows'][0] === ['班级', '组别', '学号', '姓名', '第几次', '数学', ''],
$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]]
@@ -326,14 +345,14 @@ check('班级列位移后学号 / 姓名合并列正确(C / D 列)',
// 空考试:只出两行表头
$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', 'D1:E1'],
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][5] === '数学',
$bOrder['rows'][0][3] === '英语' && $bOrder['rows'][0][4] === '数学',
json_encode($bOrder['rows'][0], JSON_UNESCAPED_UNICODE));
echo "== 单场「每次考试」宽表(截图二版式) ==\n";