Files
ny-exam-platform/tests/test-aggregate.php
T
nxxy335 555f500dc7 成绩明细历史记录:按提交年月日分类,每个分类标记最高分
新增 DataService::dayKeyOf(),日期键一律用服务端本地时区计算(避免 UTC 导致东八区零点前后错一天);aggregateByStudent 为 attempts_list 每项附加 day/day_best/day_count,expandAttempts 透传。

弹窗按天分组渲染分组行(日期 + 共N次 + 最高X分),组内新到旧;当日最高标签仅在当天有多次提交时显示,避免单次日每行都挂标签。

修复:submit_ts=0(时间未知)的记录原本被误标为第1次(升序排序 idx=1 但展示在末尾),现显示 -;未知时间统一归入「未知时间」组。

aggregate 单测 17 -> 33(跨天归属/日内次数/每日最高/未知时间组/时区边界 00:30 与 23:30);全量 148 项全绿。
2026-09-14 07:28:02 +08:00

164 lines
8.5 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
/**
* 验证 aggregateByStudent 的"考试次数 / 历史明细"修复:
* 多次提交的学生在 dedupe=true(默认)与 dedupe=false 两种情况下,
* attempts 都应等于真实提交次数、attempts_list 都应包含全部提交;
* dedupe 仅影响"代表成绩"那一行取最新或最高分。
*/
require __DIR__ . '/../bootstrap.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";
}
}
/**
* 构造一个学生的多次提交(模拟 第二周测试 中"陈苏豫 11次"这类数据)
* 提交时间递增(旧 -> 新),分数有高有低。
*/
function makeRows(string $sid, array $scoresByTime): array
{
$rows = [];
$t = 1000; // 递增时间戳
foreach ($scoresByTime as $sc) {
$t += 600;
$rows[] = [
'sid' => $sid,
'name' => '测试生',
'cls' => '1班',
'grp' => 'A',
'score' => $sc,
'submit_ts' => $t,
'submit_at' => date('Y-m-d H:i:s', $t),
'duration' => 120,
'duration_txt'=> '2分0秒',
'answer_id' => 'ans-' . $t,
'matched' => true,
'temp_save' => false,
];
}
return $rows;
}
echo "== dedupe=true(默认:代表成绩取最新一份)==\n";
// 11 次提交:分数各不相同,最新一份是 84.0,最高是 98.0
$scores = [84.0, 98.0, 96.0, 96.0, 96.0, 94.0, 94.0, 96.0, 90.0, 88.0, 98.0];
$rows = makeRows('S001', $scores);
$agg = DataService::aggregateByStudent($rows, true);
check('聚合后只有 1 行(一人一行)', count($agg['rows']) === 1);
$r = $agg['rows'][0];
check('attempts = 11(真实次数,而非被吞成 1)', ($r['attempts'] ?? 0) === 11, 'got ' . var_export($r['attempts'] ?? null, true));
check('attempts_list 含 11 条(历史可展开全部)', count($r['attempts_list'] ?? []) === 11, 'got ' . count($r['attempts_list'] ?? []));
check('best_score = 98.0(跨全部尝试的真实最高分)', (float)($r['best_score'] ?? -1) === 98.0, 'got ' . var_export($r['best_score'] ?? null, true));
// 代表成绩 = 最新一份(数组中最后一个时间 = 98.0,因为 scores 数组最后一个是 98.0;注意最新提交对应最后构造的 98.0)
check('代表成绩取最新提交 = 98.0', (float)($r['score'] ?? -1) === 98.0, 'got ' . var_export($r['score'] ?? null, true));
check('attempts_list 项数 idx 连续 1..11', (int)($r['attempts_list'][10]['idx'] ?? 0) === 11);
check('attempts_list 至少一条 is_best(最高分标记)', count(array_filter($r['attempts_list'], fn($a) => !empty($a['is_best']))) >= 1);
echo "\n== dedupe=false(代表成绩取最高分)==\n";
$agg2 = DataService::aggregateByStudent($rows, false);
$r2 = $agg2['rows'][0];
check('attempts = 11(与 dedupe 无关,始终真实)', ($r2['attempts'] ?? 0) === 11, 'got ' . var_export($r2['attempts'] ?? null, true));
check('attempts_list 含 11 条', count($r2['attempts_list'] ?? []) === 11, 'got ' . count($r2['attempts_list'] ?? []));
check('代表成绩取最高分 = 98.0', (float)($r2['score'] ?? -1) === 98.0, 'got ' . var_export($r2['score'] ?? null, true));
check('best_score = 98.0', (float)($r2['best_score'] ?? -1) === 98.0, 'got ' . var_export($r2['best_score'] ?? null, true));
echo "\n== 重考低分也应被统计(历史含低分,且因提交时间更新能被发现)==\n";
// 先考 90,后重考 70(更低):考试次数必须=2,最新提交=70
$rows3 = makeRows('S002', [90.0, 70.0]);
$agg3 = DataService::aggregateByStudent($rows3, true);
$r3 = $agg3['rows'][0];
check('attempts = 2', ($r3['attempts'] ?? 0) === 2, 'got ' . var_export($r3['attempts'] ?? null, true));
check('attempts_list = 2 条(含低分重考)', count($r3['attempts_list'] ?? []) === 2);
check('代表成绩取最新 = 70.0', (float)($r3['score'] ?? -1) === 70.0, 'got ' . var_export($r3['score'] ?? null, true));
check('last_submit_ts 指向最新提交(重考可被高亮发现)', ($r3['last_submit_ts'] ?? 0) === $rows3[1]['submit_ts']);
echo "\n== 仅 1 次提交不受影响 ==\n";
$rows4 = makeRows('S003', [85.0]);
$agg4 = DataService::aggregateByStudent($rows4, true);
$r4 = $agg4['rows'][0];
check('单次提交 attempts = 1', ($r4['attempts'] ?? 0) === 1);
check('单次提交 attempts_list = 1', count($r4['attempts_list'] ?? []) === 1);
echo "\n== 按提交时间年月日分组:每个分类内标记当日最高 ==\n";
/** 按 [时间戳, 分数] 造行;ts=0 表示时间未知 */
function makeRowsAt(string $sid, array $items): array
{
$rows = [];
foreach ($items as $it) {
[$ts, $sc] = $it;
$rows[] = [
'sid' => $sid,
'name' => '测试生',
'cls' => '1班',
'grp' => 'A',
'score' => $sc,
'submit_ts' => $ts,
'submit_at' => $ts > 0 ? date('Y-m-d H:i:s', $ts) : '-',
'duration' => 120,
'duration_txt' => '2分0秒',
'answer_id' => 'ans-' . $ts,
'matched' => true,
'temp_save' => false,
];
}
return $rows;
}
// 09-08 三次(70/95/88),09-09 两次(60/90)
$rows5 = makeRowsAt('S005', [
[strtotime('2026-09-08 09:00:00'), 70.0],
[strtotime('2026-09-08 11:00:00'), 95.0],
[strtotime('2026-09-08 15:00:00'), 88.0],
[strtotime('2026-09-09 10:00:00'), 60.0],
[strtotime('2026-09-09 14:00:00'), 90.0],
]);
$agg5 = DataService::aggregateByStudent($rows5, true);
$r5 = $agg5['rows'][0];
$al5 = $r5['attempts_list'];
check('5 次提交 attempts_list = 5', count($al5) === 5);
check('前 3 条归属 2026-09-08', ($al5[0]['day'] ?? '') === '2026-09-08' && ($al5[2]['day'] ?? '') === '2026-09-08',
'got ' . ($al5[0]['day'] ?? '?'));
check('后 2 条归属 2026-09-09', ($al5[3]['day'] ?? '') === '2026-09-09' && ($al5[4]['day'] ?? '') === '2026-09-09',
'got ' . ($al5[3]['day'] ?? '?'));
check('09-08 当天次数 = 3', ($al5[0]['day_count'] ?? 0) === 3, 'got ' . ($al5[0]['day_count'] ?? '?'));
check('09-09 当天次数 = 2', ($al5[3]['day_count'] ?? 0) === 2, 'got ' . ($al5[3]['day_count'] ?? '?'));
$dayBestIdx = array_values(array_filter($al5, fn($a) => !empty($a['day_best'])));
check('每个分类各标记 1 个当日最高(共 2 个)', count($dayBestIdx) === 2, 'got ' . count($dayBestIdx));
check('09-08 当日最高 = 95(第 2 次)', (int)($dayBestIdx[0]['idx'] ?? 0) === 2 && (float)($dayBestIdx[0]['score'] ?? -1) === 95.0,
'got idx=' . ($dayBestIdx[0]['idx'] ?? '?') . ' score=' . ($dayBestIdx[0]['score'] ?? '?'));
check('09-09 当日最高 = 90(第 5 次)', (int)($dayBestIdx[1]['idx'] ?? 0) === 5 && (float)($dayBestIdx[1]['score'] ?? -1) === 90.0,
'got idx=' . ($dayBestIdx[1]['idx'] ?? '?') . ' score=' . ($dayBestIdx[1]['score'] ?? '?'));
check('全局最佳仍是 95(第 2 次)', (int)($al5[1]['is_best'] ?? 0) === 1 || !empty($al5[1]['is_best']));
echo "\n== 时间未知(submit_ts=0)归入「未知时间」分组 ==\n";
$rows6 = makeRowsAt('S006', [[0, 50.0], [0, 80.0]]);
$agg6 = DataService::aggregateByStudent($rows6, true);
$al6 = $agg6['rows'][0]['attempts_list'];
check('时间未知时 day = 空串', ($al6[0]['day'] ?? 'x') === '' && ($al6[1]['day'] ?? 'x') === '',
'got ' . var_export($al6[0]['day'] ?? null, true));
check('未知时间组次数 = 2', ($al6[0]['day_count'] ?? 0) === 2, 'got ' . ($al6[0]['day_count'] ?? '?'));
check('未知时间组内仍标记最高(80)', !empty($al6[1]['day_best']) && (float)($al6[1]['score'] ?? -1) === 80.0);
echo "\n== 日期键时区边界(不能用 UTC,否则零点前后错一天)==\n";
check('00:30 仍算当天(东八区不会退到前一天)',
DataService::dayKeyOf((int)strtotime('2026-09-08 00:30:00')) === '2026-09-08',
'got ' . DataService::dayKeyOf((int)strtotime('2026-09-08 00:30:00')));
check('23:30 仍算当天',
DataService::dayKeyOf((int)strtotime('2026-09-08 23:30:00')) === '2026-09-08',
'got ' . DataService::dayKeyOf((int)strtotime('2026-09-08 23:30:00')));
check('无时间戳时从展示串取日期', DataService::dayKeyOf(0, '2026-09-08 09:41:20') === '2026-09-08');
check('无时间戳且展示串为 - 时返回空串', DataService::dayKeyOf(0, '-') === '');
echo "\n========================================\n";
echo "通过 {$pass} · 失败 {$fail}\n";
exit($fail === 0 ? 0 : 1);