/* 训练前端 JS 逻辑回归(由 pytest 经 node 调起,见 tests/test_training_ui_js.py)。 以 DOM 桩加载发布的 charts.js / app.js 原文,用服务端真实生成的示例快照 (services/demo.py 的 demo_snapshot JSON)驱动渲染,断言: 1) 表单由 schema 生成、字段数与后端契约一致; 2) 指标卡/曲线/日志/逐回合表都拿到数据并渲染,无异常抛出; 3) 三种收敛场景的判读文案正确落到顶部 chip; 4) 增量指标游标合并不丢点也不重点(曾是该前端的典型 bug); 5) charts.js 的刻度与格式函数在极端输入下不抛异常。 曾回归出两个"加载即白屏"隐患:job.process 缺失时直接取下标、以及游标 与 metric_limit 同时生效时重复追加同批进度点。本文件防其复发。 */ "use strict"; const fs = require("fs"); const path = require("path"); const REPO = process.env.REPO; if (!REPO) throw new Error("REPO env required"); const STATIC = path.join(REPO, "src", "mcm26b", "training_ui", "static"); const DEMO = process.env.DEMO_STATE; if (!DEMO) throw new Error("DEMO_STATE env required"); const scenarios = JSON.parse(fs.readFileSync(DEMO, "utf8")); const schema = JSON.parse(fs.readFileSync(process.env.SCHEMA, "utf8")); // ---------------------------------------------------------------- DOM 桩 /* className 是唯一真源:代码里会直接赋值 className(见 chip()),也会用 classList.add/toggle;如果 classList 自带一个独立 Set,两者就会不同步, 测试会误报。这里让 classList 每次读写都落回 className。 */ function makeClassList(el) { const read = () => String(el.className || "").split(/\s+/).filter(Boolean); const write = (list) => { el.className = [...new Set(list)].join(" "); }; return { add: (...c) => write(read().concat(c)), remove: (...c) => write(read().filter((x) => !c.includes(x))), toggle: (c, force) => { const on = force === undefined ? !read().includes(c) : !!force; write(on ? read().concat(c) : read().filter((x) => x !== c)); return on; }, contains: (c) => read().includes(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|strokeText|strokeRect|fillRect|clearRect|setLineDash|setTransform|save|restore|roundRect|measureText|scale|translate)$/.test(prop) ? (prop === "measureText" ? () => ({ width: 40 }) : () => {}) : function () {}; } return t[prop]; }, set(t, prop, v) { t[prop] = v; return true; }, }); const allElements = []; /* querySelectorAll 桩:支持 app.js 实际用到的选择器 [data-node="x"] / [data-link="n"] → 时间线节点与连接线 .chart-tab → 曲线页签(用于切换 active) tbody → 逐回合表体 子元素按 tag 记账,因此 page.querySelector("tbody") 每次返回同一个对象。 */ function matchesSelector(el, sel) { const attr = /^\[data-([a-z]+)="?([^"\]]*)"?\]$/.exec(sel); if (attr) return el.dataset && String(el.dataset[attr[1]]) === attr[2]; if (sel.startsWith(".")) return String(el.className || "").split(/\s+/).includes(sel.slice(1)); if (el.tag === sel) return true; const byTag = el.byTag && el.byTag[sel]; return Boolean(byTag); } function makeEl(id, tag) { const el = { id, tag: tag || id, textContent: "", innerHTML: "", value: "", checked: true, disabled: false, className: "", files: [], width: 600, height: 240, style: {}, handlers: {}, children: [], byTag: {}, dataset: {}, scrollTop: 0, scrollHeight: 100, clientHeight: 100, get classList() { return makeClassList(this); }, addEventListener(type, fn) { (this.handlers[type] ||= []).push(fn); }, removeEventListener() {}, click() {}, matches() { return false; }, appendChild(child) { this.children.push(child); return child; }, removeChild(child) { this.children = this.children.filter((c) => c !== child); }, setAttribute() {}, focus() {}, querySelector(sel) { return this.querySelectorAll(sel)[0] || this.childFor(sel); }, // 表单里的静态子元素(如
)不在 children 里,按需惰性创建并缓存 childFor(sel) { if (!this.byTag[sel]) this.byTag[sel] = makeEl(`${id}:${sel}`, sel); return this.byTag[sel]; }, querySelectorAll(sel) { const found = []; const walk = (node) => { for (const child of node.children || []) { if (matchesSelector(child, sel)) found.push(child); walk(child); } }; walk(this); return found; }, getContext() { return ctxStub; }, getBoundingClientRect() { return { left: 0, top: 0, width: 600, height: 240 }; }, }; allElements.push(el); return el; } const els = new Map(); const $get = (id) => { if (!els.has(id)) els.set(id, makeEl(id)); return els.get(id); }; // 几个会被读取初值的控件(否则 value 为空字符串,Number("") === 0) for (const [id, value] of [["log-tail", "400"], ["chart-window", "120"]]) $get(id).value = value; $get("chart-cursor").checked = true; $get("log-follow").checked = true; $get("log-json").checked = true; $get("log-errors-only").checked = false; // 只保留"显示进度 JSON"这一项过滤 $get("preview-scenario").value = "rising"; globalThis.requestAnimationFrame = (cb) => { cb(0); return 1; }; globalThis.cancelAnimationFrame = () => {}; globalThis.performance = { now: () => Date.now() }; globalThis.setInterval = () => 0; globalThis.location = { port: "8036" }; globalThis.confirm = () => true; globalThis.alert = () => {}; globalThis.addEventListener = () => {}; globalThis.devicePixelRatio = 2; globalThis.window = globalThis; globalThis.document = { getElementById: $get, createElement: (tag) => makeEl(tag), addEventListener() {}, }; // 元素挂载计数:断言"指标卡确实被创建"而不是静默空转 let createdCount = 0; const realCreate = globalThis.document.createElement; globalThis.document.createElement = (tag) => { createdCount++; return realCreate(tag); }; globalThis.fetch = async (url) => { const json = async () => { if (url.startsWith("/api/train/schema")) return schema; if (url.includes("state")) { const match = /scenario=([a-z-]+)/.exec(url); const key = match ? match[1] : "rising"; return { job: scenarios[key] }; } throw new Error("unexpected fetch " + url); }; return { ok: true, status: 200, json }; }; // ---------------------------------------------------------------- 装载发布代码 const chartsSrc = fs.readFileSync(path.join(STATIC, "charts.js"), "utf8"); const appSrc = fs.readFileSync(path.join(STATIC, "app.js"), "utf8"); new Function(chartsSrc)(); const appApi = new Function( appSrc + "\n;return { seriesOf, classify, highlight, absorbMetrics, state, render, fmtNumber, fmtDuration };" )(); // app.js 末尾会自动 boot(),这里等它把 schema/首次快照渲染完 const flush = async () => { for (let i = 0; i < 40; i++) await new Promise((r) => setImmediate(r)); }; function assert(cond, message) { if (!cond) throw new Error(message); } function stripTags(html) { return String(html).replace(/<[^>]*>/g, ""); } (async () => { await flush(); // 1) schema 驱动表单 const groups = $get("form-groups").children; assert(groups.length === schema.groups.length, `表单分组数应为 ${schema.groups.length},实际 ${groups.length}`); const cfgField = $get("cfg-timesteps"); assert(cfgField && String(cfgField.value) === String(schema.fields.find((f) => f.key === "timesteps").default), `默认值未写入表单: ${cfgField && cfgField.value}`); assert($get("presets").children.length === schema.presets.length, "预设按钮数不匹配"); // 2) 首帧(rising 场景)渲染 const state = appApi.state; assert(state.metrics.length > 0, "首次快照后本地进度点为空"); const chips = $get("chip-converge").innerHTML; assert(chips.includes(scenarios.rising.convergence.status), `收敛 chip 未渲染: ${chips}`); assert($get("stat-pid").textContent === "PID 42424", `PID 未渲染: ${$get("stat-pid").textContent}`); assert($get("metric-grid").children.length === schema.metrics.length, `指标卡数量应为 ${schema.metrics.length},实际 ${$get("metric-grid").children.length}`); assert($get("chart-tabs").children.length === schema.metrics.length, "曲线页签数量与指标字典不一致"); assert(stripTags($get("log-view").innerHTML).includes("progress"), "日志区没有渲染进度行"); assert($get("episode-table").querySelector("tbody") !== null, "逐回合表缺少 tbody"); const tbody = $get("episode-table").querySelector("tbody"); assert(tbody.innerHTML.includes("