@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.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts.map +1 -1
- package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js.map +1 -1
- package/dist/subsystem/SubsystemAggregateGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemAggregateGraph.js +152 -21
- package/dist/subsystem/SubsystemAggregateGraph.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts +8 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +9 -5
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/graphChrome.d.ts +15 -0
- package/dist/subsystem/graphChrome.d.ts.map +1 -1
- package/dist/subsystem/graphChrome.js +17 -0
- package/dist/subsystem/graphChrome.js.map +1 -1
- package/dist/subsystem/model.d.ts +26 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +90 -2
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +12 -9
- package/dist/subsystem/nodes.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +45 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +114 -84
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/graphify/signature.test.ts +1 -1
- package/src/index.ts +1 -0
- package/src/stories/Subsystem/{ComponentGraph → AggregateGraph}/AggregateFrameGraph.stories.tsx +1 -1
- package/src/stories/Subsystem/ComponentGraph/FrameworkStereotype.stories.tsx +4 -4
- package/src/stories/Subsystem/ComponentGraph/ModuleBadges.stories.tsx +5 -5
- package/src/stories/Subsystem/ComponentGraph/Spotlights.stories.tsx +5 -5
- package/src/subsystem/SubsystemAggregateGraph.tsx +191 -21
- package/src/subsystem/SubsystemComponentGraph.tsx +18 -4
- package/src/subsystem/graphChrome.tsx +20 -0
- package/src/subsystem/model.test.ts +91 -0
- package/src/subsystem/model.ts +94 -2
- package/src/subsystem/nodes.tsx +12 -9
- package/src/utils/elkLayout.test.ts +117 -3
- package/src/utils/elkLayout.ts +150 -81
- /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.d.ts +0 -0
- /package/dist/stories/Subsystem/{ComponentGraph → AggregateGraph}/aggregateViewFixture.js +0 -0
- /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: [
|
package/src/subsystem/model.ts
CHANGED
|
@@ -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 —
|
|
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.
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -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
|
|
459
|
-
// Always set left/transform
|
|
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:
|
|
462
|
+
top: 0,
|
|
462
463
|
left: isProcess ? '50%' : 12,
|
|
463
|
-
transform: isProcess ? 'translateX(-50%)' : '
|
|
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
|
-
|
|
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:
|
|
482
|
-
border: `
|
|
483
|
-
borderTopWidth: isProcess ? 0 :
|
|
484
|
+
background: badgeBg,
|
|
485
|
+
border: `2px solid ${color}`,
|
|
486
|
+
borderTopWidth: isProcess ? 0 : 2,
|
|
484
487
|
borderRadius: badgeRadius,
|
|
485
|
-
padding: '
|
|
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
|
-
*
|
|
6
|
-
*
|
|
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', () => {
|