@principal-ai/subsystems-react 0.51.2 → 0.51.4

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 (85) 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 +115 -22
  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 +32 -13
  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 +14 -0
  26. package/dist/subsystem/model.d.ts.map +1 -1
  27. package/dist/subsystem/model.js.map +1 -1
  28. package/dist/subsystem/nodes.d.ts.map +1 -1
  29. package/dist/subsystem/nodes.js +4 -2
  30. package/dist/subsystem/nodes.js.map +1 -1
  31. package/dist/subsystem/techIcons/bun.d.ts +11 -0
  32. package/dist/subsystem/techIcons/bun.d.ts.map +1 -0
  33. package/dist/subsystem/techIcons/bun.js +11 -0
  34. package/dist/subsystem/techIcons/bun.js.map +1 -0
  35. package/dist/subsystem/techIcons/index.d.ts +36 -0
  36. package/dist/subsystem/techIcons/index.d.ts.map +1 -0
  37. package/dist/subsystem/techIcons/index.js +55 -0
  38. package/dist/subsystem/techIcons/index.js.map +1 -0
  39. package/dist/subsystem/techIcons/node.d.ts +12 -0
  40. package/dist/subsystem/techIcons/node.d.ts.map +1 -0
  41. package/dist/subsystem/techIcons/node.js +39 -0
  42. package/dist/subsystem/techIcons/node.js.map +1 -0
  43. package/dist/subsystem/techIcons/react.d.ts +12 -0
  44. package/dist/subsystem/techIcons/react.d.ts.map +1 -0
  45. package/dist/subsystem/techIcons/react.js +19 -0
  46. package/dist/subsystem/techIcons/react.js.map +1 -0
  47. package/dist/utils/edgeLabel.d.ts +1 -1
  48. package/dist/utils/edgeLabel.d.ts.map +1 -1
  49. package/dist/utils/edgeLabel.js +1 -1
  50. package/dist/utils/edgeLabel.js.map +1 -1
  51. package/dist/utils/elkLayout.d.ts +18 -0
  52. package/dist/utils/elkLayout.d.ts.map +1 -1
  53. package/dist/utils/elkLayout.js +90 -9
  54. package/dist/utils/elkLayout.js.map +1 -1
  55. package/package.json +3 -3
  56. package/src/index.ts +4 -6
  57. package/src/stories/Subsystem/C4Graph/C4Container.stories.tsx +212 -0
  58. package/src/stories/Subsystem/C4Graph/C4NodeGallery.stories.tsx +20 -10
  59. package/src/stories/Subsystem/ComponentGraph/IssueOverlay.stories.tsx +4 -2
  60. package/src/stories/Subsystem/ComponentGraph/ProposedTrails.stories.tsx +21 -1
  61. package/src/subsystem/C4Graph.tsx +171 -19
  62. package/src/subsystem/C4NodeCard.test.ts +71 -77
  63. package/src/subsystem/C4NodeCard.tsx +65 -95
  64. package/src/subsystem/SubsystemComponentGraph.tsx +41 -13
  65. package/src/subsystem/TrailsPanel.tsx +10 -5
  66. package/src/subsystem/c4.ts +23 -357
  67. package/src/subsystem/c4Evidence.test.ts +1 -1
  68. package/src/subsystem/c4Evidence.ts +1 -1
  69. package/src/subsystem/model.ts +14 -0
  70. package/src/subsystem/nodes.tsx +5 -2
  71. package/src/subsystem/techIcons/bun.ts +10 -0
  72. package/src/subsystem/techIcons/index.tsx +73 -0
  73. package/src/subsystem/techIcons/node.ts +38 -0
  74. package/src/subsystem/techIcons/react.ts +18 -0
  75. package/src/subsystem/techIcons/techIcons.test.ts +39 -0
  76. package/src/utils/edgeLabel.ts +1 -1
  77. package/src/utils/elkLayout.test.ts +34 -0
  78. package/src/utils/elkLayout.ts +88 -9
  79. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts +0 -493
  80. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.d.ts.map +0 -1
  81. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js +0 -329
  82. package/dist/stories/Subsystem/C4Graph/c4ProposedElements.fixture.js.map +0 -1
  83. package/src/stories/Subsystem/C4Graph/ProposedElements.stories.tsx +0 -293
  84. package/src/stories/Subsystem/C4Graph/c4ProposedElements.fixture.ts +0 -366
  85. package/src/subsystem/c4.test.ts +0 -573
