14
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
/* 回放器播放器逻辑回归测试(由 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); });
|
||||
Reference in New Issue
Block a user