/* 回放器播放器逻辑回归测试(由 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(/频道\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); });