@principal-ai/subsystems-react 0.35.7 → 0.36.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.
Files changed (46) hide show
  1. package/dist/index.d.ts +1 -0
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +1 -0
  4. package/dist/index.js.map +1 -1
  5. package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts.map +1 -1
  6. package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js.map +1 -1
  7. package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -1
  8. package/dist/subsystem/SubsystemAggregateGraph.js +152 -21
  9. package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -1
  10. package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
  11. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  12. package/dist/subsystem/SubsystemComponentGraph.js +9 -5
  13. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  14. package/dist/subsystem/graphChrome.d.ts +15 -0
  15. package/dist/subsystem/graphChrome.d.ts.map +1 -1
  16. package/dist/subsystem/graphChrome.js +17 -0
  17. package/dist/subsystem/graphChrome.js.map +1 -1
  18. package/dist/subsystem/model.d.ts +26 -0
  19. package/dist/subsystem/model.d.ts.map +1 -1
  20. package/dist/subsystem/model.js +90 -2
  21. package/dist/subsystem/model.js.map +1 -1
  22. package/dist/subsystem/nodes.d.ts.map +1 -1
  23. package/dist/subsystem/nodes.js +12 -9
  24. package/dist/subsystem/nodes.js.map +1 -1
  25. package/dist/utils/elkLayout.d.ts +45 -0
  26. package/dist/utils/elkLayout.d.ts.map +1 -1
  27. package/dist/utils/elkLayout.js +114 -84
  28. package/dist/utils/elkLayout.js.map +1 -1
  29. package/package.json +1 -1
  30. package/src/graphify/signature.test.ts +1 -1
  31. package/src/index.ts +1 -0
  32. package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/AggregateFrameGraph.stories.tsx +1 -1
  33. package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +4 -4
  34. package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +5 -5
  35. package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +5 -5
  36. package/src/subsystem/SubsystemAggregateGraph.tsx +191 -21
  37. package/src/subsystem/SubsystemComponentGraph.tsx +18 -4
  38. package/src/subsystem/graphChrome.tsx +20 -0
  39. package/src/subsystem/model.test.ts +91 -0
  40. package/src/subsystem/model.ts +94 -2
  41. package/src/subsystem/nodes.tsx +12 -9
  42. package/src/utils/elkLayout.test.ts +117 -3
  43. package/src/utils/elkLayout.ts +150 -81
  44. /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts +0 -0
  45. /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js +0 -0
  46. /package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.ts +0 -0
@@ -30,6 +30,26 @@ export const GRAPH_NAV_PROPS = {
30
30
  edgesReconnectable: false,
31
31
  } satisfies Partial<ReactFlowProps>;
32
32
 
33
+ /**
34
+ * React Flow mounts its edge SVG (`.react-flow__edges`) and node layer
35
+ * (`.react-flow__nodes`) as sibling containers inside the viewport, both at
36
+ * `z-index: auto`, and leans on DOM order (edges first, nodes second) to keep
37
+ * nodes on top. Nodes carry an inline `z-index: 0`, which on its own does not
38
+ * reliably beat the edge layer, so an edge routing into a boundary frame's top
39
+ * border paints *over* that frame's label. Lift the whole node layer above the
40
+ * edge layer once for every graph that spreads `GRAPH_NAV_PROPS`.
41
+ *
42
+ * Scoped by `GRAPH_CANVAS_CLASS` so it only touches our canvases.
43
+ */
44
+ export const GRAPH_CANVAS_CLASS = 'subsystem-graph-canvas';
45
+
46
+ export const GRAPH_LAYER_CSS = `.${GRAPH_CANVAS_CLASS} .react-flow__nodes { z-index: 1; }`;
47
+
48
+ /** Scoped style tag for `GRAPH_LAYER_CSS`; render once beside `<ReactFlow>`. */
49
+ export function GraphLayerStyle() {
50
+ return <style>{GRAPH_LAYER_CSS}</style>;
51
+ }
52
+
33
53
  /** Background dots + zoom/fit/interactive widget; render inside `<ReactFlow>`. */
