@principal-ai/subsystems-react 0.37.5 → 0.37.6
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 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +3 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js +2 -0
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +1 -0
- package/dist/subsystem/C4Graph.d.ts +22 -0
- package/dist/subsystem/C4Graph.d.ts.map +1 -0
- package/dist/subsystem/C4Graph.js +305 -0
- package/dist/subsystem/C4Graph.js.map +1 -0
- package/dist/subsystem/WalkthroughsPanel.d.ts +2 -0
- package/dist/subsystem/WalkthroughsPanel.d.ts.map +1 -1
- package/dist/subsystem/WalkthroughsPanel.js +114 -48
- package/dist/subsystem/WalkthroughsPanel.js.map +1 -1
- package/dist/subsystem/model.js +6 -6
- package/dist/subsystem/toC4.d.ts +95 -0
- package/dist/subsystem/toC4.d.ts.map +1 -0
- package/dist/subsystem/toC4.js +196 -0
- package/dist/subsystem/toC4.js.map +1 -0
- package/dist/subsystem/walkthroughBrief.d.ts +18 -0
- package/dist/subsystem/walkthroughBrief.d.ts.map +1 -0
- package/dist/subsystem/walkthroughBrief.js +34 -0
- package/dist/subsystem/walkthroughBrief.js.map +1 -0
- package/package.json +1 -1
- package/src/index.ts +6 -1
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +104 -0
- package/src/stories/Subsystem/C4Graph/c4Fixture.ts +6 -0
- package/src/subsystem/C4Graph.tsx +465 -0
- package/src/subsystem/WalkthroughsPanel.test.tsx +146 -0
- package/src/subsystem/WalkthroughsPanel.tsx +163 -60
- package/src/subsystem/model.ts +6 -6
- package/src/subsystem/toC4.test.ts +121 -0
- package/src/subsystem/toC4.ts +277 -0
- package/src/subsystem/walkthroughBrief.test.ts +57 -0
- package/src/subsystem/walkthroughBrief.ts +40 -0
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
14
14
|
import type { DragEvent as ReactDragEvent } from 'react';
|
|
15
15
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
16
|
-
import { ChevronDown, GripVertical, Pause, Play } from 'lucide-react';
|
|
16
|
+
import { Check, ChevronDown, Copy, GripVertical, Pause, Play } from 'lucide-react';
|
|
17
17
|
import {
|
|
18
18
|
PROPOSED_COLOR,
|
|
19
19
|
reorderTargetIndex,
|
|
@@ -21,10 +21,17 @@ import {
|
|
|
21
21
|
walkthroughStepGraphEdgeId,
|
|
22
22
|
type SubsystemWalkthrough,
|
|
23
23
|
} from './model';
|
|
24
|
+
import { buildStepBrief } from './walkthroughBrief';
|
|
24
25
|
|
|
25
26
|
/** Pause (ms) between steps when a walkthrough autoplays. */
|
|
26
27
|
export const WALKTHROUGH_PLAY_PAUSE_MS = 2500;
|
|
27
28
|
|
|
29
|
+
/** How long a step's copy button flashes its "copied" checkmark (ms). */
|
|
30
|
+
export const STEP_COPY_FEEDBACK_MS = 2000;
|
|
31
|
+
|
|
32
|
+
/** Square size (px) of a walkthrough header's Play/Collapse control. */
|
|
33
|
+
const WALKTHROUGH_CONTROL_SIZE = 34;
|
|
34
|
+
|
|
28
35
|
/** Drag state handed to a row's grip while a reorder is in flight. */
|
|
29
36
|
interface WalkthroughReorderHandle {
|
|
30
37
|
index: number;
|
|
@@ -93,6 +100,13 @@ function WalkthroughFlow({
|
|
|
93
100
|
const [gripHover, setGripHover] = useState(false);
|
|
94
101
|
const [hoveredStep, setHoveredStep] = useState<number | null>(null);
|
|
95
102
|
const stepButtonRefs = useRef<(HTMLButtonElement | null)[]>([]);
|
|
103
|
+
// Per-step copy affordance: revealed while a row is hovered or holds focus.
|
|
104
|
+
// `focusedStep` tracks focus-within (button or its copy control) so the icon
|
|
105
|
+
// stays put while the pointer or focus moves between them.
|
|
106
|
+
const [focusedStep, setFocusedStep] = useState<number | null>(null);
|
|
107
|
+
const [copyHoverStep, setCopyHoverStep] = useState<number | null>(null);
|
|
108
|
+
const [copiedStep, setCopiedStep] = useState<number | null>(null);
|
|
109
|
+
const copyTimerRef = useRef<number | null>(null);
|
|
96
110
|
// Autoplay: stepping through the flow's steps with a pause between each.
|
|
97
111
|
const [playing, setPlaying] = useState(false);
|
|
98
112
|
const playTimerRef = useRef<number | null>(null);
|
|
@@ -109,6 +123,38 @@ function WalkthroughFlow({
|
|
|
109
123
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
110
124
|
useEffect(() => () => stopPlaying(), []);
|
|
111
125
|
|
|
126
|
+
const copyStep = useCallback(
|
|
127
|
+
(stepIndex: number) => {
|
|
128
|
+
const brief = buildStepBrief(walkthrough, stepIndex);
|
|
129
|
+
if (brief.length === 0) return;
|
|
130
|
+
const clipboard = navigator.clipboard;
|
|
131
|
+
if (!clipboard) return;
|
|
132
|
+
void clipboard
|
|
133
|
+
.writeText(brief)
|
|
134
|
+
.then(() => {
|
|
135
|
+
setCopiedStep(stepIndex);
|
|
136
|
+
if (copyTimerRef.current != null) {
|
|
137
|
+
window.clearTimeout(copyTimerRef.current);
|
|
138
|
+
}
|
|
139
|
+
copyTimerRef.current = window.setTimeout(
|
|
140
|
+
() => setCopiedStep(null),
|
|
141
|
+
STEP_COPY_FEEDBACK_MS,
|
|
142
|
+
);
|
|
143
|
+
})
|
|
144
|
+
.catch(() => {
|
|
145
|
+
// best-effort — show nothing when the clipboard is unavailable
|
|
146
|
+
});
|
|
147
|
+
},
|
|
148
|
+
[walkthrough],
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
useEffect(
|
|
152
|
+
() => () => {
|
|
153
|
+
if (copyTimerRef.current != null) window.clearTimeout(copyTimerRef.current);
|
|
154
|
+
},
|
|
155
|
+
[],
|
|
156
|
+
);
|
|
157
|
+
|
|
112
158
|
// Native HTML5 drag suppresses mouse events, so a row hovered when the drag
|
|
113
159
|
// began never gets its mouseleave — its hover styling would stick after the
|
|
114
160
|
// drop. Reset every row's hover whenever a drag starts or ends.
|
|
@@ -172,7 +218,8 @@ function WalkthroughFlow({
|
|
|
172
218
|
display: 'flex',
|
|
173
219
|
alignItems: 'center',
|
|
174
220
|
gap: 4,
|
|
175
|
-
|
|
221
|
+
height: WALKTHROUGH_CONTROL_SIZE,
|
|
222
|
+
padding: '0 0 0 16px',
|
|
176
223
|
background: wholeFlowActive || headerHover ? hoverBg : 'transparent',
|
|
177
224
|
transition: 'background 120ms ease',
|
|
178
225
|
}}
|
|
@@ -198,7 +245,7 @@ function WalkthroughFlow({
|
|
|
198
245
|
display: 'flex',
|
|
199
246
|
alignItems: 'center',
|
|
200
247
|
minWidth: 0,
|
|
201
|
-
padding:
|
|
248
|
+
padding: 0,
|
|
202
249
|
border: 'none',
|
|
203
250
|
background: 'transparent',
|
|
204
251
|
textAlign: 'left',
|
|
@@ -236,11 +283,11 @@ function WalkthroughFlow({
|
|
|
236
283
|
alignItems: 'center',
|
|
237
284
|
justifyContent: 'center',
|
|
238
285
|
flexShrink: 0,
|
|
239
|
-
|
|
240
|
-
|
|
286
|
+
alignSelf: 'stretch',
|
|
287
|
+
width: WALKTHROUGH_CONTROL_SIZE,
|
|
241
288
|
padding: 0,
|
|
242
289
|
border: 'none',
|
|
243
|
-
borderRadius:
|
|
290
|
+
borderRadius: 0,
|
|
244
291
|
background: playing || playHover ? theme.colors.border : 'transparent',
|
|
245
292
|
color: playing || playHover ? theme.colors.text : muted,
|
|
246
293
|
cursor: 'pointer',
|
|
@@ -265,11 +312,11 @@ function WalkthroughFlow({
|
|
|
265
312
|
alignItems: 'center',
|
|
266
313
|
justifyContent: 'center',
|
|
267
314
|
flexShrink: 0,
|
|
268
|
-
|
|
269
|
-
|
|
315
|
+
alignSelf: 'stretch',
|
|
316
|
+
width: WALKTHROUGH_CONTROL_SIZE,
|
|
270
317
|
padding: 0,
|
|
271
318
|
border: 'none',
|
|
272
|
-
borderRadius:
|
|
319
|
+
borderRadius: 0,
|
|
273
320
|
background: collapseHover ? theme.colors.border : 'transparent',
|
|
274
321
|
color: collapseHover ? theme.colors.text : muted,
|
|
275
322
|
cursor: 'pointer',
|
|
@@ -298,7 +345,7 @@ function WalkthroughFlow({
|
|
|
298
345
|
display: 'inline-flex',
|
|
299
346
|
alignItems: 'center',
|
|
300
347
|
flexShrink: 0,
|
|
301
|
-
marginRight:
|
|
348
|
+
marginRight: 8,
|
|
302
349
|
padding: '2px 0',
|
|
303
350
|
color: gripHover || reorder.isDragging ? theme.colors.text : muted,
|
|
304
351
|
cursor: reorder.isDragging ? 'grabbing' : 'grab',
|
|
@@ -320,65 +367,121 @@ function WalkthroughFlow({
|
|
|
320
367
|
{walkthrough.steps.map((step, i) => {
|
|
321
368
|
const stepActive = active !== null && active.stepIndex === i;
|
|
322
369
|
const proposed = stepProposed(step.from, step.to);
|
|
370
|
+
const revealed =
|
|
371
|
+
stepActive || hoveredStep === i || focusedStep === i || copiedStep === i;
|
|
372
|
+
const copied = copiedStep === i;
|
|
373
|
+
const copyHovered = copyHoverStep === i;
|
|
323
374
|
return (
|
|
324
|
-
<
|
|
375
|
+
<div
|
|
325
376
|
key={`${walkthroughStepGraphEdgeId(step)}-${i}`}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
setHoveredStep(i);
|
|
333
|
-
onHoverStep(walkthrough, i);
|
|
334
|
-
}}
|
|
335
|
-
onClick={() => onFocusStep(walkthrough, i)}
|
|
336
|
-
style={{
|
|
337
|
-
display: 'flex',
|
|
338
|
-
alignItems: 'center',
|
|
339
|
-
gap: 8,
|
|
340
|
-
minWidth: 0,
|
|
341
|
-
padding: '8px 8px 8px 17px',
|
|
342
|
-
textAlign: 'left',
|
|
343
|
-
borderRadius: 0,
|
|
344
|
-
border: 'none',
|
|
345
|
-
outline: 'none',
|
|
346
|
-
background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
|
|
347
|
-
cursor: 'pointer',
|
|
348
|
-
transition: 'background 120ms ease',
|
|
377
|
+
style={{ position: 'relative' }}
|
|
378
|
+
onFocus={() => setFocusedStep(i)}
|
|
379
|
+
onBlur={(e) => {
|
|
380
|
+
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
|
|
381
|
+
setFocusedStep((cur) => (cur === i ? null : cur));
|
|
382
|
+
}
|
|
349
383
|
}}
|
|
350
384
|
>
|
|
351
|
-
<
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
width: 14,
|
|
355
|
-
fontSize: theme.fontSizes[0],
|
|
356
|
-
fontFamily: theme.fonts.monospace,
|
|
357
|
-
fontWeight: proposed ? 700 : undefined,
|
|
358
|
-
color: proposed
|
|
359
|
-
? PROPOSED_COLOR
|
|
360
|
-
: stepActive
|
|
361
|
-
? theme.colors.text
|
|
362
|
-
: muted,
|
|
385
|
+
<button
|
|
386
|
+
ref={(el) => {
|
|
387
|
+
stepButtonRefs.current[i] = el;
|
|
363
388
|
}}
|
|
364
|
-
|
|
365
|
-
{
|
|
366
|
-
|
|
367
|
-
|
|
389
|
+
type="button"
|
|
390
|
+
title={proposed ? 'Step touches a proposed component' : undefined}
|
|
391
|
+
onMouseEnter={() => {
|
|
392
|
+
setHoveredStep(i);
|
|
393
|
+
onHoverStep(walkthrough, i);
|
|
394
|
+
}}
|
|
395
|
+
onClick={() => onFocusStep(walkthrough, i)}
|
|
368
396
|
style={{
|
|
369
|
-
|
|
397
|
+
display: 'flex',
|
|
398
|
+
alignItems: 'center',
|
|
399
|
+
gap: 8,
|
|
370
400
|
minWidth: 0,
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
401
|
+
width: '100%',
|
|
402
|
+
padding: '8px 36px 8px 17px',
|
|
403
|
+
textAlign: 'left',
|
|
404
|
+
borderRadius: 0,
|
|
405
|
+
border: 'none',
|
|
406
|
+
outline: 'none',
|
|
407
|
+
background: stepActive || hoveredStep === i ? hoverBg : 'transparent',
|
|
408
|
+
cursor: 'pointer',
|
|
409
|
+
transition: 'background 120ms ease',
|
|
377
410
|
}}
|
|
378
411
|
>
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
412
|
+
<span
|
|
413
|
+
style={{
|
|
414
|
+
flexShrink: 0,
|
|
415
|
+
width: 14,
|
|
416
|
+
fontSize: theme.fontSizes[0],
|
|
417
|
+
fontFamily: theme.fonts.monospace,
|
|
418
|
+
fontWeight: proposed ? 700 : undefined,
|
|
419
|
+
color: proposed
|
|
420
|
+
? PROPOSED_COLOR
|
|
421
|
+
: stepActive
|
|
422
|
+
? theme.colors.text
|
|
423
|
+
: muted,
|
|
424
|
+
}}
|
|
425
|
+
>
|
|
426
|
+
{i + 1}
|
|
427
|
+
</span>
|
|
428
|
+
<span
|
|
429
|
+
style={{
|
|
430
|
+
flex: 1,
|
|
431
|
+
minWidth: 0,
|
|
432
|
+
overflow: 'hidden',
|
|
433
|
+
textOverflow: 'ellipsis',
|
|
434
|
+
whiteSpace: 'nowrap',
|
|
435
|
+
fontSize: theme.fontSizes[1],
|
|
436
|
+
fontFamily: theme.fonts.monospace,
|
|
437
|
+
color: proposed ? PROPOSED_COLOR : theme.colors.text,
|
|
438
|
+
}}
|
|
439
|
+
>
|
|
440
|
+
{step.symbol}
|
|
441
|
+
</span>
|
|
442
|
+
</button>
|
|
443
|
+
{revealed && (
|
|
444
|
+
<button
|
|
445
|
+
type="button"
|
|
446
|
+
aria-label={`Copy step ${i + 1} of ${walkthrough.title} for an agent`}
|
|
447
|
+
title={copied ? 'Copied' : 'Copy this step for an agent'}
|
|
448
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
449
|
+
onMouseEnter={() => setCopyHoverStep(i)}
|
|
450
|
+
onMouseLeave={() =>
|
|
451
|
+
setCopyHoverStep((cur) => (cur === i ? null : cur))
|
|
452
|
+
}
|
|
453
|
+
onClick={() => copyStep(i)}
|
|
454
|
+
style={{
|
|
455
|
+
position: 'absolute',
|
|
456
|
+
right: 0,
|
|
457
|
+
top: 0,
|
|
458
|
+
bottom: 0,
|
|
459
|
+
display: 'inline-flex',
|
|
460
|
+
alignItems: 'center',
|
|
461
|
+
justifyContent: 'center',
|
|
462
|
+
width: 'auto',
|
|
463
|
+
aspectRatio: '1 / 1',
|
|
464
|
+
padding: 0,
|
|
465
|
+
border: 'none',
|
|
466
|
+
borderRadius: 0,
|
|
467
|
+
background: copied
|
|
468
|
+
? 'rgba(16,185,129,0.12)'
|
|
469
|
+
: copyHovered
|
|
470
|
+
? theme.colors.border
|
|
471
|
+
: 'transparent',
|
|
472
|
+
color: copied ? '#10b981' : copyHovered ? theme.colors.text : muted,
|
|
473
|
+
cursor: 'pointer',
|
|
474
|
+
transition: 'background 120ms ease, color 120ms ease',
|
|
475
|
+
}}
|
|
476
|
+
>
|
|
477
|
+
{copied ? (
|
|
478
|
+
<Check size={12} strokeWidth={2} />
|
|
479
|
+
) : (
|
|
480
|
+
<Copy size={12} strokeWidth={2} />
|
|
481
|
+
)}
|
|
482
|
+
</button>
|
|
483
|
+
)}
|
|
484
|
+
</div>
|
|
382
485
|
);
|
|
383
486
|
})}
|
|
384
487
|
</div>
|
package/src/subsystem/model.ts
CHANGED
|
@@ -1032,17 +1032,17 @@ export function isWalkthroughMechanism(
|
|
|
1032
1032
|
}
|
|
1033
1033
|
|
|
1034
1034
|
export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
|
|
1035
|
-
calls: '#
|
|
1035
|
+
calls: '#22c55e', // green
|
|
1036
1036
|
extends: '#b48ead', // purple
|
|
1037
1037
|
inherits: '#9b6fd0', // purple
|
|
1038
1038
|
implements: '#c586c0', // magenta
|
|
1039
1039
|
mixes_in: '#d474a8', // pink-magenta
|
|
1040
1040
|
uses: '#e3b341', // gold
|
|
1041
1041
|
method: '#c586c0', // magenta
|
|
1042
|
-
references: '#
|
|
1043
|
-
feeds: '#
|
|
1044
|
-
produces: '#
|
|
1045
|
-
writes: '#
|
|
1042
|
+
references: '#3b82f6', // blue
|
|
1043
|
+
feeds: '#4ec9b0', // teal — data-flow into a processor
|
|
1044
|
+
produces: '#a78bfa', // violet — emits an output type
|
|
1045
|
+
writes: '#e8853a', // orange — mutates retained state
|
|
1046
1046
|
reads: '#0ea5e9', // sky — pulls from retained state
|
|
1047
1047
|
watches: '#9ca3af', // gray — observes, owns nothing
|
|
1048
1048
|
'registers-into': '#ff6b35', // orange
|
|
@@ -1186,7 +1186,7 @@ export function packageColor(name: string): string {
|
|
|
1186
1186
|
* are the only surface using it, but pick a distinct value if roles ever
|
|
1187
1187
|
* take over node borders. */
|
|
1188
1188
|
export const ROLE_COLOR: Record<SubsystemComponentRole, string> = {
|
|
1189
|
-
entry: '#
|
|
1189
|
+
entry: '#c0c0c0', // silver — boundary element
|
|
1190
1190
|
service: '#0893d2', // blue — external system
|
|
1191
1191
|
};
|
|
1192
1192
|
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
toC4,
|
|
4
|
+
deriveRepoKey,
|
|
5
|
+
labelFromPurl,
|
|
6
|
+
isGroundedComponent,
|
|
7
|
+
} from './toC4';
|
|
8
|
+
import type { SubsystemModelDocument } from './model';
|
|
9
|
+
|
|
10
|
+
const REPO = 'pkg:github/acme/repo';
|
|
11
|
+
|
|
12
|
+
const doc: SubsystemModelDocument = {
|
|
13
|
+
components: [
|
|
14
|
+
{ alias: 'a', name: 'Alpha', construct: 'function', file: 'src/a.ts', purl: `${REPO}#src/a.ts`, process: 'p1' },
|
|
15
|
+
{ alias: 'b', name: 'Beta', construct: 'function', file: 'src/b.ts', purl: `${REPO}#src/b.ts`, process: 'p1' },
|
|
16
|
+
{ alias: 's', name: 'Cache', construct: 'store', file: 'src/s.ts', purl: `${REPO}#src/s.ts`, process: 'p1' },
|
|
17
|
+
{ alias: 'c', name: 'Gamma', construct: 'function', file: 'src/c.ts', purl: `${REPO}#src/c.ts`, process: 'p2' },
|
|
18
|
+
{ alias: 'x', name: 'Gist API', construct: 'external', file: '', purl: 'external:api.github.com/gists' },
|
|
19
|
+
{ alias: 'agent', name: 'Maintenance agent', construct: 'custom_entity', file: '', purl: 'external' },
|
|
20
|
+
],
|
|
21
|
+
relations: [
|
|
22
|
+
{ id: 'r1', from: 'a', to: 'b', relationType: 'method' }, // intra p1
|
|
23
|
+
{ id: 'r2', from: 'a', to: 'c', relationType: 'references' },
|
|
24
|
+
{ id: 'r3', from: 'b', to: 'c', relationType: 'references' },
|
|
25
|
+
{ id: 'r4', from: 'a', to: 'x', relationType: 'references' },
|
|
26
|
+
],
|
|
27
|
+
walkthroughs: [
|
|
28
|
+
{
|
|
29
|
+
id: 'w1',
|
|
30
|
+
title: 'flow',
|
|
31
|
+
steps: [
|
|
32
|
+
{ from: 'b', to: 'c', mechanism: 'calls', file: 'src/b.ts', line: 1, purl: `${REPO}#src/b.ts`, symbol: 'Beta' },
|
|
33
|
+
{ from: 'a', to: 'c', mechanism: 'calls', file: 'src/a.ts', line: 2, purl: `${REPO}#src/a.ts`, symbol: 'Alpha' },
|
|
34
|
+
{ from: 'a', to: 'agent', mechanism: 'calls', file: 'src/a.ts', line: 3, purl: `${REPO}#src/a.ts`, symbol: 'Alpha' },
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
describe('helpers', () => {
|
|
41
|
+
test('deriveRepoKey ignores external purls', () => {
|
|
42
|
+
expect(deriveRepoKey(doc)).toBe(REPO);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('labelFromPurl trims scheme and keeps owner/name', () => {
|
|
46
|
+
expect(labelFromPurl('pkg:github/acme/repo#src/a.ts')).toBe('acme/repo');
|
|
47
|
+
expect(labelFromPurl('external:api.github.com/gists')).toBe('api.github.com/gists');
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('isGroundedComponent excludes externals and entities', () => {
|
|
51
|
+
expect(isGroundedComponent(doc.components[0]!)).toBe(true);
|
|
52
|
+
expect(isGroundedComponent(doc.components[4]!)).toBe(false);
|
|
53
|
+
expect(isGroundedComponent(doc.components[5]!)).toBe(false);
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('container view', () => {
|
|
58
|
+
const model = toC4(doc, { view: 'container', systemLabel: 'Acme' });
|
|
59
|
+
|
|
60
|
+
test('system is the repo, containers are processes', () => {
|
|
61
|
+
expect(model.system.id).toBe(`system:${REPO}`);
|
|
62
|
+
expect(model.system.label).toBe('Acme');
|
|
63
|
+
expect(model.nodes.map((n) => n.id).sort()).toEqual(
|
|
64
|
+
[`container:p1`, `container:p2`, `external:external:api.github.com/gists`, `actor:agent`].sort(),
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('container members and store flag', () => {
|
|
69
|
+
const p1 = model.nodes.find((n) => n.id === 'container:p1')!;
|
|
70
|
+
expect(p1.members.sort()).toEqual(['a', 'b', 's']);
|
|
71
|
+
expect(p1.isStore).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test('one system group holds the container nodes', () => {
|
|
75
|
+
expect(model.groups).toHaveLength(1);
|
|
76
|
+
expect(model.groups[0]!.kind).toBe('system');
|
|
77
|
+
expect(model.groups[0]!.memberIds.sort()).toEqual(['container:p1', 'container:p2']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('edges roll up and intra-container edges drop', () => {
|
|
81
|
+
const byId = Object.fromEntries(model.edges.map((e) => [e.id, e]));
|
|
82
|
+
// a->b is intra-p1 — dropped.
|
|
83
|
+
expect(model.edges.some((e) => e.source === e.target)).toBe(false);
|
|
84
|
+
const rel = byId['relationship:container:p1\u0000container:p2']!;
|
|
85
|
+
expect(rel.kind).toBe('relationship');
|
|
86
|
+
expect(rel.count).toBe(2); // r2 + r3
|
|
87
|
+
const flow = byId['flow:container:p1\u0000container:p2']!;
|
|
88
|
+
expect(flow.count).toBe(2); // a->c + b->c
|
|
89
|
+
expect(byId['relationship:container:p1\u0000external:external:api.github.com/gists']).toBeDefined();
|
|
90
|
+
expect(byId['flow:container:p1\u0000actor:agent']).toBeDefined();
|
|
91
|
+
expect(model.edges).toHaveLength(4);
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('component view', () => {
|
|
96
|
+
const model = toC4(doc, { view: 'component' });
|
|
97
|
+
|
|
98
|
+
test('components parent to their container; externals stay root', () => {
|
|
99
|
+
const a = model.nodes.find((n) => n.id === 'component:a')!;
|
|
100
|
+
expect(a.parentId).toBe('container:p1');
|
|
101
|
+
const c = model.nodes.find((n) => n.id === 'component:c')!;
|
|
102
|
+
expect(c.parentId).toBe('container:p2');
|
|
103
|
+
expect(model.nodes.map((n) => n.id).sort()).toEqual(
|
|
104
|
+
['component:a', 'component:b', 'component:s', 'component:c', 'external:external:api.github.com/gists', 'actor:agent'].sort(),
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('nested groups: containers nest in the system', () => {
|
|
109
|
+
const p1 = model.groups.find((g) => g.id === 'container:p1')!;
|
|
110
|
+
expect(p1.parentId).toBe(`system:${REPO}`);
|
|
111
|
+
expect(p1.memberIds.sort()).toEqual(['component:a', 'component:b', 'component:s']);
|
|
112
|
+
const system = model.groups.find((g) => g.kind === 'system')!;
|
|
113
|
+
expect(system.memberIds.sort()).toEqual(['container:p1', 'container:p2']);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('intra-container but cross-component edges survive', () => {
|
|
117
|
+
// a->b (same container, different components) survives at component level.
|
|
118
|
+
expect(model.edges.some((e) => e.source === 'component:a' && e.target === 'component:b')).toBe(true);
|
|
119
|
+
expect(model.edges.some((e) => e.source === e.target)).toBe(false);
|
|
120
|
+
});
|
|
121
|
+
});
|