@caddis/cli 0.4.45 → 0.4.46
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bundles/antigravity-plugin/plugin.json +1 -1
- package/bundles/antigravity-plugin/skills/flow-studio/README.md +46 -7
- package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +80 -81
- package/bundles/antigravity-plugin/skills/flow-studio/examples/README.md +8 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/agent-journey.json +103 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/approval-flow.json +134 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/ci-cd-pipeline.json +132 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/incident-timeline.json +109 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/order-desk.json +99 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/service-map.json +133 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/state-machine.json +106 -0
- package/bundles/antigravity-plugin/skills/flow-studio/examples/ticket-lifecycle.json +107 -0
- package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +27 -8
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +82 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +67 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +37 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/index.ts +3 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/FlowCanvas.tsx +58 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/Inspector.tsx +27 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/index.ts +6 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.css +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/native.test.tsx +161 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useFlowGeometry.ts +22 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useTrace.ts +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/native/useViewport.ts +75 -0
- package/bundles/antigravity-plugin/skills/flow-studio/react/states.tsx +13 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +169 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/implement-in-app.md +213 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +254 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/notes-and-spec.md +74 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +385 -49
- package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +91 -0
- package/bundles/antigravity-plugin/skills/flow-studio/references/verify.md +124 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build-engine.mjs +54 -14
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/build.mjs +21 -4
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/check.mjs +320 -28
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/default-notes.json +67 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/build-engine.test.mjs +30 -2
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.mjs +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-args.test.mjs +15 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/check-selftest.test.mjs +64 -4
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/cli.test.mjs +115 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.mjs +23 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/docs.test.mjs +128 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/library.test.mjs +26 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/react-proof.mjs +92 -0
- package/bundles/antigravity-plugin/skills/flow-studio/scripts/validate.mjs +5 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.test.ts +78 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/autolayout.ts +132 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/chrome.ts +11 -7
- package/bundles/antigravity-plugin/skills/flow-studio/src/controls.ts +8 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +16 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/diff.test.ts +31 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/diff.ts +34 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +83 -32
- package/bundles/antigravity-plugin/skills/flow-studio/src/inspector.ts +23 -5
- package/bundles/antigravity-plugin/skills/flow-studio/src/interact.ts +21 -8
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +75 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +49 -11
- package/bundles/antigravity-plugin/skills/flow-studio/src/lib.ts +20 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.test.ts +57 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/metrics.ts +29 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.test.ts +62 -2
- package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +101 -16
- package/bundles/antigravity-plugin/skills/flow-studio/src/notes.ts +8 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/page.ts +21 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playback.test.ts +104 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playback.ts +106 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +193 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +66 -18
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.test.ts +21 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/routing.ts +32 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.test.ts +48 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/rules.ts +52 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/selection.test.ts +12 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/selection.ts +4 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.test.ts +7 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/stateCss.ts +1 -0
- package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +35 -2
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.test.ts +21 -1
- package/bundles/antigravity-plugin/skills/flow-studio/src/trace.ts +18 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +986 -74
- package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.shell.html +1 -39
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +190 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.d.mts +1 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +2491 -0
- package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +50 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/README.md +46 -7
- package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +80 -81
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/README.md +8 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/agent-journey.json +103 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/approval-flow.json +134 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ci-cd-pipeline.json +132 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/incident-timeline.json +109 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/order-desk.json +99 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/service-map.json +133 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/state-machine.json +106 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/examples/ticket-lifecycle.json +107 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +27 -8
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +82 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +67 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +37 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/index.ts +3 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/FlowCanvas.tsx +58 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/Inspector.tsx +27 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/index.ts +6 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.css +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/native.test.tsx +161 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useFlowGeometry.ts +22 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useTrace.ts +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/native/useViewport.ts +75 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/react/states.tsx +13 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +169 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/implement-in-app.md +213 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +254 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/notes-and-spec.md +74 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +385 -49
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +91 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/references/verify.md +124 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build-engine.mjs +54 -14
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/build.mjs +21 -4
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/check.mjs +320 -28
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/default-notes.json +67 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.mjs +5 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry-guard.test.mjs +14 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/auto-entry.test.mjs +31 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/build-engine.test.mjs +30 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.mjs +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-args.test.mjs +15 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/check-selftest.test.mjs +64 -4
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/cli.test.mjs +115 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.mjs +23 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/demo-pack.test.mjs +34 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/docs.test.mjs +128 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/library.test.mjs +26 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.mjs +12 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-cleanup.test.mjs +45 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.mjs +3 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/react-proof-page.test.mjs +9 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/react-proof.mjs +92 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/validate.mjs +5 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.test.ts +78 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/autolayout.ts +132 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/chrome.ts +11 -7
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/controls.ts +8 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +16 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.test.ts +31 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/diff.ts +34 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +83 -32
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/inspector.ts +23 -5
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/interact.ts +21 -8
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +75 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +49 -11
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/lib.ts +20 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.test.ts +57 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/metrics.ts +29 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.test.ts +62 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +101 -16
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/notes.ts +8 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/page.ts +21 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.test.ts +104 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playback.ts +106 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +193 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +66 -18
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.test.ts +21 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/routing.ts +32 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.test.ts +48 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/rules.ts +52 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.test.ts +12 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/selection.ts +4 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.test.ts +7 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/stateCss.ts +1 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +35 -2
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.test.ts +21 -1
- package/bundles/codex/.codex/skills/frontend/flow-studio/src/trace.ts +18 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +986 -74
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.shell.html +1 -39
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +190 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.d.mts +1 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +2491 -0
- package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +50 -0
- package/bundles/manifest.json +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { NodeMetric } from "./model.ts";
|
|
2
|
+
import { esc } from "./model.ts";
|
|
3
|
+
|
|
4
|
+
export function sparklinePoints(series: number[], width: number, height: number): string {
|
|
5
|
+
if (!Array.isArray(series) || series.length < 2 || series.some(v => !Number.isFinite(v))) return "";
|
|
6
|
+
const min = Math.min(...series), max = Math.max(...series), span = max - min;
|
|
7
|
+
return series.map((v, i) => `${Math.round(i / (series.length - 1) * width * 100) / 100},${Math.round((span ? 1 - (v - min) / span : .5) * height * 100) / 100}`).join(" ");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function metricBadge(metric: NodeMetric | undefined): string {
|
|
11
|
+
if (!metric) return "";
|
|
12
|
+
const label = metric.label ?? "Metric";
|
|
13
|
+
const valStr = esc(String(metric.value));
|
|
14
|
+
const unitStr = metric.unit ? " " + esc(metric.unit) : "";
|
|
15
|
+
const title = `${esc(label)}: ${valStr}${unitStr}`;
|
|
16
|
+
const hasMax = metric.max != null && Number.isFinite(metric.max) && metric.max > 0;
|
|
17
|
+
const pct = hasMax && Number.isFinite(metric.value)
|
|
18
|
+
? Math.max(0, Math.min(100, (metric.value / metric.max!) * 100))
|
|
19
|
+
: 0;
|
|
20
|
+
const bar = hasMax ? `<i style="width:${pct}%"></i>` : "";
|
|
21
|
+
return `<span class="metric-badge" title="${title}">${valStr}${unitStr}${bar}</span>`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sparkline(metric: NodeMetric | undefined, width = 56, height = 12): string {
|
|
25
|
+
if (!metric?.series || metric.series.length < 2) return "";
|
|
26
|
+
const points = sparklinePoints(metric.series, width, height);
|
|
27
|
+
if (!points) return "";
|
|
28
|
+
return `<svg class="spark" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polyline points="${points}"/></svg>`;
|
|
29
|
+
}
|
|
@@ -57,9 +57,30 @@ test("a state with an unknown tone is rejected", () => {
|
|
|
57
57
|
const m = base(); (m.states.ok as any).tone = "purple";
|
|
58
58
|
assert.ok(codes(m).includes("state-shape"));
|
|
59
59
|
});
|
|
60
|
-
test("
|
|
61
|
-
const m = base();
|
|
60
|
+
test("free layout needs finite position and positive size but no columns", () => {
|
|
61
|
+
const m = base(); m.layout = "free"; m.columns = [];
|
|
62
|
+
m.nodes[0] = { id: "a", kind: "card", lane: "l1", x: 0, y: 0, w: 100, h: 40, state: "ok", title: "A" };
|
|
63
|
+
m.nodes[1] = { id: "b", kind: "card", lane: "l1", x: 200, y: 0, state: "ok", title: "B" };
|
|
64
|
+
assert.deepEqual(codes(m), []);
|
|
65
|
+
delete m.nodes[0]!.x;
|
|
66
|
+
assert.ok(codes(m).includes("position-invalid"));
|
|
67
|
+
m.nodes[0]!.x = 0; m.nodes[0]!.w = -1;
|
|
68
|
+
assert.ok(codes(m).includes("size-invalid"));
|
|
69
|
+
});
|
|
70
|
+
test("free layout detects rectangle intersections", () => {
|
|
71
|
+
const m = base(); m.layout = "free"; m.columns = [];
|
|
72
|
+
m.nodes[0] = { id: "a", kind: "card", lane: "l1", x: 0, y: 0, w: 100, h: 50, state: "ok", title: "A" };
|
|
73
|
+
m.nodes[1] = { id: "b", kind: "card", lane: "l1", x: 90, y: 49, w: 100, h: 50, state: "ok", title: "B" };
|
|
74
|
+
assert.ok(codes(m).includes("rect-overlap"));
|
|
75
|
+
m.nodes[1]!.x = 100;
|
|
76
|
+
assert.ok(!codes(m).includes("rect-overlap"));
|
|
77
|
+
});
|
|
78
|
+
test("unsupported layout is an error; auto permits missing positions before layout", () => {
|
|
79
|
+
const m = base(); (m as any).layout = "timeline";
|
|
62
80
|
assert.ok(codes(m).includes("layout-unsupported"));
|
|
81
|
+
m.layout = "auto"; m.columns = []; m.lanes = [];
|
|
82
|
+
m.nodes.forEach(n => { delete n.col; delete n.row; delete n.lane; });
|
|
83
|
+
assert.ok(!codes(m).includes("layout-unsupported"));
|
|
63
84
|
});
|
|
64
85
|
test("a link that goes backward or sideways is a warning, not silence", () => {
|
|
65
86
|
const m = base(); m.links[0]!.from = "b"; m.links[0]!.to = "a";
|
|
@@ -70,6 +91,26 @@ test("a link that goes backward or sideways is a warning, not silence", () => {
|
|
|
70
91
|
test("a forward link has no direction warning", () => {
|
|
71
92
|
assert.ok(!validateModel(base()).some(i => i.code === "link-direction"));
|
|
72
93
|
});
|
|
94
|
+
test("a branching gateway requires a label on every exit", () => {
|
|
95
|
+
const m = base(); m.nodes[0]!.kind = "gateway";
|
|
96
|
+
m.nodes.push({ id: "c", kind: "card", lane: "l1", col: 1, row: 1, state: "ok", title: "C" });
|
|
97
|
+
m.links.push({ id: "a-c", from: "a", to: "c", state: "ok", label: "No" });
|
|
98
|
+
assert.ok(validateModel(m).some(i => i.level === "warn" && i.code === "gateway-exit-label" && i.where === "nodes[a]"));
|
|
99
|
+
m.links[0]!.label = "Yes";
|
|
100
|
+
assert.ok(!validateModel(m).some(i => i.code === "gateway-exit-label"));
|
|
101
|
+
});
|
|
102
|
+
test("a gateway with no exit warns", () => {
|
|
103
|
+
const m = base(); m.nodes[1]!.kind = "gateway";
|
|
104
|
+
assert.ok(validateModel(m).some(i => i.level === "warn" && i.code === "gateway-no-exit" && i.where === "nodes[b]"));
|
|
105
|
+
});
|
|
106
|
+
test("an explicit loop-back suppresses backward direction warning", () => {
|
|
107
|
+
const m = base(); m.links[0]!.from = "b"; m.links[0]!.to = "a"; m.links[0]!.kind = "loop-back";
|
|
108
|
+
assert.ok(!validateModel(m).some(i => i.code === "link-direction" || i.code === "loop-back-forward"));
|
|
109
|
+
});
|
|
110
|
+
test("a loop-back that runs forward warns", () => {
|
|
111
|
+
const m = base(); m.links[0]!.kind = "loop-back";
|
|
112
|
+
assert.ok(validateModel(m).some(i => i.level === "warn" && i.code === "loop-back-forward"));
|
|
113
|
+
});
|
|
73
114
|
test("a model with no meta is an error, not a crash later", () => {
|
|
74
115
|
const m = base(); delete (m as any).meta;
|
|
75
116
|
assert.ok(codes(m).includes("meta-missing"));
|
|
@@ -101,7 +142,26 @@ test("a state id must be usable as a CSS class", () => {
|
|
|
101
142
|
const m = base(); m.states['bad id"'] = m.states.ok!;
|
|
102
143
|
assert.ok(codes(m).includes("state-id"));
|
|
103
144
|
});
|
|
145
|
+
test("a metric max of null means no maximum; a non-numeric max is still an error", () => {
|
|
146
|
+
const m = base();
|
|
147
|
+
m.nodes[0]!.metrics = { value: 5, max: null };
|
|
148
|
+
assert.ok(!codes(m).includes("metric-invalid"), "null max is the author's way to say there is no maximum");
|
|
149
|
+
m.nodes[0]!.metrics = { value: 5, max: "10" as unknown as number };
|
|
150
|
+
assert.ok(codes(m).includes("metric-invalid"));
|
|
151
|
+
});
|
|
104
152
|
test("esc turns markup into text", () => {
|
|
105
153
|
assert.equal(esc(`<img src=x onerror="a()">&'`), "<img src=x onerror="a()">&'");
|
|
106
154
|
assert.equal(esc(undefined), "");
|
|
107
155
|
});
|
|
156
|
+
test("group containment validates ids, ownership, self-reference, and skips group-member overlap", () => {
|
|
157
|
+
const m = base();
|
|
158
|
+
const group = { id: "g", kind: "group" as const, lane: "l1", col: 0, row: 0, state: "ok", title: "G", contains: ["a"] };
|
|
159
|
+
m.nodes.push(group);
|
|
160
|
+
assert.ok(!codes(m).includes("cell-overlap"));
|
|
161
|
+
group.contains = ["a", "g", "missing"];
|
|
162
|
+
m.nodes.push({ ...group, id: "g2", contains: ["a"], row: 2 });
|
|
163
|
+
const found = codes(m);
|
|
164
|
+
assert.ok(found.includes("contains-self"));
|
|
165
|
+
assert.ok(found.includes("contains-undefined"));
|
|
166
|
+
assert.ok(found.includes("contains-duplicate"));
|
|
167
|
+
});
|
|
@@ -6,6 +6,7 @@ export type Border = "solid" | "dashed" | "dotted";
|
|
|
6
6
|
export type EdgeStyle = "flow" | "dash" | "dash-short" | "dot";
|
|
7
7
|
export type Motion = "flow" | "flow-slow" | "breathe" | "none";
|
|
8
8
|
export type Bucket = "good" | "neutral" | "gap";
|
|
9
|
+
export const NODE_HEIGHT: Record<Kind, number> = { card: 54, group: 54, gateway: 64, placeholder: 54, chip: 40, start: 40, end: 40, event: 40, note: 40 };
|
|
9
10
|
|
|
10
11
|
export interface StateDef {
|
|
11
12
|
word: string;
|
|
@@ -20,10 +21,14 @@ export interface LaneDef { id: string; title: string; badge?: string; badgeTone?
|
|
|
20
21
|
export interface NodeDef {
|
|
21
22
|
id: string;
|
|
22
23
|
kind: Kind;
|
|
23
|
-
lane
|
|
24
|
-
col
|
|
24
|
+
lane?: string;
|
|
25
|
+
col?: number;
|
|
25
26
|
colSpan?: number;
|
|
26
|
-
row
|
|
27
|
+
row?: number;
|
|
28
|
+
x?: number;
|
|
29
|
+
y?: number;
|
|
30
|
+
w?: number;
|
|
31
|
+
h?: number;
|
|
27
32
|
state: string;
|
|
28
33
|
stateIn?: Record<string, string>;
|
|
29
34
|
visibleIn?: string[];
|
|
@@ -34,6 +39,18 @@ export interface NodeDef {
|
|
|
34
39
|
ref?: string;
|
|
35
40
|
upgrade?: string;
|
|
36
41
|
allowIsolated?: boolean;
|
|
42
|
+
contains?: string[];
|
|
43
|
+
collapsed?: boolean;
|
|
44
|
+
sla?: number;
|
|
45
|
+
metrics?: NodeMetric;
|
|
46
|
+
}
|
|
47
|
+
export interface NodeMetric {
|
|
48
|
+
value: number;
|
|
49
|
+
/** null means "no maximum" — the same as omitting it. Renderers treat both the same way. */
|
|
50
|
+
max?: number | null;
|
|
51
|
+
unit?: string;
|
|
52
|
+
label?: string;
|
|
53
|
+
series?: number[];
|
|
37
54
|
}
|
|
38
55
|
export interface LinkDef {
|
|
39
56
|
id: string;
|
|
@@ -43,15 +60,18 @@ export interface LinkDef {
|
|
|
43
60
|
stateIn?: Record<string, string>;
|
|
44
61
|
visibleIn?: string[];
|
|
45
62
|
label?: string;
|
|
63
|
+
kind?: "forward" | "loop-back";
|
|
46
64
|
}
|
|
47
65
|
export interface ViewDef { id: string; label: string; default?: boolean }
|
|
48
66
|
export interface RelationDef { id: string; label: string; dir: "up" | "down"; filter?: { col?: number; kind?: Kind } }
|
|
49
67
|
export interface ActionDef { id: string; label: string; dir: "down"; staleText: string }
|
|
50
68
|
export interface NoteDef { anchor: string; title: string; text: string }
|
|
69
|
+
export interface StepDef { link: string; at?: number; dur?: number; caption?: string }
|
|
70
|
+
export interface ScenarioDef { id: string; label: string; steps: StepDef[] }
|
|
51
71
|
export interface Model {
|
|
52
72
|
version: 1;
|
|
53
73
|
meta: { title: string; help: string; asOf: string; source: string; labels?: Record<string, string> };
|
|
54
|
-
layout: "columns-lanes";
|
|
74
|
+
layout: "columns-lanes" | "free" | "auto";
|
|
55
75
|
columns: ColumnDef[];
|
|
56
76
|
lanes: LaneDef[];
|
|
57
77
|
states: Record<string, StateDef>;
|
|
@@ -60,6 +80,7 @@ export interface Model {
|
|
|
60
80
|
views?: ViewDef[];
|
|
61
81
|
inspector?: { relations: RelationDef[]; actions?: ActionDef[] };
|
|
62
82
|
notes?: NoteDef[];
|
|
83
|
+
scenarios?: ScenarioDef[];
|
|
63
84
|
}
|
|
64
85
|
export interface Issue { level: "error" | "warn"; code: string; where: string; message: string }
|
|
65
86
|
|
|
@@ -85,9 +106,9 @@ export function validateModel(input: unknown): Issue[] {
|
|
|
85
106
|
}
|
|
86
107
|
const m = input as Model;
|
|
87
108
|
if (m.version !== 1) add("error", "model-version", "version", "version must be 1.");
|
|
88
|
-
if (
|
|
89
|
-
if (!Array.isArray(m.columns) || m.columns.length === 0) add("error", "columns-missing", "columns", "
|
|
90
|
-
if (!Array.isArray(m.lanes) || m.lanes.length === 0) add("error", "lanes-missing", "lanes", "At least one lane is required.");
|
|
109
|
+
if (!["columns-lanes", "free", "auto"].includes(m.layout)) add("error", "layout-unsupported", "layout", `layout ${String(m.layout)} is not supported: use "columns-lanes", "free", or "auto".`);
|
|
110
|
+
if (!Array.isArray(m.columns) || (m.layout === "columns-lanes" && m.columns.length === 0)) add("error", "columns-missing", "columns", "Columns are required for columns-lanes layout.");
|
|
111
|
+
if (!Array.isArray(m.lanes) || (m.layout !== "auto" && m.lanes.length === 0)) add("error", "lanes-missing", "lanes", "At least one lane is required.");
|
|
91
112
|
if (!m.states || typeof m.states !== "object" || Object.keys(m.states).length === 0) add("error", "states-missing", "states", "At least one state is required.");
|
|
92
113
|
const columns = Array.isArray(m.columns) ? m.columns : [];
|
|
93
114
|
const lanes = Array.isArray(m.lanes) ? m.lanes : [];
|
|
@@ -119,15 +140,36 @@ export function validateModel(input: unknown): Issue[] {
|
|
|
119
140
|
const w = `nodes[${n.id}]`;
|
|
120
141
|
if (nodeIds.has(n.id)) add("error", "node-duplicate", w, `Duplicate node id ${n.id}.`);
|
|
121
142
|
nodeIds.add(n.id);
|
|
122
|
-
if (!laneIds.has(n.lane)) add("error", "lane-undefined", w, `Lane ${n.lane} is not defined.`);
|
|
143
|
+
if ((m.layout !== "auto" || n.lane !== undefined) && !laneIds.has(n.lane ?? "")) add("error", "lane-undefined", w, `Lane ${n.lane} is not defined.`);
|
|
123
144
|
for (const s of [n.state, ...Object.values(n.stateIn ?? {})]) if (!stateIds.has(s)) add("error", "state-undefined", w, `State ${s} is not defined.`);
|
|
124
|
-
if (typeof n.row !== "number" || !Number.isFinite(n.row)) add("error", "row-invalid", w, `Node ${n.id} needs a finite number for row (it sets the vertical position).`);
|
|
145
|
+
if (m.layout === "columns-lanes" && (typeof n.row !== "number" || !Number.isFinite(n.row))) add("error", "row-invalid", w, `Node ${n.id} needs a finite number for row (it sets the vertical position).`);
|
|
125
146
|
const span = n.colSpan ?? 1;
|
|
126
|
-
if (!Number.isInteger(n.col) || n.col < 0 || n.col + span > ncols) add("error", "column-range", w, `Column ${n.col} with span ${span} is outside 0..${ncols - 1}.`);
|
|
147
|
+
if (m.layout === "columns-lanes" && (!Number.isInteger(n.col) || (n.col ?? -1) < 0 || (n.col ?? 0) + span > ncols)) add("error", "column-range", w, `Column ${n.col} with span ${span} is outside 0..${ncols - 1}.`);
|
|
148
|
+
if (m.layout === "free") {
|
|
149
|
+
if (!Number.isFinite(n.x) || !Number.isFinite(n.y)) add("error", "position-invalid", w, `Node ${n.id} needs finite x and y.`);
|
|
150
|
+
if ((n.w !== undefined && (!Number.isFinite(n.w) || n.w <= 0)) || (n.h !== undefined && (!Number.isFinite(n.h) || n.h <= 0))) add("error", "size-invalid", w, `Node ${n.id} needs positive finite width and height.`);
|
|
151
|
+
}
|
|
152
|
+
const metric = n.metrics;
|
|
153
|
+
const invalidSeries = metric?.series !== undefined && (!Array.isArray(metric.series) || metric.series.length > 60 || metric.series.some(v => !Number.isFinite(v)));
|
|
154
|
+
if (metric !== undefined && (!metric || !Number.isFinite(metric.value) || (metric.max != null && !Number.isFinite(metric.max)) || invalidSeries)) add("error", "metric-invalid", w, "Metrics need finite values and at most 60 finite series points.");
|
|
155
|
+
if (n.sla !== undefined && (!Number.isFinite(n.sla) || n.sla <= 0)) add("error", "sla-invalid", w, "SLA must be a positive finite number of seconds.");
|
|
127
156
|
}
|
|
128
157
|
for (const n of nodes) {
|
|
129
158
|
if (n.kind === "chip" && (!n.ref || !nodeIds.has(n.ref))) add("error", "chip-ref", `nodes[${n.id}]`, `A chip must point at a real node (ref ${n.ref}).`);
|
|
130
159
|
}
|
|
160
|
+
const owner = new Map<string, string>();
|
|
161
|
+
for (const group of nodes.filter(n => n.contains !== undefined)) {
|
|
162
|
+
if (group.kind !== "group" || !Array.isArray(group.contains)) {
|
|
163
|
+
add("error", "contains-shape", `nodes[${group.id}]`, "Only a group may contain a list of node ids.");
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
for (const id of group.contains) {
|
|
167
|
+
if (id === group.id) add("error", "contains-self", `nodes[${group.id}]`, `Group ${group.id} cannot contain itself.`);
|
|
168
|
+
else if (!nodeIds.has(id)) add("error", "contains-undefined", `nodes[${group.id}]`, `Contained node ${id} does not exist.`);
|
|
169
|
+
else if (owner.has(id)) add("error", "contains-duplicate", `nodes[${group.id}]`, `Node ${id} is already contained by ${owner.get(id)}.`);
|
|
170
|
+
else owner.set(id, group.id);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
131
173
|
const linkIds = new Set<string>();
|
|
132
174
|
const linked = new Set<string>();
|
|
133
175
|
for (const l of links) {
|
|
@@ -138,14 +180,48 @@ export function validateModel(input: unknown): Issue[] {
|
|
|
138
180
|
else {
|
|
139
181
|
linked.add(l.from); linked.add(l.to);
|
|
140
182
|
const a = nodes.find(n => n.id === l.from)!, b = nodes.find(n => n.id === l.to)!;
|
|
141
|
-
if (
|
|
142
|
-
|
|
183
|
+
if (m.layout !== "free" && a.col !== undefined && b.col !== undefined) {
|
|
184
|
+
const forward = b.col > a.col + (a.colSpan ?? 1) - 1;
|
|
185
|
+
if (!forward && l.kind !== "loop-back") add("warn", "link-direction", w, `Link ${l.id} does not run left to right (column ${a.col} to ${b.col}); mark it loop-back if it returns to an earlier stage.`);
|
|
186
|
+
if (forward && l.kind === "loop-back") add("warn", "loop-back-forward", w, `Link ${l.id} is marked loop-back but runs forward.`);
|
|
143
187
|
}
|
|
144
188
|
}
|
|
145
189
|
for (const s of [l.state, ...Object.values(l.stateIn ?? {})]) if (!stateIds.has(s)) add("error", "state-undefined", w, `State ${s} is not defined.`);
|
|
146
190
|
}
|
|
191
|
+
const scenarioIds = new Set<string>();
|
|
192
|
+
for (const [i, scenario] of (Array.isArray(m.scenarios) ? m.scenarios : []).entries()) {
|
|
193
|
+
const w = `scenarios[${i}]`;
|
|
194
|
+
if (!scenario || typeof scenario.id !== "string" || typeof scenario.label !== "string" || !Array.isArray(scenario.steps)) { add("error", "scenario-shape", w, "A scenario needs an id, label and steps."); continue; }
|
|
195
|
+
if (scenarioIds.has(scenario.id)) add("error", "scenario-duplicate", w, `Duplicate scenario id ${scenario.id}.`);
|
|
196
|
+
scenarioIds.add(scenario.id);
|
|
197
|
+
if (!scenario.steps.length) add("error", "scenario-empty", w, "A scenario needs at least one step.");
|
|
198
|
+
const timed = scenario.steps.filter(s => s && s.at !== undefined).length;
|
|
199
|
+
if (timed && timed !== scenario.steps.length) add("error", "scenario-at-partial", w, "Give every step an at time or none.");
|
|
200
|
+
let prev: LinkDef | undefined, previousAt = -Infinity;
|
|
201
|
+
for (const [j, step] of scenario.steps.entries()) {
|
|
202
|
+
const sw = `${w}.steps[${j}]`;
|
|
203
|
+
const link = links.find(l => l.id === step?.link);
|
|
204
|
+
if (!link) add("error", "scenario-link", sw, `Unknown link ${step?.link}.`);
|
|
205
|
+
if (step?.at !== undefined) {
|
|
206
|
+
if (!Number.isFinite(step.at) || step.at < 0 || step.at < previousAt) add("error", "scenario-at-order", sw, "Step times must be finite, nonnegative and nondecreasing.");
|
|
207
|
+
if (Number.isFinite(step.at) && step.at >= 0 && step.at >= previousAt) previousAt = step.at;
|
|
208
|
+
}
|
|
209
|
+
if (step?.dur !== undefined && (!Number.isFinite(step.dur) || step.dur <= 0)) add("error", "scenario-duration", sw, "Duration must be positive and finite.");
|
|
210
|
+
if (prev && link) {
|
|
211
|
+
const previousTo = prev.to;
|
|
212
|
+
if ((nodes.find(n => n.id === previousTo)?.ref ?? previousTo) !== (nodes.find(n => n.id === link.from)?.ref ?? link.from)) add("warn", "scenario-gap", sw, "This step does not start at the previous step's target.");
|
|
213
|
+
}
|
|
214
|
+
if (link) prev = link;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
for (const n of nodes) {
|
|
218
|
+
if (n.kind !== "gateway") continue;
|
|
219
|
+
const exits = links.filter(l => l.from === n.id);
|
|
220
|
+
if (exits.length === 0) add("warn", "gateway-no-exit", `nodes[${n.id}]`, `Gateway ${n.id} has no exit.`);
|
|
221
|
+
else if (exits.length > 1 && exits.some(l => !l.label?.trim())) add("warn", "gateway-exit-label", `nodes[${n.id}]`, `Every exit from gateway ${n.id} needs a label.`);
|
|
222
|
+
}
|
|
147
223
|
for (const n of nodes) {
|
|
148
|
-
if (!linked.has(n.id) && !n.allowIsolated && n.kind !== "note") {
|
|
224
|
+
if (!linked.has(n.id) && !n.allowIsolated && n.kind !== "note" && !(n.kind === "group" && n.contains?.length)) {
|
|
149
225
|
add("error", "orphan", `nodes[${n.id}]`, `Node ${n.id} has no links. Add a link, a placeholder neighbour, or set allowIsolated.`);
|
|
150
226
|
}
|
|
151
227
|
}
|
|
@@ -153,9 +229,18 @@ export function validateModel(input: unknown): Issue[] {
|
|
|
153
229
|
for (let i = 0; i < nodes.length; i++) {
|
|
154
230
|
for (let j = i + 1; j < nodes.length; j++) {
|
|
155
231
|
const a = nodes[i]!, b = nodes[j]!;
|
|
156
|
-
|
|
157
|
-
if (
|
|
158
|
-
|
|
232
|
+
if (a.contains?.includes(b.id) || b.contains?.includes(a.id)) continue;
|
|
233
|
+
if (m.layout === "free") {
|
|
234
|
+
const aw = a.w ?? 190, ah = a.h ?? NODE_HEIGHT[a.kind] ?? 54, bw = b.w ?? 190, bh = b.h ?? NODE_HEIGHT[b.kind] ?? 54;
|
|
235
|
+
if ([a.x, a.y, b.x, b.y, aw, ah, bw, bh].every(v => typeof v === "number" && Number.isFinite(v))
|
|
236
|
+
&& a.x! < b.x! + bw && b.x! < a.x! + aw && a.y! < b.y! + bh && b.y! < a.y! + ah) {
|
|
237
|
+
add("error", "rect-overlap", `nodes[${a.id}]`, `Nodes ${a.id} and ${b.id} overlap.`);
|
|
238
|
+
}
|
|
239
|
+
} else if (a.col !== undefined && b.col !== undefined && a.row !== undefined && b.row !== undefined) {
|
|
240
|
+
const ax2 = a.col + (a.colSpan ?? 1), bx2 = b.col + (b.colSpan ?? 1);
|
|
241
|
+
if (a.col < bx2 && b.col < ax2 && Math.abs(a.row - b.row) < 1) {
|
|
242
|
+
add("error", "cell-overlap", `nodes[${a.id}]`, `Nodes ${a.id} and ${b.id} overlap (same columns, rows less than 1 apart).`);
|
|
243
|
+
}
|
|
159
244
|
}
|
|
160
245
|
}
|
|
161
246
|
}
|
|
@@ -22,6 +22,14 @@ export function install(ctx: Ctx): void {
|
|
|
22
22
|
const a = anchorOf(n.anchor);
|
|
23
23
|
if (!a) continue;
|
|
24
24
|
let r = a.getBoundingClientRect();
|
|
25
|
+
if (!r.width && !r.height) {
|
|
26
|
+
// A closed <details> hides the anchor, so the marker sits on the outermost closed details.
|
|
27
|
+
// An open details hides nothing: an anchor that is still zero-size inside one is hidden for
|
|
28
|
+
// another reason and gets no marker.
|
|
29
|
+
let d = a.closest<HTMLDetailsElement>("details");
|
|
30
|
+
while (d && d.open) d = d.parentElement?.closest<HTMLDetailsElement>("details") ?? null;
|
|
31
|
+
if (d) r = d.getBoundingClientRect();
|
|
32
|
+
}
|
|
25
33
|
if (!r.width && !r.height) continue;
|
|
26
34
|
if (a.id === "inspector" && !a.classList.contains("open")) {
|
|
27
35
|
const vr = els.vp.getBoundingClientRect();
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { mount } from "./engine.ts";
|
|
2
|
+
import type { Model, NoteDef } from "./model.ts";
|
|
3
|
+
|
|
4
|
+
function auto(): void {
|
|
5
|
+
const m = document.getElementById("flow-model");
|
|
6
|
+
if (!m) return;
|
|
7
|
+
try {
|
|
8
|
+
const model = JSON.parse(m.textContent ?? "{}") as Model;
|
|
9
|
+
const n = document.getElementById("flow-notes");
|
|
10
|
+
const notes = n ? (JSON.parse(n.textContent ?? "[]") as NoteDef[]) : undefined;
|
|
11
|
+
mount(document, model, { notes });
|
|
12
|
+
} catch (e) {
|
|
13
|
+
console.error(e);
|
|
14
|
+
const h = document.getElementById("help");
|
|
15
|
+
if (h) h.textContent = "This page could not be drawn: " + (e instanceof Error ? e.message : String(e));
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
window.flowStudioMount = mount;
|
|
20
|
+
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", auto);
|
|
21
|
+
else auto();
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { test } from "node:test";
|
|
2
|
+
import assert from "node:assert/strict";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { planScenario, positionAt, slaState, formatElapsed, exitsOf, suggestedExit, buildManualRoute, extendScenarioWithRoute } from "./playback.ts";
|
|
5
|
+
import { validateModel, type Model, type ScenarioDef, type StepDef } from "./model.ts";
|
|
6
|
+
|
|
7
|
+
const model = JSON.parse(readFileSync(new URL("../examples/order-desk.json", import.meta.url), "utf8")) as Model;
|
|
8
|
+
const steps = [{ link: "l-intake-verify" }, { link: "l-verify-route", dur: 400 }];
|
|
9
|
+
test("untimed playback is sequential and clamped", () => {
|
|
10
|
+
const p = planScenario(model, { id: "a", label: "A", steps });
|
|
11
|
+
assert.equal(p.mode, "none"); assert.equal(p.totalMs, 1300);
|
|
12
|
+
assert.deepEqual(p.steps.map(s => s.at), [0, 900]);
|
|
13
|
+
assert.deepEqual(positionAt(p, -10), { stepIndex: 0, progress: 0, done: false });
|
|
14
|
+
assert.deepEqual(positionAt(p, 900), { stepIndex: 1, progress: 0, done: false });
|
|
15
|
+
assert.deepEqual(positionAt(p, Infinity), { stepIndex: 1, progress: 1, done: true });
|
|
16
|
+
});
|
|
17
|
+
test("elapsed playback scales the shortest gap and handles empty plans", () => {
|
|
18
|
+
const p = planScenario(model, { id: "a", label: "A", steps: [{ link: "l-intake-verify", at: 2 }, { link: "l-verify-route", at: 2.2 }] });
|
|
19
|
+
assert.equal(p.mode, "elapsed"); assert.equal(p.steps[1]?.at, 900);
|
|
20
|
+
assert.equal(p.steps[0]?.caption, "New request to Verify details");
|
|
21
|
+
const empty = planScenario(model, { id: "x", label: "X", steps: [] });
|
|
22
|
+
assert.deepEqual(positionAt(empty, 0), { stepIndex: -1, progress: 0, done: true });
|
|
23
|
+
const one = planScenario(model, { id: "one", label: "One", steps: [{ link: "l-intake-verify", at: 0 }] });
|
|
24
|
+
assert.equal(one.totalMs, 900);
|
|
25
|
+
assert.deepEqual(positionAt(one, NaN), { stepIndex: 0, progress: 0, done: false });
|
|
26
|
+
assert.deepEqual(positionAt(one, 900), { stepIndex: 0, progress: 1, done: true });
|
|
27
|
+
});
|
|
28
|
+
test("time and branches have stable boundaries", () => {
|
|
29
|
+
assert.equal(slaState(79.9, 100), "ok"); assert.equal(slaState(80, 100), "warn"); assert.equal(slaState(100, 100), "breach");
|
|
30
|
+
assert.equal(formatElapsed(59.6), "1:00"); assert.equal(formatElapsed(3600), "1:00:00");
|
|
31
|
+
assert.deepEqual(exitsOf(model, "route").map(l => l.id), ["l-route-hold", "l-route-quote"]);
|
|
32
|
+
assert.equal(suggestedExit(exitsOf(model, "route"), "l-route-quote")?.id, "l-route-quote");
|
|
33
|
+
});
|
|
34
|
+
test("validator reports scenario and metric errors", () => {
|
|
35
|
+
const m = structuredClone(model);
|
|
36
|
+
m.scenarios = [{ id: "a", label: "A", steps: [{ link: "missing", at: 2 }, { link: "l-intake-verify" }] }, { id: "a", label: "B", steps: [] }];
|
|
37
|
+
m.nodes[0]!.metrics = { value: NaN, series: Array(61).fill(0) };
|
|
38
|
+
const codes = validateModel(m).map(i => i.code);
|
|
39
|
+
for (const c of ["scenario-duplicate", "scenario-empty", "scenario-link", "scenario-at-partial", "metric-invalid"]) assert.ok(codes.includes(c), c);
|
|
40
|
+
});
|
|
41
|
+
test("validator warns for disconnected steps and rejects decreasing time", () => {
|
|
42
|
+
const m = structuredClone(model);
|
|
43
|
+
m.scenarios = [{ id: "gap", label: "Gap", steps: [{ link: "l-intake-verify", at: 10 }, { link: "l-route-quote", at: 9 }] }];
|
|
44
|
+
const codes = validateModel(m).map(i => i.code);
|
|
45
|
+
assert.ok(codes.includes("scenario-at-order"));
|
|
46
|
+
assert.ok(codes.includes("scenario-gap"));
|
|
47
|
+
});
|
|
48
|
+
test("validator catches decreasing time even when intermediate step has NaN at", () => {
|
|
49
|
+
const m = structuredClone(model);
|
|
50
|
+
m.scenarios = [{
|
|
51
|
+
id: "nan-order", label: "NaN Order",
|
|
52
|
+
steps: [
|
|
53
|
+
{ link: "l-intake-verify", at: 10 },
|
|
54
|
+
{ link: "l-verify-route", at: NaN },
|
|
55
|
+
{ link: "l-route-quote", at: 5 },
|
|
56
|
+
],
|
|
57
|
+
}];
|
|
58
|
+
const issues = validateModel(m).filter(i => i.code === "scenario-at-order");
|
|
59
|
+
assert.equal(issues.length, 2, "Both step with NaN and step with decreasing time should report scenario-at-order");
|
|
60
|
+
});
|
|
61
|
+
test("buildManualRoute and extendScenarioWithRoute preserve manual branches and elapsed timing", () => {
|
|
62
|
+
const choiceLink = model.links.find(l => l.id === "l-route-hold")!;
|
|
63
|
+
const route = buildManualRoute(model, choiceLink);
|
|
64
|
+
assert.deepEqual(route.map(l => l.id), ["l-route-hold", "l-hold-verify", "l-verify-route"]);
|
|
65
|
+
|
|
66
|
+
const initialScenario = model.scenarios?.[0]!;
|
|
67
|
+
const plan1 = planScenario(model, initialScenario);
|
|
68
|
+
// User at step 1 (gateway 'route') chooses 'l-route-hold'
|
|
69
|
+
const manual1 = extendScenarioWithRoute(initialScenario, plan1, 1, route);
|
|
70
|
+
assert.equal(manual1.steps.length, 5);
|
|
71
|
+
assert.equal(manual1.steps[2].link, "l-route-hold");
|
|
72
|
+
assert.equal(manual1.steps[3].link, "l-hold-verify");
|
|
73
|
+
assert.equal(manual1.steps[4].link, "l-verify-route");
|
|
74
|
+
assert.ok(manual1.steps[2].at! > manual1.steps[1].at!);
|
|
75
|
+
assert.ok(manual1.steps[3].at! > manual1.steps[2].at!);
|
|
76
|
+
assert.ok(manual1.steps[4].at! > manual1.steps[3].at!);
|
|
77
|
+
|
|
78
|
+
const plan2 = planScenario(model, manual1);
|
|
79
|
+
assert.equal(plan2.mode, "elapsed");
|
|
80
|
+
|
|
81
|
+
// Subsequent manual choice from the new branch must NOT drop earlier manual steps
|
|
82
|
+
const route2 = [model.links.find(l => l.id === "l-route-quote")!];
|
|
83
|
+
const manual2 = extendScenarioWithRoute(manual1, plan2, 4, route2);
|
|
84
|
+
assert.equal(manual2.steps.length, 6);
|
|
85
|
+
assert.deepEqual(manual2.steps.map(s => s.link), [
|
|
86
|
+
initialScenario.steps[0].link,
|
|
87
|
+
initialScenario.steps[1].link,
|
|
88
|
+
"l-route-hold",
|
|
89
|
+
"l-hold-verify",
|
|
90
|
+
"l-verify-route",
|
|
91
|
+
"l-route-quote",
|
|
92
|
+
]);
|
|
93
|
+
assert.ok(manual2.steps[5].at! > manual2.steps[4].at!);
|
|
94
|
+
});
|
|
95
|
+
test("a scenario with malformed steps plans without throwing and keeps indices aligned", () => {
|
|
96
|
+
// The validator reports a null step (scenario-link) but the page still renders, so playback must not throw on it.
|
|
97
|
+
const sc = { id: "n", label: "N", steps: [{ link: "l-intake-verify", at: 0 }, null as unknown as StepDef, { link: "l-verify-route", at: 5 }] } as unknown as ScenarioDef;
|
|
98
|
+
const plan = planScenario(model, sc);
|
|
99
|
+
assert.deepEqual(plan.steps.map(s => [s.link, s.index]), [["l-intake-verify", 0], ["l-verify-route", 2]]);
|
|
100
|
+
// The counter reads a scenario step's at through the plan step's index, so the index must stay the original one.
|
|
101
|
+
assert.equal(sc.steps[2]!.at, 5);
|
|
102
|
+
const ext = extendScenarioWithRoute(sc, plan, 1, [model.links.find(l => l.id === "l-route-hold")!]);
|
|
103
|
+
assert.doesNotThrow(() => planScenario(model, ext));
|
|
104
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { LinkDef, Model, ScenarioDef, StepDef } from "./model.ts";
|
|
2
|
+
|
|
3
|
+
export interface PlannedStep { link: string; from: string; to: string; at: number; dur: number; caption: string; index: number }
|
|
4
|
+
export interface Plan { steps: PlannedStep[]; mode: "elapsed" | "none"; totalMs: number }
|
|
5
|
+
|
|
6
|
+
export function planScenario(model: Model, scenario: ScenarioDef): Plan {
|
|
7
|
+
const source = scenario.steps ?? [];
|
|
8
|
+
const mode = source.some(s => s?.at !== undefined) ? "elapsed" : "none";
|
|
9
|
+
const start = source[0]?.at ?? 0;
|
|
10
|
+
// One global scale keeps the relative spacing. Zero gaps cannot be scaled, so they follow the prior duration.
|
|
11
|
+
const scale = mode === "elapsed" ? Math.max(1, ...source.slice(1).map((s, i) => {
|
|
12
|
+
const gap = ((s?.at ?? 0) - (source[i]?.at ?? 0)) * 1000;
|
|
13
|
+
return gap > 0 ? (source[i]?.dur ?? 900) / gap : 1;
|
|
14
|
+
})) : 1;
|
|
15
|
+
const steps: PlannedStep[] = [];
|
|
16
|
+
for (let i = 0; i < source.length; i++) {
|
|
17
|
+
const s = source[i];
|
|
18
|
+
// A step that is not an object is a validator error (scenario-link), but the page still renders:
|
|
19
|
+
// skip it and keep the original index, because the counter reads a step's at back through it.
|
|
20
|
+
if (!s || typeof s !== "object") continue;
|
|
21
|
+
const link = model.links.find(l => l.id === s.link);
|
|
22
|
+
if (!link) continue;
|
|
23
|
+
const from = model.nodes.find(n => n.id === link.from);
|
|
24
|
+
const to = model.nodes.find(n => n.id === link.to);
|
|
25
|
+
const dur = Number.isFinite(s.dur) && (s.dur ?? 0) > 0 ? s.dur! : 900;
|
|
26
|
+
const proposed = mode === "elapsed" ? Math.max(0, ((s.at ?? 0) - start) * 1000 * scale) : 0;
|
|
27
|
+
const at = steps.length === 0 ? 0 : Math.max(proposed, steps.at(-1)!.at + steps.at(-1)!.dur);
|
|
28
|
+
steps.push({ link: s.link, from: from?.ref ?? link.from, to: to?.ref ?? link.to, at, dur, caption: s.caption ?? `${from?.title ?? link.from} to ${to?.title ?? link.to}`, index: i });
|
|
29
|
+
}
|
|
30
|
+
return { steps, mode, totalMs: steps.length ? steps.at(-1)!.at + steps.at(-1)!.dur : 0 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function positionAt(plan: Plan, tMs: number): { stepIndex: number; progress: number; done: boolean } {
|
|
34
|
+
if (!plan.steps.length) return { stepIndex: -1, progress: 0, done: true };
|
|
35
|
+
const t = Number.isNaN(tMs) ? 0 : Math.max(0, tMs);
|
|
36
|
+
if (t >= plan.totalMs) return { stepIndex: plan.steps.length - 1, progress: 1, done: true };
|
|
37
|
+
let i = plan.steps.length - 1;
|
|
38
|
+
while (i > 0 && t < plan.steps[i]!.at) i--;
|
|
39
|
+
const s = plan.steps[i]!;
|
|
40
|
+
return { stepIndex: i, progress: Math.max(0, Math.min(1, (t - s.at) / s.dur)), done: false };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function slaState(elapsedSec: number, sla: number): "ok" | "warn" | "breach" {
|
|
44
|
+
if (elapsedSec >= sla) return "breach";
|
|
45
|
+
return elapsedSec >= sla * .8 ? "warn" : "ok";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function formatElapsed(sec: number): string {
|
|
49
|
+
const n = Math.max(0, Math.round(Number.isFinite(sec) ? sec : 0));
|
|
50
|
+
const m = Math.floor(n / 60), s = String(n % 60).padStart(2, "0");
|
|
51
|
+
return n < 3600 ? `${m}:${s}` : `${Math.floor(n / 3600)}:${String(m % 60).padStart(2, "0")}:${s}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function exitsOf(model: Model, nodeId: string): LinkDef[] {
|
|
55
|
+
const canon = (id: string) => model.nodes.find(n => n.id === id)?.ref ?? id;
|
|
56
|
+
return model.links.filter(l => canon(l.from) === canon(nodeId));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function suggestedExit(exits: LinkDef[], nextLink: string | undefined): LinkDef | undefined {
|
|
60
|
+
return exits.find(l => l.id === nextLink);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function buildManualRoute(model: Model, choiceLink: LinkDef): LinkDef[] {
|
|
64
|
+
const route = [choiceLink];
|
|
65
|
+
const seen = new Set([choiceLink.id]);
|
|
66
|
+
let at = choiceLink.to;
|
|
67
|
+
while (exitsOf(model, at).length === 1) {
|
|
68
|
+
const next = exitsOf(model, at)[0]!;
|
|
69
|
+
if (seen.has(next.id)) break;
|
|
70
|
+
route.push(next);
|
|
71
|
+
seen.add(next.id);
|
|
72
|
+
at = next.to;
|
|
73
|
+
}
|
|
74
|
+
return route;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function extendScenarioWithRoute(
|
|
78
|
+
scenario: ScenarioDef,
|
|
79
|
+
plan: Plan,
|
|
80
|
+
stepIndex: number,
|
|
81
|
+
route: LinkDef[]
|
|
82
|
+
): ScenarioDef {
|
|
83
|
+
const isElapsed = plan.mode === "elapsed" || scenario.steps.some(s => s?.at !== undefined);
|
|
84
|
+
const prefix: StepDef[] = scenario.steps.slice(0, stepIndex + 1).map(s => ({ ...s }));
|
|
85
|
+
for (let i = prefix.length; i <= stepIndex && i < plan.steps.length; i++) {
|
|
86
|
+
const ps = plan.steps[i]!;
|
|
87
|
+
prefix.push({ link: ps.link, dur: ps.dur, caption: ps.caption });
|
|
88
|
+
}
|
|
89
|
+
let lastAt = 0;
|
|
90
|
+
if (isElapsed) {
|
|
91
|
+
const lastWithAt = [...prefix].reverse().find(s => s?.at !== undefined);
|
|
92
|
+
lastAt = lastWithAt?.at ?? 0;
|
|
93
|
+
}
|
|
94
|
+
const newSteps: StepDef[] = route.map(l => {
|
|
95
|
+
const step: StepDef = { link: l.id };
|
|
96
|
+
if (isElapsed) {
|
|
97
|
+
lastAt += 1;
|
|
98
|
+
step.at = lastAt;
|
|
99
|
+
}
|
|
100
|
+
return step;
|
|
101
|
+
});
|
|
102
|
+
return {
|
|
103
|
+
...scenario,
|
|
104
|
+
steps: [...prefix, ...newSteps],
|
|
105
|
+
};
|
|
106
|
+
}
|