Files
ny-exam-platform/tests/test-new-features.php
T
nxxy335 0fd880624f feat: 完善/优化/新增大批次 + 安全加固 + 性能优化
- 修复再次提交不置顶(排序键改用 last_submit_ts)
- 后端结果缓存 + 并发锁(DataCache / ny_build_data / Roster 指纹失效)
- 导出 xlsx 多表:按最新成绩 / 按班级分表 / 可选列
- 名单管理:新增改弹窗、删除多选+二次确认、登陆账号列
- 安全:CSRF token + 登录失败限流(5次锁10分钟)
- 未匹配答卷一键加入名单(unmatched_map)
- 缺考名单增强:复制学号/姓名、按班统计、打印
- 成绩快照 + 跨卷趋势(Snapshot)
- 异常检测线索(短时长高分/高频重考/雷同成绩)
- 前端性能:虚拟滚动、搜索防抖、后台暂停、失败退避
- 测试沉淀:test-new-features.php + run-all.sh
2026-09-10 17:59:45 +08:00

138 lines
7.1 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
/**
* 新功能回归测试:
* 1) 异常检测 detectAnomalies(短时长高分 / 高频重考 / 雷同成绩)
* 2) 成绩快照 Snapshot::summarize / trend
* 3) 成绩明细排序:再次提交应置顶(last_submit_ts 排序)
* 4) XlsxWriter 多工作表 renderMulti(分表 + 名字去重/截断)
*/
declare(strict_types=1);
require __DIR__ . '/../bootstrap.php';
$pass = 0;
$fail = 0;
function check(string $name, bool $cond, string $detail = ''): void
{
global $pass, $fail;
if ($cond) { $pass++; echo " ✅ {$name}\n"; }
else { $fail++; echo " ❌ {$name} {$detail}\n"; }
}
/* ============ 1) 异常检测 ============ */
echo "\n== 异常检测 ==\n";
// 构造:3 人同分(雷同)、1 人短时长高分、1 人高频重考
function mkRow(string $sid, string $name, ?float $score, ?int $duration, int $attempts): array
{
return [
'sid' => $sid, 'name' => $name, 'cls' => '1班', 'grp' => 'A',
'score' => $score, 'duration' => $duration, 'duration_txt' => $duration === null ? '-' : ($duration . '秒'),
'attempts' => $attempts, 'best_score' => $score,
];
}
$rows = [
mkRow('S01', '甲', 90.0, 600, 1),
mkRow('S02', '乙', 90.0, 610, 1), // 与甲同分
mkRow('S03', '丙', 90.0, 620, 1), // 三人同分 → 雷同
mkRow('S04', '丁', 95.0, 30, 1), // 短时长(30s)+ 高分
mkRow('S05', '戊', 70.0, 500, 9), // 高频重考 9 次
];
$stats = ['avg_duration' => 600, 'max_score' => 100.0];
$anom = DataService::detectAnomalies($rows, $stats);
check('雷同成绩:捕获 90 分 3 人组', count($anom['identical']) >= 1 && (float)$anom['identical'][0]['score'] === 90.0,
json_encode($anom['identical'], JSON_UNESCAPED_UNICODE));
check('雷同成绩:人数 = 3', (int)($anom['identical'][0]['count'] ?? 0) === 3);
check('短时长高分:捕获丁', count($anom['fast_high']) >= 1 && ($anom['fast_high'][0]['sid'] ?? '') === 'S04',
json_encode($anom['fast_high'], JSON_UNESCAPED_UNICODE));
check('高频重考:捕获戊(9 次)', count($anom['frequent']) >= 1 && ($anom['frequent'][0]['sid'] ?? '') === 'S05',
json_encode($anom['frequent'], JSON_UNESCAPED_UNICODE));
check('正常学生(甲 1 次)不被误报高频', !in_array('S01', array_column($anom['frequent'], 'sid'), true));
/* ============ 2) 快照 summarize / trend ============ */
echo "\n== 成绩快照 ==\n";
$result1 = [
'project' => ['id' => 'P1', 'name' => '数学测验'],
'stats' => ['avg_score' => 85],
'rows' => [
['sid' => 'S01', 'name' => '甲', 'cls' => '1班', 'grp' => 'A', 'score' => 90, 'best_score' => 95, 'attempts' => 2, 'last_submit_at' => '2026-09-10 10:00:00'],
['sid' => 'S02', 'name' => '乙', 'cls' => '1班', 'grp' => 'A', 'score' => 80, 'best_score' => 80, 'attempts' => 1, 'last_submit_at' => '2026-09-10 10:05:00'],
],
];
$result2 = [
'project' => ['id' => 'P1', 'name' => '数学测验'],
'stats' => ['avg_score' => 88],
'rows' => [
['sid' => 'S01', 'name' => '甲', 'cls' => '1班', 'grp' => 'A', 'score' => 92, 'best_score' => 96, 'attempts' => 3, 'last_submit_at' => '2026-09-10 12:00:00'],
['sid' => 'S03', 'name' => '丙', 'cls' => '2班', 'grp' => 'B', 'score' => 88, 'best_score' => 88, 'attempts' => 1, 'last_submit_at' => '2026-09-10 12:10:00'],
],
];
$snap1 = Snapshot::summarize($result1, '第一次');
$snap2 = Snapshot::summarize($result2, '第二次');
$snap1['at'] = 100; $snap2['at'] = 200; // 固定时间戳便于排序
check('summarize 抽取学生数 = 2', count($snap1['students']) === 2);
check('summarize 保留 best_score', (float)$snap1['students'][0]['best_score'] === 95.0);
check('summarize 按学号排序', $snap1['students'][0]['sid'] === 'S01' && $snap1['students'][1]['sid'] === 'S02');
$trend = Snapshot::trend([$snap1, $snap2]);
check('trend 列数 = 2(两份快照)', count($trend['columns']) === 2, 'got ' . count($trend['columns']));
check('trend 列按时间升序(先第一次后第二次)', $trend['columns'][0]['label'] === '第一次' && $trend['columns'][1]['label'] === '第二次');
check('trend 行数 = 3(S01/S02/S03 并集)', count($trend['rows']) === 3, 'got ' . count($trend['rows']));
$s01 = null; foreach ($trend['rows'] as $r) { if ($r['sid'] === 'S01') $s01 = $r; }
check('S01 两次快照成绩 = 95 / 96', $s01 !== null && $s01['scores'][$trend['columns'][0]['key']] == 95 && $s01['scores'][$trend['columns'][1]['key']] == 96);
$s02 = null; foreach ($trend['rows'] as $r) { if ($r['sid'] === 'S02') $s02 = $r; }
check('S02 第二次快照缺席记 null', $s02 !== null && !array_key_exists($trend['columns'][1]['key'], $s02['scores']));
/* ============ 3) 排序:再次提交置顶 ============ */
echo "\n== 排序(last_submit_ts 置顶)==\n";
// 甲:代表成绩=最高分(98,早提交);最近一次提交=重考 70(晚提交)→ last_submit_ts 更晚,应排在乙前
$aggRows = [
['sid' => 'S01', 'name' => '甲', 'score' => 98.0, 'submit_ts' => 1000, 'last_submit_ts' => 5000, 'last_submit_at' => '2026-09-10 12:00:00'],
['sid' => 'S02', 'name' => '乙', 'score' => 90.0, 'submit_ts' => 2000, 'last_submit_ts' => 2000, 'last_submit_at' => '2026-09-10 10:00:00'],
];
$sortTs = static fn(array $r): int => (int)($r['last_submit_ts'] ?? 0) ?: (int)($r['submit_ts'] ?? 0);
usort($aggRows, static function (array $x, array $y) use ($sortTs): int {
$d = $sortTs($y) <=> $sortTs($x);
return $d !== 0 ? $d : strcmp((string)($x['sid'] ?? ''), (string)($y['sid'] ?? ''));
});
check('重考后的甲(last_submit_ts=5000)排在乙前', $aggRows[0]['sid'] === 'S01', json_encode($aggRows, JSON_UNESCAPED_UNICODE));
/* ============ 4) XlsxWriter 多工作表 ============ */
echo "\n== XlsxWriter 多工作表 ==\n";
$sheets = [
['name' => '1班', 'rows' => [['学号', '姓名'], ['S01', '甲']], 'widths' => [16, 14]],
['name' => '2班', 'rows' => [['学号', '姓名'], ['S02', '乙']], 'widths' => [16, 14]],
['name' => '成绩明细', 'rows' => [['学号', '成绩'], ['S03', 90]], 'widths' => [16, 11]],
['name' => '成绩明细', 'rows' => [['学号', '成绩'], ['S04', 80]], 'widths' => [16, 11]], // 重名 → 应去重
['name' => str_repeat('长', 40), 'rows' => [['X'], [1]], 'widths' => [10]], // 超长名 → 截断
];
$bin = XlsxWriter::renderMulti($sheets);
check('renderMulti 产出 PK ZIP', substr($bin, 0, 2) === 'PK');
$names = [];
if (class_exists('ZipArchive')) {
$tmp = __DIR__ . '/_multi.xlsx';
file_put_contents($tmp, $bin);
$z = new ZipArchive();
if ($z->open($tmp) === true) {
for ($i = 0; $i < $z->numFiles; $i++) {
$n = $z->getNameIndex($i);
if (strpos($n, 'xl/worksheets/sheet') === 0) {
$names[] = $n;
}
}
$z->close();
}
@unlink($tmp);
}
check('产出 5 个工作表文件', count($names) === 5, 'got ' . count($names) . ': ' . json_encode($names));
check('sheet 文件名连续 sheet1..sheet5', in_array('xl/worksheets/sheet5.xml', $names, true));
echo "\n========================================\n";
echo "通过 {$pass} · 失败 {$fail}\n";
exit($fail === 0 ? 0 : 1);