@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.
Files changed (73) hide show
  1. package/dist/index.d.ts +5 -3
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +3 -2
  4. package/dist/index.js.map +1 -1
  5. package/dist/subsystem/C4Graph.d.ts +12 -1
  6. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  7. package/dist/subsystem/C4Graph.js +82 -14
  8. package/dist/subsystem/C4Graph.js.map +1 -1
  9. package/dist/subsystem/C4NodeCard.d.ts +22 -22
  10. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  11. package/dist/subsystem/C4NodeCard.js +56 -77
  12. package/dist/subsystem/C4NodeCard.js.map +1 -1
  13. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  14. package/dist/subsystem/SubsystemComponentGraph.js +0 -6
  15. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  16. package/dist/subsystem/TrailsPanel.d.ts.map +1 -1
  17. package/dist/subsystem/TrailsPanel.js +8 -4
  18. package/dist/subsystem/TrailsPanel.js.map +1 -1
  19. package/dist/subsystem/c4.d.ts +24 -86
  20. package/dist/subsystem/c4.d.ts.map +1 -1
  21. package/dist/subsystem/c4.js +6 -285
  22. package/dist/subsystem/c4.js.map +1 -1
  23. package/dist/subsystem/c4Evidence.js +1 -1
  24. package/dist/subsystem/c4Evidence.js.map +1 -1
  25. package/dist/subsystem/model.d.ts +8 -0
  26. package/dist/subsystem/model.d.ts.map +1 -1
  27. package/dist/subsystem/model.js.map +1 -1
  28. package/dist/subsystem/techIcons/bun.d.ts +11 -0
  29. package/dist/subsystem/techIcons/bun.d.ts.map +1 -0
  30. package/dist/subsystem/techIcons/bun.js +11 -0
  31. package/dist/subsystem/techIcons/bun.js.map +1 -0
  32. package/dist/subsystem/techIcons/index.d.ts +36 -0
  33. package/dist/subsystem/techIcons/index.d.ts.map +1 -0
  34. package/dist/subsystem/techIcons/index.js +55 -0
  35. package/dist/subsystem/techIcons/index.js.map +1 -0
  36. package/dist/subsystem/techIcons/node.d.ts +12 -0
  37. package/dist/subsystem/techIcons/node.d.ts.map +1 -0
  38. package/dist/subsystem/techIcons/node.js +39 -0
  39. package/dist/subsystem/techIcons/node.js.map +1 -0
  40. package/dist/subsystem/techIcons/react.d.ts +12 -0
  41. package/dist/subsystem/techIcons/react.d.ts.map +1 -0
  42. package/dist/subsystem/techIcons/react.js +19 -0
  43. package/dist/subsystem/techIcons/react.js.map +1 -0
  44. package/dist/utils/elkLayout.d.ts.map +1 -1
  45. package/dist/utils/elkLayout.js +16 -5
  46. package/dist/utils/elkLayout.js.map +1 -1
  47. package/package.json +3 -3
  48. package/src/index.ts +4 -6
  49. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +212 -0
  50. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +20 -10
  51. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +4 -2
  52. package/src/subsystem/C4Graph.tsx +127 -12
  53. package/src/subsystem/C4NodeCard.test.ts +71 -77
  54. package/src/subsystem/C4NodeCard.tsx +65 -95
  55. package/src/subsystem/SubsystemComponentGraph.tsx +0 -6
  56. package/src/subsystem/TrailsPanel.tsx +10 -5
  57. package/src/subsystem/c4.ts +23 -357
  58. package/src/subsystem/c4Evidence.test.ts +1 -1
  59. package/src/subsystem/c4Evidence.ts +1 -1
  60. package/src/subsystem/model.ts +8 -0
  61. package/src/subsystem/techIcons/bun.ts +10 -0
  62. package/src/subsystem/techIcons/index.tsx +73 -0
  63. package/src/subsystem/techIcons/node.ts +38 -0
  64. package/src/subsystem/techIcons/react.ts +18 -0
  65. package/src/subsystem/techIcons/techIcons.test.ts +39 -0
  66. package/src/utils/elkLayout.ts +16 -5
  67. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +0 -493
  68. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +0 -1
  69. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +0 -329
  70. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +0 -1
  71. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +0 -293
  72. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +0 -366
  73. 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 a `C4Model` from `buildC4Model()` and it draws the
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-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.
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: e.label,
166
- labelShowBg: false,
167
- labelStyle: { fill: muted, fontSize: theme.fontSizes[0], fontFamily: theme.fonts.monospace },
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
- nodeTypeLabel,
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 — 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);
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('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');
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('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
- }
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('shows the technology, and does not repeat the container kind', () => {
178
- const s = nodeSubtitle(
179
- node({ containerKind: 'application', technology: 'Bun + Electrobun' }),
180
- );
181
- expect(s).toBe('Bun + Electrobun');
182
- expect(s).not.toContain('application');
183
- });
184
-
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');
190
- });
191
-
192
- test('falls back to the construct breakdown when neither is stated', () => {
193
- const s = nodeSubtitle(node({ constructs: ['function', 'store'], members: ['a', 'b'] }));
194
- expect(s).toContain('function');
195
- expect(s).toContain('store');
196
- expect(s).toContain('2 components');
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(210);
289
- expect(NODE_H).toBe(138);
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 four fields (tag, label, kind+technology, notation
299
- // gap). A 3:1 strip truncates the last two; this is the aspect ratio that
300
- // lets each sit on its own line.
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 four lines of content', () => {
307
- // tag + 2-line label + subtitle + gap marker, plus 8px padding top/bottom.
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 four lines of text', () => {
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: tag + 2-line label slot + subtitle + gap slot + padding.
314
- const contentBudget = 16 + 40 + 16 + 18 + 20; // lines + 10px padding ×2
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('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);
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.