Files
mcm-26-b/referee/tests/js/replay_harness.cjs
T
2026-09-11 17:24:29 +08:00

178 lines
9.0 KiB
JavaScript

/* 回放器播放器逻辑回归测试(由 pytest 经 node 调起,见 test_replay_player_js.py)。
以 DOM 桩加载发布的 app.js 与 replay.js 原文,完整走一遍:
切回放Tab → 自动加载存档 → 手动导入 JSONL → 播放/逐步/进度条/缩放,
断言 HUD 刷新与时间线正确。曾回归出两类冻结 bug(applyEvents 参数遮蔽
外层状态、switch 段缺 at 导致 robot.x TypeError),本文件防其复发。 */
"use strict";
const fs = require("fs");
const path = require("path");
const REPO = process.env.REPO;
if (!REPO) throw new Error("REPO env required");
// ---- 构造一局日志:复刻"原点换频道"致病模式(第一步原地检测且换频道) ----
function base(id) { return { arena_id: "default", robot_id: "2026001", request_id: id }; }
const sources = [{ channel: 4, x: 100, y: 0, r_eff_m: 1200, directional: false, heading_deg: 0 }];
const refereeLog = [
JSON.stringify({ event: "session_start", case_code: "TEST-CASE-0001", module: "q3_practice",
robot_id: "2026001", seed: 9, truth: sources }),
JSON.stringify({ event: "action", seq: 1, method: "POST", path: "/enter", status: 200,
request: base("e1"), response: { accepted: true, virtual_time_s: 0,
remaining_real_duration_s: 1200 } }),
// 原地检测 + 换频道:无 move 段、switch 段在 t=0
JSON.stringify({ event: "action", seq: 2, method: "POST", path: "/measure", status: 200,
request: { ...base("m1"), position: { x: 0, y: 0 }, channel: 4 },
response: { accepted: true, virtual_time_s: 5, measure_result: "no_signal" } }),
JSON.stringify({ event: "action", seq: 3, method: "POST", path: "/measure", status: 200,
request: { ...base("m2"), position: { x: 300, y: 400 }, channel: 7 },
response: { accepted: true, virtual_time_s: 111, measure_result: "direction", svd_deg: 36.87 } }),
JSON.stringify({ event: "action", seq: 4, method: "POST", path: "/clear", status: 200,
request: { ...base("c1"), position: { x: 0, y: 0 }, channel: 4 },
response: { accepted: true, virtual_time_s: 194, clear_result: "no_target_in_range" } }),
JSON.stringify({ event: "action", seq: 5, method: "POST", path: "/exit", status: 200,
request: base("x1"), response: { accepted: true, virtual_time_s: 194, exit_reason: "user_exit" } }),
JSON.stringify({ event: "session_end", reason: "user_exit", virtual_time_s: 215 }),
// 参赛队客户端会话日志格式(accepted 事件,含重试噪声)
JSON.stringify({ event: "step", virtual_time_s: 1.0 }),
JSON.stringify({ event: "attempt", path: "/enter", request: base("a1") }),
JSON.stringify({ event: "accepted", path: "/clear", request: { ...base("c9"), position: { x: 10, y: 0 }, channel: 9 },
response: { accepted: true, virtual_time_s: 202, clear_result: "success" } }),
].join("\n");
// ---- DOM 桩 ----
function makeClassList() {
const set = new Set(["hidden"]);
return {
add: (...c) => c.forEach((x) => set.add(x)),
remove: (...c) => c.forEach((x) => set.delete(x)),
toggle: (c, force) => { const on = force === undefined ? !set.has(c) : !!force;
on ? set.add(c) : set.delete(c); return on; },
contains: (c) => set.has(c),
};
}
const ctxStub = new Proxy({}, {
get(t, prop) {
if (!(prop in t)) t[prop] = (typeof prop === "string" &&
/^(arc|beginPath|closePath|fill|stroke|moveTo|lineTo|fillText|strokeRect|fillRect|clearRect)$/.test(prop))
? () => {} : function () {};
return t[prop];
},
set(t, prop, v) { t[prop] = v; return true; },
});
function makeEl(id) {
return {
id, textContent: "", innerHTML: "", value: "", checked: true, files: [],
width: 640, height: 640, style: {}, handlers: {}, children: [],
get classList() { return (this._cls ||= makeClassList()); },
addEventListener(type, fn) { (this.handlers[type] ||= []).push(fn); },
click() {}, matches() { return false; },
appendChild(child) { this.children.push(child); },
querySelector() { return makeEl("nested"); },
getContext() { return ctxStub; },
getBoundingClientRect() { return { left: 0, top: 0, width: 640, height: 640 } },
};
}
const els = new Map();
const $get = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); };
$get("page-replay").classList.remove("hidden"); // 回放 Tab 打开
$get("page-live").classList.add("hidden");
const rafQueue = [];
globalThis.requestAnimationFrame = (cb) => { rafQueue.push(cb); return rafQueue.length; };
globalThis.cancelAnimationFrame = () => { rafQueue.length = 0; };
globalThis.performance = { now: () => Date.now() };
globalThis.setInterval = () => 0;
globalThis.location = { port: "8026" };
globalThis.confirm = () => true;
globalThis.alert = () => {};
globalThis.addEventListener = () => {};
globalThis.window = globalThis;
globalThis.document = {
getElementById: $get,
createElement: (tag) => makeEl(tag),
addEventListener() {},
};
globalThis.FileReader = class {
readAsText(file) { this.result = file.__text; queueMicrotask(() => this.onload && this.onload()); }
};
const replayEvents = refereeLog.split("\n").map((l) => JSON.parse(l));
globalThis.fetch = async (url) => {
const json = async () => {
if (url.startsWith("/ui/api/sessions"))
return { sessions: [{ case_code: "TEST-CASE-0001", module: "q3_practice", robot_id: "2026001" }] };
if (url.startsWith("/ui/api/replay"))
return { case_code: "TEST-CASE-0001", module: "q3_practice",
meta: { module: "q3_practice", robot_id: "2026001", truth: sources }, events: replayEvents };
if (url.startsWith("/ui/api/state"))
return { phase: "idle", action_port: 2026, display_limit: 1000, modules: {}, attempts: {} };
throw new Error("unexpected fetch " + url);
};
return { ok: true, json };
};
// ---- 装载发布代码原文(跨文件全局函数按浏览器语义显式挂到全局) ----
const staticDir = path.join(REPO, "referee", "src", "referee", "static");
const appSrc = fs.readFileSync(path.join(staticDir, "app.js"), "utf8");
const replaySrc = fs.readFileSync(path.join(staticDir, "replay.js"), "utf8");
const appApi = new Function(appSrc + "\n;return { fmtV, escapeHtml, sumReq, sumRes };")();
Object.assign(globalThis, appApi);
new Function(replaySrc)();
const flush = async () => { for (let i = 0; i < 20; i++) await new Promise((r) => setImmediate(r)); };
function assert(cond, message) { if (!cond) throw new Error(message); }
(async () => {
// 1) 自动加载存档
$get("tab-replay").onclick();
await flush();
const ev0 = $get("rp-event").innerHTML;
assert(ev0 && !ev0.includes("加载一局后开始回放"), "事件面板未刷新: " + JSON.stringify(ev0));
assert($get("rp-t-total").textContent === "215 s",
"总时长错误: " + $get("rp-t-total").textContent);
// 2) 逐步穿过 move/switch/measure/clear 段(含原点换频道的致病模式)
for (let i = 0; i < 6; i++) $get("rp-next").onclick();
assert($get("rp-event").innerHTML !== ev0, "逐步后事件面板未刷新");
assert(/频道<b>\d+<\/b>/.test($get("rp-status").innerHTML), "状态栏缺频道信息");
// 3) 播放推进(10× 倍速播 1 秒真实帧)
$get("rp-play").onclick();
let now = 1000;
for (let i = 0; i < 60; i++) { const cb = rafQueue.shift(); if (!cb) break; now += 16; cb(now); }
assert($get("rp-t-cur").textContent !== "0 s", "播放未推进: " + $get("rp-t-cur").textContent);
// 4) 进度条拖到 50%
$get("rp-slider").value = "500";
$get("rp-slider").handlers.input[0]();
assert($get("rp-t-cur").textContent === "107.5 s",
"拖到50%应为 107.5 s: " + $get("rp-t-cur").textContent);
// 5) 缩放
$get("rp-zoom-out").onclick();
assert($get("rp-zoom-label").textContent === "80%", "缩放标签未更新");
// 6) 手动导入评测机日志(含真值)
$get("rp-open-file").onclick();
$get("rp-file").files = [{ name: "BBM9-HZG2-77N5-NKKC.log.jsonl", __text: refereeLog }];
for (const fn of $get("rp-file").handlers.change || []) fn();
await flush();
const info = $get("rp-info").textContent;
assert(info.includes("BBM9-HZG2-77N5-NKKC.log.jsonl") && info.includes("动作 6 条") &&
info.includes("含真值 1 源"), "手动导入(评测机日志)信息异常: " + info);
// 7) 手动导入客户端会话日志(accepted 事件、无真值、剔除重试噪声)
$get("rp-file").files = [{ name: "client-session.jsonl", __text: refereeLog }];
await flush();
for (const fn of $get("rp-file").handlers.change || []) fn();
await flush();
for (let i = 0; i < 3; i++) $get("rp-next").onclick();
assert(($get("rp-event").innerHTML || "").length > 0, "导入后逐步无事件面板");
// 8) 拖到结尾
$get("rp-slider").value = "1000";
$get("rp-slider").handlers.input[0]();
assert($get("rp-t-cur").textContent === $get("rp-t-total").textContent, "结尾时刻不一致");
console.log("replay player harness: ALL PASS");
})().catch((err) => { console.error("replay player harness FAILED:", err.message); process.exit(1); });