新增 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 项全绿。
164 lines
8.5 KiB
PHP
164 lines
8.5 KiB
PHP
<?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);
|