@principal-ai/subsystems-react 0.37.5 → 0.37.7
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/SubsystemComponentGraph.d.ts +8 -0
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +7 -3
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- 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.d.ts +2 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +6 -6
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +2 -1
- package/dist/subsystem/nodes.js.map +1 -1
- 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/SubsystemComponentGraph.tsx +15 -3
- package/src/subsystem/WalkthroughsPanel.test.tsx +146 -0
- package/src/subsystem/WalkthroughsPanel.tsx +163 -60
- package/src/subsystem/model.ts +8 -6
- package/src/subsystem/nodes.tsx +2 -1
- 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
|
@@ -963,6 +963,8 @@ export interface SubsystemGroupNodeData extends Record<string, unknown> {
|
|
|
963
963
|
region: SubsystemProcessRegion;
|
|
964
964
|
/** True while the region's members are dimmed by flow focus. */
|
|
965
965
|
dimmed?: boolean;
|
|
966
|
+
/** Host-supplied frame color override; falls back to the derived color. */
|
|
967
|
+
color?: string;
|
|
966
968
|
}
|
|
967
969
|
|
|
968
970
|
export type SubsystemGraphNode =
|
|
@@ -1032,17 +1034,17 @@ export function isWalkthroughMechanism(
|
|
|
1032
1034
|
}
|
|
1033
1035
|
|
|
1034
1036
|
export const MECHANISM_COLOR: Record<SubsystemEdgeMechanism, string> = {
|
|
1035
|
-
calls: '#
|
|
1037
|
+
calls: '#22c55e', // green
|
|
1036
1038
|
extends: '#b48ead', // purple
|
|
1037
1039
|
inherits: '#9b6fd0', // purple
|
|
1038
1040
|
implements: '#c586c0', // magenta
|
|
1039
1041
|
mixes_in: '#d474a8', // pink-magenta
|
|
1040
1042
|
uses: '#e3b341', // gold
|
|
1041
1043
|
method: '#c586c0', // magenta
|
|
1042
|
-
references: '#
|
|
1043
|
-
feeds: '#
|
|
1044
|
-
produces: '#
|
|
1045
|
-
writes: '#
|
|
1044
|
+
references: '#3b82f6', // blue
|
|
1045
|
+
feeds: '#4ec9b0', // teal — data-flow into a processor
|
|
1046
|
+
produces: '#a78bfa', // violet — emits an output type
|
|
1047
|
+
writes: '#e8853a', // orange — mutates retained state
|
|
1046
1048
|
reads: '#0ea5e9', // sky — pulls from retained state
|
|
1047
1049
|
watches: '#9ca3af', // gray — observes, owns nothing
|
|
1048
1050
|
'registers-into': '#ff6b35', // orange
|
|
@@ -1186,7 +1188,7 @@ export function packageColor(name: string): string {
|
|
|
1186
1188
|
* are the only surface using it, but pick a distinct value if roles ever
|
|
1187
1189
|
* take over node borders. */
|
|
1188
1190
|
export const ROLE_COLOR: Record<SubsystemComponentRole, string> = {
|
|
1189
|
-
entry: '#
|
|
1191
|
+
entry: '#c0c0c0', // silver — boundary element
|
|
1190
1192
|
service: '#0893d2', // blue — external system
|
|
1191
1193
|
};
|
|
1192
1194
|
|
package/src/subsystem/nodes.tsx
CHANGED
|
@@ -359,7 +359,8 @@ export function SubsystemGroupNode(props: NodeProps<Node<SubsystemGroupNodeData,
|
|
|
359
359
|
selected?: boolean;
|
|
360
360
|
};
|
|
361
361
|
const region = data.region;
|
|
362
|
-
|
|
362
|
+
// Explicit host override wins over the derived (hash-based) frame color.
|
|
363
|
+
const color = data.color ?? packageColor(region?.key ?? 'process');
|
|
363
364
|
const dimmed = data.dimmed === true;
|
|
364
365
|
const hidden = (data as { hidden?: boolean }).hidden === true;
|
|
365
366
|
// Label is the region identity alone (path / process key / owner/name).
|
|
@@ -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
|
+
});
|