@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.
Files changed (88) hide show
  1. package/dist/index.d.ts +6 -6
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -4
  4. package/dist/index.js.map +1 -1
  5. package/dist/pierre/PierreTrailCodeView.d.ts +1 -1
  6. package/dist/pierre/PierreTrailCodeView.d.ts.map +1 -1
  7. package/dist/pierre/PierreTrailCodeView.js +44 -21
  8. package/dist/pierre/PierreTrailCodeView.js.map +1 -1
  9. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +493 -0
  10. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +1 -0
  11. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +329 -0
  12. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +1 -0
  13. package/dist/subsystem/C4Graph.d.ts +2 -2
  14. package/dist/subsystem/C4Graph.d.ts.map +1 -1
  15. package/dist/subsystem/C4Graph.js +25 -16
  16. package/dist/subsystem/C4Graph.js.map +1 -1
  17. package/dist/subsystem/C4NodeCard.d.ts +85 -35
  18. package/dist/subsystem/C4NodeCard.d.ts.map +1 -1
  19. package/dist/subsystem/C4NodeCard.js +112 -83
  20. package/dist/subsystem/C4NodeCard.js.map +1 -1
  21. package/dist/subsystem/FileDrawer.d.ts.map +1 -1
  22. package/dist/subsystem/FileDrawer.js +20 -21
  23. package/dist/subsystem/FileDrawer.js.map +1 -1
  24. package/dist/subsystem/IssueList.d.ts.map +1 -1
  25. package/dist/subsystem/IssueList.js +12 -3
  26. package/dist/subsystem/IssueList.js.map +1 -1
  27. package/dist/subsystem/SubsystemComponentGraph.d.ts.map +1 -1
  28. package/dist/subsystem/SubsystemComponentGraph.js +85 -7
  29. package/dist/subsystem/SubsystemComponentGraph.js.map +1 -1
  30. package/dist/subsystem/c4.d.ts +273 -0
  31. package/dist/subsystem/c4.d.ts.map +1 -0
  32. package/dist/subsystem/c4.js +322 -0
  33. package/dist/subsystem/c4.js.map +1 -0
  34. package/dist/subsystem/c4Evidence.d.ts +143 -0
  35. package/dist/subsystem/c4Evidence.d.ts.map +1 -0
  36. package/dist/subsystem/c4Evidence.js +240 -0
  37. package/dist/subsystem/c4Evidence.js.map +1 -0
  38. package/dist/subsystem/model.d.ts +2 -6
  39. package/dist/subsystem/model.d.ts.map +1 -1
  40. package/dist/subsystem/model.js.map +1 -1
  41. package/package.json +5 -4
  42. package/src/index.ts +26 -28
  43. package/src/pierre/PierreTrailCodeView.tsx +49 -21
  44. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +171 -80
  45. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +293 -0
  46. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +366 -0
  47. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +1 -1
  48. package/src/stories/Subsystem/ComponentGraph/Issues.stories.tsx +169 -3
  49. package/src/subsystem/C4Graph.tsx +39 -24
  50. package/src/subsystem/C4NodeCard.test.ts +204 -62
  51. package/src/subsystem/C4NodeCard.tsx +146 -101
  52. package/src/subsystem/FileDrawer.tsx +32 -34
  53. package/src/subsystem/IssueList.tsx +13 -2
  54. package/src/subsystem/SubsystemComponentGraph.tsx +98 -7
  55. package/src/subsystem/c4.test.ts +573 -0
  56. package/src/subsystem/c4.ts +559 -0
  57. package/src/subsystem/c4Evidence.test.ts +386 -0
  58. package/src/subsystem/c4Evidence.ts +329 -0
  59. package/src/subsystem/model.ts +2 -6
  60. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts +0 -95
  61. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.d.ts.map +0 -1
  62. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js +0 -340
  63. package/dist/stories/Subsystem/C4Graph/c4Associations.fixture.js.map +0 -1
  64. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts +0 -7
  65. package/dist/stories/Subsystem/C4Graph/c4Fixture.d.ts.map +0 -1
  66. package/dist/stories/Subsystem/C4Graph/c4Fixture.js +0 -7335
  67. package/dist/stories/Subsystem/C4Graph/c4Fixture.js.map +0 -1
  68. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts +0 -2
  69. package/dist/stories/Subsystem/C4Graph/generateFixture.d.ts.map +0 -1
  70. package/dist/stories/Subsystem/C4Graph/generateFixture.js +0 -145
  71. package/dist/stories/Subsystem/C4Graph/generateFixture.js.map +0 -1
  72. package/dist/subsystem/c4Associations.d.ts +0 -186
  73. package/dist/subsystem/c4Associations.d.ts.map +0 -1
  74. package/dist/subsystem/c4Associations.js +0 -284
  75. package/dist/subsystem/c4Associations.js.map +0 -1
  76. package/dist/subsystem/toC4.d.ts +0 -214
  77. package/dist/subsystem/toC4.d.ts.map +0 -1
  78. package/dist/subsystem/toC4.js +0 -282
  79. package/dist/subsystem/toC4.js.map +0 -1
  80. package/src/stories/Subsystem/C4Graph/C4Associations.stories.tsx +0 -273
  81. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +0 -101
  82. package/src/stories/Subsystem/C4Graph/c4Associations.fixture.ts +0 -344
  83. package/src/stories/Subsystem/C4Graph/c4Fixture.ts +0 -7339
  84. package/src/stories/Subsystem/C4Graph/generateFixture.ts +0 -161
  85. package/src/subsystem/c4Associations.test.ts +0 -484
  86. package/src/subsystem/c4Associations.ts +0 -398
  87. package/src/subsystem/toC4.test.ts +0 -114
  88. 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
