@dxos/diagram 0.11.1 → 0.13.0

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 (171) hide show
  1. package/dist/lib/chunk-parse.mjs +9396 -0
  2. package/dist/lib/chunk-parse.mjs.map +1 -0
  3. package/dist/lib/extension.mjs +204 -0
  4. package/dist/lib/extension.mjs.map +1 -0
  5. package/dist/lib/index.mjs +497 -2846
  6. package/dist/lib/index.mjs.map +1 -1
  7. package/dist/types/src/aesthetics.d.ts +12 -0
  8. package/dist/types/src/aesthetics.d.ts.map +1 -0
  9. package/dist/types/src/aesthetics.test.d.ts +2 -0
  10. package/dist/types/src/aesthetics.test.d.ts.map +1 -0
  11. package/dist/types/src/appeal.d.ts +55 -0
  12. package/dist/types/src/appeal.d.ts.map +1 -0
  13. package/dist/types/src/appeal.test.d.ts +2 -0
  14. package/dist/types/src/appeal.test.d.ts.map +1 -0
  15. package/dist/types/src/architecture.d.ts +86 -0
  16. package/dist/types/src/architecture.d.ts.map +1 -0
  17. package/dist/types/src/architecture.test.d.ts +2 -0
  18. package/dist/types/src/architecture.test.d.ts.map +1 -0
  19. package/dist/types/src/compact.d.ts +39 -0
  20. package/dist/types/src/compact.d.ts.map +1 -0
  21. package/dist/types/src/compact.test.d.ts +2 -0
  22. package/dist/types/src/compact.test.d.ts.map +1 -0
  23. package/dist/types/src/diagnostics.d.ts +70 -3
  24. package/dist/types/src/diagnostics.d.ts.map +1 -1
  25. package/dist/types/src/dsl/cli.d.ts +2 -0
  26. package/dist/types/src/dsl/cli.d.ts.map +1 -0
  27. package/dist/types/src/dsl/compile.d.ts +12 -0
  28. package/dist/types/src/dsl/compile.d.ts.map +1 -0
  29. package/dist/types/src/dsl/convert.d.ts +45 -0
  30. package/dist/types/src/dsl/convert.d.ts.map +1 -0
  31. package/dist/types/src/dsl/convert.test.d.ts +2 -0
  32. package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
  33. package/dist/types/src/dsl/dsl.test.d.ts +2 -0
  34. package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
  35. package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
  36. package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
  37. package/dist/types/src/dsl/index.d.ts +7 -0
  38. package/dist/types/src/dsl/index.d.ts.map +1 -0
  39. package/dist/types/src/dsl/parse.d.ts +49 -0
  40. package/dist/types/src/dsl/parse.d.ts.map +1 -0
  41. package/dist/types/src/dsl/print.d.ts +19 -0
  42. package/dist/types/src/dsl/print.d.ts.map +1 -0
  43. package/dist/types/src/dsl/semantic.test.d.ts +2 -0
  44. package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
  45. package/dist/types/src/dsl/vocabulary.d.ts +61 -0
  46. package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
  47. package/dist/types/src/dx-svg.d.ts +12 -0
  48. package/dist/types/src/dx-svg.d.ts.map +1 -0
  49. package/dist/types/src/dx-svg.test.d.ts +2 -0
  50. package/dist/types/src/dx-svg.test.d.ts.map +1 -0
  51. package/dist/types/src/extension/complete.d.ts +3 -0
  52. package/dist/types/src/extension/complete.d.ts.map +1 -0
  53. package/dist/types/src/extension/extension.test.d.ts +2 -0
  54. package/dist/types/src/extension/extension.test.d.ts.map +1 -0
  55. package/dist/types/src/extension/highlight.d.ts +14 -0
  56. package/dist/types/src/extension/highlight.d.ts.map +1 -0
  57. package/dist/types/src/extension/index.d.ts +6 -0
  58. package/dist/types/src/extension/index.d.ts.map +1 -0
  59. package/dist/types/src/extension/language.d.ts +13 -0
  60. package/dist/types/src/extension/language.d.ts.map +1 -0
  61. package/dist/types/src/extension/lint.d.ts +21 -0
  62. package/dist/types/src/extension/lint.d.ts.map +1 -0
  63. package/dist/types/src/extension/syntax.d.ts +3 -0
  64. package/dist/types/src/extension/syntax.d.ts.map +1 -0
  65. package/dist/types/src/index.d.ts +14 -0
  66. package/dist/types/src/index.d.ts.map +1 -1
  67. package/dist/types/src/mermaid-engine.d.ts +40 -1
  68. package/dist/types/src/mermaid-engine.d.ts.map +1 -1
  69. package/dist/types/src/mermaid.d.ts +3 -3
  70. package/dist/types/src/mermaid.d.ts.map +1 -1
  71. package/dist/types/src/nudge.d.ts +30 -0
  72. package/dist/types/src/nudge.d.ts.map +1 -0
  73. package/dist/types/src/nudge.test.d.ts +2 -0
  74. package/dist/types/src/nudge.test.d.ts.map +1 -0
  75. package/dist/types/src/objective.d.ts +4 -0
  76. package/dist/types/src/objective.d.ts.map +1 -1
  77. package/dist/types/src/ortho-router.d.ts +13 -1
  78. package/dist/types/src/ortho-router.d.ts.map +1 -1
  79. package/dist/types/src/ports.d.ts +40 -0
  80. package/dist/types/src/ports.d.ts.map +1 -0
  81. package/dist/types/src/ports.test.d.ts +2 -0
  82. package/dist/types/src/ports.test.d.ts.map +1 -0
  83. package/dist/types/src/relations.test.d.ts +2 -0
  84. package/dist/types/src/relations.test.d.ts.map +1 -0
  85. package/dist/types/src/rules.d.ts +30 -0
  86. package/dist/types/src/rules.d.ts.map +1 -0
  87. package/dist/types/src/scene.d.ts +99 -65
  88. package/dist/types/src/scene.d.ts.map +1 -1
  89. package/dist/types/src/score.d.ts +54 -0
  90. package/dist/types/src/score.d.ts.map +1 -0
  91. package/dist/types/src/score.test.d.ts +2 -0
  92. package/dist/types/src/score.test.d.ts.map +1 -0
  93. package/dist/types/src/semantic-engine.d.ts +41 -0
  94. package/dist/types/src/semantic-engine.d.ts.map +1 -0
  95. package/dist/types/src/semantic-engine.test.d.ts +2 -0
  96. package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
  97. package/dist/types/src/semantic-place.d.ts +81 -0
  98. package/dist/types/src/semantic-place.d.ts.map +1 -0
  99. package/dist/types/src/semantic-route.d.ts +133 -0
  100. package/dist/types/src/semantic-route.d.ts.map +1 -0
  101. package/dist/types/src/semantic.d.ts +164 -0
  102. package/dist/types/src/semantic.d.ts.map +1 -0
  103. package/dist/types/src/uml-grid.d.ts.map +1 -1
  104. package/dist/types/src/uml.d.ts +3 -5
  105. package/dist/types/src/uml.d.ts.map +1 -1
  106. package/dist/types/src/view.d.ts +53 -0
  107. package/dist/types/src/view.d.ts.map +1 -0
  108. package/dist/types/src/view.test.d.ts +2 -0
  109. package/dist/types/src/view.test.d.ts.map +1 -0
  110. package/dist/types/tsconfig.tsbuildinfo +1 -1
  111. package/package.json +23 -5
  112. package/rules/DIAGRAM.mdl +388 -0
  113. package/src/aesthetics.test.ts +89 -0
  114. package/src/aesthetics.ts +148 -0
  115. package/src/appeal.test.ts +144 -0
  116. package/src/appeal.ts +586 -0
  117. package/src/architecture.test.ts +94 -0
  118. package/src/architecture.ts +215 -0
  119. package/src/compact.test.ts +67 -0
  120. package/src/compact.ts +219 -0
  121. package/src/diagnostics.test.ts +207 -1
  122. package/src/diagnostics.ts +481 -37
  123. package/src/dsl/cli.ts +46 -0
  124. package/src/dsl/compile.ts +28 -0
  125. package/src/dsl/convert.test.ts +51 -0
  126. package/src/dsl/convert.ts +98 -0
  127. package/src/dsl/diagram.grammar +195 -0
  128. package/src/dsl/dsl.test.ts +286 -0
  129. package/src/dsl/gen/diagram.ts +22 -0
  130. package/src/dsl/index.ts +44 -0
  131. package/src/dsl/parse.ts +1054 -0
  132. package/src/dsl/print.ts +198 -0
  133. package/src/dsl/semantic.test.ts +200 -0
  134. package/src/dsl/vocabulary.ts +150 -0
  135. package/src/dx-svg.test.ts +44 -0
  136. package/src/dx-svg.ts +77 -0
  137. package/src/extension/complete.ts +105 -0
  138. package/src/extension/extension.test.ts +135 -0
  139. package/src/extension/highlight.ts +23 -0
  140. package/src/extension/index.ts +9 -0
  141. package/src/extension/language.ts +31 -0
  142. package/src/extension/lint.ts +60 -0
  143. package/src/extension/syntax.ts +62 -0
  144. package/src/index.ts +14 -0
  145. package/src/mermaid-engine.test.ts +6 -6
  146. package/src/mermaid-engine.ts +569 -119
  147. package/src/mermaid.test.ts +17 -1
  148. package/src/mermaid.ts +30 -18
  149. package/src/nudge.test.ts +155 -0
  150. package/src/nudge.ts +385 -0
  151. package/src/objective.test.ts +2 -0
  152. package/src/objective.ts +17 -1
  153. package/src/ortho-router.ts +240 -80
  154. package/src/ports.test.ts +157 -0
  155. package/src/ports.ts +186 -0
  156. package/src/relations.test.ts +98 -0
  157. package/src/rules.ts +117 -0
  158. package/src/scene.ts +64 -3
  159. package/src/score.test.ts +64 -0
  160. package/src/score.ts +130 -0
  161. package/src/semantic-engine.test.ts +635 -0
  162. package/src/semantic-engine.ts +888 -0
  163. package/src/semantic-place.ts +1508 -0
  164. package/src/semantic-route.ts +1136 -0
  165. package/src/semantic.ts +278 -0
  166. package/src/typings.d.ts +8 -0
  167. package/src/uml-grid.ts +6 -1
  168. package/src/uml.test.ts +5 -1
  169. package/src/uml.ts +24 -11
  170. package/src/view.test.ts +139 -0
  171. package/src/view.ts +376 -0