@@ -1,573 +0,0 @@
1
- import { describe, expect, test } from 'bun:test';
2
- import { buildC4Model, summarizeElements, labelFromPurl } from './c4';
3
- import type { C4Container, C4Element, C4Model } from './c4';
4
- import type { SubsystemModelDocument } from './model';
5
-
6
- const REPO = 'pkg:github/acme/repo';
7
-
8
- /**
9
- * The four element kinds as test factories.
10
- *
11
- * Separate helpers rather than one `element({ kind })`, because the point of
12
- * the union is that the shapes differ: a component has a `container`, a person
13
- * has no `technology`. A permissive factory would hide exactly what the types
14
- * are here to enforce.
15
- */
16
- function container(over: Partial<C4Container> & { id: string }): C4Container {
17
- return {
18
- kind: 'container',
19
- containerKind: 'application',
20
- technology: 'Bun',
21
- members: [],
22
- label: over.id,
23
- state: 'proposed',
24
- ...over,
25
- };
26
- }
27
-
28
- function component(over: { id: string; container: string } & Partial<Omit<C4Element, 'kind'>>): C4Element {
29
- return {
30
- kind: 'component',
31
- technology: 'React',
32
- members: [],
33
- label: over.id,
34
- state: 'proposed',
35
- ...over,
36
- } as C4Element;
37
- }
38
-
39
- function external(over: { id: string } & Partial<C4Element>): C4Element {
40
- return { kind: 'external-system', members: [], label: over.id, state: 'proposed', ...over } as C4Element;
41
- }
42
-
43
- function person(over: { id: string } & Partial<C4Element>): C4Element {
44
- return { kind: 'person', members: [], label: over.id, state: 'proposed', ...over } as C4Element;
45
- }
46
-
47
- const doc: SubsystemModelDocument = {
48
- components: [
49
- { alias: 'a', name: 'Alpha', construct: 'function', file: 'src/a.ts', purl: `${REPO}#src/a.ts` },
50
- { alias: 'b', name: 'Beta', construct: 'function', file: 'src/b.ts', purl: `${REPO}#src/b.ts` },
51
- { alias: 'c', name: 'Gamma', construct: 'function', file: 'src/c.ts', purl: `${REPO}#src/c.ts` },
52
- { alias: 'd', name: 'Delta', construct: 'function', file: 'src/d.ts', purl: `${REPO}#src/d.ts` },
53
- { alias: 's', name: 'Cache', construct: 'store', file: 'src/s.ts', purl: `${REPO}#src/s.ts` },
54
- { alias: 't', name: 'Iface', construct: 'interface', file: 'src/t.ts', purl: `${REPO}#src/t.ts` },
55
- ],
56
- trails: [
57
- {
58
- id: 'w1',
59
- title: 'flow',
60
- steps: [
61
- { from: 'a', to: 'b', mechanism: 'calls', file: 'src/a.ts', line: 1, purl: `${REPO}#src/a.ts`, symbol: 'Alpha' },
62
- { from: 'a', to: 'c', mechanism: 'calls', file: 'src/a.ts', line: 2, purl: `${REPO}#src/a.ts`, symbol: 'Alpha' },
63
- { from: 'b', to: 'c', mechanism: 'reads', file: 'src/b.ts', line: 3, purl: `${REPO}#src/b.ts`, symbol: 'Beta' },
64
- ],
65
- },
66
- ],
67
- };
68
-
69
- const ids = (m: C4Model, kind?: string) =>
70
- m.nodes.filter((n) => !kind || n.kind === kind).map((n) => n.id);
71
-
72
- describe('buildC4Model — boxes come from the author, not a derivation', () => {
73
- test('one box per element', () => {
74
- const { model } = buildC4Model({
75
- elements: [container({ id: 'container:x', members: ['a', 'b'] })],
76
- document: doc,
77
- });
78
- expect(ids(model, 'container')).toEqual(['container:x']);
79
- });
80
-
81
- test('draws a box for an element the author named, with no process involved', () => {
82
- // No `process` field anywhere — the membership is the author's claim.
83
- const { model } = buildC4Model({
84
- elements: [container({ id: 'container:invented', members: ['a'] })],
85
- document: doc,
86
- });
87
- expect(model.nodes.find((n) => n.id === 'container:invented')).toBeDefined();
88
- });
89
-
90
- test('draws nothing nobody proposed', () => {
91
- const { model, unmapped } = buildC4Model({
92
- elements: [container({ id: 'container:x', members: ['a'] })],
93
- document: doc,
94
- });
95
- expect(model.nodes).toHaveLength(1);
96
- expect(unmapped.sort()).toEqual(['b', 'c', 'd', 's', 't']);
97
- });
98
-
99
- test('an empty element set yields an empty diagram, not a fallback', () => {
100
- const { model, problems } = buildC4Model({ elements: [], document: doc });
101
- expect(model.nodes).toHaveLength(0);
102
- expect(model.edges).toHaveLength(0);
103
- expect(problems).toHaveLength(0);
104
- });
105
-
106
- test('works with no document at all', () => {
107
- const { model } = buildC4Model({
108
- elements: [container({ id: 'container:x', members: ['a'] })],
109
- });
110
- expect(model.nodes).toHaveLength(1);
111
- expect(model.nodes[0]!.members).toEqual(['a']);
112
- });
113
-
114
- test('resolves member constructs from the document', () => {
115
- const { model } = buildC4Model({
116
- elements: [container({ id: 'container:x', members: ['a', 's', 't'] })],
117
- document: doc,
118
- });
119
- expect(model.nodes[0]!.constructs.sort()).toEqual(['function', 'interface', 'store']);
120
- });
121
-
122
- test('carries the container kind onto the drawn node', () => {
123
- const { model } = buildC4Model({
124
- elements: [
125
- container({ id: 'container:store', containerKind: 'data-store', members: ['s'] }),
126
- container({ id: 'container:app', members: ['a'] }),
127
- ],
128
- document: doc,
129
- });
130
- expect(model.nodes.find((n) => n.id === 'container:store')!.containerKind).toBe('data-store');
131
- expect(model.nodes.find((n) => n.id === 'container:app')!.containerKind).toBe('application');
132
- });
133
- });
134
-
135
- describe('the four kinds', () => {
136
- test('a person carries no technology, and an external may', () => {
137
- const { model } = buildC4Model({
138
- elements: [
139
- person({ id: 'person:op', members: ['a'] }),
140
- external({ id: 'external:gh', members: ['b'], technology: 'REST' }),
141
- external({ id: 'external:db', members: ['s'] }),
142
- ],
143
- document: doc,
144
- });
145
- expect(model.nodes.find((n) => n.id === 'person:op')!.technology).toBeUndefined();
146
- expect(model.nodes.find((n) => n.id === 'external:gh')!.technology).toBe('REST');
147
- expect(model.nodes.find((n) => n.id === 'external:db')!.technology).toBeUndefined();
148
- });
149
-
150
- test('an external system sits inside the boundary; a person does not', () => {
151
- const { model } = buildC4Model({
152
- elements: [
153
- container({ id: 'container:x', members: ['a'] }),
154
- external({ id: 'external:gh', members: ['b'] }),
155
- person({ id: 'person:op', members: ['d'] }),
156
- component({ id: 'component:z', container: 'container:x', members: ['c'] }),
157
- ],
158
- document: doc,
159
- });
160
- const parentOf = (id: string) => model.nodes.find((n) => n.id === id)!.parentId;
161
- // C4 puts an external system inside the system boundary — it is something
162
- // we depend on — and a person outside every system.
163
- expect(parentOf('container:x')).toBe(model.system.id);
164
- expect(parentOf('external:gh')).toBe(model.system.id);
165
- expect(parentOf('person:op')).toBeUndefined();
166
- expect(parentOf('component:z')).toBe('container:x');
167
- });
168
-
169
- test('the container view frames only containers', () => {
170
- const { model } = buildC4Model({
171
- elements: [
172
- container({ id: 'container:x', members: ['a'] }),
173
- external({ id: 'external:gh', members: ['b'] }),
174
- person({ id: 'person:op', members: ['d'] }),
175
- component({ id: 'component:z', container: 'container:x', members: ['c'] }),
176
- ],
177
- document: doc,
178
- view: 'container',
179
- });
180
- expect(model.groups[0]!.memberIds).toEqual(['container:x']);
181
- });
182
- });
183
-
184
- describe('rejection', () => {
185
- test('a rejected element is not drawn', () => {
186
- const { model } = buildC4Model({
187
- elements: [container({ id: 'container:x', members: ['a'], state: 'rejected' })],
188
- document: doc,
189
- });
190
- expect(model.nodes).toHaveLength(0);
191
- });
192
-
193
- test('lines into a rejected element are dropped, not left dangling', () => {
194
- const { model } = buildC4Model({
195
- elements: [
196
- container({ id: 'container:x', members: ['a'] }),
197
- container({ id: 'container:y', members: ['c'], state: 'rejected' }),
198
- ],
199
- document: doc,
200
- });
201
- for (const e of model.edges) {
202
- expect(model.nodes.some((n) => n.id === e.source)).toBe(true);
203
- expect(model.nodes.some((n) => n.id === e.target)).toBe(true);
204
- }
205
- });
206
-
207
- test('its members become unclaimed rather than silently re-homed', () => {
208
- const { unmapped } = buildC4Model({
209
- elements: [
210
- container({ id: 'container:x', members: ['a'] }),
211
- container({ id: 'container:y', members: ['c'], state: 'rejected' }),
212
- ],
213
- document: doc,
214
- });
215
- expect(unmapped).toContain('c');
216
- });
217
-
218
- test('the system frame does not list a rejected element', () => {
219
- const { model } = buildC4Model({
220
- elements: [
221
- container({ id: 'container:x', members: ['a'] }),
222
- container({ id: 'container:y', members: ['c'], state: 'rejected' }),
223
- ],
224
- document: doc,
225
- });
226
- expect(model.groups[0]!.memberIds).toEqual(['container:x']);
227
- });
228
-
229
- test('rejecting a container orphans the components that named it', () => {
230
- const { model, problems } = buildC4Model({
231
- elements: [
232
- container({ id: 'container:x', members: ['a'], state: 'rejected' }),
233
- component({ id: 'component:z', container: 'container:x', members: ['c'] }),
234
- ],
235
- document: doc,
236
- });
237
- expect(problems.some((p) => p.kind === 'unknown_container')).toBe(true);
238
- expect(model.nodes.find((n) => n.id === 'component:z')!.parentId).toBeUndefined();
239
- });
240
- });
241
-
242
- describe('lines', () => {
243
- test('trail steps become lines between the owning elements', () => {
244
- const { model } = buildC4Model({
245
- elements: [
246
- container({ id: 'container:x', members: ['a'] }),
247
- container({ id: 'container:y', members: ['b', 'c'] }),
248
- ],
249
- document: doc,
250
- });
251
- // a->b and a->c are both x->y, so they are one line, counted twice.
252
- // b->c is inside y and draws nothing.
253
- const e = model.edges.find((x) => x.source === 'container:x' && x.target === 'container:y');
254
- expect(e?.count).toBe(2);
255
- expect(e?.label).toBe('calls');
256
- });
257
-
258
- test('steps inside one element do not draw a self-loop', () => {
259
- const { model } = buildC4Model({
260
- elements: [container({ id: 'container:x', members: ['a', 'b', 'c'] })],
261
- document: doc,
262
- });
263
- expect(model.edges.some((e) => e.source === e.target)).toBe(false);
264
- });
265
-
266
- test('a step between two members of one element draws no line', () => {
267
- const { model } = buildC4Model({
268
- elements: [
269
- container({ id: 'container:x', members: ['a'] }),
270
- container({ id: 'container:y', members: ['c'] }),
271
- ],
272
- document: doc,
273
- });
274
- // Only a->c is cross-element; b is claimed by nobody, and a->b dies with it.
275
- expect(model.edges).toHaveLength(1);
276
- expect(model.edges[0]).toMatchObject({ source: 'container:x', target: 'container:y', count: 1 });
277
- });
278
-
279
- test('several mechanisms on one line are labelled together', () => {
280
- const twoTrails: SubsystemModelDocument = {
281
- ...doc,
282
- trails: [
283
- doc.trails![0]!,
284
- {
285
- id: 'w2',
286
- title: 'other',
287
- steps: [{ from: 'a', to: 'c', mechanism: 'writes', file: 'src/a.ts', line: 9, purl: `${REPO}#src/a.ts`, symbol: 'Alpha' }],
288
- },
289
- ],
290
- };
291
- const { model } = buildC4Model({
292
- elements: [
293
- container({ id: 'container:x', members: ['a'] }),
294
- container({ id: 'container:y', members: ['c'] }),
295
- ],
296
- document: twoTrails,
297
- });
298
- expect(model.edges[0]!.label).toBe('calls + writes');
299
- });
300
-
301
- test('a step whose endpoint nobody claims draws no line', () => {
302
- const { model } = buildC4Model({
303
- elements: [
304
- container({ id: 'container:x', members: ['a'] }),
305
- container({ id: 'container:y', members: ['b'] }),
306
- ],
307
- document: doc,
308
- });
309
- // c is claimed by nobody, so a->c and b->c cannot be drawn.
310
- expect(model.edges).toHaveLength(1);
311
- expect(model.edges[0]).toMatchObject({ source: 'container:x', target: 'container:y', count: 1 });
312
- });
313
-
314
- test('a line to a component beats the line to its container', () => {
315
- // a->c resolves to the innermost owner of c, which is the component.
316
- const { model } = buildC4Model({
317
- elements: [
318
- container({ id: 'container:x', members: ['a', 'b'] }),
319
- container({ id: 'container:y', members: [] }),
320
- component({ id: 'component:z', container: 'container:y', members: ['c'] }),
321
- ],
322
- document: doc,
323
- view: 'component',
324
- });
325
- expect(model.edges.map((e) => `${e.source}->${e.target}`)).toContain('container:x->component:z');
326
- });
327
- });
328
-
329
- describe('component level', () => {
330
- test('a component nests under the container it names', () => {
331
- const { model } = buildC4Model({
332
- elements: [
333
- container({ id: 'container:y', members: ['c'] }),
334
- component({ id: 'component:z', container: 'container:y', members: ['t'] }),
335
- ],
336
- document: doc,
337
- view: 'component',
338
- });
339
- expect(model.nodes.find((n) => n.id === 'component:z')!.parentId).toBe('container:y');
340
- });
341
-
342
- test('the named container wins over any shared member', () => {
343
- // `c` is claimed by container:x too, but the component says it lives in y.
344
- // Inferring from shared members would have put it in x.
345
- const { model, problems } = buildC4Model({
346
- elements: [
347
- container({ id: 'container:x', members: ['a', 'b'] }),
348
- container({ id: 'container:y', members: ['d'] }),
349
- component({ id: 'component:z', container: 'container:y', members: ['c'] }),
350
- ],
351
- document: doc,
352
- view: 'component',
353
- });
354
- expect(model.nodes.find((n) => n.id === 'component:z')!.parentId).toBe('container:y');
355
- expect(problems).toHaveLength(0);
356
- });
357
-
358
- test('the container becomes a frame holding its components', () => {
359
- const { model } = buildC4Model({
360
- elements: [
361
- container({ id: 'container:y', members: ['c'] }),
362
- component({ id: 'component:z', container: 'container:y', members: ['t'] }),
363
- ],
364
- document: doc,
365
- view: 'component',
366
- });
367
- expect(model.groups.some((g) => g.kind === 'container' && g.memberIds.includes('component:z'))).toBe(true);
368
- });
369
-
370
- test('a container holds its components\' members without being told twice', () => {
371
- // Membership is authored once: the container must not repeat `t`. The
372
- // rollup is what makes the container's count mean "everything inside me".
373
- const { model, problems } = buildC4Model({
374
- elements: [
375
- container({ id: 'container:y', members: ['c'] }),
376
- component({ id: 'component:z', container: 'container:y', members: ['t'] }),
377
- ],
378
- document: doc,
379
- });
380
- expect(problems.some((p) => p.kind === 'conflicting_member')).toBe(false);
381
- expect(model.nodes.find((n) => n.id === 'container:y')!.members.sort()).toEqual(['c', 't']);
382
- expect(model.nodes.find((n) => n.id === 'component:z')!.members).toEqual(['t']);
383
- });
384
-
385
- test('a component naming a container that is not in the set is reported', () => {
386
- const { model, problems } = buildC4Model({
387
- elements: [component({ id: 'component:z', container: 'container:absent', members: ['c'] })],
388
- document: doc,
389
- view: 'component',
390
- });
391
- expect(problems.some((p) => p.kind === 'unknown_container')).toBe(true);
392
- expect(model.nodes.find((n) => n.id === 'component:z')!.parentId).toBeUndefined();
393
- });
394
-
395
- test('a component naming a non-container is reported', () => {
396
- const { problems } = buildC4Model({
397
- elements: [
398
- external({ id: 'external:gh', members: ['d'] }),
399
- component({ id: 'component:z', container: 'external:gh', members: ['c'] }),
400
- ],
401
- document: doc,
402
- view: 'component',
403
- });
404
- expect(problems.some((p) => p.kind === 'unknown_container')).toBe(true);
405
- });
406
-
407
- test('a component is not drawn inside the system frame directly', () => {
408
- const { model } = buildC4Model({
409
- elements: [
410
- container({ id: 'container:x', members: ['a', 'b'] }),
411
- container({ id: 'container:y', members: [] }),
412
- component({ id: 'component:z', container: 'container:x', members: ['c'] }),
413
- ],
414
- document: doc,
415
- view: 'container',
416
- });
417
- expect(model.nodes.find((n) => n.id === 'component:z')!.parentId).toBe('container:x');
418
- expect(model.groups[0]!.memberIds).toEqual(['container:x', 'container:y']);
419
- });
420
- });
421
-
422
- describe('authoring problems', () => {
423
- test('reports a member claimed by two elements, and keeps the first', () => {
424
- const { model, problems } = buildC4Model({
425
- elements: [
426
- container({ id: 'container:x', members: ['a'] }),
427
- container({ id: 'container:y', members: ['a'] }),
428
- ],
429
- document: doc,
430
- });
431
- expect(problems.some((p) => p.kind === 'conflicting_member')).toBe(true);
432
- expect(model.nodes.find((n) => n.id === 'container:x')!.members).toEqual(['a']);
433
- expect(model.nodes.find((n) => n.id === 'container:y')!.members).toEqual([]);
434
- });
435
-
436
- test('reports a member that is not in the document', () => {
437
- const { problems } = buildC4Model({
438
- elements: [container({ id: 'container:x', members: ['nope'] })],
439
- document: doc,
440
- });
441
- expect(problems.some((p) => p.kind === 'unknown_member')).toBe(true);
442
- });
443
-
444
- test('reports a duplicate id and draws it once', () => {
445
- const { model, problems } = buildC4Model({
446
- elements: [
447
- container({ id: 'container:x', members: ['a'] }),
448
- container({ id: 'container:x', members: ['b'] }),
449
- ],
450
- document: doc,
451
- });
452
- expect(problems.some((p) => p.kind === 'duplicate_id')).toBe(true);
453
- expect(model.nodes.filter((n) => n.id === 'container:x')).toHaveLength(1);
454
- });
455
-
456
- test('reports an element with nothing inside it', () => {
457
- const { problems } = buildC4Model({
458
- elements: [container({ id: 'container:x', members: [] })],
459
- document: doc,
460
- });
461
- expect(problems.some((p) => p.kind === 'empty_element')).toBe(true);
462
- });
463
-
464
- test('a clean set reports nothing', () => {
465
- const { problems } = buildC4Model({
466
- elements: [container({ id: 'container:x', members: ['a', 'b'] })],
467
- document: doc,
468
- });
469
- expect(problems).toHaveLength(0);
470
- });
471
- });
472
-
473
- describe('system framing', () => {
474
- test('derives the repo key from the members', () => {
475
- const { model } = buildC4Model({
476
- elements: [container({ id: 'container:x', members: ['a'] })],
477
- document: doc,
478
- });
479
- expect(model.system.repoKey).toBe(REPO);
480
- expect(model.system.label).toBe('acme/repo');
481
- });
482
-
483
- test('honours an explicit label and repo key', () => {
484
- const { model } = buildC4Model({
485
- elements: [container({ id: 'container:x', members: ['a'] })],
486
- document: doc,
487
- systemLabel: 'Acme',
488
- repoKey: REPO,
489
- });
490
- expect(model.system.label).toBe('Acme');
491
- });
492
-
493
- test('a set with nothing repo-shaped is not attributed to a repo', () => {
494
- // No document and no members, so there is no purl to attribute. Guessing a
495
- // repo here would put a repo name on a boundary diagram.
496
- const { model } = buildC4Model({
497
- elements: [external({ id: 'external:gh', members: [] })],
498
- document: { components: [], trails: [] },
499
- });
500
- expect(model.system.repoKey).toBeUndefined();
501
- expect(model.system.label).toBe('Software System');
502
- });
503
-
504
- test('labelFromPurl trims scheme and keeps owner/name', () => {
505
- expect(labelFromPurl(`${REPO}#src/a.ts`)).toBe('acme/repo');
506
- expect(labelFromPurl('external:api.github.com/gists')).toBe('api.github.com/gists');
507
- });
508
- });
509
-
510
- describe('consolidated provenance', () => {
511
- test('a box that absorbed several keys says so', () => {
512
- const { model } = buildC4Model({
513
- elements: [container({ id: 'container:x', members: ['a'], derivedFrom: ['app/host', 'other/host'] })],
514
- document: doc,
515
- });
516
- expect(model.nodes[0]!.derivedFrom).toEqual(['app/host', 'other/host']);
517
- });
518
-
519
- test('a single-key element carries no provenance list', () => {
520
- const { model } = buildC4Model({
521
- elements: [container({ id: 'container:x', members: ['a'], derivedFrom: ['app/host'] })],
522
- document: doc,
523
- });
524
- expect(model.nodes[0]!.derivedFrom).toBeUndefined();
525
- });
526
- });
527
-
528
- describe('summarizeElements', () => {
529
- test('counts confirmed, proposed, and rejected separately', () => {
530
- const s = summarizeElements([
531
- container({ id: 'a', state: 'accepted' }),
532
- container({ id: 'b', state: 'proposed' }),
533
- container({ id: 'c', state: 'rejected' }),
534
- ]);
535
- expect(s).toMatchObject({ accepted: 1, proposed: 1, rejected: 1, needsDecision: 1 });
536
- });
537
-
538
- test('a rejected element still counts — the decision was made', () => {
539
- const s = summarizeElements([container({ id: 'a', state: 'rejected' })]);
540
- expect(s.rejected).toBe(1);
541
- });
542
-
543
- test('tallies the four kinds separately', () => {
544
- const s = summarizeElements([
545
- container({ id: 'c1' }),
546
- container({ id: 'c2', containerKind: 'data-store' }),
547
- component({ id: 'k1', container: 'c1' }),
548
- external({ id: 'x1' }),
549
- person({ id: 'p1' }),
550
- ]);
551
- expect(s).toMatchObject({ containers: 2, components: 1, externals: 1, people: 1 });
552
- });
553
-
554
- test('counts the notation gaps C4 requires on containers', () => {
555
- const s = summarizeElements([
556
- container({ id: 'a', state: 'accepted', technology: 'Bun' }),
557
- container({ id: 'b', state: 'accepted', technology: '' }),
558
- ]);
559
- // `technology` is required by the type, so a gap can only be an empty
560
- // string — which is what an agent that had no framework signal writes.
561
- expect(s.missingTechnology).toBe(1);
562
- expect(s.missingDescription).toBe(2);
563
- });
564
-
565
- test('the technology requirement does not apply to a person', () => {
566
- const s = summarizeElements([
567
- component({ id: 'a', container: 'container:x', state: 'accepted' }),
568
- external({ id: 'b', state: 'accepted' }),
569
- person({ id: 'c', state: 'accepted' }),
570
- ]);
571
- expect(s.missingTechnology).toBe(0);
572
- });
573
- });