- * Prototype test case: feed it a `C4Model` from `toC4()` and it draws the
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 './toC4';
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
- const rfNodes: Node[] = model.nodes.map((n, i) => ({
134
- id: n.id,
135
- type: 'c4-node',
136
- position: { x: (i % 5) * (NODE_W + 44), y: Math.floor(i / 5) * (NODE_H + 44) },
137
- width: NODE_W,
138
- height: NODE_H,
139
- ...(n.parentId ? { parentId: n.parentId } : {}),
140
- data: { node: n, selected: false },
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
- const flow = e.kind === 'flow';
151
- const color = flow ? edgeColor(e.mechanisms[0], muted) : (theme.colors.border ?? '#666');
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
- ...(flow ? { strokeDasharray: '6 4' } : {}),
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(() => model.nodes.find((n) => n.id === selectedId) ?? null, [model, selectedId]);
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((n) => n.kind === 'external' || n.kind === 'actor').length;
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.decoration?.state ?? 'unconfirmed'} · {selected.decoration?.type ?? 'no type'}
386
+ {selected.state}
387
+ {selected.containerKind ? ` · ${selected.containerKind}` : ` · ${selected.kind}`}
373
388
  </span>
374
- {selected.decoration?.technology ? (
375
- <span>technology: {selected.decoration.technology}</span>
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.decoration?.description ? (
380
- <span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>{selected.decoration.description}</span>
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.sourceKeys && selected.sourceKeys.length > 1 && (
400
+ {selected.derivedFrom && selected.derivedFrom.length > 1 && (
386
401
  <span style={{ whiteSpace: 'normal', lineHeight: 1.4 }}>
387
- merged from: {selected.sourceKeys.join(', ')}
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 { nodeStyle, nodeSubtitle, nodeTag, nodeMissing, NODE_W, NODE_H } from './C4NodeCard';
3
- import type { C4Node, C4ElementType, C4AssociationState } from './toC4';
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: [], isStore: false, ...over };
33
+ return { id: 'n', kind: 'container', label: 'n', members: [], constructs: [], state: 'proposed', ...over };
17
34
  }
18
35
 
19
- function decorated(state: C4AssociationState, extra: Record<string, unknown> = {}): C4Node {
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(decorated('accepted'), theme, true).width).toBe(3);
26
- expect(nodeStyle(node(), theme, true).color).toBe('#4ec9b0');
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(decorated('accepted'), theme);
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(decorated('proposed'), theme);
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(decorated('rejected'), theme);
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('no association is muted, so confirmed boxes read stronger', () => {
48
- expect(nodeStyle(node(), theme).color).toBe('#555');
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('an external and an actor are distinguished without an association', () => {
52
- expect(nodeStyle(node({ kind: 'external' }), theme).color).toBe('#e8a33a');
53
- expect(nodeStyle(node({ kind: 'actor' }), theme).color).toBe('#e3b341');
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('confirmation state wins over kind', () => {
57
- const s = nodeStyle(decorated('accepted'), theme);
58
- expect(s.color).toBe('#4ec9b0');
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(decorated('accepted'), bare)).not.toThrow();
64
- expect(nodeStyle(decorated('accepted'), bare).dash).toBe('solid');
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('nodeTag', () => {
69
- test('names the confirmation state ahead of the C4 kind', () => {
70
- expect(nodeTag(decorated('accepted'))).toBe('confirmed');
71
- expect(nodeTag(decorated('proposed'))).toBe('proposed');
72
- expect(nodeTag(decorated('rejected'))).toBe('rejected');
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('falls back to the kind when there is no association', () => {
76
- expect(nodeTag(node())).toBe('container');
77
- expect(nodeTag(node({ kind: 'external' }))).toBe('external');
78
- expect(nodeTag(node({ kind: 'actor' }))).toBe('actor');
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 type and technology once confirmed', () => {
177
+ test('shows the technology, and does not repeat the container kind', () => {
84
178
  const s = nodeSubtitle(
85
- decorated('accepted', { type: 'application', technology: 'Bun + Electrobun' }),
179
+ node({ containerKind: 'application', technology: 'Bun + Electrobun' }),
86
180
  );
87
- expect(s).toBe('application · Bun + Electrobun');
181
+ expect(s).toBe('Bun + Electrobun');
182
+ expect(s).not.toContain('application');
88
183
  });
89
184
 
90
- test('omits technology when it was never stated', () => {
91
- expect(nodeSubtitle(decorated('accepted', { type: 'data-store' }))).toBe('data-store');
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 for a raw container', () => {
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 bare container reports both notation gaps', () => {
126
- expect(nodeMissing(node())).toEqual(['technology', 'description']);
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(decorated('accepted', { technology: 'React' }))).toEqual(['description']);
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(decorated('accepted', { technology: 'React', description: 'Renders graphs.' })),
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
- expect(nodeMissing(node({ kind: 'external' }))).toEqual([]);
141
- expect(nodeMissing(node({ kind: 'actor' }))).toEqual([]);
142
- expect(nodeMissing(node({ kind: 'component' }))).toEqual([]);
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).toBeGreaterThan(0);
149
- expect(NODE_H).toBeGreaterThan(0);
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, type+technology, notation
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
- /** Compile-time guard: every declared element type is reachable from a card. */
181
- const ALL_TYPES: C4ElementType[] = [
182
- 'application',
183
- 'data-store',
184
- 'queue',
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
  });