Files
mcm-26-b/tests/js/training_ui_harness.cjs
2026-09-11 21:00:39 +08:00

286 lines
14 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* 训练前端 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); },
// 表单里的静态子元素(如 <tbody>)不在 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("<tr>"),
`逐回合表未渲染任何行: 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("<mark>b</mark>"), "过滤高亮未生效");
const marked = appApi.highlight("<script>", "script");
assert(marked.startsWith("&lt;") && marked.endsWith("&gt;") && marked.includes("<mark>"),
`命中过滤时也必须转义 HTML: ${marked}`);
assert(!appApi.highlight("<script>", "").includes("<script>"),
"未过滤时也应转义 HTML");
// 6) 序列抽取:跳过 None 且横轴用 timesteps
const series = appApi.seriesOf("ep_rew_mean");
assert(series.length > 0 && isFinite(series[0][0]) && isFinite(series[0][1]),
"seriesOf 产出非法坐标");
for (let i = 1; i < series.length; i++) {
assert(series[i][0] >= series[i - 1][0], "seriesOf 横轴未单调递增");
}
// 7) charts.js 边界输入不抛异常
const Charts = globalThis.Charts;
const canvas = makeEl("probe");
Charts.line(canvas, { series: [] });
Charts.line(canvas, { series: [{ points: [[1, 1]] }] });
Charts.line(canvas, { series: [{ points: [[1, 1], [1, 1]] }] });
Charts.line(canvas, { series: [{ points: [[0, 1e-12], [1, 1e12]] }] });
Charts.sparkline(canvas, []);
Charts.sparkline(canvas, [3, 3, 3, 3]);
assert(Charts.fmt(NaN) === "—", "fmt(NaN) 应为占位符");
assert(Charts.fmt(12345) === "12345.0", `五位数应保持定点: ${Charts.fmt(12345)}`);
assert(Charts.fmt(1.2e6).includes("e+6"), `六位数应转科学计数: ${Charts.fmt(1.2e6)}`);
assert(Charts.fmt(0.000012) === "1.20e-5", `极小值应转科学计数: ${Charts.fmt(0.000012)}`);
console.log("training-ui harness: ALL PASS");
})().catch((err) => {
console.error("training-ui harness FAILED:", err && err.stack || err);
process.exit(1);
});