feat(all): 任务引擎双阶段解耦、僵尸涡旋修复、动态 CPU 配额与前端详情页重构
将 TLUSTY/SYNSPEC 拆为各自独立的 enabled/policy/strategies 阶段,
以策略链自动弹栈取代单级 seed_step 布尔回退;定向修复 2026-08-02
僵尸任务涡旋事故;新增节点并发配额热调;前端详情页从 1412 行巨型
视图拆为薄控制器 + detail 子模块,并补齐工具层与单测。
引擎与调度(task_engine_decoupling_design.md)
- models.rs: 新增 StagePolicy / EngineStageConfig / TaskSpec 阶段字段、
normalize_compat() 校正旧版在途消息策略链、failed_stage 归因
- scheduler.rs: resolve_dispatchable_chain 派发门控、
trigger_strategy_fallback 按 failed_stage 精确弹栈;启动期
force_recompute/skip_converged(默认)/skip_failed 三策略
- db.rs: tasks 表 +7 列持久化阶段配置;终态守卫
(mark_grid_point_running 仅 pending/queued→running;
record_task_report 拒绝迟到失败翻黑 converged);策略弹栈快照
僵尸涡旋修复(runbook-20260802-zombie-vortex-fix.md)
- 全链路跨库活性交叉校验:派发/claim/孤儿回收/回退统一查 MQ 队列活性,
活则放行、死则清僵尸,结构性消除"每点重复派发"
- stop/重启卫生:清队列同步 delete_tasks_by_ids,杜绝遗留 pending 行
- report_task: 幂等吸收 + 409 区分迟到冗余结果,仅 state_changed 时回退
- MQ: NULL workflow_name 回填 __legacy__、requeue 后迟到上报被 403 竞态修复
动态 CPU 配额(dynamic_cpu_slots_design.md)
- admin.rs: POST /admin/nodes/:id/quota(Option<Option<i32>> 区分
缺字段/显式 null);nodes 表 +admin_max_slots
- worker.rs: effective_max_slots = min(admin, physical),心跳下发原子生效
科学产物保全(tlusty_result_artifacts.md)
- runner.rs: SYNSPEC 启动前快照 fort.12/fort.14 → .bfac/.emflux 防覆盖
- 半失败点(大气收敛+光谱失败)改判 Failed 并写入 note;仅 SYNSPEC
场景不再恒判失败;撤销归档 LRU 200 上限改为永久保留
- executor.rs: 透传 synspec_params 数值参数(此前固定 None)
前端(dashboard/)
- workflowDetail.js 1412→328 行,拆出 views/detail/{ctx,overview,
pointsTable,parSets,pointPanel}.js,AbortController 治理监听/请求生命周期
- 删除 wfActions.js,新增 wfEnginePanel.js(双阶段三维配置编辑面板)
- 新增 utils/{errors,format,icons,polling,yamlStage}.js 纯函数模块
- 路由级动态 import 代码分割;节点配额三点菜单 + Modal 管理
- 首次引入 node:test 单测(format/polling/yamlStage/psCache,644 行)
- 系统性补齐 a11y:skip-link、ARIA、Tab 键盘漫游、toast 关闭、退出动画
文档与工具
- 新增 6 篇设计/调研:引擎解耦、动态配额、涡旋 runbook、
光谱正确性分析、收敛判断、产物归档
- PIPELINE/design/api/database 等协同重写为分布式 C/S 架构口径
- scripts/fetch_results.sh 跨节点产物备份;import_results 按 cno 升序导入
- workflows/sdB_cno.yaml: 新增 tlusty/synspec_stage 配置块,修正 wstart 笔误
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
/* DCTS 纯函数单测(node:test,零依赖,无需 npm install)
|
||||
*
|
||||
* 覆盖 utils/format.js 的格式化/转义/徽章逻辑——这些是最容易出回归 bug 的纯函数
|
||||
* (尤其 SQLite UTC 时间解析、指数记数、CSV 转义)。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
escapeHtml, parseIso, parseTsMs, fmtTime, fmtDateTime, fmtFullTs,
|
||||
fmtDuration, fmtDur, fmtRelc, csvEscape, fmtClock,
|
||||
statusBadge, pointStatusBadge, pointMethodBadge,
|
||||
} from '../src/utils/format.js';
|
||||
|
||||
// ===== escapeHtml =====
|
||||
|
||||
test('escapeHtml 转义五个特殊字符', () => {
|
||||
assert.equal(escapeHtml('<script>&"\'</script>'), '<script>&"'</script>');
|
||||
});
|
||||
|
||||
test('escapeHtml 顺序敏感:& 先转义,不二次转义', () => {
|
||||
assert.equal(escapeHtml('a & b'), 'a & b');
|
||||
assert.equal(escapeHtml('&'), '&amp;'); // 输入本身是实体时也如实转义(不做解码)
|
||||
});
|
||||
|
||||
test('escapeHtml 空值/null 返回空串', () => {
|
||||
assert.equal(escapeHtml(null), '');
|
||||
assert.equal(escapeHtml(undefined), '');
|
||||
assert.equal(escapeHtml(''), '');
|
||||
});
|
||||
|
||||
// ===== 时间解析(SQLite 'YYYY-MM-DD HH:MM:SS' 视为 UTC) =====
|
||||
|
||||
test('parseIso 解析 SQLite UTC 无时区格式', () => {
|
||||
const d = parseIso('2026-08-02 15:30:00');
|
||||
assert.ok(d instanceof Date);
|
||||
assert.equal(d.toISOString(), '2026-08-02T15:30:00.000Z');
|
||||
});
|
||||
|
||||
test('parseIso 解析带 T 的 ISO 原样', () => {
|
||||
const d = parseIso('2026-08-02T15:30:00Z');
|
||||
assert.equal(d.toISOString(), '2026-08-02T15:30:00.000Z');
|
||||
});
|
||||
|
||||
test('parseIso 非法输入返回 null', () => {
|
||||
assert.equal(parseIso('not-a-date'), null);
|
||||
assert.equal(parseIso(null), null);
|
||||
assert.equal(parseIso(''), null);
|
||||
});
|
||||
|
||||
test('parseTsMs 返回 epoch 毫秒,非法输入为 NaN', () => {
|
||||
assert.equal(parseTsMs('2026-08-02 00:00:00'), Date.UTC(2026, 7, 2));
|
||||
assert.ok(Number.isNaN(parseTsMs('garbage')));
|
||||
assert.ok(Number.isNaN(parseTsMs(null)));
|
||||
});
|
||||
|
||||
test('fmtDateTime 输出 MM-DD HH:MM 定宽(按浏览器本地时区)', () => {
|
||||
// 服务器存 UTC、前端按本地时区显示(既有行为)。断言值用同一 Date 计算,与时区无关。
|
||||
const d = parseIso('2026-08-02 09:05:07');
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
assert.equal(
|
||||
fmtDateTime('2026-08-02 09:05:07'),
|
||||
`${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
|
||||
);
|
||||
assert.equal(fmtDateTime('bad'), 'bad'); // 解析失败原样返回
|
||||
assert.equal(fmtDateTime(null), '—');
|
||||
});
|
||||
|
||||
test('fmtFullTs 输出完整时间戳(按浏览器本地时区)', () => {
|
||||
const d = parseIso('2026-08-02 09:05:07');
|
||||
const p = (n) => String(n).padStart(2, '0');
|
||||
assert.equal(
|
||||
fmtFullTs('2026-08-02 09:05:07'),
|
||||
`${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
|
||||
);
|
||||
assert.equal(fmtFullTs(null), '');
|
||||
});
|
||||
|
||||
test('fmtTime 非法时间戳回退为转义原文', () => {
|
||||
assert.equal(fmtTime('garbage'), 'garbage');
|
||||
assert.equal(fmtTime(null), '—');
|
||||
});
|
||||
|
||||
// ===== 时长格式化 =====
|
||||
|
||||
test('fmtDuration(控制条 ETA):分钟粒度', () => {
|
||||
assert.equal(fmtDuration(30), '1 分钟'); // 30s → round(0.5)=1 分钟(沿用原版算法)
|
||||
assert.equal(fmtDuration(3600), '1 小时 0 分');
|
||||
assert.equal(fmtDuration(5400), '1 小时 30 分');
|
||||
assert.equal(fmtDuration(null), '—');
|
||||
assert.equal(fmtDuration(NaN), '—');
|
||||
// ≤0 视为无数据(L3 修复:失败任务 last_elapsed_sec=0.0 不应显示"0 分钟")
|
||||
assert.equal(fmtDuration(0), '—');
|
||||
assert.equal(fmtDuration(-5), '—');
|
||||
});
|
||||
|
||||
test('fmtDur(表格单元格):42s / 12m 30s / 1h 20m', () => {
|
||||
assert.equal(fmtDur(42), '42s');
|
||||
assert.equal(fmtDur(750), '12m 30s');
|
||||
assert.equal(fmtDur(4800), '1h 20m');
|
||||
assert.equal(fmtDur(0), '—'); // ≤0 视为无数据
|
||||
assert.equal(fmtDur(-5), '—');
|
||||
assert.equal(fmtDur(''), '—');
|
||||
assert.equal(fmtDur(null), '—');
|
||||
});
|
||||
|
||||
test('fmtRelc 指数记数(max_relc 相对收敛度)', () => {
|
||||
assert.equal(fmtRelc(0.00123), '1.23e-3');
|
||||
assert.equal(fmtRelc(''), '—');
|
||||
assert.equal(fmtRelc(null), '—');
|
||||
assert.equal(fmtRelc('abc'), '—');
|
||||
});
|
||||
|
||||
test('fmtClock 心跳时间容错', () => {
|
||||
assert.match(fmtClock('2026-08-02T09:05:07Z'), /^\d{2}:\d{2}:\d{2}$/);
|
||||
assert.equal(fmtClock('invalid'), '—');
|
||||
assert.equal(fmtClock(null), '—');
|
||||
});
|
||||
|
||||
// ===== CSV 转义 =====
|
||||
|
||||
test('csvEscape 逗号/引号/换行包裹并翻倍引号', () => {
|
||||
assert.equal(csvEscape('plain'), 'plain');
|
||||
assert.equal(csvEscape('a,b'), '"a,b"');
|
||||
assert.equal(csvEscape('say "hi"'), '"say ""hi"""');
|
||||
assert.equal(csvEscape('line1\nline2'), '"line1\nline2"');
|
||||
assert.equal(csvEscape('a\rb'), '"a\rb"');
|
||||
assert.equal(csvEscape(null), '');
|
||||
assert.equal(csvEscape(123), '123');
|
||||
});
|
||||
|
||||
// ===== 状态徽章 =====
|
||||
|
||||
test('statusBadge 覆盖全部后端工作流状态', () => {
|
||||
assert.equal(statusBadge('running'), '<span class="status-badge online">运行中</span>');
|
||||
assert.equal(statusBadge('completed'), '<span class="status-badge completed">已完成</span>');
|
||||
assert.equal(statusBadge('initializing'), '<span class="status-badge warning">初始化中</span>');
|
||||
assert.equal(statusBadge('paused'), '<span class="status-badge secondary">已暂停</span>');
|
||||
assert.equal(statusBadge('idle'), '<span class="status-badge secondary">闲置</span>');
|
||||
assert.equal(statusBadge('unknown-status'), '<span class="status-badge secondary">闲置</span>'); // 兜底
|
||||
});
|
||||
|
||||
test('pointStatusBadge 覆盖网格点状态', () => {
|
||||
assert.equal(pointStatusBadge('converged'), '<span class="status-badge online">已收敛</span>');
|
||||
assert.equal(pointStatusBadge('failed'), '<span class="status-badge danger">失败</span>');
|
||||
assert.equal(pointStatusBadge('running'), '<span class="status-badge warning">运行中</span>');
|
||||
assert.equal(pointStatusBadge('queued'), '<span class="status-badge info">排队中</span>');
|
||||
assert.equal(pointStatusBadge('pending'), '<span class="status-badge secondary">未开始</span>');
|
||||
assert.equal(pointStatusBadge('???'), '<span class="status-badge secondary">未知</span>');
|
||||
});
|
||||
|
||||
test('pointMethodBadge 识别收敛途径(与后端 success_method 契约对齐)', () => {
|
||||
assert.equal(pointMethodBadge('cold_run'), '<span class="method-badge cold">冷启动</span>');
|
||||
assert.equal(pointMethodBadge('seed_step'), '<span class="method-badge seed">种子步进</span>');
|
||||
// 其它策略名(synspec-only 任务归因的 success_method,如 "standard")原样展示
|
||||
assert.equal(pointMethodBadge('standard'), '<span class="method-badge syn">standard</span>');
|
||||
// 含 HTML 元字符须转义(XSS 防护)
|
||||
assert.equal(
|
||||
pointMethodBadge('<img src=x onerror=alert(1)>'),
|
||||
'<span class="method-badge syn"><img src=x onerror=alert(1)></span>'
|
||||
);
|
||||
assert.equal(pointMethodBadge(null), '<span class="text-hint">—</span>');
|
||||
assert.equal(pointMethodBadge(''), '<span class="text-hint">—</span>');
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
/* DCTS 轮询器单测(node:test,零依赖)
|
||||
*
|
||||
* utils/polling.js 的 createPoller:指数退避、failCount 重置/增长、stop 清理。
|
||||
* 通过 stubbing setTimeout / clearTimeout 捕获调度延迟,不真等 5s+。
|
||||
*
|
||||
* 时序说明:start() 触发 schedule() 时 fn 是同步调用的(calls 立即生效),
|
||||
* 后续的 failCount 更新与 setTimeout 排期发生在微任务里——`await p.start()`
|
||||
* 之后测试续体排在其后,故断言时调度已完成(确定性)。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createPoller } from '../src/utils/polling.js';
|
||||
|
||||
// 最小 document stub:node 环境无 DOM,轮询器只在 visibilitychange 处触碰 document。
|
||||
function installDocStub() {
|
||||
const doc = { hidden: false, addEventListener() {}, removeEventListener() {} };
|
||||
const prev = globalThis.document;
|
||||
globalThis.document = doc;
|
||||
return { doc, restore() { globalThis.document = prev; } };
|
||||
}
|
||||
|
||||
// 捕获 setTimeout 调用。fire(n) 手动执行第 n 次已排定的回调(await 其完整完成)。
|
||||
function captureTimers() {
|
||||
const realSet = globalThis.setTimeout;
|
||||
const realClear = globalThis.clearTimeout;
|
||||
const scheduled = [];
|
||||
globalThis.setTimeout = (fn, ms) => {
|
||||
const id = { fn, ms };
|
||||
scheduled.push(id);
|
||||
return id;
|
||||
};
|
||||
globalThis.clearTimeout = (id) => {
|
||||
const i = scheduled.indexOf(id);
|
||||
if (i >= 0) scheduled.splice(i, 1);
|
||||
};
|
||||
return {
|
||||
delays: () => scheduled.map(t => t.ms),
|
||||
fire: async (n) => { await scheduled[n].fn(); },
|
||||
restore() {
|
||||
globalThis.setTimeout = realSet;
|
||||
globalThis.clearTimeout = realClear;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('start() 立即执行一次 fn', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
let calls = 0;
|
||||
const p = createPoller(() => { calls++; return true; }, { baseMs: 5000 });
|
||||
await p.start();
|
||||
assert.equal(calls, 1); // 立即跑了一次(fn 同步调用)
|
||||
assert.equal(p.isRunning(), true);
|
||||
// 成功后下一轮间隔为 baseMs
|
||||
assert.deepEqual(timers.delays(), [5000]);
|
||||
p.stop();
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('fn 返回 false 触发指数退避,成功后重置', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
let ok = false;
|
||||
const p = createPoller(() => ok, { baseMs: 5000, maxMs: 60000 });
|
||||
await p.start();
|
||||
// 第一次失败 → failCount=1 → 5k*2^1
|
||||
assert.deepEqual(timers.delays(), [10000]);
|
||||
await timers.fire(0); // 第二轮仍失败 → failCount=2
|
||||
assert.deepEqual(timers.delays().slice(-1), [20000]);
|
||||
ok = true;
|
||||
await timers.fire(0); // 第三轮成功 → failCount 重置
|
||||
assert.deepEqual(timers.delays().slice(-1), [5000]);
|
||||
p.stop();
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('fn 抛错同样计为失败', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
const p = createPoller(() => { throw new Error('boom'); }, { baseMs: 5000 });
|
||||
await p.start();
|
||||
assert.deepEqual(timers.delays().slice(-1), [10000]);
|
||||
p.stop();
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('stop() 清除挂起定时器且不再调度', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
let calls = 0;
|
||||
const p = createPoller(() => { calls++; return true; }, { baseMs: 5000 });
|
||||
await p.start(); // calls=1,已排下一轮
|
||||
assert.equal(timers.delays().length, 1);
|
||||
p.stop();
|
||||
assert.equal(timers.delays().length, 0); // 挂起定时器已被清除
|
||||
assert.equal(calls, 1); // fn 未再被调用
|
||||
assert.equal(p.isRunning(), false);
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('triggerNow() 立即触发一次并续排', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
let calls = 0;
|
||||
const p = createPoller(() => { calls++; return true; }, { baseMs: 5000 });
|
||||
await p.start(); // calls=1
|
||||
await p.triggerNow(); // calls=2
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(p.isRunning(), true);
|
||||
p.stop();
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
|
||||
test('退避上限 maxMs 生效(长时间连续失败不无限增长)', async () => {
|
||||
const stub = installDocStub();
|
||||
const timers = captureTimers();
|
||||
const p = createPoller(() => false, { baseMs: 5000, maxMs: 60000, maxFails: 4 });
|
||||
await p.start();
|
||||
let maxDelay = 0;
|
||||
for (let i = 0; i < 10; i++) {
|
||||
maxDelay = Math.max(maxDelay, timers.delays()[timers.delays().length - 1] || 0);
|
||||
await timers.fire(0);
|
||||
}
|
||||
assert.ok(maxDelay <= 60000, `maxDelay=${maxDelay} 不应超过 60s`);
|
||||
assert.equal(maxDelay, 60000); // 5k*2^4 clamp 到 60s
|
||||
p.stop();
|
||||
timers.restore();
|
||||
stub.restore();
|
||||
});
|
||||
@@ -0,0 +1,85 @@
|
||||
/* DCTS 平行集合图 sessionStorage 缓存单测(node:test,零依赖)
|
||||
*
|
||||
* 覆盖 parSets.js 的 savePsCache / restorePsCache:刷新页面后恢复数据、单槽位清理、
|
||||
* 损坏数据回退。node 无 sessionStorage,测试前用内存 Map stub。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
// 内存 sessionStorage stub(setItem 可注入配额错误用于 QuotaExceeded 分支)
|
||||
let store = new Map();
|
||||
let quotaExceeded = false;
|
||||
globalThis.sessionStorage = {
|
||||
get length() { return store.size; },
|
||||
key: (i) => [...store.keys()][i] ?? null,
|
||||
getItem: (k) => (store.has(k) ? store.get(k) : null),
|
||||
setItem: (k, v) => {
|
||||
if (quotaExceeded) throw new Error('QuotaExceededError');
|
||||
store.set(k, String(v));
|
||||
},
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
};
|
||||
|
||||
const { savePsCache, restorePsCache } = await import('../src/views/detail/parSets.js');
|
||||
|
||||
function ctx(name, points = [], ts = null) {
|
||||
return { name, psState: { points, ts } };
|
||||
}
|
||||
|
||||
test('savePsCache 写入 JSON 并可由 restorePsCache 恢复', () => {
|
||||
store.clear();
|
||||
const before = Date.now();
|
||||
const c = ctx('sdB_cno', [{ name: 'p1', teff: 20000, status: 'converged' }]);
|
||||
savePsCache(c);
|
||||
|
||||
// 持久化 key 存在
|
||||
assert.ok(store.has('dcts_ps_points_sdB_cno'));
|
||||
// 恢复后 points 一致;快照时间由 savePsCache 记录为保存时刻(Date.now 附近)
|
||||
const c2 = ctx('sdB_cno');
|
||||
assert.equal(restorePsCache(c2), true);
|
||||
assert.deepEqual(c2.psState.points, c.psState.points);
|
||||
assert.ok(Number.isFinite(c2.psState.ts) && c2.psState.ts >= before);
|
||||
assert.ok(Math.abs(c2.psState.ts - Date.now()) < 5000);
|
||||
});
|
||||
|
||||
test('多工作流缓存并存:切换工作流不删除彼此的缓存', () => {
|
||||
store.clear();
|
||||
savePsCache(ctx('wf_a', [{ name: 'a' }]));
|
||||
savePsCache(ctx('wf_b', [{ name: 'b' }]));
|
||||
savePsCache(ctx('wf_a', [{ name: 'a2' }])); // 再回 wf_a 更新,writes 覆盖自身 key
|
||||
|
||||
// wf_a 与 wf_b 的缓存都保留
|
||||
assert.ok(store.has('dcts_ps_points_wf_a'));
|
||||
assert.ok(store.has('dcts_ps_points_wf_b'));
|
||||
|
||||
// 各自恢复出各自的数据(不串)
|
||||
const ca = ctx('wf_a');
|
||||
const cb = ctx('wf_b');
|
||||
assert.equal(restorePsCache(ca), true);
|
||||
assert.equal(restorePsCache(cb), true);
|
||||
assert.deepEqual(ca.psState.points, [{ name: 'a2' }]); // 更新后是最新值
|
||||
assert.deepEqual(cb.psState.points, [{ name: 'b' }]);
|
||||
});
|
||||
|
||||
test('无缓存 / 空 key 时 restorePsCache 返回 false', () => {
|
||||
store.clear();
|
||||
const c = ctx('no_such_wf');
|
||||
assert.equal(restorePsCache(c), false);
|
||||
assert.deepEqual(c.psState.points, []);
|
||||
});
|
||||
|
||||
test('损坏的 JSON / 非数组数据返回 false(降级为重新拉取)', () => {
|
||||
store.clear();
|
||||
store.set('dcts_ps_points_bad_json', 'not-json{{{');
|
||||
store.set('dcts_ps_points_bad_shape', JSON.stringify({ foo: 1 }));
|
||||
assert.equal(restorePsCache(ctx('bad_json')), false);
|
||||
assert.equal(restorePsCache(ctx('bad_shape')), false);
|
||||
});
|
||||
|
||||
test('QuotaExceeded 时 savePsCache 静默失败,不抛错', () => {
|
||||
store.clear();
|
||||
quotaExceeded = true;
|
||||
assert.doesNotThrow(() => savePsCache(ctx('big_wf', new Array(10000).fill({ x: 1 }))));
|
||||
quotaExceeded = false;
|
||||
});
|
||||
@@ -0,0 +1,259 @@
|
||||
/* DCTS wfEnginePanel 阶段配置 YAML 解析/序列化单测(node:test,零依赖)
|
||||
*
|
||||
* 覆盖 utils/yamlStage.js 的注释处理、块边界、往返保真——这些是审查 #4/#5
|
||||
* (顶层注释吞块、行内注释污染解析)修复的回归守护。
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
defaultStage,
|
||||
extractTopBlock,
|
||||
parseStageFromYaml,
|
||||
resolveTlustyFromYaml,
|
||||
serializeStageBlock,
|
||||
applyStageBlocks,
|
||||
validateStageConfigs,
|
||||
} from '../src/utils/yamlStage.js';
|
||||
|
||||
test('defaultStage:tlusty 默认 [cold_run, seed_step],synspec 默认 [standard]', () => {
|
||||
assert.deepEqual(defaultStage('tlusty'), {
|
||||
enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'],
|
||||
});
|
||||
assert.deepEqual(defaultStage('synspec'), {
|
||||
enabled: true, policy: 'skip_converged', strategies: ['standard'],
|
||||
});
|
||||
});
|
||||
|
||||
test('extractTopBlock:顶层注释(列 0 #)终止块,不吞入下一块(审查 #4)', () => {
|
||||
const yaml = [
|
||||
'tlusty:',
|
||||
' enabled: true',
|
||||
' policy: skip_converged',
|
||||
'# 这是顶层注释,应终止 tlusty 块',
|
||||
'synspec_stage:',
|
||||
' enabled: false',
|
||||
' policy: force_recompute',
|
||||
].join('\n');
|
||||
const t = extractTopBlock(yaml, 'tlusty');
|
||||
assert.equal(t.lines.length, 3, 'tlusty 块应只含自身 3 行,不含顶层注释与后续块');
|
||||
assert.ok(!t.lines.some(l => l.includes('synspec_stage')), '不吞入 synspec_stage');
|
||||
|
||||
const s = extractTopBlock(yaml, 'synspec_stage');
|
||||
assert.ok(s, 'synspec_stage 块应能被独立定位(未被 tlusty 吞掉)');
|
||||
assert.equal(s.lines.length, 3);
|
||||
});
|
||||
|
||||
test('extractTopBlock:空行属块体,缩进注释属块体', () => {
|
||||
const yaml = [
|
||||
'tlusty:',
|
||||
' enabled: true',
|
||||
'',
|
||||
' # 缩进注释,属块体',
|
||||
' policy: skip_converged',
|
||||
].join('\n');
|
||||
const t = extractTopBlock(yaml, 'tlusty');
|
||||
assert.equal(t.lines.length, 5);
|
||||
});
|
||||
|
||||
test('parseStageFromYaml:行内注释不污染 policy 解析(审查 #5)', () => {
|
||||
const yaml = [
|
||||
'tlusty:',
|
||||
' enabled: true',
|
||||
' policy: skip_converged # 旧值 force_recompute 已废弃',
|
||||
' strategies: [cold_run, seed_step]',
|
||||
].join('\n');
|
||||
const t = parseStageFromYaml(yaml, 'tlusty');
|
||||
assert.equal(t.policy, 'skip_converged', '应取真值而非注释里的 force_recompute');
|
||||
assert.equal(t.enabled, true);
|
||||
assert.deepEqual(t.strategies, ['cold_run', 'seed_step']);
|
||||
});
|
||||
|
||||
test('parseStageFromYaml:整行注释里的 policy 不被当真值(审查 #5)', () => {
|
||||
const yaml = [
|
||||
'tlusty:',
|
||||
' # policy: force_recompute <- 注释掉的旧值',
|
||||
' policy: skip_failed',
|
||||
' strategies: [cold_run]',
|
||||
].join('\n');
|
||||
const t = parseStageFromYaml(yaml, 'tlusty');
|
||||
assert.equal(t.policy, 'skip_failed', '应跳过整行注释,取真值 skip_failed');
|
||||
});
|
||||
|
||||
test('parseStageFromYaml:enabled 行内注释', () => {
|
||||
const yaml = [
|
||||
'synspec_stage:',
|
||||
' enabled: false # 仅计算大气',
|
||||
' policy: skip_converged',
|
||||
' strategies: [standard]',
|
||||
].join('\n');
|
||||
const s = parseStageFromYaml(yaml, 'synspec_stage');
|
||||
assert.equal(s.enabled, false, 'enabled=false 应正确解析(注释不干扰)');
|
||||
});
|
||||
|
||||
test('parseStageFromYaml:块式 strategies 列表', () => {
|
||||
const yaml = [
|
||||
'tlusty:',
|
||||
' enabled: true',
|
||||
' policy: skip_converged',
|
||||
' strategies:',
|
||||
' - cold_run',
|
||||
' - seed_step',
|
||||
].join('\n');
|
||||
const t = parseStageFromYaml(yaml, 'tlusty');
|
||||
assert.deepEqual(t.strategies, ['cold_run', 'seed_step']);
|
||||
});
|
||||
|
||||
test('parseStageFromYaml:块不存在返回 null', () => {
|
||||
const yaml = 'grid:\n teff: [20000]\n';
|
||||
assert.equal(parseStageFromYaml(yaml, 'tlusty'), null);
|
||||
});
|
||||
|
||||
test('resolveTlustyFromYaml:无 tlusty 块时按 seed_step_fallback 推断(审查 #2 修复)', () => {
|
||||
// seed_step_fallback: false → [cold_run](不回退),与服务端 resolve_tlusty_config 同口径。
|
||||
const yamlFalse = [
|
||||
'grid:',
|
||||
' teff: [20000]',
|
||||
'seed_step_fallback: false',
|
||||
'synspec:',
|
||||
' wstart: 3000.0',
|
||||
].join('\n');
|
||||
assert.deepEqual(resolveTlustyFromYaml(yamlFalse), {
|
||||
enabled: true, policy: 'skip_converged', strategies: ['cold_run'],
|
||||
}, 'seed_step_fallback:false 应推断 [cold_run](面板保存不得静默启用回退)');
|
||||
|
||||
// seed_step_fallback: true → 默认链 [cold_run, seed_step]。
|
||||
const yamlTrue = 'seed_step_fallback: true\n';
|
||||
assert.deepEqual(resolveTlustyFromYaml(yamlTrue), defaultStage('tlusty'));
|
||||
|
||||
// 缺省(无该字段)→ 默认链。
|
||||
const yamlAbsent = 'grid:\n teff: [20000]\n';
|
||||
assert.deepEqual(resolveTlustyFromYaml(yamlAbsent), defaultStage('tlusty'));
|
||||
});
|
||||
|
||||
test('resolveTlustyFromYaml:有 tlusty 块时优先块(不受旧字段干扰)', () => {
|
||||
const yaml = [
|
||||
'tlusty:',
|
||||
' enabled: true',
|
||||
' policy: force_recompute',
|
||||
' strategies: [cold_run]',
|
||||
'seed_step_fallback: true',
|
||||
].join('\n');
|
||||
assert.deepEqual(resolveTlustyFromYaml(yaml), {
|
||||
enabled: true, policy: 'force_recompute', strategies: ['cold_run'],
|
||||
}, '显式 tlusty 块优先');
|
||||
});
|
||||
|
||||
test('resolveTlustyFromYaml:注释行不误判 seed_step_fallback', () => {
|
||||
const yaml = '# seed_step_fallback: false(注释,非真实配置)\ngrid:\n teff: [20000]\n';
|
||||
assert.deepEqual(resolveTlustyFromYaml(yaml), defaultStage('tlusty'),
|
||||
'注释行被忽略,按缺省推断默认链');
|
||||
});
|
||||
|
||||
test('applyStageBlocks:替换已存在块,保留其他配置(往返保真)', () => {
|
||||
const yaml = [
|
||||
'grid:',
|
||||
' teff: [20000]',
|
||||
'timeout_sec: 7200',
|
||||
'tlusty:',
|
||||
' enabled: true',
|
||||
' policy: skip_converged',
|
||||
' strategies: [cold_run]',
|
||||
'synspec_stage:',
|
||||
' enabled: true',
|
||||
' policy: skip_converged',
|
||||
' strategies: [standard]',
|
||||
].join('\n');
|
||||
const out = applyStageBlocks(yaml,
|
||||
{ enabled: false, policy: 'force_recompute', strategies: ['seed_step'] },
|
||||
{ enabled: true, policy: 'skip_converged', strategies: ['standard'] },
|
||||
);
|
||||
// grid 与 timeout_sec 必须保留。
|
||||
assert.match(out, /teff: \[20000\]/);
|
||||
assert.match(out, /timeout_sec: 7200/);
|
||||
// tlusty 块被替换为新值。
|
||||
assert.match(out, /tlusty:\n enabled: false\n policy: force_recompute\n strategies: \[seed_step\]/);
|
||||
// synspec_stage 块保留/替换正确。
|
||||
assert.match(out, /synspec_stage:\n enabled: true\n policy: skip_converged\n strategies: \[standard\]/);
|
||||
});
|
||||
|
||||
test('applyStageBlocks:块不存在时追加到末尾', () => {
|
||||
const yaml = 'grid:\n teff: [20000]\n';
|
||||
const out = applyStageBlocks(yaml, defaultStage('tlusty'), defaultStage('synspec'));
|
||||
assert.match(out, /tlusty:/);
|
||||
assert.match(out, /synspec_stage:/);
|
||||
assert.match(out, /teff: \[20000\]/, '原配置保留');
|
||||
});
|
||||
|
||||
test('applyStageBlocks:两块间有顶层注释时不互相破坏(审查 #4 回归)', () => {
|
||||
const yaml = [
|
||||
'tlusty:',
|
||||
' enabled: true',
|
||||
' policy: skip_converged',
|
||||
' strategies: [cold_run, seed_step]',
|
||||
'# 分隔注释',
|
||||
'synspec_stage:',
|
||||
' enabled: true',
|
||||
' policy: skip_converged',
|
||||
' strategies: [standard]',
|
||||
].join('\n');
|
||||
// 修改 tlusty 不应破坏 synspec_stage(旧实现会把注释+synspec 吞进 tlusty 块后覆盖丢失)。
|
||||
const out = applyStageBlocks(yaml,
|
||||
{ enabled: false, policy: 'skip_converged', strategies: ['cold_run'] },
|
||||
defaultStage('synspec'),
|
||||
);
|
||||
assert.match(out, /synspec_stage:\n enabled: true/, 'synspec_stage 块必须存活');
|
||||
assert.match(out, /tlusty:\n enabled: false/, 'tlusty 块已更新');
|
||||
});
|
||||
|
||||
test('serializeStageBlock:flow 序列输出格式', () => {
|
||||
const s = serializeStageBlock('tlusty', {
|
||||
enabled: true, policy: 'skip_converged', strategies: ['cold_run', 'seed_step'],
|
||||
});
|
||||
assert.equal(s, 'tlusty:\n enabled: true\n policy: skip_converged\n strategies: [cold_run, seed_step]');
|
||||
});
|
||||
|
||||
test('端到端往返:解析 → 序列化 → 再解析 保持语义', () => {
|
||||
const orig = [
|
||||
'tlusty:',
|
||||
' enabled: true',
|
||||
' policy: force_recompute',
|
||||
' strategies: [cold_run, seed_step]',
|
||||
].join('\n');
|
||||
const parsed = parseStageFromYaml(orig, 'tlusty');
|
||||
const serialized = serializeStageBlock('tlusty', parsed);
|
||||
const reparsed = parseStageFromYaml(serialized, 'tlusty');
|
||||
assert.deepEqual(parsed, reparsed, '往返保真');
|
||||
});
|
||||
|
||||
test('validateStageConfigs:双阶段全关拦截(修复审查 #5)', () => {
|
||||
const err = validateStageConfigs(
|
||||
{ enabled: false, policy: 'skip_converged', strategies: ['cold_run'] },
|
||||
{ enabled: false, policy: 'skip_converged', strategies: ['standard'] },
|
||||
);
|
||||
assert.ok(err && err.includes('至少应启用一个计算阶段'), `应拦截双关,实际: ${err}`);
|
||||
assert.equal(validateStageConfigs(
|
||||
{ enabled: true, policy: 'skip_converged', strategies: ['cold_run'] },
|
||||
{ enabled: false, policy: 'skip_converged', strategies: ['standard'] },
|
||||
), null, '单阶段启用合法');
|
||||
});
|
||||
|
||||
test('validateStageConfigs:启用阶段空策略链拦截(修复审查 #4)', () => {
|
||||
const tlustyErr = validateStageConfigs(
|
||||
{ enabled: true, policy: 'skip_converged', strategies: [] },
|
||||
{ enabled: true, policy: 'skip_converged', strategies: ['standard'] },
|
||||
);
|
||||
assert.ok(tlustyErr && tlustyErr.includes('TLUSTY'), `应拦截 TLUSTY 空链,实际: ${tlustyErr}`);
|
||||
|
||||
const synspecErr = validateStageConfigs(
|
||||
{ enabled: true, policy: 'skip_converged', strategies: ['cold_run'] },
|
||||
{ enabled: true, policy: 'skip_converged', strategies: [] },
|
||||
);
|
||||
assert.ok(synspecErr && synspecErr.includes('SYNSPEC'), `应拦截 SYNSPEC 空链,实际: ${synspecErr}`);
|
||||
|
||||
// 禁用阶段的空链不拦截(该阶段不执行)。
|
||||
assert.equal(validateStageConfigs(
|
||||
{ enabled: false, policy: 'skip_converged', strategies: [] },
|
||||
{ enabled: true, policy: 'skip_converged', strategies: ['standard'] },
|
||||
), null, '禁用阶段空链合法');
|
||||
});
|
||||
Reference in New Issue
Block a user