@bastani/atomic 0.5.0-1
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 +24 -0
- package/README.md +956 -0
- package/assets/settings.schema.json +52 -0
- package/package.json +68 -0
- package/src/cli.ts +197 -0
- package/src/commands/cli/chat/client.ts +18 -0
- package/src/commands/cli/chat/index.ts +247 -0
- package/src/commands/cli/chat.ts +8 -0
- package/src/commands/cli/config.ts +55 -0
- package/src/commands/cli/init/index.ts +452 -0
- package/src/commands/cli/init/onboarding.ts +45 -0
- package/src/commands/cli/init/scm.ts +190 -0
- package/src/commands/cli/init.ts +8 -0
- package/src/commands/cli/update.ts +46 -0
- package/src/commands/cli/workflow.ts +164 -0
- package/src/lib/merge.ts +65 -0
- package/src/lib/path-root-guard.ts +38 -0
- package/src/lib/spawn.ts +467 -0
- package/src/scripts/bump-version.ts +94 -0
- package/src/scripts/constants-base.ts +14 -0
- package/src/scripts/constants.ts +34 -0
- package/src/sdk/components/color-utils.ts +20 -0
- package/src/sdk/components/connectors.test.ts +661 -0
- package/src/sdk/components/connectors.ts +156 -0
- package/src/sdk/components/edge.tsx +11 -0
- package/src/sdk/components/error-boundary.tsx +38 -0
- package/src/sdk/components/graph-theme.ts +36 -0
- package/src/sdk/components/header.tsx +60 -0
- package/src/sdk/components/layout.test.ts +924 -0
- package/src/sdk/components/layout.ts +186 -0
- package/src/sdk/components/node-card.tsx +68 -0
- package/src/sdk/components/orchestrator-panel-contexts.ts +26 -0
- package/src/sdk/components/orchestrator-panel-store.test.ts +561 -0
- package/src/sdk/components/orchestrator-panel-store.ts +118 -0
- package/src/sdk/components/orchestrator-panel-types.ts +21 -0
- package/src/sdk/components/orchestrator-panel.tsx +143 -0
- package/src/sdk/components/session-graph-panel.tsx +364 -0
- package/src/sdk/components/status-helpers.ts +32 -0
- package/src/sdk/components/statusline.tsx +63 -0
- package/src/sdk/define-workflow.ts +98 -0
- package/src/sdk/errors.ts +39 -0
- package/src/sdk/index.ts +38 -0
- package/src/sdk/providers/claude.ts +316 -0
- package/src/sdk/providers/copilot.ts +43 -0
- package/src/sdk/providers/opencode.ts +43 -0
- package/src/sdk/runtime/discovery.ts +172 -0
- package/src/sdk/runtime/executor.test.ts +415 -0
- package/src/sdk/runtime/executor.ts +695 -0
- package/src/sdk/runtime/loader.ts +372 -0
- package/src/sdk/runtime/panel.tsx +9 -0
- package/src/sdk/runtime/theme.ts +76 -0
- package/src/sdk/runtime/tmux.ts +542 -0
- package/src/sdk/types.ts +114 -0
- package/src/sdk/workflows.ts +85 -0
- package/src/services/config/atomic-config.ts +124 -0
- package/src/services/config/atomic-global-config.ts +361 -0
- package/src/services/config/config-path.ts +19 -0
- package/src/services/config/definitions.ts +176 -0
- package/src/services/config/index.ts +7 -0
- package/src/services/config/settings-schema.ts +2 -0
- package/src/services/config/settings.ts +149 -0
- package/src/services/system/copy.ts +381 -0
- package/src/services/system/detect.ts +161 -0
- package/src/services/system/download.ts +325 -0
- package/src/services/system/file-lock.ts +289 -0
- package/src/services/system/skills.ts +67 -0
- package/src/theme/colors.ts +25 -0
- package/src/version.ts +7 -0
|
@@ -0,0 +1,924 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
computeLayout,
|
|
4
|
+
NODE_W,
|
|
5
|
+
NODE_H,
|
|
6
|
+
H_GAP,
|
|
7
|
+
V_GAP,
|
|
8
|
+
PAD,
|
|
9
|
+
} from "./layout.ts";
|
|
10
|
+
import type { SessionData, SessionStatus } from "./orchestrator-panel-types.ts";
|
|
11
|
+
|
|
12
|
+
// ─── Helpers ──────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function session(
|
|
15
|
+
name: string,
|
|
16
|
+
parents: string[] = [],
|
|
17
|
+
status: SessionStatus = "pending",
|
|
18
|
+
extra?: { error?: string; startedAt?: number; endedAt?: number },
|
|
19
|
+
): SessionData {
|
|
20
|
+
return {
|
|
21
|
+
name,
|
|
22
|
+
status,
|
|
23
|
+
parents,
|
|
24
|
+
error: extra?.error,
|
|
25
|
+
startedAt: extra?.startedAt ?? null,
|
|
26
|
+
endedAt: extra?.endedAt ?? null,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* y coordinate for a node at depth d, assuming all rows have height NODE_H.
|
|
32
|
+
* yAt(d) = sum_{i=0}^{d-1} (NODE_H + V_GAP)
|
|
33
|
+
*/
|
|
34
|
+
function yAt(depth: number): number {
|
|
35
|
+
let y = 0;
|
|
36
|
+
for (let i = 0; i < depth; i++) y += NODE_H + V_GAP;
|
|
37
|
+
return y;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// ─── Tests ────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
describe("computeLayout", () => {
|
|
43
|
+
// ─── 1. Empty input ───────────────────────────
|
|
44
|
+
|
|
45
|
+
describe("empty input", () => {
|
|
46
|
+
test("returns empty roots array", () => {
|
|
47
|
+
const result = computeLayout([]);
|
|
48
|
+
expect(result.roots).toHaveLength(0);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("returns empty map", () => {
|
|
52
|
+
const result = computeLayout([]);
|
|
53
|
+
expect(Object.keys(result.map)).toHaveLength(0);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("returns zero-like dimensions (PAD on each side with no nodes)", () => {
|
|
57
|
+
const result = computeLayout([]);
|
|
58
|
+
// maxX=0, maxY=0 → width=0+PAD, height=0+PAD
|
|
59
|
+
expect(result.width).toBe(PAD);
|
|
60
|
+
expect(result.height).toBe(PAD);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("returns empty rowH", () => {
|
|
64
|
+
const result = computeLayout([]);
|
|
65
|
+
expect(Object.keys(result.rowH)).toHaveLength(0);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ─── 2. Single node ───────────────────────────
|
|
70
|
+
|
|
71
|
+
describe("single node", () => {
|
|
72
|
+
test("produces one root", () => {
|
|
73
|
+
const result = computeLayout([session("A")]);
|
|
74
|
+
expect(result.roots).toHaveLength(1);
|
|
75
|
+
expect(result.roots[0]!.name).toBe("A");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("node is in map", () => {
|
|
79
|
+
const result = computeLayout([session("A")]);
|
|
80
|
+
expect(result.map["A"]).toBeDefined();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("node depth is 0", () => {
|
|
84
|
+
const result = computeLayout([session("A")]);
|
|
85
|
+
expect(result.map["A"]!.depth).toBe(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("node is at (PAD, PAD)", () => {
|
|
89
|
+
const result = computeLayout([session("A")]);
|
|
90
|
+
// cursor=0, leaf → x=0; yAt(0)=0; then +PAD
|
|
91
|
+
expect(result.map["A"]!.x).toBe(PAD);
|
|
92
|
+
expect(result.map["A"]!.y).toBe(PAD);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("width = NODE_W + 2*PAD", () => {
|
|
96
|
+
const result = computeLayout([session("A")]);
|
|
97
|
+
// maxX = PAD + NODE_W, width = maxX + PAD = NODE_W + 2*PAD
|
|
98
|
+
expect(result.width).toBe(NODE_W + 2 * PAD);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("height = NODE_H + 2*PAD", () => {
|
|
102
|
+
const result = computeLayout([session("A")]);
|
|
103
|
+
expect(result.height).toBe(NODE_H + 2 * PAD);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("rowH has exactly depth 0 = NODE_H", () => {
|
|
107
|
+
const result = computeLayout([session("A")]);
|
|
108
|
+
expect(result.rowH[0]).toBe(NODE_H);
|
|
109
|
+
expect(Object.keys(result.rowH)).toHaveLength(1);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ─── 3. Linear chain A → B → C ───────────────
|
|
114
|
+
|
|
115
|
+
describe("linear chain (A → B → C)", () => {
|
|
116
|
+
function makeLinear() {
|
|
117
|
+
return computeLayout([
|
|
118
|
+
session("A"),
|
|
119
|
+
session("B", ["A"]),
|
|
120
|
+
session("C", ["B"]),
|
|
121
|
+
]);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
test("one root (A)", () => {
|
|
125
|
+
expect(makeLinear().roots).toHaveLength(1);
|
|
126
|
+
expect(makeLinear().roots[0]!.name).toBe("A");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("depths are 0, 1, 2", () => {
|
|
130
|
+
const r = makeLinear();
|
|
131
|
+
expect(r.map["A"]!.depth).toBe(0);
|
|
132
|
+
expect(r.map["B"]!.depth).toBe(1);
|
|
133
|
+
expect(r.map["C"]!.depth).toBe(2);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("all nodes share the same x (single chain, no branching)", () => {
|
|
137
|
+
const r = makeLinear();
|
|
138
|
+
expect(r.map["A"]!.x).toBe(r.map["B"]!.x);
|
|
139
|
+
expect(r.map["B"]!.x).toBe(r.map["C"]!.x);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("all nodes are at x = PAD", () => {
|
|
143
|
+
const r = makeLinear();
|
|
144
|
+
// C (leaf) placed at cursor=0 → x=0+PAD=PAD; B centered over C → x=PAD; A centered over B → x=PAD
|
|
145
|
+
expect(r.map["A"]!.x).toBe(PAD);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("y values increase by NODE_H + V_GAP each level", () => {
|
|
149
|
+
const r = makeLinear();
|
|
150
|
+
const step = NODE_H + V_GAP;
|
|
151
|
+
expect(r.map["A"]!.y).toBe(yAt(0) + PAD);
|
|
152
|
+
expect(r.map["B"]!.y).toBe(yAt(1) + PAD);
|
|
153
|
+
expect(r.map["C"]!.y).toBe(yAt(2) + PAD);
|
|
154
|
+
expect(r.map["B"]!.y - r.map["A"]!.y).toBe(step);
|
|
155
|
+
expect(r.map["C"]!.y - r.map["B"]!.y).toBe(step);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("rowH has entry for each depth = NODE_H", () => {
|
|
159
|
+
const r = makeLinear();
|
|
160
|
+
expect(r.rowH[0]).toBe(NODE_H);
|
|
161
|
+
expect(r.rowH[1]).toBe(NODE_H);
|
|
162
|
+
expect(r.rowH[2]).toBe(NODE_H);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("children's y > parent's y", () => {
|
|
166
|
+
const r = makeLinear();
|
|
167
|
+
expect(r.map["B"]!.y).toBeGreaterThan(r.map["A"]!.y);
|
|
168
|
+
expect(r.map["C"]!.y).toBeGreaterThan(r.map["B"]!.y);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// ─── 4. Fan-out: A → [B, C] ──────────────────
|
|
173
|
+
|
|
174
|
+
describe("fan-out: A → [B, C]", () => {
|
|
175
|
+
function makeFanOut() {
|
|
176
|
+
return computeLayout([
|
|
177
|
+
session("A"),
|
|
178
|
+
session("B", ["A"]),
|
|
179
|
+
session("C", ["A"]),
|
|
180
|
+
]);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
test("one root (A), two children at depth 1", () => {
|
|
184
|
+
const r = makeFanOut();
|
|
185
|
+
expect(r.roots).toHaveLength(1);
|
|
186
|
+
expect(r.map["B"]!.depth).toBe(1);
|
|
187
|
+
expect(r.map["C"]!.depth).toBe(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("B and C share the same y", () => {
|
|
191
|
+
const r = makeFanOut();
|
|
192
|
+
expect(r.map["B"]!.y).toBe(r.map["C"]!.y);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("B and C y = yAt(1) + PAD", () => {
|
|
196
|
+
const r = makeFanOut();
|
|
197
|
+
expect(r.map["B"]!.y).toBe(yAt(1) + PAD);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("B is at x = PAD (leftmost leaf)", () => {
|
|
201
|
+
const r = makeFanOut();
|
|
202
|
+
// B placed first, cursor starts at 0 → x=0+PAD=PAD
|
|
203
|
+
expect(r.map["B"]!.x).toBe(PAD);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("C is at x = PAD + NODE_W + H_GAP (second leaf)", () => {
|
|
207
|
+
const r = makeFanOut();
|
|
208
|
+
expect(r.map["C"]!.x).toBe(PAD + NODE_W + H_GAP);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("A is horizontally centered above B and C", () => {
|
|
212
|
+
const r = makeFanOut();
|
|
213
|
+
const expectedX = Math.round((r.map["B"]!.x + r.map["C"]!.x) / 2);
|
|
214
|
+
expect(r.map["A"]!.x).toBe(expectedX);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("A center aligns with midpoint between B center and C center", () => {
|
|
218
|
+
const r = makeFanOut();
|
|
219
|
+
const aMid = r.map["A"]!.x + Math.floor(NODE_W / 2);
|
|
220
|
+
const bMid = r.map["B"]!.x + Math.floor(NODE_W / 2);
|
|
221
|
+
const cMid = r.map["C"]!.x + Math.floor(NODE_W / 2);
|
|
222
|
+
expect(aMid).toBe(Math.round((bMid + cMid) / 2));
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("A y < B y (parent above children)", () => {
|
|
226
|
+
const r = makeFanOut();
|
|
227
|
+
expect(r.map["A"]!.y).toBeLessThan(r.map["B"]!.y);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
test("all x values include PAD offset (x >= PAD)", () => {
|
|
231
|
+
const r = makeFanOut();
|
|
232
|
+
for (const node of Object.values(r.map)) {
|
|
233
|
+
expect(node.x).toBeGreaterThanOrEqual(PAD);
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// ─── 5. Fan-out three children: A → [B, C, D] ─
|
|
239
|
+
|
|
240
|
+
describe("fan-out three children: A → [B, C, D]", () => {
|
|
241
|
+
function makeFanOut3() {
|
|
242
|
+
return computeLayout([
|
|
243
|
+
session("A"),
|
|
244
|
+
session("B", ["A"]),
|
|
245
|
+
session("C", ["A"]),
|
|
246
|
+
session("D", ["A"]),
|
|
247
|
+
]);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
test("B, C, D at depth 1 with same y", () => {
|
|
251
|
+
const r = makeFanOut3();
|
|
252
|
+
expect(r.map["B"]!.depth).toBe(1);
|
|
253
|
+
expect(r.map["C"]!.depth).toBe(1);
|
|
254
|
+
expect(r.map["D"]!.depth).toBe(1);
|
|
255
|
+
expect(r.map["B"]!.y).toBe(r.map["C"]!.y);
|
|
256
|
+
expect(r.map["C"]!.y).toBe(r.map["D"]!.y);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("children are separated by NODE_W + H_GAP horizontally", () => {
|
|
260
|
+
const r = makeFanOut3();
|
|
261
|
+
expect(r.map["C"]!.x - r.map["B"]!.x).toBe(NODE_W + H_GAP);
|
|
262
|
+
expect(r.map["D"]!.x - r.map["C"]!.x).toBe(NODE_W + H_GAP);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("B at x = PAD, C at PAD + NODE_W + H_GAP, D at PAD + 2*(NODE_W + H_GAP)", () => {
|
|
266
|
+
const r = makeFanOut3();
|
|
267
|
+
expect(r.map["B"]!.x).toBe(PAD);
|
|
268
|
+
expect(r.map["C"]!.x).toBe(PAD + NODE_W + H_GAP);
|
|
269
|
+
expect(r.map["D"]!.x).toBe(PAD + 2 * (NODE_W + H_GAP));
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("A centered above B and D (outermost children)", () => {
|
|
273
|
+
const r = makeFanOut3();
|
|
274
|
+
// place(A) uses first child (B) and last child (D) to center
|
|
275
|
+
const expectedX = Math.round((r.map["B"]!.x + r.map["D"]!.x) / 2);
|
|
276
|
+
expect(r.map["A"]!.x).toBe(expectedX);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("A x = PAD + NODE_W + H_GAP (centered over 3 equally spaced children)", () => {
|
|
280
|
+
const r = makeFanOut3();
|
|
281
|
+
// B=0, C=42, D=84 before PAD → A center = round((0+84)/2)=42 → A.x=42+PAD=45
|
|
282
|
+
expect(r.map["A"]!.x).toBe(PAD + NODE_W + H_GAP);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
// ─── 6. Fan-in / merge node: A → C, B → C ────
|
|
287
|
+
|
|
288
|
+
describe("fan-in / merge node: A → C, B → C", () => {
|
|
289
|
+
function makeFanIn() {
|
|
290
|
+
return computeLayout([
|
|
291
|
+
session("A"),
|
|
292
|
+
session("B"),
|
|
293
|
+
session("C", ["A", "B"]),
|
|
294
|
+
]);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
test("A and B are roots, C is a merge node (not in roots)", () => {
|
|
298
|
+
const r = makeFanIn();
|
|
299
|
+
expect(r.roots).toHaveLength(2);
|
|
300
|
+
expect(r.roots.map((n) => n.name)).toContain("A");
|
|
301
|
+
expect(r.roots.map((n) => n.name)).toContain("B");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("C depth is max(parent depths) + 1 = 1", () => {
|
|
305
|
+
const r = makeFanIn();
|
|
306
|
+
expect(r.map["C"]!.depth).toBe(1);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("A and B have depth 0", () => {
|
|
310
|
+
const r = makeFanIn();
|
|
311
|
+
expect(r.map["A"]!.depth).toBe(0);
|
|
312
|
+
expect(r.map["B"]!.depth).toBe(0);
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
test("A and B share the same y = PAD", () => {
|
|
316
|
+
const r = makeFanIn();
|
|
317
|
+
expect(r.map["A"]!.y).toBe(PAD);
|
|
318
|
+
expect(r.map["B"]!.y).toBe(PAD);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("B x > A x (roots placed with gap between them)", () => {
|
|
322
|
+
const r = makeFanIn();
|
|
323
|
+
// A placed at cursor=0 → x=PAD; place(A) advances cursor by NODE_W+H_GAP=42;
|
|
324
|
+
// gap before B: cursor += H_GAP → cursor=48; B leaf: x=48+PAD=51
|
|
325
|
+
expect(r.map["A"]!.x).toBe(PAD);
|
|
326
|
+
expect(r.map["B"]!.x).toBe(PAD + NODE_W + 2 * H_GAP);
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("C is centered horizontally under A and B", () => {
|
|
330
|
+
const r = makeFanIn();
|
|
331
|
+
const aCenterX = r.map["A"]!.x + Math.floor(NODE_W / 2);
|
|
332
|
+
const bCenterX = r.map["B"]!.x + Math.floor(NODE_W / 2);
|
|
333
|
+
const expectedMid = Math.round((aCenterX + bCenterX) / 2);
|
|
334
|
+
const cCenterX = r.map["C"]!.x + Math.floor(NODE_W / 2);
|
|
335
|
+
expect(cCenterX).toBe(expectedMid);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("C y = yAt(1) + PAD", () => {
|
|
339
|
+
const r = makeFanIn();
|
|
340
|
+
expect(r.map["C"]!.y).toBe(yAt(1) + PAD);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
test("C y > A y (merge node below parents)", () => {
|
|
344
|
+
const r = makeFanIn();
|
|
345
|
+
expect(r.map["C"]!.y).toBeGreaterThan(r.map["A"]!.y);
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("C children array is empty (leaf merge node)", () => {
|
|
349
|
+
const r = makeFanIn();
|
|
350
|
+
expect(r.map["C"]!.children).toHaveLength(0);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test("C parents array preserved", () => {
|
|
354
|
+
const r = makeFanIn();
|
|
355
|
+
expect(r.map["C"]!.parents).toEqual(["A", "B"]);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ─── 7. Diamond: A → [B, C], [B, C] → D ──────
|
|
360
|
+
|
|
361
|
+
describe("diamond: A → [B, C], [B, C] → D", () => {
|
|
362
|
+
function makeDiamond() {
|
|
363
|
+
return computeLayout([
|
|
364
|
+
session("A"),
|
|
365
|
+
session("B", ["A"]),
|
|
366
|
+
session("C", ["A"]),
|
|
367
|
+
session("D", ["B", "C"]),
|
|
368
|
+
]);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
test("one root (A)", () => {
|
|
372
|
+
const r = makeDiamond();
|
|
373
|
+
expect(r.roots).toHaveLength(1);
|
|
374
|
+
expect(r.roots[0]!.name).toBe("A");
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("depths: A=0, B=1, C=1, D=2", () => {
|
|
378
|
+
const r = makeDiamond();
|
|
379
|
+
expect(r.map["A"]!.depth).toBe(0);
|
|
380
|
+
expect(r.map["B"]!.depth).toBe(1);
|
|
381
|
+
expect(r.map["C"]!.depth).toBe(1);
|
|
382
|
+
expect(r.map["D"]!.depth).toBe(2);
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("D depth = max(B.depth, C.depth) + 1 = 2", () => {
|
|
386
|
+
const r = makeDiamond();
|
|
387
|
+
expect(r.map["D"]!.depth).toBe(2);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
test("B and C at same y", () => {
|
|
391
|
+
const r = makeDiamond();
|
|
392
|
+
expect(r.map["B"]!.y).toBe(r.map["C"]!.y);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("y ordering: A < B < D", () => {
|
|
396
|
+
const r = makeDiamond();
|
|
397
|
+
expect(r.map["A"]!.y).toBeLessThan(r.map["B"]!.y);
|
|
398
|
+
expect(r.map["B"]!.y).toBeLessThan(r.map["D"]!.y);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("D is centered under B and C (merge node positioning)", () => {
|
|
402
|
+
const r = makeDiamond();
|
|
403
|
+
const bCenterX = r.map["B"]!.x + Math.floor(NODE_W / 2);
|
|
404
|
+
const cCenterX = r.map["C"]!.x + Math.floor(NODE_W / 2);
|
|
405
|
+
const expectedMid = Math.round((bCenterX + cCenterX) / 2);
|
|
406
|
+
const dCenterX = r.map["D"]!.x + Math.floor(NODE_W / 2);
|
|
407
|
+
expect(dCenterX).toBe(expectedMid);
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test("A is centered above B and C", () => {
|
|
411
|
+
const r = makeDiamond();
|
|
412
|
+
const expectedAx = Math.round((r.map["B"]!.x + r.map["C"]!.x) / 2);
|
|
413
|
+
expect(r.map["A"]!.x).toBe(expectedAx);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
test("exact positions: A=(24,3), B=(3,10), C=(45,10), D=(24,17)", () => {
|
|
417
|
+
// B at cursor=0 → x=0+PAD=3; C at cursor=42 → x=42+PAD=45
|
|
418
|
+
// A: round((0+42)/2)+PAD=21+3=24
|
|
419
|
+
// D: centers=[0+18, 42+18]=[18,60], avg=round(39)=39, x=39-18=21, +PAD=24
|
|
420
|
+
const r = makeDiamond();
|
|
421
|
+
expect(r.map["A"]!.x).toBe(24);
|
|
422
|
+
expect(r.map["A"]!.y).toBe(3);
|
|
423
|
+
expect(r.map["B"]!.x).toBe(3);
|
|
424
|
+
expect(r.map["B"]!.y).toBe(10);
|
|
425
|
+
expect(r.map["C"]!.x).toBe(45);
|
|
426
|
+
expect(r.map["C"]!.y).toBe(10);
|
|
427
|
+
expect(r.map["D"]!.x).toBe(24);
|
|
428
|
+
expect(r.map["D"]!.y).toBe(17);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
test("D children is empty", () => {
|
|
432
|
+
const r = makeDiamond();
|
|
433
|
+
expect(r.map["D"]!.children).toHaveLength(0);
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
test("rowH has entries for depths 0, 1, 2", () => {
|
|
437
|
+
const r = makeDiamond();
|
|
438
|
+
expect(r.rowH[0]).toBe(NODE_H);
|
|
439
|
+
expect(r.rowH[1]).toBe(NODE_H);
|
|
440
|
+
expect(r.rowH[2]).toBe(NODE_H);
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
|
|
444
|
+
// ─── 8. Multiple independent roots ────────────
|
|
445
|
+
|
|
446
|
+
describe("multiple independent roots: A, B", () => {
|
|
447
|
+
function makeMultiRoot() {
|
|
448
|
+
return computeLayout([session("A"), session("B")]);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
test("both are roots", () => {
|
|
452
|
+
const r = makeMultiRoot();
|
|
453
|
+
expect(r.roots).toHaveLength(2);
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
test("root order matches insertion order", () => {
|
|
457
|
+
const r = makeMultiRoot();
|
|
458
|
+
expect(r.roots[0]!.name).toBe("A");
|
|
459
|
+
expect(r.roots[1]!.name).toBe("B");
|
|
460
|
+
});
|
|
461
|
+
|
|
462
|
+
test("both at depth 0", () => {
|
|
463
|
+
const r = makeMultiRoot();
|
|
464
|
+
expect(r.map["A"]!.depth).toBe(0);
|
|
465
|
+
expect(r.map["B"]!.depth).toBe(0);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test("both at same y = PAD", () => {
|
|
469
|
+
const r = makeMultiRoot();
|
|
470
|
+
expect(r.map["A"]!.y).toBe(PAD);
|
|
471
|
+
expect(r.map["B"]!.y).toBe(PAD);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test("A at x = PAD (first root placed at cursor=0)", () => {
|
|
475
|
+
const r = makeMultiRoot();
|
|
476
|
+
expect(r.map["A"]!.x).toBe(PAD);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test("B at x = PAD + NODE_W + 2*H_GAP (gap inserted between roots)", () => {
|
|
480
|
+
const r = makeMultiRoot();
|
|
481
|
+
// After A: cursor=NODE_W+H_GAP=42; gap before B: cursor+=H_GAP → 48; B leaf: x=48+PAD=51
|
|
482
|
+
expect(r.map["B"]!.x).toBe(PAD + NODE_W + 2 * H_GAP);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
test("B x > A x", () => {
|
|
486
|
+
const r = makeMultiRoot();
|
|
487
|
+
expect(r.map["B"]!.x).toBeGreaterThan(r.map["A"]!.x);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test("horizontal separation between leaf roots = NODE_W + 2*H_GAP", () => {
|
|
491
|
+
const r = makeMultiRoot();
|
|
492
|
+
// place(A): A leaf at cursor=0, cursor → NODE_W+H_GAP=42
|
|
493
|
+
// gap before B: cursor += H_GAP → 48
|
|
494
|
+
// place(B): B leaf at cursor=48
|
|
495
|
+
// Separation (before PAD): 48 - 0 = NODE_W + 2*H_GAP
|
|
496
|
+
expect(r.map["B"]!.x - r.map["A"]!.x).toBe(NODE_W + 2 * H_GAP);
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
test("three independent roots are each separated by NODE_W + 2*H_GAP", () => {
|
|
500
|
+
const r = computeLayout([session("X"), session("Y"), session("Z")]);
|
|
501
|
+
expect(r.roots).toHaveLength(3);
|
|
502
|
+
// X at 0, cursor→42; gap→48; Y at 48, cursor→90; gap→96; Z at 96
|
|
503
|
+
// After PAD: X=PAD, Y=48+PAD, Z=96+PAD
|
|
504
|
+
expect(r.map["X"]!.x).toBe(PAD);
|
|
505
|
+
expect(r.map["Y"]!.x - r.map["X"]!.x).toBe(NODE_W + 2 * H_GAP);
|
|
506
|
+
expect(r.map["Z"]!.x - r.map["Y"]!.x).toBe(NODE_W + 2 * H_GAP);
|
|
507
|
+
});
|
|
508
|
+
});
|
|
509
|
+
|
|
510
|
+
// ─── 9. Deep chain: A → B → C → D → E ────────
|
|
511
|
+
|
|
512
|
+
describe("deep chain: A → B → C → D → E", () => {
|
|
513
|
+
function makeDeepChain() {
|
|
514
|
+
return computeLayout([
|
|
515
|
+
session("A"),
|
|
516
|
+
session("B", ["A"]),
|
|
517
|
+
session("C", ["B"]),
|
|
518
|
+
session("D", ["C"]),
|
|
519
|
+
session("E", ["D"]),
|
|
520
|
+
]);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
test("depths 0 through 4", () => {
|
|
524
|
+
const r = makeDeepChain();
|
|
525
|
+
expect(r.map["A"]!.depth).toBe(0);
|
|
526
|
+
expect(r.map["B"]!.depth).toBe(1);
|
|
527
|
+
expect(r.map["C"]!.depth).toBe(2);
|
|
528
|
+
expect(r.map["D"]!.depth).toBe(3);
|
|
529
|
+
expect(r.map["E"]!.depth).toBe(4);
|
|
530
|
+
});
|
|
531
|
+
|
|
532
|
+
test("all nodes share x = PAD (single chain)", () => {
|
|
533
|
+
const r = makeDeepChain();
|
|
534
|
+
for (const name of ["A", "B", "C", "D", "E"]) {
|
|
535
|
+
expect(r.map[name]!.x).toBe(PAD);
|
|
536
|
+
}
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
test("y increases by NODE_H + V_GAP each level", () => {
|
|
540
|
+
const r = makeDeepChain();
|
|
541
|
+
const step = NODE_H + V_GAP;
|
|
542
|
+
expect(r.map["A"]!.y).toBe(PAD + yAt(0));
|
|
543
|
+
expect(r.map["B"]!.y).toBe(PAD + yAt(1));
|
|
544
|
+
expect(r.map["C"]!.y).toBe(PAD + yAt(2));
|
|
545
|
+
expect(r.map["D"]!.y).toBe(PAD + yAt(3));
|
|
546
|
+
expect(r.map["E"]!.y).toBe(PAD + yAt(4));
|
|
547
|
+
// Consecutive differences
|
|
548
|
+
expect(r.map["B"]!.y - r.map["A"]!.y).toBe(step);
|
|
549
|
+
expect(r.map["C"]!.y - r.map["B"]!.y).toBe(step);
|
|
550
|
+
expect(r.map["D"]!.y - r.map["C"]!.y).toBe(step);
|
|
551
|
+
expect(r.map["E"]!.y - r.map["D"]!.y).toBe(step);
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
test("exact y values: 3, 10, 17, 24, 31", () => {
|
|
555
|
+
const r = makeDeepChain();
|
|
556
|
+
expect(r.map["A"]!.y).toBe(3);
|
|
557
|
+
expect(r.map["B"]!.y).toBe(10);
|
|
558
|
+
expect(r.map["C"]!.y).toBe(17);
|
|
559
|
+
expect(r.map["D"]!.y).toBe(24);
|
|
560
|
+
expect(r.map["E"]!.y).toBe(31);
|
|
561
|
+
});
|
|
562
|
+
|
|
563
|
+
test("rowH has 5 entries all equal to NODE_H", () => {
|
|
564
|
+
const r = makeDeepChain();
|
|
565
|
+
for (let d = 0; d < 5; d++) {
|
|
566
|
+
expect(r.rowH[d]).toBe(NODE_H);
|
|
567
|
+
}
|
|
568
|
+
expect(Object.keys(r.rowH)).toHaveLength(5);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
test("height = E.y + NODE_H + PAD", () => {
|
|
572
|
+
const r = makeDeepChain();
|
|
573
|
+
const e = r.map["E"]!;
|
|
574
|
+
expect(r.height).toBe(e.y + NODE_H + PAD);
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
test("width = A.x + NODE_W + PAD (single column)", () => {
|
|
578
|
+
const r = makeDeepChain();
|
|
579
|
+
expect(r.width).toBe(PAD + NODE_W + PAD);
|
|
580
|
+
});
|
|
581
|
+
});
|
|
582
|
+
|
|
583
|
+
// ─── 10. Merge node with children ─────────────
|
|
584
|
+
|
|
585
|
+
describe("merge node with children: A → C, B → C → D", () => {
|
|
586
|
+
// A and B are roots; C is merge node (parents=[A,B]) with child D
|
|
587
|
+
function makeMergeWithChild() {
|
|
588
|
+
return computeLayout([
|
|
589
|
+
session("A"),
|
|
590
|
+
session("B"),
|
|
591
|
+
session("C", ["A", "B"]),
|
|
592
|
+
session("D", ["C"]),
|
|
593
|
+
]);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
test("A and B are roots, C is merge node not in roots", () => {
|
|
597
|
+
const r = makeMergeWithChild();
|
|
598
|
+
expect(r.roots).toHaveLength(2);
|
|
599
|
+
const rootNames = r.roots.map((n) => n.name);
|
|
600
|
+
expect(rootNames).toContain("A");
|
|
601
|
+
expect(rootNames).toContain("B");
|
|
602
|
+
expect(rootNames).not.toContain("C");
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
test("depths: A=0, B=0, C=1, D=2", () => {
|
|
606
|
+
const r = makeMergeWithChild();
|
|
607
|
+
expect(r.map["A"]!.depth).toBe(0);
|
|
608
|
+
expect(r.map["B"]!.depth).toBe(0);
|
|
609
|
+
expect(r.map["C"]!.depth).toBe(1);
|
|
610
|
+
expect(r.map["D"]!.depth).toBe(2);
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
test("C center is horizontally aligned with midpoint of A and B centers", () => {
|
|
614
|
+
const r = makeMergeWithChild();
|
|
615
|
+
const aCenterX = r.map["A"]!.x + Math.floor(NODE_W / 2);
|
|
616
|
+
const bCenterX = r.map["B"]!.x + Math.floor(NODE_W / 2);
|
|
617
|
+
const expectedMid = Math.round((aCenterX + bCenterX) / 2);
|
|
618
|
+
const cCenterX = r.map["C"]!.x + Math.floor(NODE_W / 2);
|
|
619
|
+
expect(cCenterX).toBe(expectedMid);
|
|
620
|
+
});
|
|
621
|
+
|
|
622
|
+
test("D is directly below C (same x as C after shift)", () => {
|
|
623
|
+
const r = makeMergeWithChild();
|
|
624
|
+
// D is a single child of C → centered over D means D.x = C.x after shift
|
|
625
|
+
expect(r.map["D"]!.x).toBe(r.map["C"]!.x);
|
|
626
|
+
});
|
|
627
|
+
|
|
628
|
+
test("D y = yAt(2) + PAD", () => {
|
|
629
|
+
const r = makeMergeWithChild();
|
|
630
|
+
expect(r.map["D"]!.y).toBe(yAt(2) + PAD);
|
|
631
|
+
});
|
|
632
|
+
|
|
633
|
+
test("exact positions: A=(3,3), B=(51,3), C=(27,10), D=(27,17)", () => {
|
|
634
|
+
// A placed at cursor=0 → x=PAD=3; after A: cursor=42; gap: cursor=48; B at 48 → x=51
|
|
635
|
+
// C merge, has child D:
|
|
636
|
+
// place(C) → place(D) at cursor=90 → D.x=90; C centered: x=90; currentCenter=108
|
|
637
|
+
// parentCenters=[18,66], avg=42; dx=42-108=-66
|
|
638
|
+
// C.x=90-66=24 → +PAD=27; D.x=90-66=24 → +PAD=27
|
|
639
|
+
const r = makeMergeWithChild();
|
|
640
|
+
expect(r.map["A"]!.x).toBe(3);
|
|
641
|
+
expect(r.map["A"]!.y).toBe(3);
|
|
642
|
+
expect(r.map["B"]!.x).toBe(51);
|
|
643
|
+
expect(r.map["B"]!.y).toBe(3);
|
|
644
|
+
expect(r.map["C"]!.x).toBe(27);
|
|
645
|
+
expect(r.map["C"]!.y).toBe(10);
|
|
646
|
+
expect(r.map["D"]!.x).toBe(27);
|
|
647
|
+
expect(r.map["D"]!.y).toBe(17);
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("all x values are >= PAD", () => {
|
|
651
|
+
const r = makeMergeWithChild();
|
|
652
|
+
for (const node of Object.values(r.map)) {
|
|
653
|
+
expect(node.x).toBeGreaterThanOrEqual(PAD);
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
|
|
657
|
+
test("width >= rightmost node x + NODE_W + PAD", () => {
|
|
658
|
+
const r = makeMergeWithChild();
|
|
659
|
+
const rightmost = Math.max(...Object.values(r.map).map((n) => n.x + NODE_W));
|
|
660
|
+
expect(r.width).toBe(rightmost + PAD);
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("height >= bottommost node y + NODE_H + PAD", () => {
|
|
664
|
+
const r = makeMergeWithChild();
|
|
665
|
+
const bottommost = Math.max(...Object.values(r.map).map((n) => n.y + NODE_H));
|
|
666
|
+
expect(r.height).toBe(bottommost + PAD);
|
|
667
|
+
});
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
// ─── 11. Collision detection: merge-node shift causes overlap ─
|
|
671
|
+
|
|
672
|
+
describe("collision detection: A→B, C, [A,C]→M→M1", () => {
|
|
673
|
+
// A has child B; C is a standalone root; M is a merge node (parents=[A,C])
|
|
674
|
+
// with child M1. Without collision detection, shifting M to center under
|
|
675
|
+
// A and C would cause M to overlap with B at depth 1.
|
|
676
|
+
function makeOverlap() {
|
|
677
|
+
return computeLayout([
|
|
678
|
+
session("A"),
|
|
679
|
+
session("B", ["A"]),
|
|
680
|
+
session("C"),
|
|
681
|
+
session("M", ["A", "C"]),
|
|
682
|
+
session("M1", ["M"]),
|
|
683
|
+
]);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
test("no nodes at the same depth overlap horizontally", () => {
|
|
687
|
+
const r = makeOverlap();
|
|
688
|
+
const byDepth: Record<number, Array<{ name: string; x: number }>> = {};
|
|
689
|
+
for (const n of Object.values(r.map)) {
|
|
690
|
+
(byDepth[n.depth] ??= []).push({ name: n.name, x: n.x });
|
|
691
|
+
}
|
|
692
|
+
for (const nodes of Object.values(byDepth)) {
|
|
693
|
+
nodes.sort((a, b) => a.x - b.x);
|
|
694
|
+
for (let i = 1; i < nodes.length; i++) {
|
|
695
|
+
const prev = nodes[i - 1]!;
|
|
696
|
+
const curr = nodes[i]!;
|
|
697
|
+
expect(curr.x).toBeGreaterThanOrEqual(
|
|
698
|
+
prev.x + NODE_W + H_GAP,
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("M and B are both at depth 1 but do not overlap", () => {
|
|
705
|
+
const r = makeOverlap();
|
|
706
|
+
expect(r.map["B"]!.depth).toBe(1);
|
|
707
|
+
expect(r.map["M"]!.depth).toBe(1);
|
|
708
|
+
const gap = Math.abs(r.map["M"]!.x - r.map["B"]!.x);
|
|
709
|
+
expect(gap).toBeGreaterThanOrEqual(NODE_W + H_GAP);
|
|
710
|
+
});
|
|
711
|
+
|
|
712
|
+
test("M1 is a child of M (depth 2)", () => {
|
|
713
|
+
const r = makeOverlap();
|
|
714
|
+
expect(r.map["M1"]!.depth).toBe(2);
|
|
715
|
+
});
|
|
716
|
+
|
|
717
|
+
test("all x values are >= PAD after collision resolution", () => {
|
|
718
|
+
const r = makeOverlap();
|
|
719
|
+
for (const node of Object.values(r.map)) {
|
|
720
|
+
expect(node.x).toBeGreaterThanOrEqual(PAD);
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
});
|
|
724
|
+
|
|
725
|
+
// ─── 12. Status and error propagation ─────────
|
|
726
|
+
|
|
727
|
+
describe("status and error propagation", () => {
|
|
728
|
+
test("preserves 'pending' status", () => {
|
|
729
|
+
const r = computeLayout([session("A", [], "pending")]);
|
|
730
|
+
expect(r.map["A"]!.status).toBe("pending");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
test("preserves 'running' status", () => {
|
|
734
|
+
const r = computeLayout([session("A", [], "running")]);
|
|
735
|
+
expect(r.map["A"]!.status).toBe("running");
|
|
736
|
+
});
|
|
737
|
+
|
|
738
|
+
test("preserves 'complete' status", () => {
|
|
739
|
+
const r = computeLayout([session("A", [], "complete")]);
|
|
740
|
+
expect(r.map["A"]!.status).toBe("complete");
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
test("preserves 'error' status", () => {
|
|
744
|
+
const r = computeLayout([session("A", [], "error")]);
|
|
745
|
+
expect(r.map["A"]!.status).toBe("error");
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("preserves error message", () => {
|
|
749
|
+
const r = computeLayout([
|
|
750
|
+
session("A", [], "error", { error: "something went wrong" }),
|
|
751
|
+
]);
|
|
752
|
+
expect(r.map["A"]!.error).toBe("something went wrong");
|
|
753
|
+
});
|
|
754
|
+
|
|
755
|
+
test("error is undefined when not provided", () => {
|
|
756
|
+
const r = computeLayout([session("A")]);
|
|
757
|
+
expect(r.map["A"]!.error).toBeUndefined();
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
test("preserves startedAt timestamp", () => {
|
|
761
|
+
const r = computeLayout([
|
|
762
|
+
session("A", [], "running", { startedAt: 1700000000000 }),
|
|
763
|
+
]);
|
|
764
|
+
expect(r.map["A"]!.startedAt).toBe(1700000000000);
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test("preserves endedAt timestamp", () => {
|
|
768
|
+
const r = computeLayout([
|
|
769
|
+
session("A", [], "complete", { startedAt: 100, endedAt: 200 }),
|
|
770
|
+
]);
|
|
771
|
+
expect(r.map["A"]!.endedAt).toBe(200);
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
test("startedAt and endedAt are null when not provided", () => {
|
|
775
|
+
const r = computeLayout([session("A")]);
|
|
776
|
+
expect(r.map["A"]!.startedAt).toBeNull();
|
|
777
|
+
expect(r.map["A"]!.endedAt).toBeNull();
|
|
778
|
+
});
|
|
779
|
+
|
|
780
|
+
test("preserves all fields on a node with mixed statuses in the graph", () => {
|
|
781
|
+
const sessions: SessionData[] = [
|
|
782
|
+
{ name: "start", status: "complete", parents: [], startedAt: 1000, endedAt: 2000 },
|
|
783
|
+
{ name: "mid", status: "running", parents: ["start"], startedAt: 2001, endedAt: null },
|
|
784
|
+
{ name: "end", status: "error", parents: ["mid"], error: "timed out", startedAt: 3000, endedAt: 3500 },
|
|
785
|
+
];
|
|
786
|
+
const r = computeLayout(sessions);
|
|
787
|
+
|
|
788
|
+
expect(r.map["start"]!.status).toBe("complete");
|
|
789
|
+
expect(r.map["start"]!.startedAt).toBe(1000);
|
|
790
|
+
expect(r.map["start"]!.endedAt).toBe(2000);
|
|
791
|
+
expect(r.map["start"]!.error).toBeUndefined();
|
|
792
|
+
|
|
793
|
+
expect(r.map["mid"]!.status).toBe("running");
|
|
794
|
+
expect(r.map["mid"]!.startedAt).toBe(2001);
|
|
795
|
+
expect(r.map["mid"]!.endedAt).toBeNull();
|
|
796
|
+
|
|
797
|
+
expect(r.map["end"]!.status).toBe("error");
|
|
798
|
+
expect(r.map["end"]!.error).toBe("timed out");
|
|
799
|
+
expect(r.map["end"]!.startedAt).toBe(3000);
|
|
800
|
+
expect(r.map["end"]!.endedAt).toBe(3500);
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
test("preserves parents array on layout nodes", () => {
|
|
804
|
+
const r = computeLayout([
|
|
805
|
+
session("A"),
|
|
806
|
+
session("B"),
|
|
807
|
+
session("C", ["A", "B"]),
|
|
808
|
+
]);
|
|
809
|
+
expect(r.map["C"]!.parents).toEqual(["A", "B"]);
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
test("preserves name on layout nodes", () => {
|
|
813
|
+
const r = computeLayout([session("my-session-name")]);
|
|
814
|
+
expect(r.map["my-session-name"]!.name).toBe("my-session-name");
|
|
815
|
+
});
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
// ─── Invariants applying to all layouts ───────
|
|
819
|
+
|
|
820
|
+
describe("layout invariants", () => {
|
|
821
|
+
const TOPOLOGIES = [
|
|
822
|
+
{
|
|
823
|
+
name: "single node",
|
|
824
|
+
sessions: () => [session("A")],
|
|
825
|
+
},
|
|
826
|
+
{
|
|
827
|
+
name: "linear chain",
|
|
828
|
+
sessions: () => [session("A"), session("B", ["A"]), session("C", ["B"])],
|
|
829
|
+
},
|
|
830
|
+
{
|
|
831
|
+
name: "fan-out",
|
|
832
|
+
sessions: () => [session("A"), session("B", ["A"]), session("C", ["A"])],
|
|
833
|
+
},
|
|
834
|
+
{
|
|
835
|
+
name: "fan-in merge",
|
|
836
|
+
sessions: () => [session("A"), session("B"), session("C", ["A", "B"])],
|
|
837
|
+
},
|
|
838
|
+
{
|
|
839
|
+
name: "diamond",
|
|
840
|
+
sessions: () => [
|
|
841
|
+
session("A"),
|
|
842
|
+
session("B", ["A"]),
|
|
843
|
+
session("C", ["A"]),
|
|
844
|
+
session("D", ["B", "C"]),
|
|
845
|
+
],
|
|
846
|
+
},
|
|
847
|
+
{
|
|
848
|
+
name: "multiple roots",
|
|
849
|
+
sessions: () => [session("X"), session("Y"), session("Z")],
|
|
850
|
+
},
|
|
851
|
+
{
|
|
852
|
+
name: "merge-node overlap (A→B, C, [A,C]→M→M1)",
|
|
853
|
+
sessions: () => [
|
|
854
|
+
session("A"),
|
|
855
|
+
session("B", ["A"]),
|
|
856
|
+
session("C"),
|
|
857
|
+
session("M", ["A", "C"]),
|
|
858
|
+
session("M1", ["M"]),
|
|
859
|
+
],
|
|
860
|
+
},
|
|
861
|
+
];
|
|
862
|
+
|
|
863
|
+
for (const topo of TOPOLOGIES) {
|
|
864
|
+
test(`[${topo.name}] all x values are >= PAD`, () => {
|
|
865
|
+
const r = computeLayout(topo.sessions());
|
|
866
|
+
for (const node of Object.values(r.map)) {
|
|
867
|
+
expect(node.x).toBeGreaterThanOrEqual(PAD);
|
|
868
|
+
}
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
test(`[${topo.name}] all y values are >= PAD`, () => {
|
|
872
|
+
const r = computeLayout(topo.sessions());
|
|
873
|
+
for (const node of Object.values(r.map)) {
|
|
874
|
+
expect(node.y).toBeGreaterThanOrEqual(PAD);
|
|
875
|
+
}
|
|
876
|
+
});
|
|
877
|
+
|
|
878
|
+
test(`[${topo.name}] width = max(node.x + NODE_W) + PAD`, () => {
|
|
879
|
+
const r = computeLayout(topo.sessions());
|
|
880
|
+
const rightmost = Math.max(...Object.values(r.map).map((n) => n.x + NODE_W));
|
|
881
|
+
expect(r.width).toBe(rightmost + PAD);
|
|
882
|
+
});
|
|
883
|
+
|
|
884
|
+
test(`[${topo.name}] height = max(node.y + NODE_H) + PAD`, () => {
|
|
885
|
+
const r = computeLayout(topo.sessions());
|
|
886
|
+
const bottommost = Math.max(...Object.values(r.map).map((n) => n.y + NODE_H));
|
|
887
|
+
expect(r.height).toBe(bottommost + PAD);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
test(`[${topo.name}] nodes at same depth share the same y`, () => {
|
|
891
|
+
const r = computeLayout(topo.sessions());
|
|
892
|
+
const byDepth: Record<number, number[]> = {};
|
|
893
|
+
for (const node of Object.values(r.map)) {
|
|
894
|
+
(byDepth[node.depth] ??= []).push(node.y);
|
|
895
|
+
}
|
|
896
|
+
for (const ys of Object.values(byDepth)) {
|
|
897
|
+
const first = ys[0]!;
|
|
898
|
+
for (const y of ys) expect(y).toBe(first);
|
|
899
|
+
}
|
|
900
|
+
});
|
|
901
|
+
|
|
902
|
+
test(`[${topo.name}] rowH[d] = NODE_H for all used depths`, () => {
|
|
903
|
+
const r = computeLayout(topo.sessions());
|
|
904
|
+
for (const d of Object.keys(r.rowH)) {
|
|
905
|
+
expect(r.rowH[Number(d)]).toBe(NODE_H);
|
|
906
|
+
}
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
test(`[${topo.name}] no horizontal overlap between nodes at same depth`, () => {
|
|
910
|
+
const r = computeLayout(topo.sessions());
|
|
911
|
+
const byDepth: Record<number, number[]> = {};
|
|
912
|
+
for (const node of Object.values(r.map)) {
|
|
913
|
+
(byDepth[node.depth] ??= []).push(node.x);
|
|
914
|
+
}
|
|
915
|
+
for (const xs of Object.values(byDepth)) {
|
|
916
|
+
xs.sort((a, b) => a - b);
|
|
917
|
+
for (let i = 1; i < xs.length; i++) {
|
|
918
|
+
expect(xs[i]! - xs[i - 1]!).toBeGreaterThanOrEqual(NODE_W + H_GAP);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
});
|
|
922
|
+
}
|
|
923
|
+
});
|
|
924
|
+
});
|