286 lines
14 KiB
JavaScript
286 lines
14 KiB
JavaScript
/* 训练前端 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("<") && marked.endsWith(">") && 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);
|
||
});
|