178 lines
9.0 KiB
JavaScript
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); });
|