@@ -0,0 +1,635 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import * as EffectEx from '@dxos/effect/EffectEx';
8
+
9
+ import { analyze, errors } from './diagnostics.ts';
10
+ import { compile, parse, read, toScene } from './dsl/index.ts';
11
+ import * as MermaidEngine from './mermaid-engine.ts';
12
+ import type * as Scene from './scene.ts';
13
+ import { measureBox, pitchFor, stretchIssues } from './semantic-engine.ts';
14
+ import * as Route from './semantic-route.ts';
15
+ import * as Semantic from './semantic.ts';
16
+
17
+ type Rect = { x: number; y: number; w: number; h: number };
18
+
19
+ const sceneOf = (text: string) => {
20
+ const { commands, problems } = parse(text);
21
+ return { objects: toScene(commands).objects, problems };
22
+ };
23
+
24
+ /** Absolute rect of an object's first box. */
25
+ const rectOf = (objects: readonly Scene.WorldObject[], id: string): Rect => {
26
+ const object = objects.find((entry) => entry.id === id);
27
+ const box = object?.elements.find(
28
+ (element): element is Scene.Box => element.kind === 'rect' || element.kind === 'ellipse',
29
+ );
30
+ if (!object || !box) {
31
+ throw new Error(`no box for ${id}`);
32
+ }
33
+ return { x: (object.origin?.x ?? 0) + box.x, y: (object.origin?.y ?? 0) + box.y, w: box.w, h: box.h };
34
+ };
35
+
36
+ /** A connector's whole polyline: its `-path` line (if any) joined with its arrow, or a bare line. */
37
+ const pathOf = (objects: readonly Scene.WorldObject[], id: string): Scene.Point[] => {
38
+ const elements = objects.find((entry) => entry.id === 'edges')?.elements ?? [];
39
+ const line = elements.find(
40
+ (element) => element.id === `${id}-path` || (element.id === id && element.kind === 'line'),
41
+ );
42
+ const arrow = elements.find((element) => element.id === id && element.kind === 'arrow');
43
+ const prefix = line?.kind === 'line' ? line.points : [];
44
+ const head = arrow?.kind === 'arrow' && arrow.start && arrow.end ? [arrow.start, arrow.end] : [];
45
+ return [...prefix.slice(0, head.length ? -1 : undefined), ...head];
46
+ };
47
+
48
+ const bendsOf = (points: readonly Scene.Point[]) => Math.max(0, points.length - 2);
49
+
50
+ const contains = (outer: Rect, inner: Rect) =>
51
+ inner.x >= outer.x &&
52
+ inner.y >= outer.y &&
53
+ inner.x + inner.w <= outer.x + outer.w &&
54
+ inner.y + inner.h <= outer.y + outer.h;
55
+
56
+ const intersects = (left: Rect, right: Rect) =>
57
+ left.x < right.x + right.w && right.x < left.x + left.w && left.y < right.y + right.h && right.y < left.y + left.h;
58
+
59
+ const PROCESS = `
60
+ diagram flow=down
61
+ group req "Requesters" {
62
+ node TrigDisp "TriggerDispatcher"
63
+ node AgentSvc "AgentService" right-of TrigDisp
64
+ }
65
+ group local "Local runtime" below req {
66
+ node PM "ProcessManager" below AgentSvc
67
+ node OpInvoker "ProcOpInvoker" right-of PM
68
+ node LocalHandle "ProcessHandle" below PM
69
+ node Store "ProcessStore" left-of LocalHandle
70
+ node ProcDef "Process def" right-of LocalHandle
71
+ }
72
+ group remote "EDGE runtime" right-of local {
73
+ node RPM "RemoteProcessMgr"
74
+ node RHandle "RemoteProcHandle" right-of RPM
75
+ node Queued "QueuedRemoteCtl" below RHandle
76
+ node EdgeCtl "EdgeProcControl" left-of Queued
77
+ }
78
+ edge AgentSvc -> PM "spawn local"
79
+ edge AgentSvc -> RPM:top "spawn on EDGE"
80
+ edge TrigDisp -> PM "spawn"
81
+ edge PM -> LocalHandle "owns, no cap"
82
+ edge PM -> Store "persist"
83
+ edge PM -> OpInvoker
84
+ edge OpInvoker -> PM "spawn child"
85
+ edge LocalHandle -> ProcDef "run"
86
+ edge LocalHandle -> Store "delete on exit"
87
+ edge RPM -> RHandle "make"
88
+ edge RHandle -> Queued "control"
89
+ edge Queued -> EdgeCtl "deliver"
90
+ `;
91
+
92
+ describe('semantic engine', { timeout: 120_000 }, () => {
93
+ test('cell pins land on their cell and exact pins on their point', ({ expect }) => {
94
+ const { objects, problems } = sceneOf('node A @cell(2,1)\nnode B @ 0,0\nedge A -> B');
95
+ expect(problems).toEqual([]);
96
+ const pitch = pitchFor(measureBox(['A', 'B']));
97
+ expect(rectOf(objects, 'A')).toMatchObject({ x: 2 * pitch.w, y: pitch.h });
98
+ expect(rectOf(objects, 'B')).toMatchObject({ x: 0, y: 0 });
99
+ });
100
+
101
+ test('relations hold', ({ expect }) => {
102
+ const { objects } = sceneOf(`
103
+ node Hub
104
+ node East right-of Hub
105
+ node South below Hub
106
+ node West left-of Hub
107
+ node North above Hub
108
+ node Far same-row South
109
+ edge Hub -> East
110
+ edge Hub -> South
111
+ `);
112
+ const [hub, east, south, west, north, far] = ['Hub', 'East', 'South', 'West', 'North', 'Far'].map((id) =>
113
+ rectOf(objects, id),
114
+ );
115
+ expect(east.y).toBe(hub.y);
116
+ expect(east.x).toBeGreaterThan(hub.x);
117
+ expect(south.x).toBe(hub.x);
118
+ expect(south.y).toBeGreaterThan(hub.y);
119
+ expect(west.y).toBe(hub.y);
120
+ expect(west.x).toBeLessThan(hub.x);
121
+ expect(north.x).toBe(hub.x);
122
+ expect(north.y).toBeLessThan(hub.y);
123
+ expect(far.y).toBe(south.y);
124
+ });
125
+
126
+ test('a group is a unit: its frame holds its members and no other box', ({ expect }) => {
127
+ const { objects } = sceneOf(`
128
+ group g "G" { node A node B node C edge A -> B edge B -> C }
129
+ node X node Y
130
+ edge X -> A
131
+ edge C -> Y
132
+ edge X -> Y
133
+ `);
134
+ const frame = rectOf(objects, 'g');
135
+ for (const id of ['A', 'B', 'C']) {
136
+ expect(contains(frame, rectOf(objects, id))).toBe(true);
137
+ }
138
+ for (const id of ['X', 'Y']) {
139
+ expect(intersects(frame, rectOf(objects, id))).toBe(false);
140
+ }
141
+ });
142
+
143
+ test('groups are placed relative to each other', ({ expect }) => {
144
+ const { objects } = sceneOf(`
145
+ group a "A" { node A1 node A2 below A1 }
146
+ group b "B" right-of a gap=64 { node B1 }
147
+ edge A1 -> B1
148
+ `);
149
+ const [a, b] = [rectOf(objects, 'a'), rectOf(objects, 'b')];
150
+ expect(b.x).toBeGreaterThanOrEqual(a.x + a.w + 64);
151
+ });
152
+
153
+ test('allowed sides constrain the ports', ({ expect }) => {
154
+ const { objects } = sceneOf('node A @cell(0,0)\nnode B @cell(1,1)\nedge A:left -> B:bottom');
155
+ const [a, b] = [rectOf(objects, 'A'), rectOf(objects, 'B')];
156
+ const points = pathOf(objects, 'A-B-0');
157
+ expect(points[0].x).toBe(a.x);
158
+ expect(points[points.length - 1].y).toBe(b.y + b.h);
159
+ });
160
+
161
+ test('a waypoint pins a coordinate of the route', ({ expect }) => {
162
+ const { objects, problems } = sceneOf('node A @cell(0,0)\nnode B @cell(2,1)\nedge A:right -> B:top via 256,_');
163
+ expect(problems).toEqual([]);
164
+ const points = pathOf(objects, 'A-B-0');
165
+ const verticalAt = points.slice(1).some((point, index) => point.x === 256 && points[index].x === 256);
166
+ expect(verticalAt).toBe(true);
167
+ expect(points[points.length - 1].y).toBe(rectOf(objects, 'B').y);
168
+ });
169
+
170
+ test('a waypoint no route can honour is reported, and the edge is still drawn', ({ expect }) => {
171
+ // A run at y=-900 lies far above B, so no route from it can come back up into B's bottom side.
172
+ const { objects, problems } = sceneOf('node B @cell(0,0)\nnode A @cell(0,1)\nedge A:left -> B:bottom via _,-900');
173
+ expect(problems.map(({ severity }) => severity)).toEqual(['warning']);
174
+ expect(pathOf(objects, 'A-B-0').length).toBeGreaterThan(1);
175
+ });
176
+
177
+ test('a channel waypoint in cell units runs through the gutter', ({ expect }) => {
178
+ const { objects } = sceneOf('node A @cell(0,0)\nnode B @cell(0,2)\nnode X @cell(0,1)\nedge A -> B via cell(0.5,_)');
179
+ const [a, x] = [rectOf(objects, 'A'), rectOf(objects, 'X')];
180
+ const points = pathOf(objects, 'A-B-0');
181
+ const channel = points.find((point, index) => index > 0 && point.x === points[index - 1].x && point.x > a.x + a.w);
182
+ expect(channel).toBeDefined();
183
+ expect(channel && channel.x).toBeGreaterThan(x.x + x.w);
184
+ });
185
+
186
+ test('a bus fans out of one trunk', ({ expect }) => {
187
+ const { objects } = sceneOf(`
188
+ node Hub @cell(1,0)
189
+ node A @cell(0,1)
190
+ node B @cell(1,1)
191
+ node C @cell(2,1)
192
+ edge Hub -> A, B, C "fan" bus
193
+ `);
194
+ const elements = objects.find((entry) => entry.id === 'edges')?.elements ?? [];
195
+ const trunk = elements.find((element) => element.kind === 'line' && element.id.endsWith('-trunk'));
196
+ expect(trunk?.kind).toBe('line');
197
+ const junction = trunk?.kind === 'line' ? trunk.points[trunk.points.length - 1] : undefined;
198
+ for (const id of ['Hub-A-0', 'Hub-B-1', 'Hub-C-2']) {
199
+ expect(pathOf(objects, id)[0]).toEqual(junction);
200
+ }
201
+ expect(elements.some((element) => element.kind === 'text' && element.text === 'fan')).toBe(true);
202
+ });
203
+
204
+ describe('inheritance', () => {
205
+ const connectorsOf = (objects: readonly Scene.WorldObject[]) =>
206
+ objects.find((entry) => entry.id === 'edges')?.elements ?? [];
207
+
208
+ test('subtypes of one abstraction share a row under it', ({ expect }) => {
209
+ const { objects } = sceneOf(`
210
+ diagram flow=down
211
+ node Animal
212
+ node Dog
213
+ node Cat
214
+ node Fish
215
+ edge Dog extends Animal
216
+ edge Cat extends Animal
217
+ edge Fish extends Animal
218
+ `);
219
+ const [animal, dog, cat, fish] = ['Animal', 'Dog', 'Cat', 'Fish'].map((id) => rectOf(objects, id));
220
+ expect(cat.y).toBe(dog.y);
221
+ expect(fish.y).toBe(dog.y);
222
+ expect(dog.y).toBeGreaterThan(animal.y + animal.h);
223
+ });
224
+
225
+ test('subtypes gather into one trunk with one triangle at the abstraction', ({ expect }) => {
226
+ const { objects } = sceneOf(`
227
+ node Animal @cell(1,0)
228
+ node Dog @cell(0,1)
229
+ node Cat @cell(2,1)
230
+ edge Dog extends Animal
231
+ edge Cat extends Animal
232
+ `);
233
+ const [animal, dog, cat] = ['Animal', 'Dog', 'Cat'].map((id) => rectOf(objects, id));
234
+ const elements = connectorsOf(objects);
235
+ const arrows = elements.filter((element) => element.kind === 'arrow');
236
+ expect(arrows).toHaveLength(1);
237
+ const [arrow] = arrows;
238
+ expect(arrow.kind === 'arrow' && arrow.relation).toBe('inheritance');
239
+ expect(arrow.kind === 'arrow' && arrow.end).toEqual({ x: animal.x + animal.w / 2, y: animal.y + animal.h });
240
+ const trunk = pathOf(objects, arrow.id);
241
+ const junction = trunk[0];
242
+ for (const [id, rect] of [
243
+ ['Dog-Animal-0', dog],
244
+ ['Cat-Animal-1', cat],
245
+ ] as const) {
246
+ const spoke = pathOf(objects, id);
247
+ expect(spoke[0].y).toBe(rect.y);
248
+ expect(spoke[spoke.length - 1]).toEqual(junction);
249
+ }
250
+ });
251
+
252
+ test('a single subtype keeps its own straight edge', ({ expect }) => {
253
+ const { objects } = sceneOf('node Animal @cell(0,0)\nnode Dog @cell(0,1)\nedge Dog extends Animal');
254
+ const elements = connectorsOf(objects);
255
+ expect(elements.some((element) => element.id.endsWith('-trunk'))).toBe(false);
256
+ expect(bendsOf(pathOf(objects, 'Dog-Animal-0'))).toBe(0);
257
+ });
258
+
259
+ test('explicit hints win over the shared row and the trunk', ({ expect }) => {
260
+ const { objects } = sceneOf(`
261
+ diagram flow=down
262
+ node Animal
263
+ node Dog below Animal
264
+ node Cat right-of Animal
265
+ edge Dog extends Animal
266
+ edge Cat:left extends Animal:right
267
+ `);
268
+ const [animal, dog, cat] = ['Animal', 'Dog', 'Cat'].map((id) => rectOf(objects, id));
269
+ expect(cat.y).toBe(animal.y);
270
+ expect(dog.y).toBeGreaterThan(animal.y);
271
+ expect(connectorsOf(objects).filter((element) => element.kind === 'arrow')).toHaveLength(2);
272
+ const route = pathOf(objects, 'Cat-Animal-1');
273
+ expect(route[0].x).toBe(cat.x);
274
+ expect(route[route.length - 1].x).toBe(animal.x + animal.w);
275
+ });
276
+ });
277
+
278
+ describe('port significance', () => {
279
+ const FACE = 'node Hub @cell(1,0)\nnode Left @cell(0,1)\nnode Right @cell(2,1)\n';
280
+
281
+ test('relations rank from inheritance down to dependency', ({ expect }) => {
282
+ const ranked = [
283
+ Semantic.significance('inheritance'),
284
+ Semantic.significance('composition'),
285
+ Semantic.significance('aggregation'),
286
+ Semantic.significance(undefined),
287
+ Semantic.significance('dependency'),
288
+ ];
289
+ expect(ranked).toEqual([...ranked].sort((left, right) => right - left));
290
+ expect(new Set(ranked).size).toBe(ranked.length);
291
+ expect(Semantic.significance('implementation')).toBe(Semantic.significance('inheritance'));
292
+ expect(Semantic.significance(undefined, 'dashed')).toBe(Semantic.significance('dependency'));
293
+ });
294
+
295
+ test('the more significant of two edges on one face enters at its centre', ({ expect }) => {
296
+ for (const [statements, central, aside] of [
297
+ ['edge Left:top depends-on Hub:bottom\nedge Right:top composes Hub:bottom', 'Right-Hub-1', 'Left-Hub-0'],
298
+ ['edge Left:top composes Hub:bottom\nedge Right:top depends-on Hub:bottom', 'Left-Hub-0', 'Right-Hub-1'],
299
+ ] as const) {
300
+ const { objects } = sceneOf(`${FACE}${statements}`);
301
+ const hub = rectOf(objects, 'Hub');
302
+ const [centralEnd, asideEnd] = [central, aside].map((id) => pathOf(objects, id).at(-1));
303
+ expect(centralEnd).toEqual({ x: hub.x + hub.w / 2, y: hub.y + hub.h });
304
+ expect(asideEnd?.y).toBe(hub.y + hub.h);
305
+ expect(asideEnd?.x).not.toBe(hub.x + hub.w / 2);
306
+ expect(analyze(objects).metrics.crossings).toBe(0);
307
+ }
308
+ });
309
+
310
+ test('edges of equal significance keep their order, centred on the face', ({ expect }) => {
311
+ const { objects } = sceneOf(`${FACE}edge Left:top -> Hub:bottom\nedge Right:top -> Hub:bottom`);
312
+ const hub = rectOf(objects, 'Hub');
313
+ const [left, right] = ['Left-Hub-0', 'Right-Hub-1'].map((id) => pathOf(objects, id).at(-1)?.x ?? NaN);
314
+ expect(left).toBeLessThan(right);
315
+ expect(left + right).toBe(2 * (hub.x + hub.w / 2));
316
+ });
317
+
318
+ test('an inheritance trunk leaves the subtype from its centre beside a dependency', ({ expect }) => {
319
+ const { objects } = sceneOf(`
320
+ diagram flow=down
321
+ node Animal
322
+ node Dog below Animal
323
+ node Cat right-of Dog
324
+ node Logger above Cat
325
+ edge Dog extends Animal
326
+ edge Cat extends Animal
327
+ edge Cat depends-on Logger
328
+ `);
329
+ const cat = rectOf(objects, 'Cat');
330
+ const centre = { x: cat.x + cat.w / 2, y: cat.y };
331
+ expect(pathOf(objects, 'Cat-Animal-1')[0]).toEqual(centre);
332
+ const logger = pathOf(objects, 'Cat-Logger-2')[0];
333
+ expect(logger.y).toBe(cat.y);
334
+ expect(logger.x).not.toBe(centre.x);
335
+ });
336
+ });
337
+
338
+ test('stacked boxes connect side to side down the free channel instead of snaking', ({ expect }) => {
339
+ const stack = 'node A @cell(0,0)\nnode X @cell(0,1)\nnode B @cell(0,2)\nedge A -> X\nedge X -> B\n';
340
+ const free = sceneOf(`${stack}edge A -> B`).objects;
341
+ const forced = sceneOf(`${stack}edge A:bottom -> B:top`).objects;
342
+ const [a, b] = [rectOf(free, 'A'), rectOf(free, 'B')];
343
+ const route = pathOf(free, 'A-B-2');
344
+ // Left to left (or right to right): one U through the channel beside X.
345
+ expect(bendsOf(route)).toBe(2);
346
+ expect([a.x, a.x + a.w]).toContain(route[0].x);
347
+ expect(route[route.length - 1].x).toBe(route[0].x === a.x ? b.x : b.x + b.w);
348
+ expect(bendsOf(pathOf(forced, 'A-B-2'))).toBeGreaterThan(bendsOf(route));
349
+ expect(errors(analyze(free))).toEqual([]);
350
+ });
351
+
352
+ test('end to end: an intent-level diagram lays out without defects', ({ expect }) => {
353
+ const { objects, problems } = sceneOf(PROCESS);
354
+ expect(problems).toEqual([]);
355
+ const report = analyze(objects);
356
+ expect(errors(report)).toEqual([]);
357
+ expect(report.metrics.crossings).toBe(0);
358
+ expect(report.metrics.bends).toBeLessThanOrEqual(4);
359
+ });
360
+
361
+ test('compile without hints is no worse than the mermaid engine', async ({ expect }) => {
362
+ const text = PROCESS.replace(/ (right-of|left-of|below|above) \w+/g, '').replace(/:top/g, '');
363
+ const { commands, problems } = await EffectEx.runPromise(compile(text));
364
+ expect(problems).toEqual([]);
365
+ const ours = analyze(toScene(commands).objects);
366
+ const mermaid = `flowchart TB
367
+ subgraph req [Requesters]
368
+ TrigDisp[TriggerDispatcher]
369
+ AgentSvc[AgentService]
370
+ end
371
+ subgraph local [Local runtime]
372
+ PM[ProcessManager]
373
+ OpInvoker[ProcOpInvoker]
374
+ LocalHandle[ProcessHandle]
375
+ Store[ProcessStore]
376
+ ProcDef[Process def]
377
+ end
378
+ subgraph remote [EDGE runtime]
379
+ RPM[RemoteProcessMgr]
380
+ RHandle[RemoteProcHandle]
381
+ Queued[QueuedRemoteCtl]
382
+ EdgeCtl[EdgeProcControl]
383
+ end
384
+ AgentSvc -->|spawn local| PM
385
+ AgentSvc -->|spawn on EDGE| RPM
386
+ TrigDisp -->|spawn| PM
387
+ PM -->|owns, no cap| LocalHandle
388
+ PM -->|persist| Store
389
+ PM --> OpInvoker
390
+ OpInvoker -->|spawn child| PM
391
+ LocalHandle -->|run| ProcDef
392
+ LocalHandle -->|delete on exit| Store
393
+ RPM -->|make| RHandle
394
+ RHandle -->|control| Queued
395
+ Queued -->|deliver| EdgeCtl`;
396
+ const engine = analyze(toScene(await MermaidEngine.compile(mermaid)).objects);
397
+ expect(errors(ours)).toEqual([]);
398
+ expect(ours.metrics.crossings).toBeLessThanOrEqual(engine.metrics.crossings);
399
+ expect(ours.metrics.crossings * 3 + ours.metrics.bends).toBeLessThanOrEqual(
400
+ engine.metrics.crossings * 3 + engine.metrics.bends,
401
+ );
402
+ });
403
+
404
+ describe('edge labels', () => {
405
+ /** Gap between a rect and a polyline of axis-aligned runs. */
406
+ const gapTo = (rect: Rect, points: readonly Scene.Point[]) =>
407
+ Math.min(
408
+ ...points.slice(1).map((to, index) => {
409
+ const from = points[index];
410
+ const dx = Math.max(Math.min(from.x, to.x) - (rect.x + rect.w), rect.x - Math.max(from.x, to.x), 0);
411
+ const dy = Math.max(Math.min(from.y, to.y) - (rect.y + rect.h), rect.y - Math.max(from.y, to.y), 0);
412
+ return Math.hypot(dx, dy);
413
+ }),
414
+ );
415
+
416
+ /** Each label's drawn text (the SVG renderer's narrower glyphs) with its own route. */
417
+ const labelsOf = (objects: readonly Scene.WorldObject[]) =>
418
+ (objects.find((entry) => entry.id === 'edges')?.elements ?? []).flatMap((element) =>
419
+ element.kind === 'text'
420
+ ? [
421
+ {
422
+ text: element.text,
423
+ rect: { x: element.x, y: element.y, w: element.text.length * 12 * 0.58, h: 26 },
424
+ route: pathOf(objects, element.id.replace(/-label$/, '')),
425
+ },
426
+ ]
427
+ : [],
428
+ );
429
+
430
+ test('each label sits beside its own route and off every frame border', ({ expect }) => {
431
+ const text = `
432
+ diagram flow=down
433
+ group top "Agents" {
434
+ node Toolkit
435
+ node Assistant below Toolkit
436
+ node Runtime right-of Assistant
437
+ node Link right-of Runtime
438
+ }
439
+ group middle "Runtime" below top {
440
+ node Conductor below Assistant
441
+ node Compute below Runtime
442
+ node Edge right-of Compute
443
+ }
444
+ group bottom "Primitives" below middle {
445
+ node Umbrella below Compute
446
+ node Ai left-of Umbrella
447
+ node Ops right-of Umbrella
448
+ }
449
+ edge Compute -> Umbrella "processes, triggers"
450
+ edge Conductor -> Umbrella "graph nodes"
451
+ edge Runtime -> Umbrella "AgentService impl"
452
+ edge Runtime -> Compute "spawns"
453
+ edge Edge -> Compute "remote seams"
454
+ edge Conductor -> Assistant "wrapped by"
455
+ edge Toolkit -> Runtime "registered in"
456
+ edge Umbrella -> Ai
457
+ edge Ops -> Umbrella
458
+ edge Compute -> Link "test impl"
459
+ `;
460
+ const { objects } = sceneOf(text);
461
+ const frames = ['top', 'middle', 'bottom'].map((id) => rectOf(objects, id));
462
+ const labels = labelsOf(objects);
463
+ expect(labels).toHaveLength(8);
464
+ for (const { text: label, rect, route } of labels) {
465
+ expect(route.length, label).toBeGreaterThan(1);
466
+ expect(gapTo(rect, route), label).toBeLessThanOrEqual(16);
467
+ expect(
468
+ frames.some((frame) => Route.onBorder(rect, frame, 0)),
469
+ label,
470
+ ).toBe(false);
471
+ }
472
+ });
473
+
474
+ test('a label leaves the frame band for the gutter its route crosses', ({ expect }) => {
475
+ const { objects } = sceneOf(PROCESS);
476
+ const frames = ['req', 'local', 'remote'].map((id) => rectOf(objects, id));
477
+ for (const { text: label, rect, route } of labelsOf(objects)) {
478
+ expect(gapTo(rect, route), label).toBeLessThanOrEqual(16);
479
+ expect(
480
+ frames.some((frame) => Route.onBorder(rect, frame, 0)),
481
+ label,
482
+ ).toBe(false);
483
+ }
484
+ });
485
+ });
486
+
487
+ describe('soft sides', () => {
488
+ test('a preferred side is taken when it costs little', ({ expect }) => {
489
+ const { objects, problems } = sceneOf('node A @cell(0,0)\nnode B @cell(1,1)\nedge A:~right -> B');
490
+ expect(problems).toEqual([]);
491
+ const a = rectOf(objects, 'A');
492
+ expect(pathOf(objects, 'A-B-0')[0].x).toBe(a.x + a.w);
493
+ });
494
+
495
+ test('a preferred side gives way to a much better route, where a hard one would not', ({ expect }) => {
496
+ const soft = sceneOf('node A @cell(0,0)\nnode B @cell(1,0)\nedge A:~bottom -> B');
497
+ const hard = sceneOf('node A @cell(0,0)\nnode B @cell(1,0)\nedge A:bottom -> B');
498
+ expect(soft.problems).toEqual([]);
499
+ const a = rectOf(soft.objects, 'A');
500
+ expect(bendsOf(pathOf(soft.objects, 'A-B-0'))).toBe(0);
501
+ expect(pathOf(hard.objects, 'A-B-0')[0].y).toBe(a.y + a.h);
502
+ });
503
+ });
504
+
505
+ describe('group shape', () => {
506
+ const CHAIN =
507
+ 'node A node B node C node D node E node F edge A -> B edge B -> C edge C -> D edge D -> E edge E -> F';
508
+ const spanOf = (objects: readonly Scene.WorldObject[], ids: readonly string[]) => {
509
+ const rects = ids.map((id) => rectOf(objects, id));
510
+ return {
511
+ cols: new Set(rects.map((rect) => rect.x)).size,
512
+ rows: new Set(rects.map((rect) => rect.y)).size,
513
+ };
514
+ };
515
+
516
+ test('`compact` keeps a group near-square', ({ expect }) => {
517
+ const { objects, problems } = sceneOf(`diagram flow=right\ngroup g compact { ${CHAIN} }`);
518
+ expect(problems).toEqual([]);
519
+ const { cols, rows } = spanOf(objects, ['A', 'B', 'C', 'D', 'E', 'F']);
520
+ expect(Math.abs(cols - rows)).toBeLessThanOrEqual(1);
521
+ });
522
+
523
+ test('`max-width` caps the columns a group spans', ({ expect }) => {
524
+ const { objects, problems } = sceneOf(`diagram flow=right\ngroup g max-width=2 { ${CHAIN} }`);
525
+ expect(problems).toEqual([]);
526
+ expect(spanOf(objects, ['A', 'B', 'C', 'D', 'E', 'F']).cols).toBeLessThanOrEqual(2);
527
+ });
528
+
529
+ test('`aspect` leans the whole drawing wide or tall', ({ expect }) => {
530
+ const wide = spanOf(sceneOf(`diagram aspect=4:1\n${CHAIN}`).objects, ['A', 'B', 'C', 'D', 'E', 'F']);
531
+ const tall = spanOf(sceneOf(`diagram aspect=1:4\n${CHAIN}`).objects, ['A', 'B', 'C', 'D', 'E', 'F']);
532
+ expect(wide.cols).toBeGreaterThan(wide.rows);
533
+ expect(tall.rows).toBeGreaterThan(tall.cols);
534
+ });
535
+
536
+ test('a frame stretched by a relation outside it is reported', ({ expect }) => {
537
+ const { problems } = parse(`
538
+ group upper { node X node Y right-of X node Z right-of Y }
539
+ group lower below upper { node A below X node B below Z }
540
+ edge X -> Y
541
+ edge Y -> Z
542
+ edge A -> B
543
+ `);
544
+ expect(problems.map(({ severity, message }) => `${severity}: ${message}`).join('\n')).toMatch(
545
+ /warning: Group "lower" is stretched/,
546
+ );
547
+ });
548
+
549
+ test('a stretched frame blames only hard relations, since soft is the suggested remedy', ({ expect }) => {
550
+ const cells = new Map([
551
+ ['X', { col: 0, row: 0 }],
552
+ ['Y', { col: 1, row: 0 }],
553
+ ['Z', { col: 2, row: 0 }],
554
+ ['A', { col: 0, row: 1 }],
555
+ ['B', { col: 2, row: 1 }],
556
+ ]);
557
+ const stretchedBy = (relation: string) => {
558
+ const { diagram } = read(`
559
+ group upper { node X node Y right-of X node Z right-of Y }
560
+ group lower below upper { node A ${relation} X node B ${relation} Z }
561
+ `);
562
+ return diagram ? stretchIssues(diagram, cells).map(({ message }) => message) : [];
563
+ };
564
+ expect(stretchedBy('below')).toEqual([expect.stringMatching(/Group "lower" is stretched/)]);
565
+ expect(stretchedBy('~below')).toEqual([]);
566
+ });
567
+ });
568
+
569
+ test('a fan-in bus gathers its sources into one trunk with one arrowhead', ({ expect }) => {
570
+ const { objects, problems } = sceneOf(`
571
+ diagram flow=down
572
+ node A
573
+ node B
574
+ node C
575
+ node D below B
576
+ edge A, B, C -> D "spawns" bus
577
+ `);
578
+ expect(problems).toEqual([]);
579
+ const [a, b, c, d] = ['A', 'B', 'C', 'D'].map((id) => rectOf(objects, id));
580
+ expect(a.y).toBe(b.y);
581
+ expect(c.y).toBe(b.y);
582
+ const elements = objects.find((entry) => entry.id === 'edges')?.elements ?? [];
583
+ const arrows = elements.filter((element) => element.kind === 'arrow');
584
+ expect(arrows).toHaveLength(1);
585
+ expect(arrows[0].kind === 'arrow' && arrows[0].end?.y).toBe(d.y);
586
+ const junction = arrows[0].kind === 'arrow' ? arrows[0].start : undefined;
587
+ for (const id of ['A-D-0', 'B-D-1', 'C-D-2']) {
588
+ expect(pathOf(objects, id).at(-1)).toEqual(junction);
589
+ }
590
+ expect(elements.some((element) => element.kind === 'text' && element.text === 'spawns')).toBe(true);
591
+ });
592
+
593
+ test('a bus no junction serves falls back to separate edges that do not run along each other', ({ expect }) => {
594
+ const sides = ['top', 'bottom', 'left', 'right'] as const;
595
+ const [hub, a, b] = [
596
+ { x: 0, y: 0, w: 64, h: 64 },
597
+ { x: 192, y: 384, w: 64, h: 64 },
598
+ { x: 192, y: 192, w: 64, h: 64 },
599
+ ];
600
+ // No channels, and every spoke-aligned junction lies inside a spoke, so the bus cannot form.
601
+ const pieces = Route.routeAll(
602
+ [],
603
+ [
604
+ {
605
+ id: 'bus',
606
+ hub: { rect: hub, node: 'H', sides },
607
+ spokes: [
608
+ { id: 'H-A', end: { rect: a, node: 'A', sides } },
609
+ { id: 'H-B', end: { rect: b, node: 'B', sides } },
610
+ ],
611
+ direction: 'out',
612
+ },
613
+ ],
614
+ { obstacles: [hub, a, b], avoid: [], channels: { xs: [], ys: [] } },
615
+ );
616
+ expect(pieces.map(({ id, bus }) => ({ id, bus }))).toEqual([
617
+ { id: 'H-A', bus: undefined },
618
+ { id: 'H-B', bus: undefined },
619
+ ]);
620
+ const runsOf = (points: readonly Scene.Point[]) => points.slice(1).map((point, index) => [points[index], point]);
621
+ const sharedRun = ([a1, a2]: Scene.Point[], [b1, b2]: Scene.Point[]) =>
622
+ a1.x === a2.x && b1.x === b2.x && a1.x === b1.x
623
+ ? Math.min(Math.max(a1.y, a2.y), Math.max(b1.y, b2.y)) - Math.max(Math.min(a1.y, a2.y), Math.min(b1.y, b2.y))
624
+ : a1.y === a2.y && b1.y === b2.y && a1.y === b1.y
625
+ ? Math.min(Math.max(a1.x, a2.x), Math.max(b1.x, b2.x)) - Math.max(Math.min(a1.x, a2.x), Math.min(b1.x, b2.x))
626
+ : 0;
627
+ const [first, second] = pieces.map(({ points }) => runsOf(points));
628
+ expect(first.some((run) => second.some((other) => sharedRun(run, other) > 0))).toBe(false);
629
+ });
630
+
631
+ test('scene statements in the same document pass through after the layout', ({ expect }) => {
632
+ const { commands } = parse('node A\nnode B\nedge A -> B\nelements A { text note 0,-30 "hi" }');
633
+ expect(commands[commands.length - 1]).toMatchObject({ op: 'upsert-elements', objectId: 'A' });
634
+ });
635
+ });