14
This commit is contained in:
@@ -48,7 +48,7 @@
|
||||
} else {
|
||||
$("rp-info").textContent = "还没有可回放的会话";
|
||||
}
|
||||
} catch (err) { $("rp-info").textContent = "会话列表加载失败"; }
|
||||
} catch (err) { console.error("[replay] 会话加载失败:", err); $("rp-info").textContent = "会话列表加载失败"; }
|
||||
}
|
||||
|
||||
async function loadReplay(code) {
|
||||
@@ -64,8 +64,10 @@
|
||||
$("rp-session").onchange = () => loadReplay($("rp-session").value);
|
||||
|
||||
// ------------------------------------------------ 手动选定 JSONL(文件选择 + 拖拽)
|
||||
function applyEvents(events, meta, label) {
|
||||
events = events; meta = meta || {};
|
||||
function applyEvents(newEvents, newMeta, label) {
|
||||
// 注意:参数必须换名——`events = events` 会自赋值,模块级状态永远为空
|
||||
events = newEvents;
|
||||
meta = newMeta || {};
|
||||
buildTimeline();
|
||||
t = 0; view = { zoom: 1, panX: 0, panY: 0 };
|
||||
loaded = true;
|
||||
@@ -163,7 +165,8 @@
|
||||
virt += moveS; pos = to;
|
||||
}
|
||||
if (ev.path === "/measure" && req.channel !== channel) {
|
||||
segments.push({ kind: "switch", t0: virt, t1: virt + SWITCH_S, from: channel, to: req.channel, ev });
|
||||
segments.push({ kind: "switch", t0: virt, t1: virt + SWITCH_S, at: { ...pos },
|
||||
from: channel, to: req.channel, ev });
|
||||
virt += SWITCH_S; channel = req.channel;
|
||||
}
|
||||
if (ev.path === "/measure") {
|
||||
@@ -227,6 +230,9 @@
|
||||
if (rafId !== null) { cancelAnimationFrame(rafId); rafId = null; }
|
||||
}
|
||||
function frame(now) {
|
||||
// 先重排下一帧:即使某帧绘制抛错,播放循环也不被永久杀死(错误照常进控制台)
|
||||
if (!$("page-replay").classList.contains("hidden")) rafId = requestAnimationFrame(frame);
|
||||
else { rafId = null; return; }
|
||||
if (playing) {
|
||||
const speed = Number($("rp-speed").value);
|
||||
t += (now - lastFrame) / 1000 * speed;
|
||||
@@ -234,8 +240,6 @@
|
||||
}
|
||||
lastFrame = now;
|
||||
draw();
|
||||
if (!$("page-replay").classList.contains("hidden")) rafId = requestAnimationFrame(frame);
|
||||
else { rafId = null; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 进度刻度
|
||||
@@ -327,7 +331,8 @@
|
||||
robot = { x: curSeg.from.x + (curSeg.to.x - curSeg.from.x) * u,
|
||||
y: curSeg.from.y + (curSeg.to.y - curSeg.from.y) * u };
|
||||
pts.push(robot);
|
||||
} else { robot = { ...curSeg.at }; pts.push(robot); }
|
||||
} else if (curSeg.at) { robot = { ...curSeg.at }; pts.push(robot); }
|
||||
// enter/exit/reject 等瞬时段不携带位置:沿用上一位置,避免 NaN 传导
|
||||
}
|
||||
// 轨迹线
|
||||
if (pts.length > 1) {
|
||||
@@ -364,7 +369,8 @@
|
||||
}
|
||||
|
||||
function drawTruth() {
|
||||
for (const s of (meta.truth || [])) {
|
||||
const truthList = (meta && meta.truth) || [];
|
||||
for (const s of truthList) {
|
||||
const sx = X(s.x), sy = Y(s.y);
|
||||
ctx.strokeStyle = "rgba(244,63,94,.15)";
|
||||
ctx.beginPath(); ctx.arc(sx, sy, s.r_eff_m * scale(), 0, Math.PI * 2); ctx.stroke();
|
||||
@@ -459,11 +465,13 @@
|
||||
|
||||
// ---------------------------------------------------------------- HUD
|
||||
function updateHud(curSeg, robot, channel, cur) {
|
||||
const rx = Number.isFinite(robot && robot.x) ? robot.x : 0;
|
||||
const ry = Number.isFinite(robot && robot.y) ? robot.y : 0;
|
||||
const cleared = segments.filter((s) => s.kind === "clear" && s.result === "success" && s.t1 <= t + 1e-9).length;
|
||||
const chips = [
|
||||
["虚拟时刻", `${fmtV(t)} / ${fmtV(total)} s`],
|
||||
["频道", channel],
|
||||
["位置", `(${robot.x.toFixed(1)}, ${robot.y.toFixed(1)})`],
|
||||
["位置", `(${rx.toFixed(1)}, ${ry.toFixed(1)})`],
|
||||
["清除成功", cleared],
|
||||
["进度", `${boundaries.findIndex((b) => b >= t - 1e-9)} / ${boundaries.length - 1} 步`],
|
||||
];
|
||||
|
||||
@@ -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); });
|
||||
@@ -0,0 +1,25 @@
|
||||
"""回放器播放器的 JS 逻辑回归:用 DOM 桩跑发布的 app.js/replay.js 原文。
|
||||
|
||||
曾回归出两类"加载即冻结"bug(applyEvents 参数遮蔽模块级状态、switch 段缺
|
||||
at 导致 robot.x TypeError),当时只有 Python 层测试、JS 层零覆盖——本文件
|
||||
补上该防线:有 node 环境则执行 referee/tests/js/replay_harness.cjs,
|
||||
无 node 环境跳过(回放器为纯前端功能,Python 测试仅覆盖静态资源存在性)。
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO = Path(__file__).resolve().parents[2]
|
||||
HARNESS = REPO / "referee" / "tests" / "js" / "replay_harness.cjs"
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("node") is None, reason="需要 node 环境运行 JS 桩测试")
|
||||
def test_replay_player_logic():
|
||||
result = subprocess.run(["node", str(HARNESS)], capture_output=True, text=True,
|
||||
env={**os.environ, "REPO": str(REPO)}, timeout=60)
|
||||
assert result.returncode == 0, f"回放器逻辑测试失败:\n{result.stdout}\n{result.stderr}"
|
||||
assert "ALL PASS" in result.stdout
|
||||
Reference in New Issue
Block a user