/* 训练前端 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(""), `逐回合表未渲染任何行: monitor=${(scenarios.rising.monitor || []).length}`); assert(Number($get("steps-total").textContent.replace(/,/g, "")) === scenarios.rising.config.timesteps, `总步数渲染异常: ${$get("steps-total").textContent}`); // 3) 三种收敛场景切到对应 chip const tones = { rising: "ok", plateau: "ok", "kl-spike": "bad" }; for (const key of Object.keys(scenarios)) { const rendered = appApi.render(scenarios[key]); void rendered; const html = $get("chip-converge").innerHTML; assert(html.includes(scenarios[key].convergence.status), `场景 ${key} 的判读未落到 chip: ${html}`); assert($get("chip-converge").classList.contains(tones[key]), `场景 ${key} 的色调应为 ${tones[key]}`); } assert(String($get("chart2-title").textContent).length > 0, "第二张曲线标题为空"); // 4) 增量游标合并:续接 / 有缺口 / 过期批次 三条分支 const job = JSON.parse(JSON.stringify(scenarios.rising)); const total = job.metrics.length; const reset = () => { appApi.state.metrics = []; appApi.state.metricCache = []; appApi.state.metricNext = 0; appApi.state.metricTotal = 0; }; const feed = (metrics, offset) => appApi.absorbMetrics( { ...job, metrics, metric_offset: offset, metric_total: total }); const stamp = (i) => job.metrics[i].timesteps; reset(); feed(job.metrics.slice(0, 10), 0); assert(appApi.state.metrics.length === 10, `首批应 10 点,实际 ${appApi.state.metrics.length}`); feed(job.metrics.slice(10, 14), 10); assert(appApi.state.metrics.length === 14, `续接后应 14 点,实际 ${appApi.state.metrics.length}`); assert(appApi.state.metricNext === 14, `metricNext 应为 14,实际 ${appApi.state.metricNext}`); feed([], 14); assert(appApi.state.metrics.length === 14, `无新点不得改变长度,实际 ${appApi.state.metrics.length}`); // 有缺口(服务端窗口前移):整体替换,避免曲线出现空洞 feed(job.metrics.slice(20, 30), 20); assert(appApi.state.metrics.length === 10, `有缺口时应替换为 10 点,实际 ${appApi.state.metrics.length}`); assert(appApi.state.metrics[0].timesteps === stamp(20), "替换后起点错位"); assert(appApi.state.metricNext === 30, `游标未随替换更新: ${appApi.state.metricNext}`); // 过期批次(服务端计数回退):忽略,本地不动 feed(job.metrics.slice(0, 5), 0); assert(appApi.state.metrics.length === 10, `过期批次应被忽略,实际 ${appApi.state.metrics.length}`); assert(appApi.state.metrics[0].timesteps === stamp(20), "过期批次污染了本地缓存"); // 字符串形式的 offset:必须按数值比较,不得退化成字典序 reset(); feed(job.metrics.slice(0, 10), "0"); feed(job.metrics.slice(10, 14), "10"); assert(appApi.state.metrics.length === 14, `字符串 offset 应正常续接为 14 点,实际 ${appApi.state.metrics.length}`); // 缺失 offset(旧版服务端):退化为按到达顺序追加,不得丢点 reset(); appApi.absorbMetrics({ ...job, metrics: job.metrics.slice(0, 6) }); assert(appApi.state.metrics.length === 6, `缺 offset 时首批应 6 点,实际 ${appApi.state.metrics.length}`); // 5) 日志分类与高亮 assert(appApi.classify("Traceback (most recent call last):") === "err", "Traceback 未归入错误行"); assert(appApi.classify("UserWarning: x") === "warn", "警告未归入 warn"); assert(appApi.classify('{"event": "progress"}') === "json", "进度 JSON 未归入 json"); assert(appApi.highlight("abc", "b").includes("b"), "过滤高亮未生效"); const marked = appApi.highlight("