@principal-ai/subsystems-react 0.51.0 → 0.51.2
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 +6 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -4
- 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 +25 -16
- package/dist/subsystem/C4Graph.js.map +1 -1
- package/dist/subsystem/C4NodeCard.d.ts +85 -35
- package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
- package/dist/subsystem/C4NodeCard.js +112 -83
- package/dist/subsystem/C4NodeCard.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.d.ts.map +1 -1
- package/dist/subsystem/IssueList.js +12 -3
- package/dist/subsystem/IssueList.js.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
- package/dist/subsystem/SubsystemComponentGraph.js +85 -7
- package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
- package/dist/subsystem/c4.d.ts +273 -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 +2 -6
- package/dist/subsystem/model.d.ts.map +1 -1
- package/dist/subsystem/model.js.map +1 -1
- package/package.json +5 -4
- package/src/index.ts +26 -28
- package/src/pierre/PierreTrailCodeView.tsx +49 -21
- package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +171 -80
- 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/IssueOverlay.stories.tsx +1 -1
- package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +169 -3
- package/src/subsystem/C4Graph.tsx +39 -24
- package/src/subsystem/C4NodeCard.test.ts +204 -62
- package/src/subsystem/C4NodeCard.tsx +146 -101
- package/src/subsystem/FileDrawer.tsx +32 -34
- package/src/subsystem/IssueList.tsx +13 -2
- package/src/subsystem/SubsystemComponentGraph.tsx +98 -7
- package/src/subsystem/c4.test.ts +573 -0
- package/src/subsystem/c4.ts +559 -0
- package/src/subsystem/c4Evidence.test.ts +386 -0
- package/src/subsystem/c4Evidence.ts +329 -0
- package/src/subsystem/model.ts +2 -6
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts +0 -95
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts.map +0 -1
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js +0 -340
- package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js.map +0 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +0 -7
- package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +0 -1
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js +0 -7335
- package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +0 -1
- package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts +0 -2
- package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts.map +0 -1
- package/dist/stories/Subsystem/C4Graph/generateFixture.js +0 -145
- package/dist/stories/Subsystem/C4Graph/generateFixture.js.map +0 -1
- package/dist/subsystem/c4Associations.d.ts +0 -186
- package/dist/subsystem/c4Associations.d.ts.map +0 -1
- package/dist/subsystem/c4Associations.js +0 -284
- package/dist/subsystem/c4Associations.js.map +0 -1
- package/dist/subsystem/toC4.d.ts +0 -214
- package/dist/subsystem/toC4.d.ts.map +0 -1
- package/dist/subsystem/toC4.js +0 -282
- package/dist/subsystem/toC4.js.map +0 -1
- package/src/stories/Subsystem/C4Graph/C4Associations.stories.tsx +0 -273
- package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +0 -101
- package/src/stories/Subsystem/C4Graph/c4Associations.fixture.ts +0 -344
- package/src/stories/Subsystem/C4Graph/c4Fixture.ts +0 -7339
- package/src/stories/Subsystem/C4Graph/generateFixture.ts +0 -161
- package/src/subsystem/c4Associations.test.ts +0 -484
- package/src/subsystem/c4Associations.ts +0 -398
- package/src/subsystem/toC4.test.ts +0 -114
- package/src/subsystem/toC4.ts +0 -478
|
@@ -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.
|
|
@@ -26,9 +26,9 @@ import {
|
|
|
26
26
|
import { useTheme } from '@principal-ade/industry-theme';
|
|
27
27
|
import { computeElkLayout } from '../utils/elkLayout';
|
|
28
28
|
import { MECHANISM_COLOR } from './model';
|
|
29
|
-
import { C4NodeCard, NODE_H, NODE_W, nodeStyle } from './C4NodeCard';
|
|
29
|
+
import { C4NodeCard, NODE_H, NODE_W, nodeSize, nodeStyle } from './C4NodeCard';
|
|
30
30
|
import { GRAPH_CANVAS_CLASS, GRAPH_NAV_PROPS, GraphChrome, GraphLayerStyle } from './graphChrome';
|
|
31
|
-
import type { C4Model, C4Node } from './
|
|
31
|
+
import type { C4Model, C4Node } from './c4';
|
|
32
32
|
|
|
33
33
|
export interface C4GraphProps {
|
|
34
34
|
model: C4Model;
|
|
@@ -130,15 +130,24 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
130
130
|
setReady(false);
|
|
131
131
|
setSelectedId(null);
|
|
132
132
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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
|
+
});
|
|
142
151
|
|
|
143
152
|
const groups = model.groups.map((g) => ({
|
|
144
153
|
id: g.id,
|
|
@@ -147,8 +156,8 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
147
156
|
}));
|
|
148
157
|
|
|
149
158
|
const rfEdges: Edge[] = model.edges.map((e) => {
|
|
150
|
-
|
|
151
|
-
const color =
|
|
159
|
+
// Every edge here came from a trail step, so the mechanism colours apply.
|
|
160
|
+
const color = edgeColor(e.mechanisms[0], muted);
|
|
152
161
|
return {
|
|
153
162
|
id: e.id,
|
|
154
163
|
source: e.source,
|
|
@@ -159,7 +168,7 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
159
168
|
style: {
|
|
160
169
|
stroke: color,
|
|
161
170
|
strokeWidth: 1 + Math.min(e.count, 6) * 0.4,
|
|
162
|
-
|
|
171
|
+
strokeDasharray: '6 4',
|
|
163
172
|
},
|
|
164
173
|
markerEnd: { type: MarkerType.ArrowClosed, color, width: 18, height: 18 },
|
|
165
174
|
data: { edge: e },
|
|
@@ -241,7 +250,10 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
241
250
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
242
251
|
}, [layoutKey]);
|
|
243
252
|
|
|
244
|
-
const selected = useMemo(
|
|
253
|
+
const selected = useMemo(
|
|
254
|
+
() => model.nodes.find((n) => n.id === selectedId) ?? null,
|
|
255
|
+
[model, selectedId],
|
|
256
|
+
);
|
|
245
257
|
|
|
246
258
|
const displayNodes = useMemo(
|
|
247
259
|
() =>
|
|
@@ -254,7 +266,9 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
254
266
|
);
|
|
255
267
|
|
|
256
268
|
const runtime = model.nodes.filter((n) => n.kind === 'container' || n.kind === 'component').length;
|
|
257
|
-
const outside = model.nodes.filter(
|
|
269
|
+
const outside = model.nodes.filter(
|
|
270
|
+
(n) => n.kind === 'external-system' || n.kind === 'person',
|
|
271
|
+
).length;
|
|
258
272
|
|
|
259
273
|
return (
|
|
260
274
|
<div style={{ position: 'relative', width: '100%', height: '100%', background: theme.colors.background }}>
|
|
@@ -369,22 +383,23 @@ function Inner({ model, title, onSelectNode }: C4GraphProps) {
|
|
|
369
383
|
}}
|
|
370
384
|
>
|
|
371
385
|
<span style={{ color: nodeStyle(selected, theme).color }}>
|
|
372
|
-
{selected.
|
|
386
|
+
{selected.state}
|
|
387
|
+
{selected.containerKind ? ` · ${selected.containerKind}` : ` · ${selected.kind}`}
|
|
373
388
|
</span>
|
|
374
|
-
{selected.
|
|
375
|
-
<span>technology: {selected.
|
|
389
|
+
{selected.technology ? (
|
|
390
|
+
<span>technology: {selected.technology}</span>
|
|
376
391
|
) : (
|
|
377
392
|
<span style={{ color: theme.colors.warning ?? '#e8a33a' }}>technology: not stated</span>
|
|
378
393
|
)}
|
|
379
|
-
{selected.
|
|
380
|
-
<span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>{selected.
|
|
394
|
+
{selected.description ? (
|
|
395
|
+
<span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>{selected.description}</span>
|
|
381
396
|
) : (
|
|
382
397
|
<span style={{ color: theme.colors.warning ?? '#e8a33a' }}>description: not stated</span>
|
|
383
398
|
)}
|
|
384
399
|
{/* A merge hides its inputs, so always show what it absorbed. */}
|
|
385
|
-
{selected.
|
|
400
|
+
{selected.derivedFrom && selected.derivedFrom.length > 1 && (
|
|
386
401
|
<span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
|
|
387
|
-
|
|
402
|
+
consolidated from: {selected.derivedFrom.join(', ')}
|
|
388
403
|
</span>
|
|
389
404
|
)}
|
|
390
405
|
</div>
|
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test';
|
|
2
|
-
import {
|
|
3
|
-
|
|
2
|
+
import {
|
|
3
|
+
nodeStyle,
|
|
4
|
+
nodeSubtitle,
|
|
5
|
+
nodeTag,
|
|
6
|
+
nodeKindIcon,
|
|
7
|
+
nodeTypeLabel,
|
|
8
|
+
nodeShape,
|
|
9
|
+
nodeMissing,
|
|
10
|
+
NODE_W,
|
|
11
|
+
NODE_H,
|
|
12
|
+
nodeSize,
|
|
13
|
+
COMPONENT_SIZE,
|
|
14
|
+
} from './C4NodeCard';
|
|
15
|
+
import type { C4Node } from './c4';
|
|
4
16
|
|
|
5
17
|
const theme = {
|
|
6
18
|
colors: {
|
|
@@ -12,86 +24,172 @@ const theme = {
|
|
|
12
24
|
},
|
|
13
25
|
} as Parameters<typeof nodeStyle>[1];
|
|
14
26
|
|
|
27
|
+
/**
|
|
28
|
+
* A drawn node, flat. `state` is required on a real `C4Node` — every authored
|
|
29
|
+
* element carries a decision — so the card never has to cope with a box nobody
|
|
30
|
+
* proposed, and no test pretends otherwise.
|
|
31
|
+
*/
|
|
15
32
|
function node(over: Partial<C4Node> = {}): C4Node {
|
|
16
|
-
return { id: 'n', kind: 'container', label: 'n', members: [], constructs: [],
|
|
33
|
+
return { id: 'n', kind: 'container', label: 'n', members: [], constructs: [], state: 'proposed', ...over };
|
|
17
34
|
}
|
|
18
35
|
|
|
19
|
-
|
|
20
|
-
return node({ decoration: { id: 'container:x', state, ...extra } as C4Node['decoration'] });
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
describe('nodeStyle', () => {
|
|
36
|
+
describe('nodeStyle — one axis per channel', () => {
|
|
24
37
|
test('selected overrides everything', () => {
|
|
25
|
-
expect(nodeStyle(
|
|
26
|
-
expect(nodeStyle(node(), theme, true).
|
|
38
|
+
expect(nodeStyle(node({ state: 'accepted' }), theme, true).width).toBe(3);
|
|
39
|
+
expect(nodeStyle(node({ state: 'accepted' }), theme, true).dash).toBe('solid');
|
|
27
40
|
});
|
|
28
41
|
|
|
29
42
|
test('accepted is solid and uses the primary colour', () => {
|
|
30
|
-
const s = nodeStyle(
|
|
43
|
+
const s = nodeStyle(node({ state: 'accepted' }), theme);
|
|
31
44
|
expect(s.dash).toBe('solid');
|
|
32
45
|
expect(s.color).toBe('#4ec9b0');
|
|
33
46
|
});
|
|
34
47
|
|
|
35
48
|
test('proposed is dashed and uses the warning colour', () => {
|
|
36
|
-
const s = nodeStyle(
|
|
49
|
+
const s = nodeStyle(node({ state: 'proposed' }), theme);
|
|
37
50
|
expect(s.dash).toBe('dashed');
|
|
38
51
|
expect(s.color).toBe('#e8a33a');
|
|
39
52
|
});
|
|
40
53
|
|
|
41
54
|
test('rejected is dotted and muted', () => {
|
|
42
|
-
const s = nodeStyle(
|
|
55
|
+
const s = nodeStyle(node({ state: 'rejected' }), theme);
|
|
43
56
|
expect(s.dash).toBe('dotted');
|
|
44
57
|
expect(s.color).toBe('#555');
|
|
45
58
|
});
|
|
46
59
|
|
|
47
|
-
test('
|
|
48
|
-
|
|
60
|
+
test('the dash belongs to state, never to kind', () => {
|
|
61
|
+
// This is the bug that made proposed boxes draw solid: the card rendered
|
|
62
|
+
// `shape.dash`, and shape answered to kind. Every kind at the same state
|
|
63
|
+
// must now draw the same dash.
|
|
64
|
+
for (const kind of ['container', 'component', 'external-system', 'person'] as const) {
|
|
65
|
+
expect(nodeStyle(node({ kind, state: 'proposed' }), theme).dash).toBe('dashed');
|
|
66
|
+
expect(nodeStyle(node({ kind, state: 'accepted' }), theme).dash).toBe('solid');
|
|
67
|
+
}
|
|
49
68
|
});
|
|
50
69
|
|
|
51
|
-
test('
|
|
52
|
-
|
|
53
|
-
|
|
70
|
+
test('confirmation state wins over kind for colour', () => {
|
|
71
|
+
// Same state, four kinds: one colour. Kind is shape's job.
|
|
72
|
+
const colors = (['container', 'component', 'external-system', 'person'] as const).map(
|
|
73
|
+
(kind) => nodeStyle(node({ kind, state: 'accepted' }), theme).color,
|
|
74
|
+
);
|
|
75
|
+
expect(new Set(colors).size).toBe(1);
|
|
54
76
|
});
|
|
55
77
|
|
|
56
|
-
test('
|
|
57
|
-
|
|
58
|
-
|
|
78
|
+
test('border weight carries the level, not the state', () => {
|
|
79
|
+
// Otherwise a component and a container differ only by hue, which is
|
|
80
|
+
// invisible in grayscale and to a colourblind reader.
|
|
81
|
+
expect(nodeStyle(node({ kind: 'component' }), theme).width).toBe(1);
|
|
82
|
+
expect(nodeStyle(node(), theme).width).toBe(2);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('a component stays thinner once confirmed', () => {
|
|
86
|
+
expect(nodeStyle(node({ kind: 'component', state: 'accepted' }), theme).width).toBe(1);
|
|
59
87
|
});
|
|
60
88
|
|
|
61
89
|
test('falls back safely when the theme has no colour', () => {
|
|
62
90
|
const bare = { colors: {} } as Parameters<typeof nodeStyle>[1];
|
|
63
|
-
expect(() => nodeStyle(
|
|
64
|
-
expect(nodeStyle(
|
|
91
|
+
expect(() => nodeStyle(node({ state: 'accepted' }), bare)).not.toThrow();
|
|
92
|
+
expect(nodeStyle(node({ state: 'accepted' }), bare).dash).toBe('solid');
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('nodeTag — what the box IS', () => {
|
|
97
|
+
test('names the C4 kind, never the confirmation state', () => {
|
|
98
|
+
expect(nodeTag(node({ state: 'accepted' }))).toBe('container');
|
|
99
|
+
expect(nodeTag(node({ state: 'proposed' }))).toBe('container');
|
|
100
|
+
expect(nodeTag(node({ state: 'rejected' }))).toBe('container');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('keeps all four kinds distinct', () => {
|
|
104
|
+
expect(nodeTag(node({ kind: 'component' }))).toBe('component');
|
|
105
|
+
expect(nodeTag(node({ kind: 'external-system' }))).toBe('external-system');
|
|
106
|
+
expect(nodeTag(node({ kind: 'person' }))).toBe('person');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('a confirmed container is still a container', () => {
|
|
110
|
+
// This is the regression that made the two levels unreadable: state used
|
|
111
|
+
// to overwrite the kind, so an accepted container stopped being one.
|
|
112
|
+
expect(nodeTag(node({ state: 'accepted' }))).not.toBe('confirmed');
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
describe('nodeKindIcon — the kind as a glyph, not a word', () => {
|
|
117
|
+
test('every kind has its own icon', () => {
|
|
118
|
+
const icons = (['container', 'component', 'external-system', 'person'] as const).map((kind) =>
|
|
119
|
+
nodeKindIcon(node({ kind })),
|
|
120
|
+
);
|
|
121
|
+
for (const icon of icons) expect(icon).toBeDefined();
|
|
122
|
+
// Different kinds must not share a glyph, or the icon says nothing.
|
|
123
|
+
expect(new Set(icons).size).toBe(4);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('the icon does not depend on confirmation state', () => {
|
|
127
|
+
expect(nodeKindIcon(node({ state: 'proposed' }))).toBe(
|
|
128
|
+
nodeKindIcon(node({ state: 'accepted' })),
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
describe('nodeTypeLabel — the one word in the icon row', () => {
|
|
134
|
+
test('a container names its kind', () => {
|
|
135
|
+
expect(nodeTypeLabel(node({ containerKind: 'application' }))).toBe('application');
|
|
136
|
+
expect(nodeTypeLabel(node({ containerKind: 'data-store' }))).toBe('data-store');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test('the other three kinds have no subtype, so no word', () => {
|
|
140
|
+
for (const kind of ['component', 'external-system', 'person'] as const) {
|
|
141
|
+
expect(nodeTypeLabel(node({ kind }))).toBeUndefined();
|
|
142
|
+
}
|
|
65
143
|
});
|
|
66
144
|
});
|
|
67
145
|
|
|
68
|
-
describe('
|
|
69
|
-
test('
|
|
70
|
-
|
|
71
|
-
expect(
|
|
72
|
-
expect(
|
|
146
|
+
describe('nodeShape — kind without relying on colour or dash', () => {
|
|
147
|
+
test('a container is a sharp rectangle', () => {
|
|
148
|
+
const s = nodeShape(node());
|
|
149
|
+
expect(s.kind).toBe('rect');
|
|
150
|
+
expect(s.radius).toBeLessThanOrEqual(4);
|
|
73
151
|
});
|
|
74
152
|
|
|
75
|
-
test('
|
|
76
|
-
expect(
|
|
77
|
-
|
|
78
|
-
|
|
153
|
+
test('a component is a square', () => {
|
|
154
|
+
expect(nodeShape(node({ kind: 'component' })).kind).toBe('square');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('an external system is a rounded rectangle', () => {
|
|
158
|
+
const s = nodeShape(node({ kind: 'external-system' }));
|
|
159
|
+
expect(s.kind).toBe('rect');
|
|
160
|
+
expect(s.radius).toBeGreaterThan(nodeShape(node()).radius);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test('a person is a pill', () => {
|
|
164
|
+
const s = nodeShape(node({ kind: 'person' }));
|
|
165
|
+
expect(s.kind).toBe('pill');
|
|
166
|
+
expect(s.radius).toBeGreaterThanOrEqual(NODE_H / 2);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('shape does not depend on confirmation state', () => {
|
|
170
|
+
expect(nodeShape(node({ state: 'proposed' })).kind).toBe(
|
|
171
|
+
nodeShape(node({ state: 'accepted' })).kind,
|
|
172
|
+
);
|
|
79
173
|
});
|
|
80
174
|
});
|
|
81
175
|
|
|
82
176
|
describe('nodeSubtitle', () => {
|
|
83
|
-
test('shows
|
|
177
|
+
test('shows the technology, and does not repeat the container kind', () => {
|
|
84
178
|
const s = nodeSubtitle(
|
|
85
|
-
|
|
179
|
+
node({ containerKind: 'application', technology: 'Bun + Electrobun' }),
|
|
86
180
|
);
|
|
87
|
-
expect(s).toBe('
|
|
181
|
+
expect(s).toBe('Bun + Electrobun');
|
|
182
|
+
expect(s).not.toContain('application');
|
|
88
183
|
});
|
|
89
184
|
|
|
90
|
-
test('
|
|
91
|
-
|
|
185
|
+
test('a container with no technology falls back to its construct breakdown', () => {
|
|
186
|
+
const s = nodeSubtitle(
|
|
187
|
+
node({ containerKind: 'data-store', technology: '', constructs: ['store'], members: ['a'] }),
|
|
188
|
+
);
|
|
189
|
+
expect(s).toBe('1 store · 1 component');
|
|
92
190
|
});
|
|
93
191
|
|
|
94
|
-
test('falls back to the construct breakdown
|
|
192
|
+
test('falls back to the construct breakdown when neither is stated', () => {
|
|
95
193
|
const s = nodeSubtitle(node({ constructs: ['function', 'store'], members: ['a', 'b'] }));
|
|
96
194
|
expect(s).toContain('function');
|
|
97
195
|
expect(s).toContain('store');
|
|
@@ -119,38 +217,85 @@ describe('nodeSubtitle', () => {
|
|
|
119
217
|
);
|
|
120
218
|
expect(s).toBe('function · model.ts');
|
|
121
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');
|
|
223
|
+
});
|
|
122
224
|
});
|
|
123
225
|
|
|
124
226
|
describe('nodeMissing', () => {
|
|
125
|
-
test('a
|
|
126
|
-
expect(nodeMissing(node())).toEqual([
|
|
227
|
+
test('a container with neither reports both notation gaps', () => {
|
|
228
|
+
expect(nodeMissing(node({ technology: '', description: undefined }))).toEqual([
|
|
229
|
+
'technology',
|
|
230
|
+
'description',
|
|
231
|
+
]);
|
|
127
232
|
});
|
|
128
233
|
|
|
129
234
|
test('one supplied gap drops only that one', () => {
|
|
130
|
-
expect(nodeMissing(
|
|
235
|
+
expect(nodeMissing(node({ technology: 'React', description: undefined }))).toEqual([
|
|
236
|
+
'description',
|
|
237
|
+
]);
|
|
131
238
|
});
|
|
132
239
|
|
|
133
240
|
test('a fully stated container reports nothing', () => {
|
|
134
241
|
expect(
|
|
135
|
-
nodeMissing(
|
|
242
|
+
nodeMissing(node({ technology: 'React', description: 'Renders graphs.', state: 'accepted' })),
|
|
136
243
|
).toEqual([]);
|
|
137
244
|
});
|
|
138
245
|
|
|
139
246
|
test('the requirement is on containers only', () => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
247
|
+
for (const kind of ['external-system', 'person', 'component'] as const) {
|
|
248
|
+
expect(nodeMissing(node({ kind, technology: '', description: undefined }))).toEqual([]);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
describe('nodeSize — a component is a proper square', () => {
|
|
254
|
+
test('a component has equal width and height', () => {
|
|
255
|
+
const s = nodeSize(node({ kind: 'component' }));
|
|
256
|
+
expect(s.width).toBe(s.height);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test('a container is wider than it is tall', () => {
|
|
260
|
+
const s = nodeSize(node());
|
|
261
|
+
expect(s.width).toBeGreaterThan(s.height);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('a component is smaller than its container', () => {
|
|
265
|
+
const c = nodeSize(node({ kind: 'component' }));
|
|
266
|
+
const k = nodeSize(node());
|
|
267
|
+
expect(c.width).toBeLessThan(k.width);
|
|
268
|
+
expect(c.height).toBeLessThan(k.height);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('the square still fits its three lines of text', () => {
|
|
272
|
+
// tag + 2-line label slot + subtitle + 10px padding x2. Without this the
|
|
273
|
+
// square clips its own subtitle.
|
|
274
|
+
const s = nodeSize(node({ kind: 'component' }));
|
|
275
|
+
const content = 16 + 40 + 16 + 20;
|
|
276
|
+
expect(s.height).toBeGreaterThanOrEqual(content);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('every kind but a component shares the container box', () => {
|
|
280
|
+
const c = nodeSize(node());
|
|
281
|
+
expect(nodeSize(node({ kind: 'external-system' }))).toEqual(c);
|
|
282
|
+
expect(nodeSize(node({ kind: 'person' }))).toEqual(c);
|
|
143
283
|
});
|
|
144
284
|
});
|
|
145
285
|
|
|
146
286
|
describe('card metrics', () => {
|
|
147
287
|
test('exposes the size the graph lays out against', () => {
|
|
148
|
-
expect(NODE_W).
|
|
149
|
-
expect(NODE_H).
|
|
288
|
+
expect(NODE_W).toBe(210);
|
|
289
|
+
expect(NODE_H).toBe(138);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('COMPONENT_SIZE is a square, not just a smaller rectangle', () => {
|
|
293
|
+
expect(COMPONENT_SIZE).toBe(nodeSize(node({ kind: 'component' })).width);
|
|
294
|
+
expect(COMPONENT_SIZE).toBe(nodeSize(node({ kind: 'component' })).height);
|
|
150
295
|
});
|
|
151
296
|
|
|
152
297
|
test('is near-square, not a wide strip', () => {
|
|
153
|
-
// The card carries four fields (tag, label,
|
|
298
|
+
// The card carries four fields (tag, label, kind+technology, notation
|
|
154
299
|
// gap). A 3:1 strip truncates the last two; this is the aspect ratio that
|
|
155
300
|
// lets each sit on its own line.
|
|
156
301
|
const ratio = NODE_W / NODE_H;
|
|
@@ -177,20 +322,17 @@ describe('card metrics', () => {
|
|
|
177
322
|
});
|
|
178
323
|
});
|
|
179
324
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
'library',
|
|
186
|
-
'software-system',
|
|
187
|
-
'person',
|
|
188
|
-
];
|
|
189
|
-
|
|
190
|
-
describe('element types', () => {
|
|
191
|
-
test('each one renders as a subtitle without special-casing', () => {
|
|
192
|
-
for (const type of ALL_TYPES) {
|
|
193
|
-
expect(nodeSubtitle(decorated('accepted', { type }))).toBe(type);
|
|
325
|
+
describe('container kinds', () => {
|
|
326
|
+
test('every container kind renders as the icon-row label without special-casing', () => {
|
|
327
|
+
const kinds = ['application', 'data-store'] as const;
|
|
328
|
+
for (const containerKind of kinds) {
|
|
329
|
+
expect(nodeTypeLabel(node({ containerKind }))).toBe(containerKind);
|
|
194
330
|
}
|
|
195
331
|
});
|
|
332
|
+
|
|
333
|
+
test('a library is not one of them', () => {
|
|
334
|
+
// C4 says a module "typically" is not an element at all, so there is no
|
|
335
|
+
// kind to render. A library-shaped group is a review comment.
|
|
336
|
+
expect(['application', 'data-store']).not.toContain('library');
|
|
337
|
+
});
|
|
196
338
|
});
|