@principal-ai/subsystems-react 0.51.2 → 0.51.3
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 +5 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/subsystem/C4Graph.d.ts +12 -1
- package/dist/subsystem/C4Graph.d.ts.map +1 -1
- package/dist/subsystem/C4Graph.js +82 -14
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +22 -22
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +56 -77
- package/dist/subsystem/C4NodeCard.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +0 -6
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
- package/dist/subsystem/TrailsPanel.js +8 -4
- package/dist/subsystem/TrailsPanel.js.map +1 -1
- package/dist/subsystem/c4.d.ts +24 -86
- package/dist/subsystem/c4.d.ts.map +1 -1
- package/dist/subsystem/c4.js +6 -285
- package/dist/subsystem/c4.js.map +1 -1
- package/dist/subsystem/c4Evidence.js +1 -1
- package/dist/subsystem/c4Evidence.js.map +1 -1
- package/dist/subsystem/model.d.ts +8 -0
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/dist/subsystem/techIcons/bun.d.ts +11 -0
- package/dist/subsystem/techIcons/bun.d.ts.map +1 -0
- package/dist/subsystem/techIcons/bun.js +11 -0
- package/dist/subsystem/techIcons/bun.js.map +1 -0
- package/dist/subsystem/techIcons/index.d.ts +36 -0
- package/dist/subsystem/techIcons/index.d.ts.map +1 -0
- package/dist/subsystem/techIcons/index.js +55 -0
- package/dist/subsystem/techIcons/index.js.map +1 -0
- package/dist/subsystem/techIcons/node.d.ts +12 -0
- package/dist/subsystem/techIcons/node.d.ts.map +1 -0
- package/dist/subsystem/techIcons/node.js +39 -0
- package/dist/subsystem/techIcons/node.js.map +1 -0
- package/dist/subsystem/techIcons/react.d.ts +12 -0
- package/dist/subsystem/techIcons/react.d.ts.map +1 -0
- package/dist/subsystem/techIcons/react.js +19 -0
- package/dist/subsystem/techIcons/react.js.map +1 -0
- package/dist/utils/elkLayout.d.ts.map +1 -1
- package/dist/utils/elkLayout.js +16 -5
- package/dist/utils/elkLayout.js.map +1 -1
- package/package.json +3 -3
- package/src/index.ts +4 -6
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +212 -0
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +20 -10
- package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +4 -2
- package/src/subsystem/C4Graph.tsx +127 -12
- package/src/subsystem/C4NodeCard.test.ts +71 -77
- package/src/subsystem/C4NodeCard.tsx +65 -95
- package/src/subsystem/SubsystemComponentGraph.tsx +0 -6
- package/src/subsystem/TrailsPanel.tsx +10 -5
- package/src/subsystem/c4.ts +23 -357
- package/src/subsystem/c4Evidence.test.ts +1 -1
- package/src/subsystem/c4Evidence.ts +1 -1
- package/src/subsystem/model.ts +8 -0
- package/src/subsystem/techIcons/bun.ts +10 -0
- package/src/subsystem/techIcons/index.tsx +73 -0
- package/src/subsystem/techIcons/node.ts +38 -0
- package/src/subsystem/techIcons/react.ts +18 -0
- package/src/subsystem/techIcons/techIcons.test.ts +39 -0
- package/src/utils/elkLayout.ts +16 -5
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +0 -493
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +0 -1
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +0 -329
- package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +0 -1
- package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +0 -293
- package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +0 -366
- package/src/subsystem/c4.test.ts +0 -573
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* C4Graph — a C4 projection of a (composed) subsystem model on React Flow.
|
|
3
3
|
*
|
|
4
|
-
* Feed it
|
|
4
|
+
* Feed it an authored `C4Model` 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.
|
|
@@ -22,9 +22,11 @@ import {
|
|
|
22
22
|
ReactFlow,
|
|
23
23
|
ReactFlowProvider,
|
|
24
24
|
useReactFlow,
|
|
25
|
+
useViewport,
|
|
25
26
|
} from '@xyflow/react';
|
|
26
27
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
27
28
|
import { computeElkLayout } from '../utils/elkLayout';
|
|
29
|
+
import { EDGE_LABEL_FONT_SIZE, EDGE_LABEL_HEIGHT, EDGE_LABEL_WIDTH } from '../utils/edgeLabel';
|
|
28
30
|
import { MECHANISM_COLOR } from './model';
|
|
29
31
|
import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
|
|
30
32
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
@@ -34,6 +36,17 @@ export interface C4GraphProps {
|
|
|
34
36
|
model: C4Model;
|
|
35
37
|
title?: string;
|
|
36
38
|
onSelectNode?: (id: string | null) => void;
|
|
39
|
+
/**
|
|
40
|
+
* Extra width / height reserved around each card when laying out, in flow px.
|
|
41
|
+
* The visible gap between boxes is `2 × gutter` (ELK's `nodeNode` spacing,
|
|
42
|
+
* plus the frame padding where a frame adds one).
|
|
43
|
+
*
|
|
44
|
+
* Above the default 28 the `needs …` gap row was removed, so cards are
|
|
45
|
+
* shorter and packing them tighter than the spacing they were tuned at makes
|
|
46
|
+
* them collide. Raise it for a more airy graph.
|
|
47
|
+
* @default 28
|
|
48
|
+
*/
|
|
49
|
+
gutter?: number;
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
function edgeColor(mechanism: string | undefined, fallback: string): string {
|
|
@@ -57,6 +70,98 @@ function C4NodeView(props: NodeProps<Node<{ node: C4Node; selected: boolean }>>)
|
|
|
57
70
|
);
|
|
58
71
|
}
|
|
59
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Edge-label overlay.
|
|
75
|
+
*
|
|
76
|
+
* Drawn as an HTML layer above the canvas rather than React Flow's built-in
|
|
77
|
+
* edge label, so a C4 line's label reads like the subsystem graph's: an opaque
|
|
78
|
+
* rounded chip in the mechanism colour, monospace text, scaling with zoom.
|
|
79
|
+
*
|
|
80
|
+
* Positions come from ELK's computed label anchors (`edgeLabelPositions`, flow
|
|
81
|
+
* coords); the current viewport converts them to screen space.
|
|
82
|
+
*/
|
|
83
|
+
function C4EdgeLabels({
|
|
84
|
+
positions,
|
|
85
|
+
edges,
|
|
86
|
+
muted,
|
|
87
|
+
}: {
|
|
88
|
+
positions: Map<string, { x: number; y: number }>;
|
|
89
|
+
edges: C4Model['edges'];
|
|
90
|
+
muted: string;
|
|
91
|
+
}) {
|
|
92
|
+
const { theme } = useTheme();
|
|
93
|
+
const viewport = useViewport();
|
|
94
|
+
const surface = theme.colors.backgroundSecondary ?? theme.colors.background;
|
|
95
|
+
const byId = useMemo(() => new Map(edges.map((e) => [e.id, e])), [edges]);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
style={{
|
|
100
|
+
position: 'absolute',
|
|
101
|
+
inset: 0,
|
|
102
|
+
overflow: 'hidden',
|
|
103
|
+
pointerEvents: 'none',
|
|
104
|
+
zIndex: 5,
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{[...positions].map(([id, p]) => {
|
|
108
|
+
const edge = byId.get(id);
|
|
109
|
+
if (!edge) return null;
|
|
110
|
+
const color = edgeColor(edge.mechanisms[0], muted);
|
|
111
|
+
// C4 asks the line to name the interaction's protocol; fall back to the
|
|
112
|
+
// label when a caller supplied none.
|
|
113
|
+
const text = edge.protocol ?? edge.label;
|
|
114
|
+
return (
|
|
115
|
+
<div
|
|
116
|
+
key={id}
|
|
117
|
+
style={{
|
|
118
|
+
position: 'absolute',
|
|
119
|
+
left: p.x * viewport.zoom + viewport.x,
|
|
120
|
+
top: p.y * viewport.zoom + viewport.y,
|
|
121
|
+
transform: `translate(-50%, -50%) scale(${viewport.zoom})`,
|
|
122
|
+
transformOrigin: 'center center',
|
|
123
|
+
display: 'flex',
|
|
124
|
+
alignItems: 'center',
|
|
125
|
+
justifyContent: 'center',
|
|
126
|
+
width: EDGE_LABEL_WIDTH,
|
|
127
|
+
height: EDGE_LABEL_HEIGHT,
|
|
128
|
+
boxSizing: 'border-box',
|
|
129
|
+
padding: '7px 8px',
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
<span
|
|
133
|
+
aria-hidden
|
|
134
|
+
style={{
|
|
135
|
+
position: 'absolute',
|
|
136
|
+
inset: 0,
|
|
137
|
+
borderRadius: 4,
|
|
138
|
+
border: `0.5px solid ${color}`,
|
|
139
|
+
background: surface,
|
|
140
|
+
}}
|
|
141
|
+
/>
|
|
142
|
+
<span
|
|
143
|
+
style={{
|
|
144
|
+
position: 'relative',
|
|
145
|
+
maxWidth: '100%',
|
|
146
|
+
overflow: 'hidden',
|
|
147
|
+
textOverflow: 'ellipsis',
|
|
148
|
+
whiteSpace: 'nowrap',
|
|
149
|
+
fontSize: EDGE_LABEL_FONT_SIZE,
|
|
150
|
+
lineHeight: 1,
|
|
151
|
+
fontFamily: theme.fonts.monospace,
|
|
152
|
+
fontWeight: 500,
|
|
153
|
+
color,
|
|
154
|
+
}}
|
|
155
|
+
>
|
|
156
|
+
{text}
|
|
157
|
+
</span>
|
|
158
|
+
</div>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
60
165
|
function C4GroupView(props: NodeProps<Node<{ label: string; kind: 'system' | 'container'; color: string }>>) {
|
|
61
166
|
const { theme } = useTheme();
|
|
62
167
|
const color = props.data.color;
|
|
@@ -106,7 +211,7 @@ const nodeTypes = {
|
|
|
106
211
|
'c4-group': C4GroupView,
|
|
107
212
|
};
|
|
108
213
|
|
|
109
|
-
function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
214
|
+
function Inner({ model, title, onSelectNode, gutter = 28 }: C4GraphProps) {
|
|
110
215
|
const { theme } = useTheme();
|
|
111
216
|
const { fitView } = useReactFlow();
|
|
112
217
|
const muted = theme.colors.textMuted ?? theme.colors.textSecondary;
|
|
@@ -114,6 +219,9 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
114
219
|
const [rfEdges, setRfEdges] = useState<Edge[]>([]);
|
|
115
220
|
const [ready, setReady] = useState(false);
|
|
116
221
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
|
222
|
+
const [labelPositions, setLabelPositions] = useState<Map<string, { x: number; y: number }>>(
|
|
223
|
+
() => new Map(),
|
|
224
|
+
);
|
|
117
225
|
|
|
118
226
|
const layoutKey = useMemo(
|
|
119
227
|
() =>
|
|
@@ -130,10 +238,11 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
130
238
|
setReady(false);
|
|
131
239
|
setSelectedId(null);
|
|
132
240
|
|
|
133
|
-
// Per-
|
|
134
|
-
// container dimensions for everything would make ELK reserve
|
|
135
|
-
// space around a
|
|
136
|
-
// apart as it needs.
|
|
241
|
+
// Per-kind size, so a square component is laid out as a square. Feeding the
|
|
242
|
+
// container dimensions for everything would make ELK reserve 250x150 of
|
|
243
|
+
// space around a 140x140 box and space the component view twice as far
|
|
244
|
+
// apart as it needs. `gutter` is added here, not passed as node spacing, so
|
|
245
|
+
// a frame's padding clears the card inside it by the same margin.
|
|
137
246
|
const rfNodes: Node[] = model.nodes.map((n, i) => {
|
|
138
247
|
const size = nodeSize(n);
|
|
139
248
|
return {
|
|
@@ -142,8 +251,8 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
142
251
|
// Fallback grid pitch uses the container box — the largest, so the
|
|
143
252
|
// pre-layout positions never overlap. ELK replaces them on success.
|
|
144
253
|
position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
|
|
145
|
-
width: size.width,
|
|
146
|
-
height: size.height,
|
|
254
|
+
width: size.width + gutter,
|
|
255
|
+
height: size.height + gutter,
|
|
147
256
|
...(n.parentId ? { parentId: n.parentId } : {}),
|
|
148
257
|
data: { node: n, selected: false },
|
|
149
258
|
};
|
|
@@ -162,16 +271,17 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
162
271
|
id: e.id,
|
|
163
272
|
source: e.source,
|
|
164
273
|
target: e.target,
|
|
165
|
-
label
|
|
166
|
-
|
|
167
|
-
|
|
274
|
+
// The reserved label box and the chip must agree on size, so ELK is
|
|
275
|
+
// given the same text the chip draws (protocol first, then label). The
|
|
276
|
+
// chip itself is the C4EdgeLabels overlay, not this field.
|
|
277
|
+
label: e.protocol ?? e.label,
|
|
168
278
|
style: {
|
|
169
279
|
stroke: color,
|
|
170
280
|
strokeWidth: 1 + Math.min(e.count, 6) * 0.4,
|
|
171
281
|
strokeDasharray: '6 4',
|
|
172
282
|
},
|
|
173
283
|
markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
|
|
174
|
-
data: { edge: e },
|
|
284
|
+
data: { edge: e, color },
|
|
175
285
|
};
|
|
176
286
|
});
|
|
177
287
|
|
|
@@ -180,10 +290,12 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
180
290
|
preserveNodePositions: false,
|
|
181
291
|
groups,
|
|
182
292
|
keepSingletonGroups: true,
|
|
293
|
+
edgeLabels: { enabled: true, placement: 'CENTER' },
|
|
183
294
|
})
|
|
184
295
|
.then((result) => {
|
|
185
296
|
if (!alive) return;
|
|
186
297
|
const positioned = new Map(result.nodes.map((n) => [n.id, n]));
|
|
298
|
+
setLabelPositions(result.edgeLabelPositions ?? new Map());
|
|
187
299
|
const builtGroups = new Set(result.groupBounds.keys());
|
|
188
300
|
const groupById = new Map(model.groups.map((g) => [g.id, g]));
|
|
189
301
|
|
|
@@ -293,6 +405,9 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
293
405
|
>
|
|
294
406
|
<GraphChrome />
|
|
295
407
|
</ReactFlow>
|
|
408
|
+
{ready && labelPositions.size > 0 && (
|
|
409
|
+
<C4EdgeLabels positions={labelPositions} edges={model.edges} muted={muted} />
|
|
410
|
+
)}
|
|
296
411
|
{!ready && (
|
|
297
412
|
<div
|
|
298
413
|
style={{
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
nodeSubtitle,
|
|
5
5
|
nodeTag,
|
|
6
6
|
nodeKindIcon,
|
|
7
|
-
|
|
7
|
+
nodeTopLabel,
|
|
8
8
|
nodeShape,
|
|
9
9
|
nodeMissing,
|
|
10
10
|
NODE_W,
|
|
@@ -113,14 +113,19 @@ describe('nodeTag — what the box IS', () => {
|
|
|
113
113
|
});
|
|
114
114
|
});
|
|
115
115
|
|
|
116
|
-
describe('nodeKindIcon —
|
|
117
|
-
test('
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
116
|
+
describe('nodeKindIcon — a container’s sort, not its C4 kind', () => {
|
|
117
|
+
test('application and data-store get different icons', () => {
|
|
118
|
+
const app = nodeKindIcon(node({ containerKind: 'application' }));
|
|
119
|
+
const store = nodeKindIcon(node({ containerKind: 'data-store' }));
|
|
120
|
+
expect(app).toBeDefined();
|
|
121
|
+
expect(store).toBeDefined();
|
|
122
|
+
expect(app).not.toBe(store);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('only containers get an icon — the shape carries the rest', () => {
|
|
126
|
+
for (const kind of ['component', 'external-system', 'person'] as const) {
|
|
127
|
+
expect(nodeKindIcon(node({ kind }))).toBeUndefined();
|
|
128
|
+
}
|
|
124
129
|
});
|
|
125
130
|
|
|
126
131
|
test('the icon does not depend on confirmation state', () => {
|
|
@@ -130,16 +135,22 @@ describe('nodeKindIcon — the kind as a glyph, not a word', () => {
|
|
|
130
135
|
});
|
|
131
136
|
});
|
|
132
137
|
|
|
133
|
-
describe('
|
|
134
|
-
test('a
|
|
135
|
-
expect(
|
|
136
|
-
|
|
138
|
+
describe('nodeTopLabel — the technology, or the kind when none is stated', () => {
|
|
139
|
+
test('a stated technology is the label', () => {
|
|
140
|
+
expect(
|
|
141
|
+
nodeTopLabel(node({ containerKind: 'application', technology: 'Bun + Electrobun' })),
|
|
142
|
+
).toBe('Bun + Electrobun');
|
|
143
|
+
expect(nodeTopLabel(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
|
|
137
144
|
});
|
|
138
145
|
|
|
139
|
-
test('
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
146
|
+
test('a container with no technology falls back to its kind', () => {
|
|
147
|
+
expect(nodeTopLabel(node({ containerKind: 'data-store', technology: '' }))).toBe('data-store');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('the other kinds fall back to their C4 kind', () => {
|
|
151
|
+
expect(nodeTopLabel(node({ kind: 'component' }))).toBe('component');
|
|
152
|
+
expect(nodeTopLabel(node({ kind: 'external-system' }))).toBe('external-system');
|
|
153
|
+
expect(nodeTopLabel(node({ kind: 'person' }))).toBe('person');
|
|
143
154
|
});
|
|
144
155
|
});
|
|
145
156
|
|
|
@@ -174,52 +185,28 @@ describe('nodeShape — kind without relying on colour or dash', () => {
|
|
|
174
185
|
});
|
|
175
186
|
|
|
176
187
|
describe('nodeSubtitle', () => {
|
|
177
|
-
test('
|
|
178
|
-
|
|
179
|
-
node({
|
|
180
|
-
);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
const
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
test('singularises a single member', () => {
|
|
200
|
-
const s = nodeSubtitle(node({ constructs: ['function'], members: ['a'] }));
|
|
201
|
-
expect(s).toContain('1 component');
|
|
202
|
-
expect(s).not.toContain('1 components');
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test('a component shows its construct and file basename', () => {
|
|
206
|
-
const s = nodeSubtitle(
|
|
207
|
-
node({
|
|
208
|
-
kind: 'component',
|
|
209
|
-
component: {
|
|
210
|
-
alias: 'a',
|
|
211
|
-
name: 'buildGroups',
|
|
212
|
-
construct: 'function',
|
|
213
|
-
file: 'src/subsystem/model.ts',
|
|
214
|
-
purl: 'pkg:x#src/subsystem/model.ts',
|
|
215
|
-
},
|
|
216
|
-
}),
|
|
217
|
-
);
|
|
218
|
-
expect(s).toBe('function · model.ts');
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
test('a component shows its technology when it has one', () => {
|
|
222
|
-
expect(nodeSubtitle(node({ kind: 'component', technology: 'React + ELK' }))).toBe('React + ELK');
|
|
188
|
+
test('is the element description when it has one', () => {
|
|
189
|
+
expect(
|
|
190
|
+
nodeSubtitle(node({ technology: 'Bun', description: 'Runs the audit pipeline.' })),
|
|
191
|
+
).toBe('Runs the audit pipeline.');
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test('is empty when nothing is stated — there is no fallback', () => {
|
|
195
|
+
const withTech = node({ technology: 'Bun + Electrobun' });
|
|
196
|
+
const withMembers = node({ constructs: ['function', 'store'], members: ['a', 'b'] });
|
|
197
|
+
const component = node({
|
|
198
|
+
kind: 'component',
|
|
199
|
+
component: {
|
|
200
|
+
alias: 'a',
|
|
201
|
+
name: 'buildGroups',
|
|
202
|
+
construct: 'function',
|
|
203
|
+
file: 'src/subsystem/model.ts',
|
|
204
|
+
purl: 'pkg:x#src/subsystem/model.ts',
|
|
205
|
+
},
|
|
206
|
+
});
|
|
207
|
+
for (const n of [withTech, withMembers, component]) {
|
|
208
|
+
expect(nodeSubtitle(n)).toBe('');
|
|
209
|
+
}
|
|
223
210
|
});
|
|
224
211
|
});
|
|
225
212
|
|
|
@@ -285,8 +272,8 @@ describe('nodeSize — a component is a proper square', () => {
|
|
|
285
272
|
|
|
286
273
|
describe('card metrics', () => {
|
|
287
274
|
test('exposes the size the graph lays out against', () => {
|
|
288
|
-
expect(NODE_W).toBe(
|
|
289
|
-
expect(NODE_H).toBe(
|
|
275
|
+
expect(NODE_W).toBe(250);
|
|
276
|
+
expect(NODE_H).toBe(150);
|
|
290
277
|
});
|
|
291
278
|
|
|
292
279
|
test('COMPONENT_SIZE is a square, not just a smaller rectangle', () => {
|
|
@@ -295,23 +282,23 @@ describe('card metrics', () => {
|
|
|
295
282
|
});
|
|
296
283
|
|
|
297
284
|
test('is near-square, not a wide strip', () => {
|
|
298
|
-
// The card carries
|
|
299
|
-
//
|
|
300
|
-
//
|
|
285
|
+
// The card carries three fields (technology row, label, description). A
|
|
286
|
+
// 3:1 strip wraps the last two hard; this is the aspect ratio that lets
|
|
287
|
+
// each sit on its own line.
|
|
301
288
|
const ratio = NODE_W / NODE_H;
|
|
302
289
|
expect(ratio).toBeGreaterThan(1);
|
|
303
290
|
expect(ratio).toBeLessThan(1.8);
|
|
304
291
|
});
|
|
305
292
|
|
|
306
|
-
test('is tall enough for
|
|
307
|
-
//
|
|
293
|
+
test('is tall enough for three lines of content', () => {
|
|
294
|
+
// technology row + 2-line label + description, plus padding.
|
|
308
295
|
expect(NODE_H).toBeGreaterThanOrEqual(130);
|
|
309
296
|
});
|
|
310
297
|
|
|
311
|
-
test('leaves breathing room around
|
|
298
|
+
test('leaves breathing room around three lines of text', () => {
|
|
312
299
|
// Top-aligned now, not centred, so this is about slack rather than
|
|
313
|
-
// balance:
|
|
314
|
-
const contentBudget = 16 + 40 + 16 +
|
|
300
|
+
// balance: technology row + 2-line label slot + description + padding.
|
|
301
|
+
const contentBudget = 16 + 40 + 16 + 20; // lines + 10px padding ×2
|
|
315
302
|
expect(NODE_H - contentBudget).toBeGreaterThanOrEqual(12);
|
|
316
303
|
});
|
|
317
304
|
|
|
@@ -323,13 +310,20 @@ describe('card metrics', () => {
|
|
|
323
310
|
});
|
|
324
311
|
|
|
325
312
|
describe('container kinds', () => {
|
|
326
|
-
test('
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
313
|
+
test('each container kind has an icon and a fallback label', () => {
|
|
314
|
+
for (const containerKind of ['application', 'data-store'] as const) {
|
|
315
|
+
expect(nodeKindIcon(node({ containerKind }))).toBeDefined();
|
|
316
|
+
// No technology stated, so the label falls back to the kind.
|
|
317
|
+
expect(nodeTopLabel(node({ containerKind, technology: '' }))).toBe(containerKind);
|
|
330
318
|
}
|
|
331
319
|
});
|
|
332
320
|
|
|
321
|
+
test('application and data-store do not share an icon', () => {
|
|
322
|
+
expect(nodeKindIcon(node({ containerKind: 'application' }))).not.toBe(
|
|
323
|
+
nodeKindIcon(node({ containerKind: 'data-store' })),
|
|
324
|
+
);
|
|
325
|
+
});
|
|
326
|
+
|
|
333
327
|
test('a library is not one of them', () => {
|
|
334
328
|
// C4 says a module "typically" is not an element at all, so there is no
|
|
335
329
|
// kind to render. A library-shaped group is a review comment.
|