@principal-ai/subsystems-react 0.50.0 → 0.51.1
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 +8 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -1
- package/dist/pierre/PierreTrailCodeView.d.ts +1 -1
- package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
- package/dist/pierre/PierreTrailCodeView.js +44 -21
- package/dist/pierre/PierreTrailCodeView.js.map +1 -1
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +493 -0
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +1 -0
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +329 -0
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +1 -0
- package/dist/subsystem/C4Graph.d.ts +2 -2
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +38 -73
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +119 -0
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -0
- package/dist/subsystem/C4NodeCard.js +223 -0
- package/dist/subsystem/C4NodeCard.js.map +1 -0
- package/dist/subsystem/DiagnosticToggle.d.ts +8 -1
- package/dist/subsystem/DiagnosticToggle.d.ts.map +1 -1
- package/dist/subsystem/DiagnosticToggle.js +40 -6
- package/dist/subsystem/DiagnosticToggle.js.map +1 -1
- package/dist/subsystem/FileDrawer.d.ts.map +1 -1
- package/dist/subsystem/FileDrawer.js +20 -21
- package/dist/subsystem/FileDrawer.js.map +1 -1
- package/dist/subsystem/IssueList.js +1 -1
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +13 -4
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4.d.ts +272 -0
- package/dist/subsystem/c4.d.ts.map +1 -0
- package/dist/subsystem/c4.js +322 -0
- package/dist/subsystem/c4.js.map +1 -0
- package/dist/subsystem/c4Evidence.d.ts +143 -0
- package/dist/subsystem/c4Evidence.d.ts.map +1 -0
- package/dist/subsystem/c4Evidence.js +240 -0
- package/dist/subsystem/c4Evidence.js.map +1 -0
- package/dist/subsystem/model.d.ts +27 -7
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js +15 -0
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/nodes.d.ts.map +1 -1
- package/dist/subsystem/nodes.js +78 -7
- package/dist/subsystem/nodes.js.map +1 -1
- package/package.json +3 -2
- package/src/index.ts +37 -0
- package/src/pierre/PierreTrailCodeView.tsx +49 -21
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +413 -0
- package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +293 -0
- package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +366 -0
- package/src/stories/Subsystem/ComponentGraph/Deprecated.stories.tsx +358 -0
- package/src/stories/Subsystem/ComponentGraph/Diagnostics.stories.tsx +27 -2
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -1
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +1 -1
- package/src/stories/Subsystem/ComponentGraph/{Flows.stories.tsx → Trails.stories.tsx} +4 -4
- package/src/subsystem/C4Graph.tsx +81 -107
- package/src/subsystem/C4NodeCard.test.ts +301 -0
- package/src/subsystem/C4NodeCard.tsx +301 -0
- package/src/subsystem/DiagnosticToggle.tsx +58 -4
- package/src/subsystem/FileDrawer.tsx +32 -34
- package/src/subsystem/IssueList.tsx +1 -1
- package/src/subsystem/SubsystemComponentGraph.tsx +13 -5
- package/src/subsystem/c4.test.ts +573 -0
- package/src/subsystem/c4.ts +558 -0
- package/src/subsystem/c4Evidence.test.ts +386 -0
- package/src/subsystem/c4Evidence.ts +329 -0
- package/src/subsystem/model.ts +36 -7
- package/src/subsystem/nodes.tsx +124 -5
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +0 -3
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +0 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js +0 -2
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +0 -1
- package/dist/subsystem/toC4.d.ts +0 -95
- package/dist/subsystem/toC4.d.ts.map +0 -1
- package/dist/subsystem/toC4.js +0 -192
- package/dist/subsystem/toC4.js.map +0 -1
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +0 -101
- package/src/stories/Subsystem/C4Graph/c4Fixture.ts +0 -6
- package/src/subsystem/toC4.test.ts +0 -114
- package/src/subsystem/toC4.ts +0 -273
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* C4Graph — a C4 projection of a (composed) subsystem model on React Flow.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Feed it a `C4Model` from `buildC4Model()` and it draws the
|
|
5
5
|
* system as a compound frame, its containers (or components) as boxes, and
|
|
6
6
|
* externals/actors outside the system. `trail` steps render as flow
|
|
7
7
|
* edges. Click a box to list the source components it rolled up.
|
|
@@ -25,9 +25,10 @@ import {
|
|
|
25
25
|
} from '@xyflow/react';
|
|
26
26
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
27
27
|
import { computeElkLayout } from '../utils/elkLayout';
|
|
28
|
-
import {
|
|
28
|
+
import { MECHANISM_COLOR } from './model';
|
|
29
|
+
import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
|
|
29
30
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
30
|
-
import type { C4Model, C4Node } from './
|
|
31
|
+
import type { C4Model, C4Node } from './c4';
|
|
31
32
|
|
|
32
33
|
export interface C4GraphProps {
|
|
33
34
|
model: C4Model;
|
|
@@ -35,104 +36,24 @@ export interface C4GraphProps {
|
|
|
35
36
|
onSelectNode?: (id: string | null) => void;
|
|
36
37
|
}
|
|
37
38
|
|
|
38
|
-
const NODE_W = 230;
|
|
39
|
-
const NODE_H = 80;
|
|
40
|
-
|
|
41
|
-
function baseName(path: string): string {
|
|
42
|
-
const parts = path.split('/').filter(Boolean);
|
|
43
|
-
return parts[parts.length - 1] ?? path;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
39
|
function edgeColor(mechanism: string | undefined, fallback: string): string {
|
|
47
40
|
if (!mechanism) return fallback;
|
|
48
41
|
return (MECHANISM_COLOR as Record<string, string>)[mechanism] ?? fallback;
|
|
49
42
|
}
|
|
50
43
|
|
|
51
44
|
function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>) {
|
|
52
|
-
const { theme } = useTheme();
|
|
53
45
|
const { node, selected } = props.data;
|
|
54
|
-
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
55
|
-
|
|
56
|
-
const kindColor =
|
|
57
|
-
node.kind === 'external'
|
|
58
|
-
? (theme.colors.warning ?? theme.colors.accent ?? theme.colors.info)
|
|
59
|
-
: node.kind === 'actor'
|
|
60
|
-
? (theme.colors.accent ?? theme.colors.info)
|
|
61
|
-
: (theme.colors.border ?? '#333');
|
|
62
|
-
|
|
63
|
-
const tag = node.kind === 'container' ? 'container' : node.kind === 'external' ? 'external system' : node.kind === 'actor' ? 'actor' : 'component';
|
|
64
|
-
|
|
65
|
-
const subtitle =
|
|
66
|
-
node.kind === 'component'
|
|
67
|
-
? `${node.component?.construct ?? 'code'}${node.component?.file ? ` · ${baseName(node.component.file)}` : ''}`
|
|
68
|
-
: `${describeConstructBreakdown(node.constructs)}${
|
|
69
|
-
node.members.length > 0 ? ` · ${node.members.length} component${node.members.length === 1 ? '' : 's'}` : ''
|
|
70
|
-
}`;
|
|
71
|
-
|
|
72
46
|
return (
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
borderRadius: node.kind === 'actor' ? 20 : 8,
|
|
84
|
-
background: theme.colors.backgroundSecondary ?? theme.colors.background,
|
|
85
|
-
border: `2px ${node.kind === 'component' ? 'solid' : node.kind === 'container' ? 'solid' : 'dashed'} ${
|
|
86
|
-
selected ? theme.colors.primary : kindColor
|
|
87
|
-
}`,
|
|
88
|
-
boxShadow: '0 1px 4px rgba(0,0,0,0.25)',
|
|
89
|
-
cursor: 'pointer',
|
|
90
|
-
fontFamily: theme.fonts.body,
|
|
91
|
-
}}
|
|
92
|
-
>
|
|
93
|
-
<span
|
|
94
|
-
style={{
|
|
95
|
-
fontFamily: theme.fonts.monospace,
|
|
96
|
-
fontSize: theme.fontSizes[0],
|
|
97
|
-
letterSpacing: 0.5,
|
|
98
|
-
textTransform: 'uppercase',
|
|
99
|
-
color: kindColor,
|
|
100
|
-
whiteSpace: 'nowrap',
|
|
101
|
-
overflow: 'hidden',
|
|
102
|
-
textOverflow: 'ellipsis',
|
|
103
|
-
}}
|
|
104
|
-
>
|
|
105
|
-
{tag}
|
|
106
|
-
</span>
|
|
107
|
-
<span
|
|
108
|
-
style={{
|
|
109
|
-
fontWeight: 600,
|
|
110
|
-
fontSize: theme.fontSizes[2],
|
|
111
|
-
color: theme.colors.text,
|
|
112
|
-
whiteSpace: 'nowrap',
|
|
113
|
-
overflow: 'hidden',
|
|
114
|
-
textOverflow: 'ellipsis',
|
|
115
|
-
}}
|
|
116
|
-
title={node.label}
|
|
117
|
-
>
|
|
118
|
-
{node.label}
|
|
119
|
-
</span>
|
|
120
|
-
<span
|
|
121
|
-
style={{
|
|
122
|
-
fontFamily: theme.fonts.monospace,
|
|
123
|
-
fontSize: theme.fontSizes[0],
|
|
124
|
-
color: muted,
|
|
125
|
-
whiteSpace: 'nowrap',
|
|
126
|
-
overflow: 'hidden',
|
|
127
|
-
textOverflow: 'ellipsis',
|
|
128
|
-
}}
|
|
129
|
-
title={node.members.join(', ')}
|
|
130
|
-
>
|
|
131
|
-
{subtitle}
|
|
132
|
-
</span>
|
|
133
|
-
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
|
|
134
|
-
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
|
|
135
|
-
</div>
|
|
47
|
+
<C4NodeCard
|
|
48
|
+
node={node}
|
|
49
|
+
selected={selected}
|
|
50
|
+
handles={
|
|
51
|
+
<>
|
|
52
|
+
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
|
|
53
|
+
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
|
|
54
|
+
</>
|
|
55
|
+
}
|
|
56
|
+
/>
|
|
136
57
|
);
|
|
137
58
|
}
|
|
138
59
|
|
|
@@ -209,15 +130,24 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
209
130
|
setReady(false);
|
|
210
131
|
setSelectedId(null);
|
|
211
132
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
133
|
+
// Per-node size, so a square component is laid out as a square. Feeding the
|
|
134
|
+
// container dimensions for everything would make ELK reserve 210x138 of
|
|
135
|
+
// space around a 132x132 box and space the component view twice as far
|
|
136
|
+
// apart as it needs.
|
|
137
|
+
const rfNodes: Node[] = model.nodes.map((n, i) => {
|
|
138
|
+
const size = nodeSize(n);
|
|
139
|
+
return {
|
|
140
|
+
id: n.id,
|
|
141
|
+
type: 'c4-node',
|
|
142
|
+
// Fallback grid pitch uses the container box — the largest, so the
|
|
143
|
+
// pre-layout positions never overlap. ELK replaces them on success.
|
|
144
|
+
position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
|
|
145
|
+
width: size.width,
|
|
146
|
+
height: size.height,
|
|
147
|
+
...(n.parentId ? { parentId: n.parentId } : {}),
|
|
148
|
+
data: { node: n, selected: false },
|
|
149
|
+
};
|
|
150
|
+
});
|
|
221
151
|
|
|
222
152
|
const groups = model.groups.map((g) => ({
|
|
223
153
|
id: g.id,
|
|
@@ -226,8 +156,8 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
226
156
|
}));
|
|
227
157
|
|
|
228
158
|
const rfEdges: Edge[] = model.edges.map((e) => {
|
|
229
|
-
|
|
230
|
-
const color =
|
|
159
|
+
// Every edge here came from a trail step, so the mechanism colours apply.
|
|
160
|
+
const color = edgeColor(e.mechanisms[0], muted);
|
|
231
161
|
return {
|
|
232
162
|
id: e.id,
|
|
233
163
|
source: e.source,
|
|
@@ -238,7 +168,7 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
238
168
|
style: {
|
|
239
169
|
stroke: color,
|
|
240
170
|
strokeWidth: 1 + Math.min(e.count, 6) * 0.4,
|
|
241
|
-
|
|
171
|
+
strokeDasharray: '6 4',
|
|
242
172
|
},
|
|
243
173
|
markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
|
|
244
174
|
data: { edge: e },
|
|
@@ -320,7 +250,10 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
320
250
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
321
251
|
}, [layoutKey]);
|
|
322
252
|
|
|
323
|
-
const selected = useMemo(
|
|
253
|
+
const selected = useMemo(
|
|
254
|
+
() => model.nodes.find((n) => n.id === selectedId) ?? null,
|
|
255
|
+
[model, selectedId],
|
|
256
|
+
);
|
|
324
257
|
|
|
325
258
|
const displayNodes = useMemo(
|
|
326
259
|
() =>
|
|
@@ -333,7 +266,9 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
333
266
|
);
|
|
334
267
|
|
|
335
268
|
const runtime = model.nodes.filter((n) => n.kind === 'container' || n.kind === 'component').length;
|
|
336
|
-
const outside = model.nodes.filter(
|
|
269
|
+
const outside = model.nodes.filter(
|
|
270
|
+
(n) => n.kind === 'external-system' || n.kind === 'person',
|
|
271
|
+
).length;
|
|
337
272
|
|
|
338
273
|
return (
|
|
339
274
|
<div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
|
|
@@ -431,6 +366,45 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
431
366
|
{selected.kind} · {selected.members.length} component{selected.members.length === 1 ? '' : 's'}
|
|
432
367
|
{selected.models && selected.models.length > 0 ? ` · ${selected.models.length} model${selected.models.length === 1 ? '' : 's'}` : ''}
|
|
433
368
|
</div>
|
|
369
|
+
|
|
370
|
+
{/* Confirmation state, with what is still missing. */}
|
|
371
|
+
{selected.kind === 'container' && (
|
|
372
|
+
<div
|
|
373
|
+
style={{
|
|
374
|
+
fontFamily: theme.fonts.monospace,
|
|
375
|
+
fontSize: theme.fontSizes[0],
|
|
376
|
+
color: muted,
|
|
377
|
+
borderTop: `1px solid ${theme.colors.border ?? '#333'}`,
|
|
378
|
+
paddingTop: 6,
|
|
379
|
+
marginBottom: 8,
|
|
380
|
+
display: 'flex',
|
|
381
|
+
flexDirection: 'column',
|
|
382
|
+
gap: 3,
|
|
383
|
+
}}
|
|
384
|
+
>
|
|
385
|
+
<span style={{ color: nodeStyle(selected, theme).color }}>
|
|
386
|
+
{selected.state}
|
|
387
|
+
{selected.containerKind ? ` · ${selected.containerKind}` : ` · ${selected.kind}`}
|
|
388
|
+
</span>
|
|
389
|
+
{selected.technology ? (
|
|
390
|
+
<span>technology: {selected.technology}</span>
|
|
391
|
+
) : (
|
|
392
|
+
<span style={{ color: theme.colors.warning ?? '#e8a33a' }}>technology: not stated</span>
|
|
393
|
+
)}
|
|
394
|
+
{selected.description ? (
|
|
395
|
+
<span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>{selected.description}</span>
|
|
396
|
+
) : (
|
|
397
|
+
<span style={{ color: theme.colors.warning ?? '#e8a33a' }}>description: not stated</span>
|
|
398
|
+
)}
|
|
399
|
+
{/* A merge hides its inputs, so always show what it absorbed. */}
|
|
400
|
+
{selected.derivedFrom && selected.derivedFrom.length > 1 && (
|
|
401
|
+
<span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
|
|
402
|
+
consolidated from: {selected.derivedFrom.join(', ')}
|
|
403
|
+
</span>
|
|
404
|
+
)}
|
|
405
|
+
</div>
|
|
406
|
+
)}
|
|
407
|
+
|
|
434
408
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
435
409
|
{selected.members.map((alias) => (
|
|
436
410
|
<span
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
import {
|
|
3
|
+
nodeStyle,
|
|
4
|
+
nodeSubtitle,
|
|
5
|
+
nodeTag,
|
|
6
|
+
nodeShape,
|
|
7
|
+
nodeMissing,
|
|
8
|
+
NODE_W,
|
|
9
|
+
NODE_H,
|
|
10
|
+
nodeSize,
|
|
11
|
+
COMPONENT_SIZE,
|
|
12
|
+
} from './C4NodeCard';
|
|
13
|
+
import type { C4Node } from './c4';
|
|
14
|
+
|
|
15
|
+
const theme = {
|
|
16
|
+
colors: {
|
|
17
|
+
border: '#555',
|
|
18
|
+
primary: '#4ec9b0',
|
|
19
|
+
warning: '#e8a33a',
|
|
20
|
+
accent: '#e3b341',
|
|
21
|
+
info: '#0ea5e9',
|
|
22
|
+
},
|
|
23
|
+
} as Parameters<typeof nodeStyle>[1];
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A drawn node, flat. `state` is required on a real `C4Node` — every authored
|
|
27
|
+
* element carries a decision — so the card never has to cope with a box nobody
|
|
28
|
+
* proposed, and no test pretends otherwise.
|
|
29
|
+
*/
|
|
30
|
+
function node(over: Partial<C4Node> = {}): C4Node {
|
|
31
|
+
return { id: 'n', kind: 'container', label: 'n', members: [], constructs: [], state: 'proposed', ...over };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('nodeStyle — one axis per channel', () => {
|
|
35
|
+
test('selected overrides everything', () => {
|
|
36
|
+
expect(nodeStyle(node({ state: 'accepted' }), theme, true).width).toBe(3);
|
|
37
|
+
expect(nodeStyle(node({ state: 'accepted' }), theme, true).dash).toBe('solid');
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test('accepted is solid and uses the primary colour', () => {
|
|
41
|
+
const s = nodeStyle(node({ state: 'accepted' }), theme);
|
|
42
|
+
expect(s.dash).toBe('solid');
|
|
43
|
+
expect(s.color).toBe('#4ec9b0');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('proposed is dashed and uses the warning colour', () => {
|
|
47
|
+
const s = nodeStyle(node({ state: 'proposed' }), theme);
|
|
48
|
+
expect(s.dash).toBe('dashed');
|
|
49
|
+
expect(s.color).toBe('#e8a33a');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('rejected is dotted and muted', () => {
|
|
53
|
+
const s = nodeStyle(node({ state: 'rejected' }), theme);
|
|
54
|
+
expect(s.dash).toBe('dotted');
|
|
55
|
+
expect(s.color).toBe('#555');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('the dash belongs to state, never to kind', () => {
|
|
59
|
+
// This is the bug that made proposed boxes draw solid: the card rendered
|
|
60
|
+
// `shape.dash`, and shape answered to kind. Every kind at the same state
|
|
61
|
+
// must now draw the same dash.
|
|
62
|
+
for (const kind of ['container', 'component', 'external-system', 'person'] as const) {
|
|
63
|
+
expect(nodeStyle(node({ kind, state: 'proposed' }), theme).dash).toBe('dashed');
|
|
64
|
+
expect(nodeStyle(node({ kind, state: 'accepted' }), theme).dash).toBe('solid');
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('confirmation state wins over kind for colour', () => {
|
|
69
|
+
// Same state, four kinds: one colour. Kind is shape's job.
|
|
70
|
+
const colors = (['container', 'component', 'external-system', 'person'] as const).map(
|
|
71
|
+
(kind) => nodeStyle(node({ kind, state: 'accepted' }), theme).color,
|
|
72
|
+
);
|
|
73
|
+
expect(new Set(colors).size).toBe(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('border weight carries the level, not the state', () => {
|
|
77
|
+
// Otherwise a component and a container differ only by hue, which is
|
|
78
|
+
// invisible in grayscale and to a colourblind reader.
|
|
79
|
+
expect(nodeStyle(node({ kind: 'component' }), theme).width).toBe(1);
|
|
80
|
+
expect(nodeStyle(node(), theme).width).toBe(2);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a component stays thinner once confirmed', () => {
|
|
84
|
+
expect(nodeStyle(node({ kind: 'component', state: 'accepted' }), theme).width).toBe(1);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test('falls back safely when the theme has no colour', () => {
|
|
88
|
+
const bare = { colors: {} } as Parameters<typeof nodeStyle>[1];
|
|
89
|
+
expect(() => nodeStyle(node({ state: 'accepted' }), bare)).not.toThrow();
|
|
90
|
+
expect(nodeStyle(node({ state: 'accepted' }), bare).dash).toBe('solid');
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe('nodeTag — what the box IS', () => {
|
|
95
|
+
test('names the C4 kind, never the confirmation state', () => {
|
|
96
|
+
expect(nodeTag(node({ state: 'accepted' }))).toBe('container');
|
|
97
|
+
expect(nodeTag(node({ state: 'proposed' }))).toBe('container');
|
|
98
|
+
expect(nodeTag(node({ state: 'rejected' }))).toBe('container');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('keeps all four kinds distinct', () => {
|
|
102
|
+
expect(nodeTag(node({ kind: 'component' }))).toBe('component');
|
|
103
|
+
expect(nodeTag(node({ kind: 'external-system' }))).toBe('external-system');
|
|
104
|
+
expect(nodeTag(node({ kind: 'person' }))).toBe('person');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('a confirmed container is still a container', () => {
|
|
108
|
+
// This is the regression that made the two levels unreadable: state used
|
|
109
|
+
// to overwrite the kind, so an accepted container stopped being one.
|
|
110
|
+
expect(nodeTag(node({ state: 'accepted' }))).not.toBe('confirmed');
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('nodeShape — kind without relying on colour or dash', () => {
|
|
115
|
+
test('a container is a sharp rectangle', () => {
|
|
116
|
+
const s = nodeShape(node());
|
|
117
|
+
expect(s.kind).toBe('rect');
|
|
118
|
+
expect(s.radius).toBeLessThanOrEqual(4);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a component is a square', () => {
|
|
122
|
+
expect(nodeShape(node({ kind: 'component' })).kind).toBe('square');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('an external system is a rounded rectangle', () => {
|
|
126
|
+
const s = nodeShape(node({ kind: 'external-system' }));
|
|
127
|
+
expect(s.kind).toBe('rect');
|
|
128
|
+
expect(s.radius).toBeGreaterThan(nodeShape(node()).radius);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('a person is a pill', () => {
|
|
132
|
+
const s = nodeShape(node({ kind: 'person' }));
|
|
133
|
+
expect(s.kind).toBe('pill');
|
|
134
|
+
expect(s.radius).toBeGreaterThanOrEqual(NODE_H / 2);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('shape does not depend on confirmation state', () => {
|
|
138
|
+
expect(nodeShape(node({ state: 'proposed' })).kind).toBe(
|
|
139
|
+
nodeShape(node({ state: 'accepted' })).kind,
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe('nodeSubtitle', () => {
|
|
145
|
+
test('shows the container kind and technology', () => {
|
|
146
|
+
expect(
|
|
147
|
+
nodeSubtitle(node({ containerKind: 'application', technology: 'Bun + Electrobun' })),
|
|
148
|
+
).toBe('application · Bun + Electrobun');
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('shows a data store with no technology as just the kind', () => {
|
|
152
|
+
expect(nodeSubtitle(node({ containerKind: 'data-store', technology: '' }))).toBe('data-store');
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test('falls back to the construct breakdown when neither is stated', () => {
|
|
156
|
+
const s = nodeSubtitle(node({ constructs: ['function', 'store'], members: ['a', 'b'] }));
|
|
157
|
+
expect(s).toContain('function');
|
|
158
|
+
expect(s).toContain('store');
|
|
159
|
+
expect(s).toContain('2 components');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('singularises a single member', () => {
|
|
163
|
+
const s = nodeSubtitle(node({ constructs: ['function'], members: ['a'] }));
|
|
164
|
+
expect(s).toContain('1 component');
|
|
165
|
+
expect(s).not.toContain('1 components');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a component shows its construct and file basename', () => {
|
|
169
|
+
const s = nodeSubtitle(
|
|
170
|
+
node({
|
|
171
|
+
kind: 'component',
|
|
172
|
+
component: {
|
|
173
|
+
alias: 'a',
|
|
174
|
+
name: 'buildGroups',
|
|
175
|
+
construct: 'function',
|
|
176
|
+
file: 'src/subsystem/model.ts',
|
|
177
|
+
purl: 'pkg:x#src/subsystem/model.ts',
|
|
178
|
+
},
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
expect(s).toBe('function · model.ts');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test('a component shows its technology when it has one', () => {
|
|
185
|
+
expect(nodeSubtitle(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('nodeMissing', () => {
|
|
190
|
+
test('a container with neither reports both notation gaps', () => {
|
|
191
|
+
expect(nodeMissing(node({ technology: '', description: undefined }))).toEqual([
|
|
192
|
+
'technology',
|
|
193
|
+
'description',
|
|
194
|
+
]);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('one supplied gap drops only that one', () => {
|
|
198
|
+
expect(nodeMissing(node({ technology: 'React', description: undefined }))).toEqual([
|
|
199
|
+
'description',
|
|
200
|
+
]);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test('a fully stated container reports nothing', () => {
|
|
204
|
+
expect(
|
|
205
|
+
nodeMissing(node({ technology: 'React', description: 'Renders graphs.', state: 'accepted' })),
|
|
206
|
+
).toEqual([]);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('the requirement is on containers only', () => {
|
|
210
|
+
for (const kind of ['external-system', 'person', 'component'] as const) {
|
|
211
|
+
expect(nodeMissing(node({ kind, technology: '', description: undefined }))).toEqual([]);
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('nodeSize — a component is a proper square', () => {
|
|
217
|
+
test('a component has equal width and height', () => {
|
|
218
|
+
const s = nodeSize(node({ kind: 'component' }));
|
|
219
|
+
expect(s.width).toBe(s.height);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test('a container is wider than it is tall', () => {
|
|
223
|
+
const s = nodeSize(node());
|
|
224
|
+
expect(s.width).toBeGreaterThan(s.height);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('a component is smaller than its container', () => {
|
|
228
|
+
const c = nodeSize(node({ kind: 'component' }));
|
|
229
|
+
const k = nodeSize(node());
|
|
230
|
+
expect(c.width).toBeLessThan(k.width);
|
|
231
|
+
expect(c.height).toBeLessThan(k.height);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
test('the square still fits its three lines of text', () => {
|
|
235
|
+
// tag + 2-line label slot + subtitle + 10px padding x2. Without this the
|
|
236
|
+
// square clips its own subtitle.
|
|
237
|
+
const s = nodeSize(node({ kind: 'component' }));
|
|
238
|
+
const content = 16 + 40 + 16 + 20;
|
|
239
|
+
expect(s.height).toBeGreaterThanOrEqual(content);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('every kind but a component shares the container box', () => {
|
|
243
|
+
const c = nodeSize(node());
|
|
244
|
+
expect(nodeSize(node({ kind: 'external-system' }))).toEqual(c);
|
|
245
|
+
expect(nodeSize(node({ kind: 'person' }))).toEqual(c);
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
describe('card metrics', () => {
|
|
250
|
+
test('exposes the size the graph lays out against', () => {
|
|
251
|
+
expect(NODE_W).toBe(210);
|
|
252
|
+
expect(NODE_H).toBe(138);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('COMPONENT_SIZE is a square, not just a smaller rectangle', () => {
|
|
256
|
+
expect(COMPONENT_SIZE).toBe(nodeSize(node({ kind: 'component' })).width);
|
|
257
|
+
expect(COMPONENT_SIZE).toBe(nodeSize(node({ kind: 'component' })).height);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('is near-square, not a wide strip', () => {
|
|
261
|
+
// The card carries four fields (tag, label, kind+technology, notation
|
|
262
|
+
// gap). A 3:1 strip truncates the last two; this is the aspect ratio that
|
|
263
|
+
// lets each sit on its own line.
|
|
264
|
+
const ratio = NODE_W / NODE_H;
|
|
265
|
+
expect(ratio).toBeGreaterThan(1);
|
|
266
|
+
expect(ratio).toBeLessThan(1.8);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
test('is tall enough for four lines of content', () => {
|
|
270
|
+
// tag + 2-line label + subtitle + gap marker, plus 8px padding top/bottom.
|
|
271
|
+
expect(NODE_H).toBeGreaterThanOrEqual(130);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('leaves breathing room around four lines of text', () => {
|
|
275
|
+
// Top-aligned now, not centred, so this is about slack rather than
|
|
276
|
+
// balance: tag + 2-line label slot + subtitle + gap slot + padding.
|
|
277
|
+
const contentBudget = 16 + 40 + 16 + 18 + 20; // lines + 10px padding ×2
|
|
278
|
+
expect(NODE_H - contentBudget).toBeGreaterThanOrEqual(12);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('fits the longest real container label on two lines', () => {
|
|
282
|
+
// "subsystems-studio/renderer" is the longest label the 40 stored models
|
|
283
|
+
// produce. It must be allowed to wrap rather than ellipsize.
|
|
284
|
+
expect(NODE_W).toBeGreaterThanOrEqual(200);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe('container kinds', () => {
|
|
289
|
+
test('every container kind renders as a subtitle without special-casing', () => {
|
|
290
|
+
const kinds = ['application', 'data-store', 'queue'] as const;
|
|
291
|
+
for (const containerKind of kinds) {
|
|
292
|
+
expect(nodeSubtitle(node({ containerKind, technology: '' }))).toBe(containerKind);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test('a library is not one of them', () => {
|
|
297
|
+
// C4 says a module "typically" is not an element at all, so there is no
|
|
298
|
+
// kind to render. A library-shaped group is a review comment.
|
|
299
|
+
expect(['application', 'data-store', 'queue']).not.toContain('library');
|
|
300
|
+
});
|
|
301
|
+
});
|