34
54
  export function GraphChrome() {
35
55
  return (
@@ -435,6 +435,97 @@ describe('subsystem graph model', () => {
435
435
  expect(nodes.find((n) => n.id === processGroupNodeId('app/lonely'))).toBeUndefined();
436
436
  });
437
437
 
438
+ // NOTE: buildSubsystemGraph cannot assert ELK-built frames here — elkjs
439
+ // constructs a Worker, which bun's test env lacks, so every call falls back
440
+ // to manual positions. Frame existence is covered by planCompoundGroups
441
+ // tests in utils/elkLayout.test.ts; these cover the grouping inputs.
442
+
443
+ test('buildBoundaryLayoutGroups keeps singleton regions only when asked', () => {
444
+ const doc = {
445
+ components: [
446
+ { alias: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
447
+ { alias: 'b', name: 'b', construct: 'function', file: 'b.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
448
+ { alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
449
+ ],
450
+ };
451
+ const off = buildBoundaryLayoutGroups(doc as never);
452
+ expect(off.map((g) => g.id)).not.toContain(processGroupNodeId('app/lonely'));
453
+
454
+ const on = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: true });
455
+ const lonely = on.find((g) => g.id === processGroupNodeId('app/lonely'));
456
+ expect(lonely).toBeDefined();
457
+ expect(lonely!.memberAliases).toEqual(['solo']);
458
+ });
459
+
460
+ test('subsystem component graph keeps singleton frames unless opted out', () => {
461
+ // The component graph's default differs from buildBoundaryLayoutGroups'
462
+ // own default: SubsystemComponentGraph passes showSingletonFrames=true so
463
+ // a one-file process keeps its boundary, matching the aggregate graph.
464
+ // Only an explicit false falls back to the 2+ member rule.
465
+ const doc = {
466
+ components: [
467
+ { alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
468
+ ],
469
+ };
470
+ // Mirrors the Inner default: `showSingletonFrames = true`.
471
+ const componentGraphDefault = true;
472
+ const kept = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: componentGraphDefault });
473
+ expect(kept.map((g) => g.id)).toContain(processGroupNodeId('app/lonely'));
474
+
475
+ const optedOut = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: false });
476
+ expect(optedOut.map((g) => g.id)).not.toContain(processGroupNodeId('app/lonely'));
477
+ });
478
+
479
+ test('buildBoundaryLayoutGroups parents every leaf to exactly one frame', () => {
480
+ // The invariant that keeps ELK from throwing "value already present".
481
+ // Singletons do not claim (see buildBoundaryLayoutGroups): they always
482
+ // nest, so the enclosing frame's own filter has to exclude them. This
483
+ // asserts the outcome rather than the mechanism.
484
+ const doc = {
485
+ components: [
486
+ { alias: 'solo', name: 'solo', construct: 'function', file: 's.ts', purl: 'pkg:github/acme/app', process: 'app/lonely' },
487
+ { alias: 'x', name: 'x', construct: 'function', file: 'x.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
488
+ { alias: 'y', name: 'y', construct: 'function', file: 'y.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
489
+ { alias: 'z', name: 'z', construct: 'function', file: 'z.ts', purl: 'pkg:github/acme/other', process: 'other/host' },
490
+ { alias: 'w', name: 'w', construct: 'function', file: 'w.ts', purl: 'pkg:github/acme/other', process: 'other/host' },
491
+ // module-less leaf in a multi-member process
492
+ { alias: 'loose', name: 'loose', construct: 'function', file: 'l.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
493
+ ],
494
+ };
495
+ const groups = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: true });
496
+ const owners = new Map<string, string[]>();
497
+ for (const g of groups) {
498
+ for (const alias of g.memberAliases) {
499
+ owners.set(alias, [...(owners.get(alias) ?? []), g.id]);
500
+ }
501
+ }
502
+ for (const [alias, claimedBy] of owners) {
503
+ expect(new Set(claimedBy).size, `leaf ${alias} claimed by ${claimedBy.join(', ')}`).toBe(1);
504
+ }
505
+ // The singleton process nests under its package rather than claiming.
506
+ const lonely = groups.find((g) => g.id === processGroupNodeId('app/lonely'));
507
+ expect(lonely!.memberAliases).toEqual(['solo']);
508
+ expect(lonely!.parentId).toBe(packageGroupNodeId('pkg:github/acme/app'));
509
+ });
510
+
511
+ test('buildBoundaryLayoutGroups singleton module claims its leaf from the process', () => {
512
+ const doc = {
513
+ components: [
514
+ { alias: 'a', name: 'a', construct: 'function', file: 'a.ts', purl: 'pkg:github/acme/app', process: 'app/host' },
515
+ { alias: 'm1', name: 'm1', construct: 'function', file: 'm1.ts', purl: 'pkg:github/acme/app', module: 'src/one.ts', process: 'app/host' },
516
+ { alias: 'm2', name: 'm2', construct: 'function', file: 'm1.ts', purl: 'pkg:github/acme/app', module: 'src/one.ts', process: 'app/host' },
517
+ ],
518
+ };
519
+ const groups = buildBoundaryLayoutGroups(doc as never, { showSingletonFrames: true });
520
+ const mod = groups.find((g) => g.id === moduleGroupNodeId('src/one.ts'));
521
+ const proc = groups.find((g) => g.id === processGroupNodeId('app/host'));
522
+ expect(mod!.memberAliases).toEqual(['m1', 'm2']);
523
+ // The module is nested, so the process lists the module — not its
524
+ // leaves. `a` has no module, so it stays a direct leaf of the process.
525
+ expect(proc!.memberAliases).toEqual([moduleGroupNodeId('src/one.ts'), 'a']);
526
+ expect(proc!.parentId).toBeUndefined();
527
+ });
528
+
438
529
  test('buildSubsystemGraph frames multi-member modules', async () => {
439
530
  const { nodes, regions } = await buildSubsystemGraph({
440
531
  components: [
@@ -824,6 +824,13 @@ export function buildBoundaryLayoutGroups(
824
824
  // ("value already present"). Mixed-process modules (common in composed
825
825
  // graphs, where models frame one file under different processes) never
826
826
  // nest — without this claim they land in both frames.
827
+ //
828
+ // Singletons deliberately do NOT claim, `showSingletonFrames` or not: a
829
+ // one-member process spans exactly one package and a one-member module
830
+ // sits in exactly one process, so each always nests, and the enclosing
831
+ // frame's own `claimed`/`nestedModuleKeys` filter already excludes the
832
+ // leaf. Claiming as well would only strip those leaves from a package's
833
+ // direct leaves for no gain.
827
834
  const claimedByModule = new Set<string>();
828
835
  for (const r of modules) {
829
836
  if (r.memberAliases.length >= 2) {
@@ -832,8 +839,7 @@ export function buildBoundaryLayoutGroups(
832
839
  }
833
840
  // Same rule one level up: a leaf owned by a multi-member process frame
834
841
  // must not also sit directly in a package frame. (Singleton processes
835
- // never claim — ELK skips them and promotes the member upward, so the
836
- // member has to stay reachable through its package or ungrouped.)
842
+ // never claim — see above.)
837
843
  const claimedByProcess = new Set<string>();
838
844
  for (const r of processes) {
839
845
  if (r.memberAliases.length >= 2) {
@@ -1046,6 +1052,87 @@ export const MECHANISM_STYLE: Record<SubsystemEdgeMechanism, 'solid' | 'dashed'
1046
1052
  'registers-into': 'dashed',
1047
1053
  };
1048
1054
 
1055
+ /**
1056
+ * Region colors for process boundaries in the aggregate graph. Kept separate
1057
+ * from MECHANISM_COLOR on purpose: that palette encodes edge semantics on thin
1058
+ * arrowed strokes, this one encodes containment on wide filled regions, and a
1059
+ * shared hue would make the two readings ambiguous.
1060
+ */
1061
+ export const BOUNDARY_COLOR: readonly string[] = [
1062
+ '#6c9eff', // blue
1063
+ '#3fb8a0', // teal
1064
+ '#8fbf5f', // green
1065
+ '#d9a441', // amber
1066
+ '#d67ab1', // magenta
1067
+ '#9d7fe0', // violet
1068
+ '#e8705f', // coral
1069
+ '#4fb0d4', // sky
1070
+ '#c2a83c', // olive
1071
+ '#a876c8', // orchid
1072
+ '#d1487f', // crimson
1073
+ '#a3c94f', // lime
1074
+ ];
1075
+
1076
+ /** Stable 32-bit hash (FNV-1a plus a final mix) so low bits still spread. */
1077
+ function hashBoundaryKey(key: string): number {
1078
+ let h = 0x811c9dc5;
1079
+ for (let i = 0; i < key.length; i++) {
1080
+ h ^= key.charCodeAt(i);
1081
+ h = Math.imul(h, 0x01000193);
1082
+ }
1083
+ h ^= h >>> 15;
1084
+ h = Math.imul(h, 0x2545f491);
1085
+ h ^= h >>> 13;
1086
+ return h >>> 0;
1087
+ }
1088
+
1089
+ /**
1090
+ * Assign each process boundary key a color from BOUNDARY_COLOR.
1091
+ *
1092
+ * A bare `hash % palette.length` collides constantly once a repo has more than
1093
+ * a handful of processes — unrelated boundaries end up the same hue, which
1094
+ * defeats the point of coding them at all. So the hash only picks the
1095
+ * *preferred* slot and collisions probe forward to the next free one, with
1096
+ * keys visited in sorted order to keep the result deterministic. Distinct
1097
+ * boundaries therefore never share a color unless the key count exceeds the
1098
+ * palette, and past that point the least-used hue wins rather than an
1099
+ * arbitrary one, so the busiest boundaries keep unique colors longest.
1100
+ *
1101
+ * Assignment depends on the key set, so filtering the graph can reshuffle
1102
+ * hues. That is the deliberate trade: the legend is always on screen, whereas
1103
+ * a stable-but-colliding mapping is unreadable.
1104
+ */
1105
+ export function assignBoundaryColors(keys: readonly string[]): Map<string, string> {
1106
+ const out = new Map<string, string>();
1107
+ const owners = new Map<string, number>();
1108
+ for (const key of [...keys].sort()) {
1109
+ if (out.has(key)) continue;
1110
+ const start = hashBoundaryKey(key) % BOUNDARY_COLOR.length;
1111
+ let picked: string | undefined;
1112
+ for (let i = 0; i < BOUNDARY_COLOR.length; i++) {
1113
+ const candidate = BOUNDARY_COLOR[(start + i) % BOUNDARY_COLOR.length]!;
1114
+ if (owners.get(candidate)) continue;
1115
+ picked = candidate;
1116
+ break;
1117
+ }
1118
+ // Palette exhausted: share the least-used hue, ties going to the earlier
1119
+ // slot so the result stays deterministic.
1120
+ if (!picked) {
1121
+ picked = BOUNDARY_COLOR.reduce((a, b) =>
1122
+ (owners.get(a) ?? 0) <= (owners.get(b) ?? 0) ? a : b,
1123
+ )!;
1124
+ }
1125
+ owners.set(picked, (owners.get(picked) ?? 0) + 1);
1126
+ out.set(key, picked);
1127
+ }
1128
+ return out;
1129
+ }
1130
+
1131
+ /** Low-alpha fill for a boundary region, from its assigned color. */
1132
+ export function boundaryFill(color: string, alpha = '1f'): string {
1133
+ return `${color}${alpha}`;
1134
+ }
1135
+
1049
1136
  /** Mechanism → [description, verifiable-with-graphify]. Drives the "not
1050
1137
  * directly verifiable" styling of edge labels. */
1051
1138
  export const MECHANISM_DESCRIPTIONS: [SubsystemEdgeMechanism, string, boolean][] = [
@@ -1552,6 +1639,11 @@ export async function buildSubsystemGraph(
1552
1639
  parentId: g.parentId,
1553
1640
  minWidth: g.region.kind === 'module' ? moduleMinWidthForBadge(g.region.label) : undefined,
1554
1641
  })),
1642
+ // Honour `showSingletonFrames`: a one-member process or module is
1643
+ // still a real boundary. `buildBoundaryLayoutGroups` already made
1644
+ // those regions claim their members, so keeping them here cannot
1645
+ // double-parent a leaf.
1646
+ keepSingletonGroups: showSingletonFrames === true,
1555
1647
  });
1556
1648
  const builtGroupIds = new Set(result.groupBounds.keys());
1557
1649
  // Parents before children — package, then process, then module frames.
@@ -405,6 +405,7 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
405
405
  isModule && canToggle ? moduleBadgeHoverLabel(label, availableBadgeWidth) : label;
406
406
  const frameRadius = isProcess ? 0 : 12;
407
407
  const badgeRadius = isProcess ? 0 : 4;
408
+ const badgeBg = theme.colors.backgroundSecondary ?? theme.colors.background;
408
409
  // Processes are deployment units — solid frame. Modules/packages stay dashed
409
410
  // until selected.
410
411
  const frameStyle = isProcess || selected ? 'solid' : 'dashed';
@@ -455,12 +456,12 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
455
456
  position: 'absolute',
456
457
  // Process: badge fill starts with the process fill (inside the
457
458
  // frame border); no top badge border so widths don't fight.
458
- // Module/package sit astride the top edge like component badges.
459
- // Always set left/transform/borderTop explicitly — React Flow reuses
459
+ // Module/package are centred astride the top edge like component
460
+ // badges. Always set left/transform explicitly — React Flow reuses
460
461
  // group DOM nodes and `undefined` does not clear a prior value.
461
- top: isProcess ? 0 : -19,
462
+ top: 0,
462
463
  left: isProcess ? '50%' : 12,
463
- transform: isProcess ? 'translateX(-50%)' : 'none',
464
+ transform: isProcess ? 'translateX(-50%)' : 'translateY(-50%)',
464
465
  zIndex: canToggle ? 10 : undefined,
465
466
  // Width is explicit while collapsible so hover-reveal of more path
466
467
  // text animates; the cap keeps both states inside the frame.
@@ -472,17 +473,19 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
472
473
  : undefined,
473
474
  transition: 'width 140ms ease, box-shadow 120ms ease',
474
475
  cursor: canToggle ? 'pointer' : undefined,
475
- boxShadow: canToggle && hover ? `0 2px 10px ${color}55` : undefined,
476
+ // The trailing halo squares the rounded corners back off in the fill
477
+ // colour, so an edge crossing a corner can't show through the notch.
478
+ boxShadow: `${canToggle && hover ? `0 2px 10px ${color}55, ` : ''}0 0 0 1.5px ${badgeBg}`,
476
479
  fontFamily: theme.fonts.monospace,
477
480
  fontSize: theme.fontSizes[3],
478
481
  fontWeight: 700,
479
482
  letterSpacing: 0.6,
480
483
  color,
481
- background: theme.colors.backgroundSecondary ?? theme.colors.background,
482
- border: `1px solid ${color}`,
483
- borderTopWidth: isProcess ? 0 : 1,
484
+ background: badgeBg,
485
+ border: `2px solid ${color}`,
486
+ borderTopWidth: isProcess ? 0 : 2,
484
487
  borderRadius: badgeRadius,
485
- padding: '1px 7px',
488
+ padding: '3px 9px',
486
489
  whiteSpace: 'nowrap',
487
490
  overflow: 'hidden',
488
491
  textOverflow: 'ellipsis',
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Tests for ELK Layout Utility
3
3
  *
4
- * Tests the pure helper functions that don't require the ELK runtime.
5
- * The computeElkLayout function is tested via Storybook visual tests
6
- * since ELK requires a web worker environment.
4
+ * Tests the pure helper functions that don't require the ELK runtime, plus
5
+ * the compound-group planner. computeElkLayout itself needs a web worker
6
+ * (elkjs constructs one), so it is covered by Storybook visual tests and by
7
+ * testing planCompoundGroups — the part that decides which frames exist.
7
8
  */
8
9
 
9
10
  import { describe, test, expect } from 'bun:test';
@@ -11,9 +12,122 @@ import {
11
12
  pointsToPath,
12
13
  pointsToSmoothPath,
13
14
  calculatePathMidpoint,
15
+ planCompoundGroups,
14
16
  type Point,
15
17
  } from './elkLayout';
16
18
 
19
+ describe('planCompoundGroups', () => {
20
+ test('drops a single-leaf group by default and promotes its member', () => {
21
+ const plan = planCompoundGroups(
22
+ [{ id: 'proc', memberIds: ['solo'] }],
23
+ ['solo'],
24
+ );
25
+ expect(plan.built.map((g) => g.id)).toEqual([]);
26
+ expect(plan.skipped).toEqual(['proc']);
27
+ });
28
+
29
+ test('keeps a single-leaf group when the caller opts in', () => {
30
+ const plan = planCompoundGroups(
31
+ [{ id: 'proc', memberIds: ['solo'] }],
32
+ ['solo'],
33
+ true,
34
+ );
35
+ expect(plan.skipped).toEqual([]);
36
+ expect(plan.built).toEqual([{ id: 'proc', childIds: ['solo'], minWidth: undefined }]);
37
+ });
38
+
39
+ test('keeps a multi-leaf group in both modes', () => {
40
+ const defs = [{ id: 'proc', memberIds: ['a', 'b'] }];
41
+ for (const keep of [false, true]) {
42
+ const plan = planCompoundGroups(defs, ['a', 'b'], keep);
43
+ expect(plan.built.map((g) => g.id)).toEqual(['proc']);
44
+ expect(plan.skipped).toEqual([]);
45
+ }
46
+ });
47
+
48
+ test('always drops an empty group even when singletons are kept', () => {
49
+ const plan = planCompoundGroups([{ id: 'proc', memberIds: [] }], ['a'], true);
50
+ expect(plan.built).toEqual([]);
51
+ expect(plan.skipped).toEqual(['proc']);
52
+ });
53
+
54
+ test('counts leaves through nested groups', () => {
55
+ // process → module(one leaf) is still a single leaf, so both drop by
56
+ // default even though the process lists one member id.
57
+ const defs = [
58
+ { id: 'mod', memberIds: ['a'] },
59
+ { id: 'proc', memberIds: ['mod'] },
60
+ ];
61
+ expect(planCompoundGroups(defs, ['a']).skipped).toEqual(['mod', 'proc']);
62
+ expect(planCompoundGroups(defs, ['a'], true).built.map((g) => g.id)).toEqual([
63
+ 'mod',
64
+ 'proc',
65
+ ]);
66
+ });
67
+
68
+ test('a two-leaf module keeps its process alive under the default', () => {
69
+ const defs = [
70
+ { id: 'mod', memberIds: ['a', 'b'] },
71
+ { id: 'proc', memberIds: ['mod'] },
72
+ ];
73
+ const plan = planCompoundGroups(defs, ['a', 'b']);
74
+ expect(plan.built.map((g) => g.id)).toEqual(['mod', 'proc']);
75
+ expect(plan.built[1]!.childIds).toEqual(['mod']);
76
+ });
77
+
78
+ test('promotes a dropped group member into its built parent', () => {
79
+ // proc contains a singleton module (dropped) plus a bare leaf, so proc
80
+ // still has two leaves and survives; the module's leaf is promoted in.
81
+ const defs = [
82
+ { id: 'mod', memberIds: ['a'] },
83
+ { id: 'proc', memberIds: ['mod', 'b'] },
84
+ ];
85
+ const plan = planCompoundGroups(defs, ['a', 'b']);
86
+ expect(plan.skipped).toEqual(['mod']);
87
+ expect(plan.built.map((g) => g.id)).toEqual(['proc']);
88
+ expect(plan.built[0]!.childIds.sort()).toEqual(['a', 'b']);
89
+ });
90
+
91
+ test('builds children before parents', () => {
92
+ const defs = [
93
+ { id: 'pkg', memberIds: ['procA', 'procB'] },
94
+ { id: 'procA', memberIds: ['a1', 'a2'] },
95
+ { id: 'procB', memberIds: ['b1', 'b2'] },
96
+ ];
97
+ const plan = planCompoundGroups(defs, ['a1', 'a2', 'b1', 'b2']);
98
+ const order = plan.built.map((g) => g.id);
99
+ expect(order.indexOf('procA')).toBeLessThan(order.indexOf('pkg'));
100
+ expect(order.indexOf('procB')).toBeLessThan(order.indexOf('pkg'));
101
+ });
102
+
103
+ test('drops groups caught in a cycle instead of looping forever', () => {
104
+ const defs = [
105
+ { id: 'x', memberIds: ['y'] },
106
+ { id: 'y', memberIds: ['x'] },
107
+ ];
108
+ const plan = planCompoundGroups(defs, ['a']);
109
+ expect(plan.built).toEqual([]);
110
+ expect(plan.skipped.sort()).toEqual(['x', 'y']);
111
+ });
112
+
113
+ test('ignores member ids that match no leaf or group', () => {
114
+ const plan = planCompoundGroups(
115
+ [{ id: 'proc', memberIds: ['a', 'ghost'] }],
116
+ ['a'],
117
+ true,
118
+ );
119
+ expect(plan.built[0]!.childIds).toEqual(['a']);
120
+ });
121
+
122
+ test('carries minWidth through to the plan', () => {
123
+ const plan = planCompoundGroups(
124
+ [{ id: 'mod', memberIds: ['a', 'b'], minWidth: 220 }],
125
+ ['a', 'b'],
126
+ );
127
+ expect(plan.built[0]!.minWidth).toBe(220);
128
+ });
129
+ });
130
+
17
131
  describe('elkLayout helper functions', () => {
18
132
  describe('pointsToPath', () => {
19
133
  test('should return empty string for empty array', () => {