@principal-ai/subsystems-react 0.43.1 → 0.44.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 +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/subsystem/SubsystemModelTransition.d.ts +40 -0
- package/dist/subsystem/SubsystemModelTransition.d.ts.map +1 -0
- package/dist/subsystem/SubsystemModelTransition.js +232 -0
- package/dist/subsystem/SubsystemModelTransition.js.map +1 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +112 -82
- package/dist/subsystem/model.js.map +1 -1
- package/dist/utils/elkLayout.d.ts +8 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +100 -0
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/stories/Subsystem/ComponentGraph/AnimatedProgression.stories.tsx +120 -0
- package/src/subsystem/SubsystemModelTransition.tsx +389 -0
- package/src/subsystem/model.test.ts +30 -0
- package/src/subsystem/model.ts +109 -79
- package/src/utils/elkLayout.ts +106 -0
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubsystemModelTransition — a sibling of `SubsystemComponentGraph` that
|
|
3
|
+
* presents ONE subsystem as a sequence of steps (e.g. constructs → static
|
|
4
|
+
* topology → dynamic topology → walkthrough) and animates between them.
|
|
5
|
+
*
|
|
6
|
+
* Same look as the component graph: it reuses the same node / frame / edge
|
|
7
|
+
* components and the edge-label chrome, so only the motion is new. Each step is
|
|
8
|
+
* laid out with `buildSubsystemGraph`; nodes and frames are flattened to
|
|
9
|
+
* absolute rects and position + size + opacity are interpolated on a rAF tween.
|
|
10
|
+
* The viewport refits to the incoming layout at the start of a transition, and
|
|
11
|
+
* the graph's real (ELK-routed, mechanism-colored) edges appear once it settles.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useEffect, useMemo, useRef, useState, type ComponentType, type CSSProperties } from 'react';
|
|
15
|
+
import {
|
|
16
|
+
Background,
|
|
17
|
+
EdgeLabelRenderer,
|
|
18
|
+
ReactFlow,
|
|
19
|
+
ReactFlowProvider,
|
|
20
|
+
useReactFlow,
|
|
21
|
+
type Edge,
|
|
22
|
+
type EdgeProps,
|
|
23
|
+
type EdgeTypes,
|
|
24
|
+
type Node,
|
|
25
|
+
type NodeTypes,
|
|
26
|
+
} from '@xyflow/react';
|
|
27
|
+
import '@xyflow/react/dist/style.css';
|
|
28
|
+
import { useTheme } from '@principal-ade/industry-theme';
|
|
29
|
+
import {
|
|
30
|
+
buildSubsystemGraph,
|
|
31
|
+
edgeColor,
|
|
32
|
+
MECHANISM_DESCRIPTIONS,
|
|
33
|
+
type SubsystemEdgeProvenance,
|
|
34
|
+
type SubsystemGraphNode,
|
|
35
|
+
type SubsystemModelDocument,
|
|
36
|
+
} from './model';
|
|
37
|
+
import {
|
|
38
|
+
hexWithAlpha,
|
|
39
|
+
SubsystemCallbacksProvider,
|
|
40
|
+
SubsystemComponentNode,
|
|
41
|
+
SubsystemEdge,
|
|
42
|
+
SubsystemGroupNode,
|
|
43
|
+
} from './nodes';
|
|
44
|
+
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome } from './graphChrome';
|
|
45
|
+
import {
|
|
46
|
+
EDGE_LABEL_CLOUD_EXTRA_TOP,
|
|
47
|
+
EDGE_LABEL_CLOUD_PATH,
|
|
48
|
+
EDGE_LABEL_FONT_SIZE,
|
|
49
|
+
EDGE_LABEL_HEIGHT,
|
|
50
|
+
EDGE_LABEL_WIDTH,
|
|
51
|
+
} from '../utils/edgeLabel';
|
|
52
|
+
|
|
53
|
+
/** One step of the transition — a full model plus its view options. */
|
|
54
|
+
export interface SubsystemTransitionStep {
|
|
55
|
+
model: SubsystemModelDocument;
|
|
56
|
+
/** `path` derives directory frames and nests modules under them. */
|
|
57
|
+
moduleNesting?: 'exact' | 'path';
|
|
58
|
+
/** Show mechanism labels on edges. @default true */
|
|
59
|
+
showEdgeLabels?: boolean;
|
|
60
|
+
/** Explicit boundary frame colors (region key → hex); host override. */
|
|
61
|
+
boundaryColors?: Record<string, string>;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface SubsystemModelTransitionProps {
|
|
65
|
+
steps: SubsystemTransitionStep[];
|
|
66
|
+
/** Which step is shown. Controlled by the host (its nav / timer). */
|
|
67
|
+
activeIndex: number;
|
|
68
|
+
/** Tween duration, ms. @default 750 */
|
|
69
|
+
durationMs?: number;
|
|
70
|
+
/** fitView padding for the viewport refit. @default 0.18 */
|
|
71
|
+
fitPadding?: number;
|
|
72
|
+
/** Called when a node is clicked. */
|
|
73
|
+
onSelect?: (componentAlias: string) => void;
|
|
74
|
+
className?: string;
|
|
75
|
+
style?: CSSProperties;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Graph edge look (ELK orth path) + the mechanism label the graph overlays. */
|
|
79
|
+
function TransitionEdge(props: EdgeProps) {
|
|
80
|
+
const { theme } = useTheme();
|
|
81
|
+
const data = props.data as
|
|
82
|
+
| { mechanism?: string; provenance?: SubsystemEdgeProvenance; labelX?: number; labelY?: number }
|
|
83
|
+
| undefined;
|
|
84
|
+
const mechanism = data?.mechanism ?? 'uses';
|
|
85
|
+
const color = edgeColor({ mechanism, provenance: data?.provenance });
|
|
86
|
+
const verifiable =
|
|
87
|
+
data?.provenance === 'graphify' ||
|
|
88
|
+
MECHANISM_DESCRIPTIONS.find(([m]) => m === mechanism)?.[2] !== false;
|
|
89
|
+
const labelBg = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
90
|
+
const showLabel = data?.labelX != null && data?.labelY != null;
|
|
91
|
+
return (
|
|
92
|
+
<>
|
|
93
|
+
<EdgeRenderer {...props} />
|
|
94
|
+
{showLabel && (
|
|
95
|
+
<EdgeLabelRenderer>
|
|
96
|
+
<div
|
|
97
|
+
style={{
|
|
98
|
+
position: 'absolute',
|
|
99
|
+
transform: `translate(-50%, -50%) translate(${data!.labelX}px, ${data!.labelY}px)`,
|
|
100
|
+
display: 'flex',
|
|
101
|
+
alignItems: 'center',
|
|
102
|
+
justifyContent: 'center',
|
|
103
|
+
width: EDGE_LABEL_WIDTH,
|
|
104
|
+
height: EDGE_LABEL_HEIGHT,
|
|
105
|
+
boxSizing: 'border-box',
|
|
106
|
+
padding: '7px 8px',
|
|
107
|
+
pointerEvents: 'none',
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{verifiable ? (
|
|
111
|
+
<span
|
|
112
|
+
aria-hidden
|
|
113
|
+
style={{
|
|
114
|
+
position: 'absolute',
|
|
115
|
+
inset: 0,
|
|
116
|
+
borderRadius: 4,
|
|
117
|
+
border: `0.5px solid ${color}`,
|
|
118
|
+
background: labelBg,
|
|
119
|
+
}}
|
|
120
|
+
/>
|
|
121
|
+
) : (
|
|
122
|
+
<svg
|
|
123
|
+
aria-hidden
|
|
124
|
+
width={EDGE_LABEL_WIDTH}
|
|
125
|
+
height={EDGE_LABEL_HEIGHT + EDGE_LABEL_CLOUD_EXTRA_TOP}
|
|
126
|
+
viewBox={`0 0 ${EDGE_LABEL_WIDTH} ${EDGE_LABEL_HEIGHT + EDGE_LABEL_CLOUD_EXTRA_TOP}`}
|
|
127
|
+
style={{ position: 'absolute', left: 0, top: -EDGE_LABEL_CLOUD_EXTRA_TOP }}
|
|
128
|
+
>
|
|
129
|
+
<path
|
|
130
|
+
d={EDGE_LABEL_CLOUD_PATH}
|
|
131
|
+
fill={labelBg}
|
|
132
|
+
stroke={hexWithAlpha(color, 0.75)}
|
|
133
|
+
strokeWidth={1.2}
|
|
134
|
+
strokeLinejoin="round"
|
|
135
|
+
/>
|
|
136
|
+
</svg>
|
|
137
|
+
)}
|
|
138
|
+
<span
|
|
139
|
+
style={{
|
|
140
|
+
position: 'relative',
|
|
141
|
+
display: 'block',
|
|
142
|
+
maxWidth: '100%',
|
|
143
|
+
overflow: 'hidden',
|
|
144
|
+
textOverflow: 'ellipsis',
|
|
145
|
+
whiteSpace: 'nowrap',
|
|
146
|
+
fontSize: EDGE_LABEL_FONT_SIZE,
|
|
147
|
+
lineHeight: 1,
|
|
148
|
+
fontFamily: theme.fonts.monospace,
|
|
149
|
+
fontWeight: 500,
|
|
150
|
+
color,
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
{mechanism}
|
|
154
|
+
</span>
|
|
155
|
+
</div>
|
|
156
|
+
</EdgeLabelRenderer>
|
|
157
|
+
)}
|
|
158
|
+
</>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const nodeTypes: NodeTypes = {
|
|
163
|
+
'subsystem-component': SubsystemComponentNode,
|
|
164
|
+
'subsystem-group': SubsystemGroupNode,
|
|
165
|
+
};
|
|
166
|
+
const edgeTypes: EdgeTypes = { 'subsystem-edge': TransitionEdge };
|
|
167
|
+
|
|
168
|
+
/** `SubsystemEdge` narrows its props; render it through a permissive alias. */
|
|
169
|
+
const EdgeRenderer = SubsystemEdge as unknown as ComponentType<EdgeProps>;
|
|
170
|
+
|
|
171
|
+
type Rect = { x: number; y: number; w: number; h: number };
|
|
172
|
+
type Live = Rect & { o: number };
|
|
173
|
+
type StepMeta = { type: 'subsystem-component' | 'subsystem-group'; data: Record<string, unknown> };
|
|
174
|
+
type StepLayout = {
|
|
175
|
+
rects: Map<string, Rect>;
|
|
176
|
+
meta: Map<string, StepMeta>;
|
|
177
|
+
order: string[];
|
|
178
|
+
edges: Edge[];
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const ease = (t: number) => t * t * (3 - 2 * t);
|
|
182
|
+
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
183
|
+
|
|
184
|
+
function flatten(
|
|
185
|
+
result: Awaited<ReturnType<typeof buildSubsystemGraph>>,
|
|
186
|
+
boundaryColors?: Record<string, string>,
|
|
187
|
+
): StepLayout {
|
|
188
|
+
const rects = new Map<string, Rect>();
|
|
189
|
+
const meta = new Map<string, StepMeta>();
|
|
190
|
+
const order: string[] = [];
|
|
191
|
+
const nodes: SubsystemGraphNode[] = [
|
|
192
|
+
...result.nodes.filter((n) => n.type === 'subsystem-group'),
|
|
193
|
+
...result.nodes.filter((n) => n.type !== 'subsystem-group'),
|
|
194
|
+
];
|
|
195
|
+
for (const n of nodes) {
|
|
196
|
+
const r = result.absoluteRects.get(n.id);
|
|
197
|
+
if (!r) continue;
|
|
198
|
+
rects.set(n.id, { x: r.x, y: r.y, w: r.width, h: r.height });
|
|
199
|
+
const data = { ...((n.data ?? {}) as Record<string, unknown>) };
|
|
200
|
+
if (n.type === 'subsystem-group' && boundaryColors) {
|
|
201
|
+
const key = (data as { region?: { key?: string } }).region?.key;
|
|
202
|
+
if (key && boundaryColors[key] != null) data.color = boundaryColors[key];
|
|
203
|
+
}
|
|
204
|
+
meta.set(n.id, { type: n.type as StepMeta['type'], data });
|
|
205
|
+
order.push(n.id);
|
|
206
|
+
}
|
|
207
|
+
return { rects, meta, order, edges: result.edges as unknown as Edge[] };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function TransitionInner({
|
|
211
|
+
steps,
|
|
212
|
+
activeIndex,
|
|
213
|
+
durationMs = 750,
|
|
214
|
+
fitPadding = 0.18,
|
|
215
|
+
onSelect,
|
|
216
|
+
className,
|
|
217
|
+
style,
|
|
218
|
+
}: SubsystemModelTransitionProps) {
|
|
219
|
+
const { theme } = useTheme();
|
|
220
|
+
const { fitBounds } = useReactFlow();
|
|
221
|
+
const [layouts, setLayouts] = useState<StepLayout[] | null>(null);
|
|
222
|
+
const [display, setDisplay] = useState<Map<string, Live>>(new Map());
|
|
223
|
+
const [settled, setSettled] = useState(false);
|
|
224
|
+
const displayRef = useRef(display);
|
|
225
|
+
displayRef.current = display;
|
|
226
|
+
const rafRef = useRef<number | null>(null);
|
|
227
|
+
|
|
228
|
+
// Lay out every step whenever the steps prop changes (identity-keyed).
|
|
229
|
+
useEffect(() => {
|
|
230
|
+
let alive = true;
|
|
231
|
+
void (async () => {
|
|
232
|
+
const next: StepLayout[] = [];
|
|
233
|
+
for (const s of steps) {
|
|
234
|
+
const result = await buildSubsystemGraph(s.model, {
|
|
235
|
+
showSingletonFrames: true,
|
|
236
|
+
moduleNesting: s.moduleNesting,
|
|
237
|
+
showEdgeLabels: s.showEdgeLabels ?? true,
|
|
238
|
+
});
|
|
239
|
+
next.push(flatten(result, s.boundaryColors));
|
|
240
|
+
}
|
|
241
|
+
if (alive) setLayouts(next);
|
|
242
|
+
})();
|
|
243
|
+
return () => {
|
|
244
|
+
alive = false;
|
|
245
|
+
};
|
|
246
|
+
}, [steps]);
|
|
247
|
+
|
|
248
|
+
// Tween to the active step; refit; hide edges until settled.
|
|
249
|
+
useEffect(() => {
|
|
250
|
+
if (!layouts) return;
|
|
251
|
+
const step = layouts[activeIndex];
|
|
252
|
+
if (!step) return;
|
|
253
|
+
setSettled(false);
|
|
254
|
+
|
|
255
|
+
const rects = [...step.rects.values()];
|
|
256
|
+
if (rects.length > 0) {
|
|
257
|
+
const minX = Math.min(...rects.map((r) => r.x));
|
|
258
|
+
const minY = Math.min(...rects.map((r) => r.y));
|
|
259
|
+
const maxX = Math.max(...rects.map((r) => r.x + r.w));
|
|
260
|
+
const maxY = Math.max(...rects.map((r) => r.y + r.h));
|
|
261
|
+
fitBounds(
|
|
262
|
+
{ x: minX, y: minY, width: maxX - minX, height: maxY - minY },
|
|
263
|
+
{ padding: fitPadding, duration: durationMs },
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const start = new Map(displayRef.current);
|
|
268
|
+
const ids = new Set<string>([...start.keys(), ...step.rects.keys()]);
|
|
269
|
+
const t0 = performance.now();
|
|
270
|
+
const tick = (now: number) => {
|
|
271
|
+
const t = Math.min(1, (now - t0) / durationMs);
|
|
272
|
+
const e = ease(t);
|
|
273
|
+
const next = new Map<string, Live>();
|
|
274
|
+
for (const id of ids) {
|
|
275
|
+
const cur = start.get(id);
|
|
276
|
+
const tgt = step.rects.get(id);
|
|
277
|
+
if (tgt) {
|
|
278
|
+
const from = cur ?? { ...tgt, o: 0 };
|
|
279
|
+
next.set(id, {
|
|
280
|
+
x: lerp(from.x, tgt.x, e),
|
|
281
|
+
y: lerp(from.y, tgt.y, e),
|
|
282
|
+
w: lerp(from.w, tgt.w, e),
|
|
283
|
+
h: lerp(from.h, tgt.h, e),
|
|
284
|
+
o: lerp(from.o ?? 0, 1, e),
|
|
285
|
+
});
|
|
286
|
+
} else if (cur) {
|
|
287
|
+
next.set(id, { ...cur, o: lerp(cur.o, 0, e) });
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
setDisplay(next);
|
|
291
|
+
if (t < 1) {
|
|
292
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
293
|
+
} else {
|
|
294
|
+
setSettled(true);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
rafRef.current = requestAnimationFrame(tick);
|
|
298
|
+
return () => {
|
|
299
|
+
if (rafRef.current != null) cancelAnimationFrame(rafRef.current);
|
|
300
|
+
};
|
|
301
|
+
}, [layouts, activeIndex, durationMs, fitPadding, fitBounds]);
|
|
302
|
+
|
|
303
|
+
const unionMeta = useMemo(() => {
|
|
304
|
+
const m = new Map<string, StepMeta>();
|
|
305
|
+
for (const s of layouts ?? []) for (const [id, meta] of s.meta) if (!m.has(id)) m.set(id, meta);
|
|
306
|
+
return m;
|
|
307
|
+
}, [layouts]);
|
|
308
|
+
|
|
309
|
+
const activeLayout = layouts?.[activeIndex];
|
|
310
|
+
|
|
311
|
+
const nodes: Node[] = useMemo(() => {
|
|
312
|
+
const activeOrder = new Set(activeLayout?.order ?? []);
|
|
313
|
+
const ids = [...display.entries()]
|
|
314
|
+
.filter(([id, r]) => r.o > 0.01 || activeOrder.has(id))
|
|
315
|
+
.map(([id]) => id);
|
|
316
|
+
ids.sort((a, b) => {
|
|
317
|
+
const ga = unionMeta.get(a)?.type === 'subsystem-group' ? 0 : 1;
|
|
318
|
+
const gb = unionMeta.get(b)?.type === 'subsystem-group' ? 0 : 1;
|
|
319
|
+
return ga - gb;
|
|
320
|
+
});
|
|
321
|
+
const out: Node[] = [];
|
|
322
|
+
for (const id of ids) {
|
|
323
|
+
const meta = unionMeta.get(id);
|
|
324
|
+
const rect = display.get(id);
|
|
325
|
+
if (!meta || !rect) continue;
|
|
326
|
+
out.push({
|
|
327
|
+
id,
|
|
328
|
+
type: meta.type,
|
|
329
|
+
position: { x: rect.x, y: rect.y },
|
|
330
|
+
width: rect.w,
|
|
331
|
+
height: rect.h,
|
|
332
|
+
data: meta.data,
|
|
333
|
+
draggable: false,
|
|
334
|
+
selectable: false,
|
|
335
|
+
style: { opacity: rect.o },
|
|
336
|
+
} as Node);
|
|
337
|
+
}
|
|
338
|
+
return out;
|
|
339
|
+
}, [display, activeLayout, unionMeta]);
|
|
340
|
+
|
|
341
|
+
const edges: Edge[] = useMemo(
|
|
342
|
+
() => (settled ? activeLayout?.edges ?? [] : []),
|
|
343
|
+
[settled, activeLayout],
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const callbacks = useMemo(() => (onSelect ? { onSelect } : {}), [onSelect]);
|
|
347
|
+
|
|
348
|
+
return (
|
|
349
|
+
<div
|
|
350
|
+
className={className}
|
|
351
|
+
style={{
|
|
352
|
+
position: 'relative',
|
|
353
|
+
width: '100%',
|
|
354
|
+
height: '100%',
|
|
355
|
+
minHeight: 0,
|
|
356
|
+
background: theme.colors.background,
|
|
357
|
+
...style,
|
|
358
|
+
}}
|
|
359
|
+
>
|
|
360
|
+
{!layouts ? null : (
|
|
361
|
+
<SubsystemCallbacksProvider value={callbacks}>
|
|
362
|
+
<ReactFlow
|
|
363
|
+
nodes={nodes}
|
|
364
|
+
edges={edges}
|
|
365
|
+
nodeTypes={nodeTypes}
|
|
366
|
+
edgeTypes={edgeTypes}
|
|
367
|
+
className={GRAPH_CANVAS_CLASS}
|
|
368
|
+
{...GRAPH_NAV_PROPS}
|
|
369
|
+
proOptions={{ hideAttribution: true }}
|
|
370
|
+
nodesDraggable={false}
|
|
371
|
+
nodesConnectable={false}
|
|
372
|
+
style={{ width: '100%', height: '100%', background: theme.colors.background }}
|
|
373
|
+
>
|
|
374
|
+
<Background />
|
|
375
|
+
<GraphChrome />
|
|
376
|
+
</ReactFlow>
|
|
377
|
+
</SubsystemCallbacksProvider>
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
export function SubsystemModelTransition(props: SubsystemModelTransitionProps) {
|
|
384
|
+
return (
|
|
385
|
+
<ReactFlowProvider>
|
|
386
|
+
<TransitionInner {...props} />
|
|
387
|
+
</ReactFlowProvider>
|
|
388
|
+
);
|
|
389
|
+
}
|
|
@@ -954,6 +954,36 @@ describe('buildBoundaryLayoutGroupsByPath', () => {
|
|
|
954
954
|
expect(ids.has(directoryGroupNodeId('lib'))).toBe(true);
|
|
955
955
|
});
|
|
956
956
|
|
|
957
|
+
test('module directories are scoped to their process (process stays primary)', () => {
|
|
958
|
+
const comps: SubsystemComponent[] = [
|
|
959
|
+
{ alias: 'page', name: 'page', construct: 'function', file: 'app/book/page.tsx', module: 'app/book/page.tsx', process: 'web/client', purl: 'pkg:github/p/app' },
|
|
960
|
+
{ alias: 'a1', name: 'a1', construct: 'function', file: 'app/book/actions.ts', module: 'app/book/actions.ts', process: 'web/server', purl: 'pkg:github/p/app' },
|
|
961
|
+
{ alias: 'a2', name: 'a2', construct: 'function', file: 'app/book/actions.ts', module: 'app/book/actions.ts', process: 'web/server', purl: 'pkg:github/p/app' },
|
|
962
|
+
];
|
|
963
|
+
const groups = buildBoundaryLayoutGroupsByPath(
|
|
964
|
+
{ components: comps },
|
|
965
|
+
{ showSingletonFrames: true },
|
|
966
|
+
);
|
|
967
|
+
const client = groups.find((g) => g.id === processGroupNodeId('web/client'))!;
|
|
968
|
+
const server = groups.find((g) => g.id === processGroupNodeId('web/server'))!;
|
|
969
|
+
expect(client).toBeDefined();
|
|
970
|
+
expect(server).toBeDefined();
|
|
971
|
+
|
|
972
|
+
const clientDirs = groups.filter(
|
|
973
|
+
(g) => g.region.kind === 'directory' && g.parentId === processGroupNodeId('web/client'),
|
|
974
|
+
);
|
|
975
|
+
const serverDirs = groups.filter(
|
|
976
|
+
(g) => g.region.kind === 'directory' && g.parentId === processGroupNodeId('web/server'),
|
|
977
|
+
);
|
|
978
|
+
// Same folder path is split per process — separate, scoped frames.
|
|
979
|
+
expect(clientDirs.map((g) => g.region.key)).toEqual(['app/book']);
|
|
980
|
+
expect(serverDirs.map((g) => g.region.key)).toEqual(['app/book']);
|
|
981
|
+
expect(clientDirs[0]!.id).not.toBe(serverDirs[0]!.id);
|
|
982
|
+
// Process frames own those directories as children (not dropped/empty).
|
|
983
|
+
expect(client.memberAliases).toContain(clientDirs[0]!.id);
|
|
984
|
+
expect(server.memberAliases).toContain(serverDirs[0]!.id);
|
|
985
|
+
});
|
|
986
|
+
|
|
957
987
|
test('exact mode adds no directory frames', () => {
|
|
958
988
|
const groups = buildBoundaryLayoutGroups(
|
|
959
989
|
{ components: pathComps },
|