@mantaq/viz 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +44 -2
- package/dist/embed-BRQxeM4W.d.mts +32 -0
- package/dist/embed-BtlbyzmZ.mjs +1080 -0
- package/dist/html-BfvuzJs6.mjs +55 -0
- package/dist/index.d.mts +82 -0
- package/dist/index.mjs +13 -0
- package/dist/live.d.mts +1 -0
- package/dist/live.mjs +16 -0
- package/dist/page.d.mts +7 -0
- package/dist/page.mjs +53 -0
- package/dist/routing-BNJN5Oiu.mjs +759 -0
- package/dist/standalone.d.mts +1 -0
- package/dist/standalone.mjs +6 -0
- package/dist/style.css +404 -0
- package/dist/validate-3D5LrtlE.d.mts +189 -0
- package/dist/viewer.d.mts +2 -0
- package/dist/viewer.mjs +1 -0
- package/package.json +43 -3
- package/src/viewer/page.css +12 -0
- package/src/viewer/viewer.css +406 -0
|
@@ -0,0 +1,1080 @@
|
|
|
1
|
+
import { a as stateAfter, d as coveredByAny, f as isAnyState, i as movesFrom, n as decodePath, o as unhandledSends, p as scopeOf, r as encodeStep, s as parseViz, t as routingWarnings } from "./routing-BNJN5Oiu.mjs";
|
|
2
|
+
import { Either } from "@mantaq/utils";
|
|
3
|
+
import { Graph, layout } from "@dagrejs/dagre";
|
|
4
|
+
// src/viewer/dom.ts
|
|
5
|
+
const SVG_NS = "http://www.w3.org/2000/svg";
|
|
6
|
+
function append(parent, children) {
|
|
7
|
+
for (const child of children) {
|
|
8
|
+
if (child === null) continue;
|
|
9
|
+
parent.append(child);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function setAttributes(element, attrs) {
|
|
13
|
+
for (const [name, value] of Object.entries(attrs)) if (value !== null) element.setAttribute(name, String(value));
|
|
14
|
+
}
|
|
15
|
+
/** HTML element with attributes (null skips) and children. */
|
|
16
|
+
function html(doc, tag, attrs = {}, children = []) {
|
|
17
|
+
const element = doc.createElement(tag);
|
|
18
|
+
setAttributes(element, attrs);
|
|
19
|
+
append(element, children);
|
|
20
|
+
return element;
|
|
21
|
+
}
|
|
22
|
+
/** SVG element with attributes (null skips) and children. */
|
|
23
|
+
function svg(doc, tag, attrs = {}, children = []) {
|
|
24
|
+
const element = doc.createElementNS(SVG_NS, tag);
|
|
25
|
+
setAttributes(element, attrs);
|
|
26
|
+
append(element, children);
|
|
27
|
+
return element;
|
|
28
|
+
}
|
|
29
|
+
/** Make an element act as a button for mouse and keyboard (Enter / Space). */
|
|
30
|
+
function activatable(element, onActivate) {
|
|
31
|
+
element.setAttribute("tabindex", "0");
|
|
32
|
+
element.setAttribute("role", "button");
|
|
33
|
+
element.addEventListener("click", onActivate);
|
|
34
|
+
element.addEventListener("keydown", (event) => {
|
|
35
|
+
if (!("key" in event)) return;
|
|
36
|
+
if (event.key !== "Enter" && event.key !== " ") return;
|
|
37
|
+
event.preventDefault();
|
|
38
|
+
onActivate();
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
// src/viewer/coverage-mode.ts
|
|
42
|
+
function items(machine) {
|
|
43
|
+
return [...machine.states, ...machine.edges];
|
|
44
|
+
}
|
|
45
|
+
/** Modes this machine has data for, best first: tests answer "what is untested?". */
|
|
46
|
+
function availableModes(machine) {
|
|
47
|
+
const modes = [];
|
|
48
|
+
if (items(machine).some((item) => item.tested !== null)) modes.push("tests");
|
|
49
|
+
if (items(machine).some((item) => item.coverage !== null)) modes.push("run");
|
|
50
|
+
return modes;
|
|
51
|
+
}
|
|
52
|
+
function measure(mode, item) {
|
|
53
|
+
if (mode === "run") return item.coverage;
|
|
54
|
+
if (mode === "tests") return item.tested;
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
function modeLabel(mode, machine) {
|
|
58
|
+
if (mode === "tests") return "example tests";
|
|
59
|
+
if (mode === "run") return machine.fuzz === null ? "supplied run" : "fuzz runs";
|
|
60
|
+
return "off";
|
|
61
|
+
}
|
|
62
|
+
/** Legend text for a dimmed element: what "unreached" means for this source. */
|
|
63
|
+
function unreachedText(mode, machine) {
|
|
64
|
+
if (mode === "tests") return "dimmed and dashed: no test exercises this";
|
|
65
|
+
if (machine.fuzz === null) return "dimmed and dashed: never taken in the supplied run";
|
|
66
|
+
return "dimmed and dashed: never observed while fuzzing (declared edges here may be unreachable)";
|
|
67
|
+
}
|
|
68
|
+
// src/viewer/labels.ts
|
|
69
|
+
/** Names of the effects the state behind an effect edge runs on entry. */
|
|
70
|
+
function effectNames(edge, machine) {
|
|
71
|
+
return (machine.states.find((state) => state.id === edge.from)?.effects ?? []).map((effect) => effect.name);
|
|
72
|
+
}
|
|
73
|
+
/** Text drawn on an edge: the event, or for an effect loop the effects it runs. */
|
|
74
|
+
function edgeText(edge, machine) {
|
|
75
|
+
if (edge.kind !== "effect") return edge.event;
|
|
76
|
+
const names = effectNames(edge, machine);
|
|
77
|
+
return names.length === 0 ? "effect" : `runs ${names.join(", ")}`;
|
|
78
|
+
}
|
|
79
|
+
// src/viewer/layout.ts
|
|
80
|
+
const CHAR_WIDTH = 7;
|
|
81
|
+
const NODE_HEIGHT = 36;
|
|
82
|
+
const LABEL_HEIGHT = 16;
|
|
83
|
+
const UNKNOWN_SIZE = 28;
|
|
84
|
+
function anyIds(machine) {
|
|
85
|
+
return [...new Set(machine.edges.map((edge) => edge.from).filter(isAnyState))].sort();
|
|
86
|
+
}
|
|
87
|
+
function unknownNodeId(edge) {
|
|
88
|
+
return `?:${edge.id}`;
|
|
89
|
+
}
|
|
90
|
+
function regionNodeId(region) {
|
|
91
|
+
return `region:${region}`;
|
|
92
|
+
}
|
|
93
|
+
function stateWidth(state) {
|
|
94
|
+
return Math.max(80, state.label.length * CHAR_WIDTH + 24);
|
|
95
|
+
}
|
|
96
|
+
function parentRegion(region) {
|
|
97
|
+
const cut = region.lastIndexOf(".");
|
|
98
|
+
return cut === -1 ? null : region.slice(0, cut);
|
|
99
|
+
}
|
|
100
|
+
function addRegions(graph, regions) {
|
|
101
|
+
for (const region of regions) graph.setNode(regionNodeId(region), {
|
|
102
|
+
width: 0,
|
|
103
|
+
height: 0
|
|
104
|
+
});
|
|
105
|
+
for (const region of regions) {
|
|
106
|
+
const parent = parentRegion(region);
|
|
107
|
+
if (parent !== null && regions.includes(parent)) graph.setParent(regionNodeId(region), regionNodeId(parent));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
function addStates(graph, machine) {
|
|
111
|
+
for (const state of machine.states) {
|
|
112
|
+
graph.setNode(state.id, {
|
|
113
|
+
width: stateWidth(state),
|
|
114
|
+
height: NODE_HEIGHT
|
|
115
|
+
});
|
|
116
|
+
if (state.region !== null) graph.setParent(state.id, regionNodeId(state.region));
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
function addAnyNodes(graph, machine) {
|
|
120
|
+
for (const id of anyIds(machine)) {
|
|
121
|
+
graph.setNode(id, {
|
|
122
|
+
width: 87,
|
|
123
|
+
height: NODE_HEIGHT
|
|
124
|
+
});
|
|
125
|
+
const region = scopeOf(id);
|
|
126
|
+
if (region !== "" && machine.regions.includes(region)) graph.setParent(id, regionNodeId(region));
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function addEdges(graph, machine) {
|
|
130
|
+
for (const edge of machine.edges) {
|
|
131
|
+
if (edge.kind === "initial" || edge.to === edge.from) continue;
|
|
132
|
+
const target = edge.to ?? unknownNodeId(edge);
|
|
133
|
+
if (edge.to === null) {
|
|
134
|
+
graph.setNode(target, {
|
|
135
|
+
width: UNKNOWN_SIZE,
|
|
136
|
+
height: UNKNOWN_SIZE
|
|
137
|
+
});
|
|
138
|
+
const region = machine.states.find((state) => state.id === edge.from)?.region ?? null;
|
|
139
|
+
if (region !== null) graph.setParent(target, regionNodeId(region));
|
|
140
|
+
}
|
|
141
|
+
const label = {
|
|
142
|
+
width: edgeText(edge, machine).length * CHAR_WIDTH + 8,
|
|
143
|
+
height: LABEL_HEIGHT,
|
|
144
|
+
labelpos: "c"
|
|
145
|
+
};
|
|
146
|
+
graph.setEdge(edge.from, target, label, edge.id);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
function box(label) {
|
|
150
|
+
return {
|
|
151
|
+
x: label.x ?? 0,
|
|
152
|
+
y: label.y ?? 0,
|
|
153
|
+
width: label.width,
|
|
154
|
+
height: label.height
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function placeNodes(graph, machine) {
|
|
158
|
+
const states = machine.states.map((state) => ({
|
|
159
|
+
kind: "state",
|
|
160
|
+
id: state.id,
|
|
161
|
+
state,
|
|
162
|
+
...box(graph.node(state.id))
|
|
163
|
+
}));
|
|
164
|
+
const unknowns = machine.edges.filter((edge) => edge.kind !== "initial" && edge.to === null).map((edge) => ({
|
|
165
|
+
kind: "unknown",
|
|
166
|
+
id: unknownNodeId(edge),
|
|
167
|
+
edgeId: edge.id,
|
|
168
|
+
...box(graph.node(unknownNodeId(edge)))
|
|
169
|
+
}));
|
|
170
|
+
const anys = anyIds(machine).map((id) => ({
|
|
171
|
+
kind: "any",
|
|
172
|
+
id,
|
|
173
|
+
...box(graph.node(id))
|
|
174
|
+
}));
|
|
175
|
+
return [
|
|
176
|
+
...states,
|
|
177
|
+
...anys,
|
|
178
|
+
...unknowns
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
function polyline(points) {
|
|
182
|
+
return points.map((point, index) => `${index === 0 ? "M" : "L"}${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" ");
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Self-loops are drawn by hand above the node: dagre routes them as a flat
|
|
186
|
+
* line. The nth loop on a node is taller, so several loops stay readable.
|
|
187
|
+
*/
|
|
188
|
+
function selfLoop(node, nth) {
|
|
189
|
+
const { x, y, width, height } = box(node);
|
|
190
|
+
const top = y - height / 2;
|
|
191
|
+
const rise = 26 + nth * 18;
|
|
192
|
+
const spread = width / 2;
|
|
193
|
+
return {
|
|
194
|
+
path: `M${(x - width / 4).toFixed(1)},${top.toFixed(1)} C${(x - spread).toFixed(1)},${(top - rise).toFixed(1)} ${(x + spread).toFixed(1)},${(top - rise).toFixed(1)} ${(x + width / 4).toFixed(1)},${top.toFixed(1)}`,
|
|
195
|
+
label: {
|
|
196
|
+
x,
|
|
197
|
+
y: top - rise * .75 - 4
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
function placeEdges(graph, machine) {
|
|
202
|
+
const loopsSeen = /* @__PURE__ */ new Map();
|
|
203
|
+
return machine.edges.filter((edge) => edge.kind !== "initial").map((edge) => {
|
|
204
|
+
if (edge.to === edge.from) {
|
|
205
|
+
const nth = loopsSeen.get(edge.from) ?? 0;
|
|
206
|
+
loopsSeen.set(edge.from, nth + 1);
|
|
207
|
+
return {
|
|
208
|
+
edge,
|
|
209
|
+
target: edge.from,
|
|
210
|
+
...selfLoop(graph.node(edge.from), nth)
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const target = edge.to ?? unknownNodeId(edge);
|
|
214
|
+
const label = graph.edge(edge.from, target, edge.id);
|
|
215
|
+
return {
|
|
216
|
+
edge,
|
|
217
|
+
target,
|
|
218
|
+
path: polyline(label.points ?? []),
|
|
219
|
+
label: {
|
|
220
|
+
x: label.x ?? 0,
|
|
221
|
+
y: label.y ?? 0
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function placeEntry(graph, machine) {
|
|
227
|
+
if (machine.initial === null) return null;
|
|
228
|
+
const node = box(graph.node(machine.initial));
|
|
229
|
+
const left = node.x - node.width / 2;
|
|
230
|
+
return {
|
|
231
|
+
from: {
|
|
232
|
+
x: left - 28,
|
|
233
|
+
y: node.y
|
|
234
|
+
},
|
|
235
|
+
to: {
|
|
236
|
+
x: left,
|
|
237
|
+
y: node.y
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* Layered layout (dagre, left to right). Pure: same machine in, same
|
|
243
|
+
* coordinates out. The initial edge is drawn as an entry arrow, not a graph edge.
|
|
244
|
+
*/
|
|
245
|
+
function layoutMachine(machine) {
|
|
246
|
+
const graph = new Graph({
|
|
247
|
+
multigraph: true,
|
|
248
|
+
compound: true
|
|
249
|
+
});
|
|
250
|
+
graph.setGraph({
|
|
251
|
+
rankdir: "LR",
|
|
252
|
+
nodesep: 28,
|
|
253
|
+
ranksep: 64,
|
|
254
|
+
edgesep: 14,
|
|
255
|
+
marginx: 48,
|
|
256
|
+
marginy: 64
|
|
257
|
+
});
|
|
258
|
+
addRegions(graph, machine.regions);
|
|
259
|
+
addStates(graph, machine);
|
|
260
|
+
addAnyNodes(graph, machine);
|
|
261
|
+
addEdges(graph, machine);
|
|
262
|
+
layout(graph);
|
|
263
|
+
const size = graph.graph();
|
|
264
|
+
return {
|
|
265
|
+
width: size.width ?? 0,
|
|
266
|
+
height: size.height ?? 0,
|
|
267
|
+
nodes: placeNodes(graph, machine),
|
|
268
|
+
edges: placeEdges(graph, machine),
|
|
269
|
+
regions: machine.regions.map((region) => ({
|
|
270
|
+
region,
|
|
271
|
+
...box(graph.node(regionNodeId(region)))
|
|
272
|
+
})),
|
|
273
|
+
entry: placeEntry(graph, machine)
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
// src/viewer/graph.ts
|
|
277
|
+
function sourceText$1(from) {
|
|
278
|
+
return isAnyState(from) ? "any state" : from;
|
|
279
|
+
}
|
|
280
|
+
function isUnreached(coverage) {
|
|
281
|
+
return coverage !== null && coverage.hits === 0;
|
|
282
|
+
}
|
|
283
|
+
function classes(names) {
|
|
284
|
+
return Object.entries(names).filter(([, on]) => on).map(([name]) => name).join(" ");
|
|
285
|
+
}
|
|
286
|
+
function defs(doc) {
|
|
287
|
+
return svg(doc, "defs", {}, [svg(doc, "marker", {
|
|
288
|
+
id: "viz-arrow",
|
|
289
|
+
viewBox: "0 0 10 10",
|
|
290
|
+
refX: 9,
|
|
291
|
+
refY: 5,
|
|
292
|
+
markerWidth: 7,
|
|
293
|
+
markerHeight: 7,
|
|
294
|
+
orient: "auto-start-reverse"
|
|
295
|
+
}, [svg(doc, "path", {
|
|
296
|
+
d: "M0,0 L10,5 L0,10 z",
|
|
297
|
+
class: "arrow-head"
|
|
298
|
+
})])]);
|
|
299
|
+
}
|
|
300
|
+
function regionGroup(doc, placed) {
|
|
301
|
+
return svg(doc, "g", {
|
|
302
|
+
class: "region",
|
|
303
|
+
"data-region": placed.region
|
|
304
|
+
}, [svg(doc, "rect", {
|
|
305
|
+
x: placed.x - placed.width / 2,
|
|
306
|
+
y: placed.y - placed.height / 2,
|
|
307
|
+
width: placed.width,
|
|
308
|
+
height: placed.height,
|
|
309
|
+
rx: 10
|
|
310
|
+
}), svg(doc, "text", {
|
|
311
|
+
x: placed.x - placed.width / 2 + 8,
|
|
312
|
+
y: placed.y - placed.height / 2 + 14
|
|
313
|
+
}, [placed.region])]);
|
|
314
|
+
}
|
|
315
|
+
function stateGroup(doc, placed, callbacks) {
|
|
316
|
+
const { state } = placed;
|
|
317
|
+
const left = placed.x - placed.width / 2;
|
|
318
|
+
const top = placed.y - placed.height / 2;
|
|
319
|
+
const frame = {
|
|
320
|
+
x: left,
|
|
321
|
+
y: top,
|
|
322
|
+
width: placed.width,
|
|
323
|
+
height: placed.height,
|
|
324
|
+
rx: 8
|
|
325
|
+
};
|
|
326
|
+
const group = svg(doc, "g", {
|
|
327
|
+
class: classes({
|
|
328
|
+
state: true,
|
|
329
|
+
initial: state.initial,
|
|
330
|
+
final: state.final,
|
|
331
|
+
unreached: isUnreached(measure(callbacks.mode, state))
|
|
332
|
+
}),
|
|
333
|
+
"data-node-id": state.id,
|
|
334
|
+
"aria-label": `state ${state.id}${state.final ? " (final)" : ""}`
|
|
335
|
+
}, [
|
|
336
|
+
svg(doc, "rect", {
|
|
337
|
+
...frame,
|
|
338
|
+
class: "frame"
|
|
339
|
+
}),
|
|
340
|
+
state.final ? svg(doc, "rect", {
|
|
341
|
+
x: left + 4,
|
|
342
|
+
y: top + 4,
|
|
343
|
+
width: placed.width - 8,
|
|
344
|
+
height: placed.height - 8,
|
|
345
|
+
rx: 5,
|
|
346
|
+
class: "frame inner"
|
|
347
|
+
}) : null,
|
|
348
|
+
svg(doc, "text", {
|
|
349
|
+
x: placed.x,
|
|
350
|
+
y: placed.y + 4,
|
|
351
|
+
"text-anchor": "middle"
|
|
352
|
+
}, [state.label])
|
|
353
|
+
]);
|
|
354
|
+
activatable(group, () => callbacks.select({
|
|
355
|
+
kind: "state",
|
|
356
|
+
id: state.id
|
|
357
|
+
}));
|
|
358
|
+
return group;
|
|
359
|
+
}
|
|
360
|
+
function unknownGroup(doc, placed) {
|
|
361
|
+
return svg(doc, "g", {
|
|
362
|
+
class: "unknown-node",
|
|
363
|
+
"data-node-id": placed.id,
|
|
364
|
+
"aria-label": "undetermined target"
|
|
365
|
+
}, [svg(doc, "circle", {
|
|
366
|
+
cx: placed.x,
|
|
367
|
+
cy: placed.y,
|
|
368
|
+
r: placed.width / 2
|
|
369
|
+
}), svg(doc, "text", {
|
|
370
|
+
x: placed.x,
|
|
371
|
+
y: placed.y + 5,
|
|
372
|
+
"text-anchor": "middle"
|
|
373
|
+
}, ["?"])]);
|
|
374
|
+
}
|
|
375
|
+
function anyGroup(doc, placed) {
|
|
376
|
+
return svg(doc, "g", {
|
|
377
|
+
class: "any-node",
|
|
378
|
+
"data-node-id": placed.id,
|
|
379
|
+
"aria-label": "any non-final state (onAny handlers)"
|
|
380
|
+
}, [svg(doc, "rect", {
|
|
381
|
+
x: placed.x - placed.width / 2,
|
|
382
|
+
y: placed.y - placed.height / 2,
|
|
383
|
+
width: placed.width,
|
|
384
|
+
height: placed.height,
|
|
385
|
+
rx: placed.height / 2
|
|
386
|
+
}), svg(doc, "text", {
|
|
387
|
+
x: placed.x,
|
|
388
|
+
y: placed.y + 4,
|
|
389
|
+
"text-anchor": "middle"
|
|
390
|
+
}, ["any state"])]);
|
|
391
|
+
}
|
|
392
|
+
function nodeGroup(doc, node, callbacks) {
|
|
393
|
+
if (node.kind === "state") return stateGroup(doc, node, callbacks);
|
|
394
|
+
return node.kind === "any" ? anyGroup(doc, node) : unknownGroup(doc, node);
|
|
395
|
+
}
|
|
396
|
+
function edgeAria(edge, text) {
|
|
397
|
+
if (edge.kind === "effect") return `${edge.from} ${text} on entry`;
|
|
398
|
+
if (edge.kind === "stay") return `${sourceText$1(edge.from)} on ${text} stays`;
|
|
399
|
+
return `${sourceText$1(edge.from)} on ${text} to ${edge.to ?? "undetermined"}`;
|
|
400
|
+
}
|
|
401
|
+
function edgeGroup(doc, placed, context) {
|
|
402
|
+
const { edge } = placed;
|
|
403
|
+
const internal = context.machine.events.find((event) => event.id === edge.event)?.internal;
|
|
404
|
+
const text = edgeText(edge, context.machine);
|
|
405
|
+
const group = svg(doc, "g", {
|
|
406
|
+
class: classes({
|
|
407
|
+
edge: true,
|
|
408
|
+
effect: edge.kind === "effect",
|
|
409
|
+
stay: edge.kind === "stay",
|
|
410
|
+
fuzz: edge.origin === "fuzz",
|
|
411
|
+
undetermined: edge.to === null,
|
|
412
|
+
internal: internal === true,
|
|
413
|
+
unreached: isUnreached(measure(context.callbacks.mode, edge))
|
|
414
|
+
}),
|
|
415
|
+
"data-edge-id": edge.id,
|
|
416
|
+
"data-from": edge.from,
|
|
417
|
+
"data-to": placed.target,
|
|
418
|
+
"aria-label": edgeAria(edge, text)
|
|
419
|
+
}, [svg(doc, "path", {
|
|
420
|
+
d: placed.path,
|
|
421
|
+
"marker-end": "url(#viz-arrow)"
|
|
422
|
+
}), svg(doc, "text", {
|
|
423
|
+
x: placed.label.x,
|
|
424
|
+
y: placed.label.y + 4,
|
|
425
|
+
"text-anchor": "middle"
|
|
426
|
+
}, [text])]);
|
|
427
|
+
activatable(group, () => context.callbacks.select({
|
|
428
|
+
kind: "edge",
|
|
429
|
+
id: edge.id
|
|
430
|
+
}));
|
|
431
|
+
return group;
|
|
432
|
+
}
|
|
433
|
+
function entryArrow(doc, entry) {
|
|
434
|
+
return svg(doc, "g", {
|
|
435
|
+
class: "entry",
|
|
436
|
+
"aria-hidden": "true"
|
|
437
|
+
}, [svg(doc, "circle", {
|
|
438
|
+
cx: entry.from.x,
|
|
439
|
+
cy: entry.from.y,
|
|
440
|
+
r: 4
|
|
441
|
+
}), svg(doc, "path", {
|
|
442
|
+
d: `M${entry.from.x},${entry.from.y} L${entry.to.x},${entry.to.y}`,
|
|
443
|
+
"marker-end": "url(#viz-arrow)"
|
|
444
|
+
})]);
|
|
445
|
+
}
|
|
446
|
+
/** Toggle `highlight` on an edge and both of its endpoints while hovered. */
|
|
447
|
+
function wireHover(root) {
|
|
448
|
+
for (const edge of root.querySelectorAll(".edge")) {
|
|
449
|
+
const ends = /* @__PURE__ */ new Set([edge.dataset["from"], edge.dataset["to"]]);
|
|
450
|
+
const endpoints = () => [...root.querySelectorAll("[data-node-id]")].filter((node) => ends.has(node.dataset["nodeId"]));
|
|
451
|
+
edge.addEventListener("mouseenter", () => {
|
|
452
|
+
for (const element of [edge, ...endpoints()]) element.classList.add("highlight");
|
|
453
|
+
});
|
|
454
|
+
edge.addEventListener("mouseleave", () => {
|
|
455
|
+
for (const element of [edge, ...endpoints()]) element.classList.remove("highlight");
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
/** Static SVG for one machine. Regions first, then edges, then nodes, so nodes sit on top. */
|
|
460
|
+
function renderGraph(doc, machine, callbacks) {
|
|
461
|
+
const placed = layoutMachine(machine);
|
|
462
|
+
const root = svg(doc, "svg", {
|
|
463
|
+
class: "graph",
|
|
464
|
+
viewBox: `0 0 ${Math.ceil(placed.width)} ${Math.ceil(placed.height)}`,
|
|
465
|
+
width: Math.ceil(placed.width),
|
|
466
|
+
height: Math.ceil(placed.height),
|
|
467
|
+
style: `max-width: ${Math.ceil(placed.width)}px`,
|
|
468
|
+
role: "group",
|
|
469
|
+
"aria-label": `machine ${machine.id}`
|
|
470
|
+
}, [
|
|
471
|
+
defs(doc),
|
|
472
|
+
...placed.regions.map((region) => regionGroup(doc, region)),
|
|
473
|
+
...placed.edges.map((edge) => edgeGroup(doc, edge, {
|
|
474
|
+
machine,
|
|
475
|
+
callbacks
|
|
476
|
+
})),
|
|
477
|
+
placed.entry ? entryArrow(doc, placed.entry) : null,
|
|
478
|
+
...placed.nodes.map((node) => nodeGroup(doc, node, callbacks))
|
|
479
|
+
]);
|
|
480
|
+
wireHover(root);
|
|
481
|
+
return root;
|
|
482
|
+
}
|
|
483
|
+
// src/viewer/explanation.ts
|
|
484
|
+
function where(location) {
|
|
485
|
+
return location === null ? "unknown location" : `${location.file}:${location.line}:${location.column}`;
|
|
486
|
+
}
|
|
487
|
+
function targetText$2(branch) {
|
|
488
|
+
if (branch.target.kind === "state") return branch.target.state;
|
|
489
|
+
if (branch.target.kind === "stay") return "(stay)";
|
|
490
|
+
return `? ${branch.target.text}`;
|
|
491
|
+
}
|
|
492
|
+
/** Does the graph hold an edge for this branch from the selected edge's state? */
|
|
493
|
+
function inGraph(branch, context) {
|
|
494
|
+
const { edge, machine } = context;
|
|
495
|
+
const target = branch.target;
|
|
496
|
+
return machine.edges.some((candidate) => {
|
|
497
|
+
if (candidate.from !== edge.from || candidate.event !== edge.event) return false;
|
|
498
|
+
if (target.kind === "stay") return candidate.kind === "stay";
|
|
499
|
+
return target.kind === "state" && candidate.to === target.state;
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
function isThisEdge(branch, edge) {
|
|
503
|
+
if (branch.target.kind === "stay") return edge.kind === "stay";
|
|
504
|
+
return branch.target.kind === "state" && branch.target.state === edge.to;
|
|
505
|
+
}
|
|
506
|
+
function branchItem(doc, branch, context) {
|
|
507
|
+
const condition = branch.conditions.length === 0 ? "always" : branch.conditions.join(" && ");
|
|
508
|
+
const status = isThisEdge(branch, context.edge) ? "this edge" : inGraph(branch, context) ? "in graph" : "not in graph";
|
|
509
|
+
return html(doc, "li", {
|
|
510
|
+
class: "branch",
|
|
511
|
+
"data-branch-status": status
|
|
512
|
+
}, [
|
|
513
|
+
html(doc, "code", {}, [condition]),
|
|
514
|
+
` → ${targetText$2(branch)} `,
|
|
515
|
+
html(doc, "span", { class: "branch-status" }, [`(${status})`])
|
|
516
|
+
]);
|
|
517
|
+
}
|
|
518
|
+
function comparisonText(comparison) {
|
|
519
|
+
return `${comparison.source}.${comparison.path} ${comparison.operator} ${JSON.stringify(comparison.literal)}`;
|
|
520
|
+
}
|
|
521
|
+
function line(doc, label, value) {
|
|
522
|
+
return html(doc, "p", { "data-explain": label }, [html(doc, "span", { class: "muted" }, [`${label}: `]), value]);
|
|
523
|
+
}
|
|
524
|
+
function joined$1(values) {
|
|
525
|
+
return values.length === 0 ? "none" : values.join(", ");
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Static explanation of an edge's handler. Branches the graph lacks are
|
|
529
|
+
* listed as "not in graph": the AST annotates, it never adds edges.
|
|
530
|
+
*/
|
|
531
|
+
function renderExplanation(doc, context) {
|
|
532
|
+
const explanation = context.edge.explanation;
|
|
533
|
+
if (explanation === null) {
|
|
534
|
+
const kind = context.edge.kind;
|
|
535
|
+
return kind === "initial" || kind === "effect" ? "no handler behind this edge" : "not analyzed";
|
|
536
|
+
}
|
|
537
|
+
if (explanation.status === "not_analyzed") return html(doc, "div", { class: "explanation not-analyzed" }, [
|
|
538
|
+
line(doc, "not analyzed", explanation.reason),
|
|
539
|
+
line(doc, "at", where(explanation.location)),
|
|
540
|
+
explanation.snippet === null ? null : html(doc, "pre", {}, [explanation.snippet])
|
|
541
|
+
]);
|
|
542
|
+
return html(doc, "div", { class: "explanation" }, [
|
|
543
|
+
line(doc, "at", `${where(explanation.location)} (m.${explanation.registration})`),
|
|
544
|
+
html(doc, "ul", { class: "branches" }, explanation.branches.map((branch) => branchItem(doc, branch, context))),
|
|
545
|
+
explanation.readsActorState ? line(doc, "note", "reads live actor state; probing cannot determine the target") : null,
|
|
546
|
+
line(doc, "payload reads", joined$1(explanation.payloadReads)),
|
|
547
|
+
line(doc, "context reads", joined$1(explanation.contextReads)),
|
|
548
|
+
line(doc, "context writes", joined$1(explanation.contextWrites)),
|
|
549
|
+
line(doc, "comparisons", joined$1(explanation.comparisons.map(comparisonText)))
|
|
550
|
+
]);
|
|
551
|
+
}
|
|
552
|
+
// src/viewer/panel.ts
|
|
553
|
+
function list(doc, rows) {
|
|
554
|
+
return html(doc, "dl", {}, rows.flatMap(([label, value]) => [html(doc, "dt", {}, [label]), html(doc, "dd", { "data-field": label }, [value])]));
|
|
555
|
+
}
|
|
556
|
+
function joined(values) {
|
|
557
|
+
return values.length === 0 ? "none" : values.join(", ");
|
|
558
|
+
}
|
|
559
|
+
function hitsText(coverage) {
|
|
560
|
+
return coverage === null ? "not supplied" : String(coverage.hits);
|
|
561
|
+
}
|
|
562
|
+
const ORIGIN_LABELS = {
|
|
563
|
+
declared: "declared (builder `to`)",
|
|
564
|
+
probe: "probe",
|
|
565
|
+
fuzz: "fuzz (observed, not declared)"
|
|
566
|
+
};
|
|
567
|
+
function runLabel(machine) {
|
|
568
|
+
return machine.fuzz === null ? "run hits" : "fuzz hits";
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* A declared or probed edge no fuzz run produced. `to` lists what the handler
|
|
572
|
+
* may return, not what its guards allow; probing runs it with `{}` and a sample
|
|
573
|
+
* context. Either way the live machine may never take it.
|
|
574
|
+
*/
|
|
575
|
+
function predictionNote(doc, edge, machine) {
|
|
576
|
+
const unobserved = edge.coverage !== null && edge.coverage.hits === 0;
|
|
577
|
+
if (machine.fuzz === null || edge.origin === "fuzz" || edge.kind !== "transition" || !unobserved) return null;
|
|
578
|
+
return html(doc, "p", {
|
|
579
|
+
class: "prediction-note",
|
|
580
|
+
role: "note"
|
|
581
|
+
}, [`${edge.origin === "declared" ? "The handler declares this target in `to`, but no fuzz run produced it. Its guards may never allow it." : "Probing predicted this edge, but no fuzz run produced it. Probing calls the handler with an empty payload and a sample context, so this edge may be impossible in a real run."} Fuzzing never proves that, so check the handler.`]);
|
|
582
|
+
}
|
|
583
|
+
function stepText(step) {
|
|
584
|
+
if (step.kind === "advance") return `advance ${step.ms}ms`;
|
|
585
|
+
return step.payload === null ? step.event : `${step.event} ${JSON.stringify(step.payload)}`;
|
|
586
|
+
}
|
|
587
|
+
function featureOf(edge) {
|
|
588
|
+
if (edge.kind === "stay") return `stay:${edge.from}>${edge.event}`;
|
|
589
|
+
if (edge.kind !== "transition" || edge.to === null) return null;
|
|
590
|
+
return `edge:${edge.from}>${edge.event}>${edge.to}`;
|
|
591
|
+
}
|
|
592
|
+
/** Shortest kept fuzz sequence that first reached this edge, as a numbered list. */
|
|
593
|
+
function reachedBy(doc, edge, machine) {
|
|
594
|
+
const feature = featureOf(edge);
|
|
595
|
+
if (machine.fuzz === null) return "not fuzzed";
|
|
596
|
+
const shortest = machine.fuzz.corpus.filter((sequence) => feature !== null && sequence.discovered.includes(feature)).sort((left, right) => left.steps.length - right.steps.length)[0];
|
|
597
|
+
if (shortest === void 0) return "no kept sequence";
|
|
598
|
+
if (shortest.steps.length === 0) return "initial state";
|
|
599
|
+
return html(doc, "ol", { class: "steps" }, shortest.steps.map((step) => html(doc, "li", {}, [stepText(step)])));
|
|
600
|
+
}
|
|
601
|
+
function targetText$1(edge) {
|
|
602
|
+
if (edge.kind === "stay") return `${edge.to ?? edge.from} (handled, no state change)`;
|
|
603
|
+
return edge.to ?? "undetermined";
|
|
604
|
+
}
|
|
605
|
+
function sourceText(from) {
|
|
606
|
+
return isAnyState(from) ? "any state" : from;
|
|
607
|
+
}
|
|
608
|
+
function routingNote(doc, edge, machine) {
|
|
609
|
+
if (!isAnyState(edge.from)) return null;
|
|
610
|
+
const warning = routingWarnings(machine).find((each) => each.event === edge.event);
|
|
611
|
+
return warning ? html(doc, "p", {
|
|
612
|
+
class: "routing-note",
|
|
613
|
+
role: "note"
|
|
614
|
+
}, [warning.message]) : null;
|
|
615
|
+
}
|
|
616
|
+
function edgeLink(doc, edge, select) {
|
|
617
|
+
const link = html(doc, "span", {
|
|
618
|
+
class: "link",
|
|
619
|
+
"data-edge-link": edge.id
|
|
620
|
+
}, [`${sourceText(edge.from)} —${edge.event || "initial"}→ ${edge.to ?? "?"}`]);
|
|
621
|
+
activatable(link, () => select({
|
|
622
|
+
kind: "edge",
|
|
623
|
+
id: edge.id
|
|
624
|
+
}));
|
|
625
|
+
return html(doc, "li", {}, [link]);
|
|
626
|
+
}
|
|
627
|
+
function edgeList(doc, edges, select) {
|
|
628
|
+
if (edges.length === 0) return "none";
|
|
629
|
+
return html(doc, "ul", {}, edges.map((edge) => edgeLink(doc, edge, select)));
|
|
630
|
+
}
|
|
631
|
+
function edgePanel(doc, edge, machine) {
|
|
632
|
+
const internal = machine.events.find((event) => event.id === edge.event)?.internal === true;
|
|
633
|
+
return html(doc, "section", {
|
|
634
|
+
class: "details",
|
|
635
|
+
"data-selected-edge": edge.id
|
|
636
|
+
}, [
|
|
637
|
+
html(doc, "h2", {}, [`Edge ${edge.id}`]),
|
|
638
|
+
routingNote(doc, edge, machine),
|
|
639
|
+
predictionNote(doc, edge, machine),
|
|
640
|
+
list(doc, [
|
|
641
|
+
["source", isAnyState(edge.from) ? "any state (onAny)" : edge.from],
|
|
642
|
+
["event", edge.kind === "initial" ? "(initial)" : `${edge.event}${internal ? " (internal)" : ""}`],
|
|
643
|
+
["target", targetText$1(edge)],
|
|
644
|
+
["kind", edge.kind],
|
|
645
|
+
["origin", ORIGIN_LABELS[edge.origin]],
|
|
646
|
+
["emits", joined(edge.emits)],
|
|
647
|
+
["contexts", joined(edge.contexts)],
|
|
648
|
+
[runLabel(machine), hitsText(edge.coverage)],
|
|
649
|
+
["test hits", hitsText(edge.tested)],
|
|
650
|
+
["explanation", renderExplanation(doc, {
|
|
651
|
+
edge,
|
|
652
|
+
machine
|
|
653
|
+
})],
|
|
654
|
+
["reached by", reachedBy(doc, edge, machine)]
|
|
655
|
+
])
|
|
656
|
+
]);
|
|
657
|
+
}
|
|
658
|
+
function statePanel(doc, state, context) {
|
|
659
|
+
const { machine, select } = context;
|
|
660
|
+
const flags = [state.initial ? "initial" : "", state.final ? "final" : ""].filter(Boolean);
|
|
661
|
+
const outgoing = machine.edges.filter((edge) => edge.from === state.id || !state.final && coveredByAny(edge.from, state.id));
|
|
662
|
+
const incoming = machine.edges.filter((edge) => edge.to === state.id && edge.kind !== "initial");
|
|
663
|
+
return html(doc, "section", {
|
|
664
|
+
class: "details",
|
|
665
|
+
"data-selected-state": state.id
|
|
666
|
+
}, [html(doc, "h2", {}, [`State ${state.id}`]), list(doc, [
|
|
667
|
+
["id", state.id],
|
|
668
|
+
["region", state.region ?? "(root)"],
|
|
669
|
+
["flags", joined(flags)],
|
|
670
|
+
["effects on entry", joined(state.effects.map((effect) => effect.sends === null ? effect.name : `${effect.name} (sends ${effect.sends.join(", ") || "nothing"})`))],
|
|
671
|
+
[machine.fuzz === null ? "run visits" : "fuzz visits", hitsText(state.coverage)],
|
|
672
|
+
["test visits", hitsText(state.tested)],
|
|
673
|
+
["outgoing", edgeList(doc, outgoing, select)],
|
|
674
|
+
["incoming", edgeList(doc, incoming, select)]
|
|
675
|
+
])]);
|
|
676
|
+
}
|
|
677
|
+
function fuzzSummary(doc, machine) {
|
|
678
|
+
const { fuzz } = machine;
|
|
679
|
+
if (fuzz === null) return null;
|
|
680
|
+
const errors = fuzz.errors.length === 0 ? "none" : html(doc, "ul", {}, fuzz.errors.map((error) => html(doc, "li", { class: "fuzz-error" }, [`${error.from} on ${error.event}: ${error.message} — `, html(doc, "code", {}, [error.steps.map(stepText).join(" → ")])])));
|
|
681
|
+
return html(doc, "section", { class: "fuzz-summary" }, [html(doc, "h2", {}, ["Fuzz run"]), list(doc, [
|
|
682
|
+
["seed", String(fuzz.seed)],
|
|
683
|
+
["iterations", String(fuzz.iterations)],
|
|
684
|
+
["kept sequences", String(fuzz.corpus.length)],
|
|
685
|
+
["handler errors", errors]
|
|
686
|
+
])]);
|
|
687
|
+
}
|
|
688
|
+
/** Side-panel body for the current selection, or a hint when nothing is selected. */
|
|
689
|
+
function renderPanel(doc, machine, context) {
|
|
690
|
+
const { selection } = context;
|
|
691
|
+
if (selection?.kind === "edge") {
|
|
692
|
+
const edge = machine.edges.find((candidate) => candidate.id === selection.id);
|
|
693
|
+
if (edge) return edgePanel(doc, edge, machine);
|
|
694
|
+
}
|
|
695
|
+
if (selection?.kind === "state") {
|
|
696
|
+
const state = machine.states.find((candidate) => candidate.id === selection.id);
|
|
697
|
+
if (state) return statePanel(doc, state, {
|
|
698
|
+
machine,
|
|
699
|
+
select: context.select
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
return html(doc, "div", {}, [html(doc, "p", { class: "hint" }, ["Select a state or edge."]), fuzzSummary(doc, machine)]);
|
|
703
|
+
}
|
|
704
|
+
// src/viewer/explore.ts
|
|
705
|
+
const GROUP_TITLES = {
|
|
706
|
+
input: "Send an input",
|
|
707
|
+
effect: "Effect reports back",
|
|
708
|
+
internal: "Other internal events"
|
|
709
|
+
};
|
|
710
|
+
function code(doc, text) {
|
|
711
|
+
return html(doc, "code", {}, [text]);
|
|
712
|
+
}
|
|
713
|
+
function button(doc, label, context) {
|
|
714
|
+
const element = html(doc, "button", {
|
|
715
|
+
type: "button",
|
|
716
|
+
disabled: context.disabled ? "" : null,
|
|
717
|
+
...context.attrs
|
|
718
|
+
}, label);
|
|
719
|
+
element.addEventListener("click", context.onClick);
|
|
720
|
+
return element;
|
|
721
|
+
}
|
|
722
|
+
function targetText(move, target, current) {
|
|
723
|
+
return target === current && move.edge.kind === "stay" ? "stays" : `→ ${target}`;
|
|
724
|
+
}
|
|
725
|
+
function moveItem(doc, move, context) {
|
|
726
|
+
const { edge } = move;
|
|
727
|
+
const via = edge.from === context.current ? null : html(doc, "span", { class: "muted" }, [" (any state)"]);
|
|
728
|
+
if (move.targets.length === 0) return html(doc, "li", { "data-move": edge.id }, [
|
|
729
|
+
code(doc, edge.event),
|
|
730
|
+
via,
|
|
731
|
+
html(doc, "span", { class: "muted" }, [" → ? target depends on payload or context"])
|
|
732
|
+
]);
|
|
733
|
+
return html(doc, "li", { "data-move": edge.id }, move.targets.map((target) => button(doc, [
|
|
734
|
+
code(doc, edge.event),
|
|
735
|
+
` ${targetText(move, target, context.current)}`,
|
|
736
|
+
...via ? [via] : []
|
|
737
|
+
], {
|
|
738
|
+
onClick: () => context.take({
|
|
739
|
+
edge: edge.id,
|
|
740
|
+
to: target
|
|
741
|
+
}),
|
|
742
|
+
attrs: {
|
|
743
|
+
"data-take": edge.id,
|
|
744
|
+
"data-target": target
|
|
745
|
+
}
|
|
746
|
+
})));
|
|
747
|
+
}
|
|
748
|
+
function effectsSection(doc, machine, current) {
|
|
749
|
+
const effects = machine.states.find((state) => state.id === current)?.effects ?? [];
|
|
750
|
+
if (effects.length === 0) return null;
|
|
751
|
+
const unhandled = unhandledSends(machine, current);
|
|
752
|
+
const isUnhandled = (effect, event) => unhandled.some((each) => each.effect === effect && each.event === event);
|
|
753
|
+
return html(doc, "div", { class: "explore-effects" }, [
|
|
754
|
+
html(doc, "p", {}, [`Entering ${current} runs:`]),
|
|
755
|
+
html(doc, "ul", {}, effects.map((effect) => {
|
|
756
|
+
const sends = effect.sends ?? [];
|
|
757
|
+
const summary = effect.sends === null ? html(doc, "span", { class: "muted" }, [" (sends not declared)"]) : sends.length === 0 ? " sends nothing" : " sends ";
|
|
758
|
+
return html(doc, "li", { "data-effect": effect.name }, [
|
|
759
|
+
code(doc, effect.name),
|
|
760
|
+
summary,
|
|
761
|
+
...sends.flatMap((event, index) => [index === 0 ? "" : ", ", html(doc, "span", { class: isUnhandled(effect.name, event) ? "unhandled" : null }, [code(doc, event)])])
|
|
762
|
+
]);
|
|
763
|
+
})),
|
|
764
|
+
unhandled.length === 0 ? null : html(doc, "p", {
|
|
765
|
+
class: "explore-warning",
|
|
766
|
+
role: "note"
|
|
767
|
+
}, [
|
|
768
|
+
`No handler in ${current} for `,
|
|
769
|
+
...unhandled.flatMap((each, index) => [index === 0 ? "" : ", ", code(doc, each.event)]),
|
|
770
|
+
": the actor ignores it."
|
|
771
|
+
])
|
|
772
|
+
]);
|
|
773
|
+
}
|
|
774
|
+
function movesSection(doc, moves, context) {
|
|
775
|
+
if (context.final) return html(doc, "p", {
|
|
776
|
+
class: "explore-end explore-done",
|
|
777
|
+
role: "status"
|
|
778
|
+
}, [html(doc, "strong", {}, ["Done."]), ` ${context.current} is final: the actor has stopped.`]);
|
|
779
|
+
if (moves.length === 0) return html(doc, "p", { class: "explore-end" }, [`No handler in ${context.current}: stuck.`]);
|
|
780
|
+
return html(doc, "div", { class: "explore-moves" }, [
|
|
781
|
+
"input",
|
|
782
|
+
"effect",
|
|
783
|
+
"internal"
|
|
784
|
+
].flatMap((group) => {
|
|
785
|
+
const inGroup = moves.filter((move) => move.group === group);
|
|
786
|
+
if (inGroup.length === 0) return [];
|
|
787
|
+
return [html(doc, "h3", {}, [GROUP_TITLES[group]]), html(doc, "ul", { "data-group": group }, inGroup.map((move) => moveItem(doc, move, context)))];
|
|
788
|
+
}));
|
|
789
|
+
}
|
|
790
|
+
function pathSection(doc, context) {
|
|
791
|
+
const { machine, path } = context;
|
|
792
|
+
const edges = new Map(machine.edges.map((edge) => [edge.id, edge]));
|
|
793
|
+
let from = machine.initial ?? "";
|
|
794
|
+
const items = path.map((step, index) => {
|
|
795
|
+
const event = edges.get(step.edge)?.event ?? step.edge;
|
|
796
|
+
const text = step.to === from ? `${event} in ${from}: stays` : `${event}: ${from} → ${step.to}`;
|
|
797
|
+
from = step.to;
|
|
798
|
+
return html(doc, "li", { "aria-current": index === path.length - 1 ? "step" : null }, [button(doc, [text], {
|
|
799
|
+
onClick: () => context.rewind(index + 1),
|
|
800
|
+
attrs: { "data-rewind": String(index + 1) }
|
|
801
|
+
})]);
|
|
802
|
+
});
|
|
803
|
+
return html(doc, "div", { class: "explore-path" }, [html(doc, "h3", {}, ["Path"]), html(doc, "ol", {}, [html(doc, "li", { "aria-current": path.length === 0 ? "step" : null }, [button(doc, [`start in ${machine.initial ?? "?"}`], {
|
|
804
|
+
onClick: () => context.rewind(0),
|
|
805
|
+
attrs: { "data-rewind": "0" }
|
|
806
|
+
})]), ...items])]);
|
|
807
|
+
}
|
|
808
|
+
/** Side-panel section: where the walk is, what can happen next, and how it got here. */
|
|
809
|
+
function explorePanel(doc, context) {
|
|
810
|
+
const { machine, path } = context;
|
|
811
|
+
const current = stateAfter(machine, path) ?? "";
|
|
812
|
+
const final = machine.states.find((state) => state.id === current)?.final === true;
|
|
813
|
+
const moves = movesFrom(machine, current);
|
|
814
|
+
return html(doc, "section", {
|
|
815
|
+
class: "explore",
|
|
816
|
+
"data-current": current,
|
|
817
|
+
"data-done": final ? "" : null
|
|
818
|
+
}, [
|
|
819
|
+
html(doc, "h2", {}, ["Explore"]),
|
|
820
|
+
html(doc, "p", { class: "explore-now" }, [
|
|
821
|
+
"In ",
|
|
822
|
+
html(doc, "strong", {}, [current]),
|
|
823
|
+
final ? " (final)" : ""
|
|
824
|
+
]),
|
|
825
|
+
effectsSection(doc, machine, current),
|
|
826
|
+
movesSection(doc, moves, {
|
|
827
|
+
current,
|
|
828
|
+
final,
|
|
829
|
+
take: context.take
|
|
830
|
+
}),
|
|
831
|
+
html(doc, "div", { class: "explore-controls" }, [button(doc, ["Back"], {
|
|
832
|
+
onClick: () => context.rewind(path.length - 1),
|
|
833
|
+
disabled: path.length === 0,
|
|
834
|
+
attrs: { "data-explore-action": "back" }
|
|
835
|
+
}), button(doc, ["Reset"], {
|
|
836
|
+
onClick: () => context.rewind(0),
|
|
837
|
+
disabled: path.length === 0,
|
|
838
|
+
attrs: { "data-explore-action": "reset" }
|
|
839
|
+
})]),
|
|
840
|
+
pathSection(doc, context)
|
|
841
|
+
]);
|
|
842
|
+
}
|
|
843
|
+
/**
|
|
844
|
+
* Where a click on the diagram moves the walk, or null to just show details:
|
|
845
|
+
* a next-step edge with one target, or a state exactly one next step reaches.
|
|
846
|
+
*/
|
|
847
|
+
function clickStep(machine, context) {
|
|
848
|
+
const current = stateAfter(machine, context.path);
|
|
849
|
+
if (current === null) return null;
|
|
850
|
+
const { selection } = context;
|
|
851
|
+
const steps = movesFrom(machine, current).flatMap((move) => move.targets.map((to) => ({
|
|
852
|
+
edge: move.edge.id,
|
|
853
|
+
to
|
|
854
|
+
})));
|
|
855
|
+
const matching = selection.kind === "edge" ? steps.filter((step) => step.edge === selection.id) : steps.filter((step) => step.to === selection.id && step.to !== current);
|
|
856
|
+
return matching.length === 1 ? matching[0] ?? null : null;
|
|
857
|
+
}
|
|
858
|
+
/** Mark the walk on the drawn graph: current state, path taken, possible next steps. */
|
|
859
|
+
function markExplore(root, context) {
|
|
860
|
+
const { machine, path } = context;
|
|
861
|
+
const current = stateAfter(machine, path);
|
|
862
|
+
const moves = current === null ? [] : movesFrom(machine, current);
|
|
863
|
+
const next = new Set(moves.filter((move) => move.targets.length > 0).map((move) => move.edge.id));
|
|
864
|
+
const reachable = new Set(moves.flatMap((move) => move.targets).filter((to) => to !== current));
|
|
865
|
+
const taken = new Set(path.map((step) => step.edge));
|
|
866
|
+
for (const element of root.querySelectorAll(".graph g[data-edge-id]")) {
|
|
867
|
+
const id = element.dataset["edgeId"] ?? "";
|
|
868
|
+
element.classList.toggle("taken", taken.has(id));
|
|
869
|
+
element.classList.toggle("next", next.has(id));
|
|
870
|
+
element.classList.toggle("running", element.classList.contains("effect") && element.dataset["from"] === current);
|
|
871
|
+
}
|
|
872
|
+
for (const element of root.querySelectorAll(".graph g[data-node-id]")) {
|
|
873
|
+
const id = element.dataset["nodeId"];
|
|
874
|
+
element.classList.toggle("current", id === current);
|
|
875
|
+
element.classList.toggle("reachable", id !== void 0 && reachable.has(id));
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
// src/viewer/viewer.ts
|
|
879
|
+
const STRUCTURE_BANNER = "Click a pulsing edge, or a state it leads to, to walk the machine. Edges come from each handler's declared `to` list and effects' `sends`; guards are not evaluated, so a path here may not happen at runtime.";
|
|
880
|
+
const FUZZ_NOTE = "Green edges were observed while fuzzing but not declared. Fuzzing finds behaviour; it never proves an edge is impossible.";
|
|
881
|
+
function effectiveMode(view, machine) {
|
|
882
|
+
const modes = availableModes(machine);
|
|
883
|
+
if (view.coverage === "off") return "off";
|
|
884
|
+
if (view.coverage !== null && modes.includes(view.coverage)) return view.coverage;
|
|
885
|
+
return modes[0] ?? "off";
|
|
886
|
+
}
|
|
887
|
+
function legend(doc, { mode, machine }) {
|
|
888
|
+
const entries = [
|
|
889
|
+
["legend-final", "double border: final state"],
|
|
890
|
+
["legend-undetermined", "dashed edge to ?: target undetermined"],
|
|
891
|
+
["legend-internal", "dotted edge, italic label: internal event"],
|
|
892
|
+
["legend-effect", "faint self-loop: state runs effects on entry"],
|
|
893
|
+
["legend-current", "thick accent border: where the walk is"],
|
|
894
|
+
["legend-next", "pulsing: a possible next step"],
|
|
895
|
+
["legend-taken", "bold accent: path taken"]
|
|
896
|
+
];
|
|
897
|
+
if (machine.fuzz !== null) entries.push(["legend-fuzz", "green: observed only while fuzzing"], ["legend-stay", "dotted self-loop: handled, no state change"]);
|
|
898
|
+
if (mode !== "off") entries.push(["legend-unreached", unreachedText(mode, machine)]);
|
|
899
|
+
return html(doc, "ul", { class: "legend" }, entries.map(([name, text]) => html(doc, "li", { class: name }, [text])));
|
|
900
|
+
}
|
|
901
|
+
function machineSelect(doc, viz, context) {
|
|
902
|
+
if (viz.machines.length < 2) return null;
|
|
903
|
+
const select = html(doc, "select", { class: "machine-select" }, viz.machines.map((machine) => html(doc, "option", {
|
|
904
|
+
value: machine.id,
|
|
905
|
+
selected: machine.id === context.view.machineId ? "" : null
|
|
906
|
+
}, [machine.id])));
|
|
907
|
+
select.addEventListener("change", () => context.onChange(select.value));
|
|
908
|
+
return html(doc, "label", {}, ["Machine ", select]);
|
|
909
|
+
}
|
|
910
|
+
function isMode(value) {
|
|
911
|
+
return value === "off" || value === "run" || value === "tests";
|
|
912
|
+
}
|
|
913
|
+
function coveragePicker(doc, context) {
|
|
914
|
+
const modes = availableModes(context.machine);
|
|
915
|
+
if (modes.length === 0) return null;
|
|
916
|
+
const select = html(doc, "select", { class: "coverage-mode" }, [...modes, "off"].map((mode) => html(doc, "option", {
|
|
917
|
+
value: mode,
|
|
918
|
+
selected: mode === context.mode ? "" : null
|
|
919
|
+
}, [modeLabel(mode, context.machine)])));
|
|
920
|
+
select.addEventListener("change", () => {
|
|
921
|
+
if (isMode(select.value)) context.onChange(select.value);
|
|
922
|
+
});
|
|
923
|
+
return html(doc, "label", {}, ["Coverage from ", select]);
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Mount a viewer for an viz document into `root`. Walking the graph moves a
|
|
927
|
+
* cursor over the drawn edges; no actor runs. The document is the only input.
|
|
928
|
+
*/
|
|
929
|
+
function mountViewer(root, viz, options = {}) {
|
|
930
|
+
const doc = root.ownerDocument;
|
|
931
|
+
const first = viz.machines.find((machine) => machine.id === options.machine) ?? viz.machines[0];
|
|
932
|
+
const view = {
|
|
933
|
+
machineId: first?.id ?? "",
|
|
934
|
+
selection: null,
|
|
935
|
+
coverage: null,
|
|
936
|
+
path: first === void 0 ? [] : decodePath(first, options.steps ?? [])
|
|
937
|
+
};
|
|
938
|
+
let back = () => void 0;
|
|
939
|
+
root.addEventListener("keydown", (event) => {
|
|
940
|
+
if (!("key" in event) || event.target instanceof HTMLSelectElement) return;
|
|
941
|
+
if (event.key === "ArrowLeft") back();
|
|
942
|
+
});
|
|
943
|
+
function render() {
|
|
944
|
+
const machine = viz.machines.find((candidate) => candidate.id === view.machineId);
|
|
945
|
+
root.replaceChildren();
|
|
946
|
+
root.classList.add("viz");
|
|
947
|
+
if (!machine) {
|
|
948
|
+
root.append(html(doc, "p", { class: "empty" }, ["Document has no machines."]));
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
const mode = effectiveMode(view, machine);
|
|
952
|
+
root.classList.toggle("coverage-on", mode !== "off");
|
|
953
|
+
const panel = html(doc, "aside", {
|
|
954
|
+
class: "panel",
|
|
955
|
+
"aria-live": "polite"
|
|
956
|
+
});
|
|
957
|
+
const refresh = () => {
|
|
958
|
+
panel.replaceChildren(explorePanel(doc, {
|
|
959
|
+
machine,
|
|
960
|
+
path: view.path,
|
|
961
|
+
take,
|
|
962
|
+
rewind
|
|
963
|
+
}), renderPanel(doc, machine, {
|
|
964
|
+
selection: view.selection,
|
|
965
|
+
select
|
|
966
|
+
}));
|
|
967
|
+
markExplore(root, {
|
|
968
|
+
machine,
|
|
969
|
+
path: view.path
|
|
970
|
+
});
|
|
971
|
+
markSelection(root, view.selection);
|
|
972
|
+
};
|
|
973
|
+
const walked = () => {
|
|
974
|
+
options.onNavigate?.({
|
|
975
|
+
machine: machine.id,
|
|
976
|
+
steps: view.path.map((step) => encodeStep(machine, step))
|
|
977
|
+
});
|
|
978
|
+
refresh();
|
|
979
|
+
};
|
|
980
|
+
const take = (step) => {
|
|
981
|
+
view.path = [...view.path, step];
|
|
982
|
+
walked();
|
|
983
|
+
};
|
|
984
|
+
const rewind = (length) => {
|
|
985
|
+
view.path = view.path.slice(0, Math.max(0, length));
|
|
986
|
+
walked();
|
|
987
|
+
};
|
|
988
|
+
const select = (selection) => {
|
|
989
|
+
const step = clickStep(machine, {
|
|
990
|
+
path: view.path,
|
|
991
|
+
selection
|
|
992
|
+
});
|
|
993
|
+
view.selection = selection;
|
|
994
|
+
if (step === null) refresh();
|
|
995
|
+
else take(step);
|
|
996
|
+
};
|
|
997
|
+
back = () => rewind(view.path.length - 1);
|
|
998
|
+
root.append(headerOf(doc, {
|
|
999
|
+
viz,
|
|
1000
|
+
machine,
|
|
1001
|
+
view,
|
|
1002
|
+
heading: options.heading ?? "h1",
|
|
1003
|
+
mode,
|
|
1004
|
+
render,
|
|
1005
|
+
onNavigate: options.onNavigate
|
|
1006
|
+
}), html(doc, "p", {
|
|
1007
|
+
class: "banner",
|
|
1008
|
+
role: "note"
|
|
1009
|
+
}, [banner(machine)]), legend(doc, {
|
|
1010
|
+
mode,
|
|
1011
|
+
machine
|
|
1012
|
+
}), html(doc, "div", { class: "viz-main" }, [html(doc, "div", { class: "canvas" }, [renderGraph(doc, machine, {
|
|
1013
|
+
select,
|
|
1014
|
+
mode
|
|
1015
|
+
})]), panel]));
|
|
1016
|
+
refresh();
|
|
1017
|
+
}
|
|
1018
|
+
render();
|
|
1019
|
+
}
|
|
1020
|
+
function headerOf(doc, context) {
|
|
1021
|
+
const { machine, view, render } = context;
|
|
1022
|
+
return html(doc, "header", { class: "viz-header" }, [
|
|
1023
|
+
html(doc, context.heading, {}, [`Viz: ${machine.id}`]),
|
|
1024
|
+
machineSelect(doc, context.viz, {
|
|
1025
|
+
view,
|
|
1026
|
+
onChange: (id) => {
|
|
1027
|
+
view.machineId = id;
|
|
1028
|
+
view.selection = null;
|
|
1029
|
+
view.path = [];
|
|
1030
|
+
context.onNavigate?.({
|
|
1031
|
+
machine: id,
|
|
1032
|
+
steps: []
|
|
1033
|
+
});
|
|
1034
|
+
render();
|
|
1035
|
+
}
|
|
1036
|
+
}),
|
|
1037
|
+
coveragePicker(doc, {
|
|
1038
|
+
machine,
|
|
1039
|
+
mode: context.mode,
|
|
1040
|
+
onChange: (next) => {
|
|
1041
|
+
view.coverage = next;
|
|
1042
|
+
render();
|
|
1043
|
+
}
|
|
1044
|
+
})
|
|
1045
|
+
]);
|
|
1046
|
+
}
|
|
1047
|
+
function banner(machine) {
|
|
1048
|
+
return machine.fuzz === null ? STRUCTURE_BANNER : `${STRUCTURE_BANNER} ${FUZZ_NOTE}`;
|
|
1049
|
+
}
|
|
1050
|
+
function markSelection(root, selection) {
|
|
1051
|
+
const attribute = selection?.kind === "edge" ? "edgeId" : "nodeId";
|
|
1052
|
+
for (const element of root.querySelectorAll(".graph g")) element.classList.toggle("selected", selection !== null && element.dataset[attribute] === selection.id);
|
|
1053
|
+
}
|
|
1054
|
+
// src/viewer/embed.ts
|
|
1055
|
+
/**
|
|
1056
|
+
* Browser entry for embedding the viewer in another page (e.g. the docs site).
|
|
1057
|
+
* Kept apart from the root entry so bundles skip the fuzzer and the AST
|
|
1058
|
+
* annotator (TypeScript compiler).
|
|
1059
|
+
*/
|
|
1060
|
+
/** Replace `root` with a list of reasons `source` cannot be shown. */
|
|
1061
|
+
function showIssues(root, context) {
|
|
1062
|
+
const doc = root.ownerDocument;
|
|
1063
|
+
root.replaceChildren(html(doc, "section", {
|
|
1064
|
+
class: "load-error",
|
|
1065
|
+
role: "alert"
|
|
1066
|
+
}, [html(doc, "h2", {}, [`Cannot show ${context.source}`]), html(doc, "ul", {}, context.issues.map((issue) => html(doc, "li", {}, [`${issue.path}: ${issue.message}`])))]));
|
|
1067
|
+
}
|
|
1068
|
+
/** Validate viz JSON text and mount it, or list what is wrong with it. */
|
|
1069
|
+
function mountVizText(root, context, options = {}) {
|
|
1070
|
+
const result = parseViz(context.text);
|
|
1071
|
+
if (Either.isLeft(result)) {
|
|
1072
|
+
showIssues(root, {
|
|
1073
|
+
source: context.source,
|
|
1074
|
+
issues: result[0]
|
|
1075
|
+
});
|
|
1076
|
+
return;
|
|
1077
|
+
}
|
|
1078
|
+
mountViewer(root, result[1], options);
|
|
1079
|
+
}
|
|
1080
|
+
export { mountViewer as a, STRUCTURE_BANNER as i, showIssues as n, FUZZ_NOTE as r, mountVizText as t };
|