将 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 笔误
166 lines
6.9 KiB
JavaScript
166 lines
6.9 KiB
JavaScript
/* 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>');
|
||
});
|