@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.
- package/dist/lib/chunk-parse.mjs +9396 -0
- package/dist/lib/chunk-parse.mjs.map +1 -0
- package/dist/lib/extension.mjs +204 -0
- package/dist/lib/extension.mjs.map +1 -0
- package/dist/lib/index.mjs +497 -2846
- package/dist/lib/index.mjs.map +1 -1
- package/dist/types/src/aesthetics.d.ts +12 -0
- package/dist/types/src/aesthetics.d.ts.map +1 -0
- package/dist/types/src/aesthetics.test.d.ts +2 -0
- package/dist/types/src/aesthetics.test.d.ts.map +1 -0
- package/dist/types/src/appeal.d.ts +55 -0
- package/dist/types/src/appeal.d.ts.map +1 -0
- package/dist/types/src/appeal.test.d.ts +2 -0
- package/dist/types/src/appeal.test.d.ts.map +1 -0
- package/dist/types/src/architecture.d.ts +86 -0
- package/dist/types/src/architecture.d.ts.map +1 -0
- package/dist/types/src/architecture.test.d.ts +2 -0
- package/dist/types/src/architecture.test.d.ts.map +1 -0
- package/dist/types/src/compact.d.ts +39 -0
- package/dist/types/src/compact.d.ts.map +1 -0
- package/dist/types/src/compact.test.d.ts +2 -0
- package/dist/types/src/compact.test.d.ts.map +1 -0
- package/dist/types/src/diagnostics.d.ts +70 -3
- package/dist/types/src/diagnostics.d.ts.map +1 -1
- package/dist/types/src/dsl/cli.d.ts +2 -0
- package/dist/types/src/dsl/cli.d.ts.map +1 -0
- package/dist/types/src/dsl/compile.d.ts +12 -0
- package/dist/types/src/dsl/compile.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.d.ts +45 -0
- package/dist/types/src/dsl/convert.d.ts.map +1 -0
- package/dist/types/src/dsl/convert.test.d.ts +2 -0
- package/dist/types/src/dsl/convert.test.d.ts.map +1 -0
- package/dist/types/src/dsl/dsl.test.d.ts +2 -0
- package/dist/types/src/dsl/dsl.test.d.ts.map +1 -0
- package/dist/types/src/dsl/gen/diagram.d.ts +3 -0
- package/dist/types/src/dsl/gen/diagram.d.ts.map +1 -0
- package/dist/types/src/dsl/index.d.ts +7 -0
- package/dist/types/src/dsl/index.d.ts.map +1 -0
- package/dist/types/src/dsl/parse.d.ts +49 -0
- package/dist/types/src/dsl/parse.d.ts.map +1 -0
- package/dist/types/src/dsl/print.d.ts +19 -0
- package/dist/types/src/dsl/print.d.ts.map +1 -0
- package/dist/types/src/dsl/semantic.test.d.ts +2 -0
- package/dist/types/src/dsl/semantic.test.d.ts.map +1 -0
- package/dist/types/src/dsl/vocabulary.d.ts +61 -0
- package/dist/types/src/dsl/vocabulary.d.ts.map +1 -0
- package/dist/types/src/dx-svg.d.ts +12 -0
- package/dist/types/src/dx-svg.d.ts.map +1 -0
- package/dist/types/src/dx-svg.test.d.ts +2 -0
- package/dist/types/src/dx-svg.test.d.ts.map +1 -0
- package/dist/types/src/extension/complete.d.ts +3 -0
- package/dist/types/src/extension/complete.d.ts.map +1 -0
- package/dist/types/src/extension/extension.test.d.ts +2 -0
- package/dist/types/src/extension/extension.test.d.ts.map +1 -0
- package/dist/types/src/extension/highlight.d.ts +14 -0
- package/dist/types/src/extension/highlight.d.ts.map +1 -0
- package/dist/types/src/extension/index.d.ts +6 -0
- package/dist/types/src/extension/index.d.ts.map +1 -0
- package/dist/types/src/extension/language.d.ts +13 -0
- package/dist/types/src/extension/language.d.ts.map +1 -0
- package/dist/types/src/extension/lint.d.ts +21 -0
- package/dist/types/src/extension/lint.d.ts.map +1 -0
- package/dist/types/src/extension/syntax.d.ts +3 -0
- package/dist/types/src/extension/syntax.d.ts.map +1 -0
- package/dist/types/src/index.d.ts +14 -0
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/mermaid-engine.d.ts +40 -1
- package/dist/types/src/mermaid-engine.d.ts.map +1 -1
- package/dist/types/src/mermaid.d.ts +3 -3
- package/dist/types/src/mermaid.d.ts.map +1 -1
- package/dist/types/src/nudge.d.ts +30 -0
- package/dist/types/src/nudge.d.ts.map +1 -0
- package/dist/types/src/nudge.test.d.ts +2 -0
- package/dist/types/src/nudge.test.d.ts.map +1 -0
- package/dist/types/src/objective.d.ts +4 -0
- package/dist/types/src/objective.d.ts.map +1 -1
- package/dist/types/src/ortho-router.d.ts +13 -1
- package/dist/types/src/ortho-router.d.ts.map +1 -1
- package/dist/types/src/ports.d.ts +40 -0
- package/dist/types/src/ports.d.ts.map +1 -0
- package/dist/types/src/ports.test.d.ts +2 -0
- package/dist/types/src/ports.test.d.ts.map +1 -0
- package/dist/types/src/relations.test.d.ts +2 -0
- package/dist/types/src/relations.test.d.ts.map +1 -0
- package/dist/types/src/rules.d.ts +30 -0
- package/dist/types/src/rules.d.ts.map +1 -0
- package/dist/types/src/scene.d.ts +99 -65
- package/dist/types/src/scene.d.ts.map +1 -1
- package/dist/types/src/score.d.ts +54 -0
- package/dist/types/src/score.d.ts.map +1 -0
- package/dist/types/src/score.test.d.ts +2 -0
- package/dist/types/src/score.test.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.d.ts +41 -0
- package/dist/types/src/semantic-engine.d.ts.map +1 -0
- package/dist/types/src/semantic-engine.test.d.ts +2 -0
- package/dist/types/src/semantic-engine.test.d.ts.map +1 -0
- package/dist/types/src/semantic-place.d.ts +81 -0
- package/dist/types/src/semantic-place.d.ts.map +1 -0
- package/dist/types/src/semantic-route.d.ts +133 -0
- package/dist/types/src/semantic-route.d.ts.map +1 -0
- package/dist/types/src/semantic.d.ts +164 -0
- package/dist/types/src/semantic.d.ts.map +1 -0
- package/dist/types/src/uml-grid.d.ts.map +1 -1
- package/dist/types/src/uml.d.ts +3 -5
- package/dist/types/src/uml.d.ts.map +1 -1
- package/dist/types/src/view.d.ts +53 -0
- package/dist/types/src/view.d.ts.map +1 -0
- package/dist/types/src/view.test.d.ts +2 -0
- package/dist/types/src/view.test.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -5
- package/rules/DIAGRAM.mdl +388 -0
- package/src/aesthetics.test.ts +89 -0
- package/src/aesthetics.ts +148 -0
- package/src/appeal.test.ts +144 -0
- package/src/appeal.ts +586 -0
- package/src/architecture.test.ts +94 -0
- package/src/architecture.ts +215 -0
- package/src/compact.test.ts +67 -0
- package/src/compact.ts +219 -0
- package/src/diagnostics.test.ts +207 -1
- package/src/diagnostics.ts +481 -37
- package/src/dsl/cli.ts +46 -0
- package/src/dsl/compile.ts +28 -0
- package/src/dsl/convert.test.ts +51 -0
- package/src/dsl/convert.ts +98 -0
- package/src/dsl/diagram.grammar +195 -0
- package/src/dsl/dsl.test.ts +286 -0
- package/src/dsl/gen/diagram.ts +22 -0
- package/src/dsl/index.ts +44 -0
- package/src/dsl/parse.ts +1054 -0
- package/src/dsl/print.ts +198 -0
- package/src/dsl/semantic.test.ts +200 -0
- package/src/dsl/vocabulary.ts +150 -0
- package/src/dx-svg.test.ts +44 -0
- package/src/dx-svg.ts +77 -0
- package/src/extension/complete.ts +105 -0
- package/src/extension/extension.test.ts +135 -0
- package/src/extension/highlight.ts +23 -0
- package/src/extension/index.ts +9 -0
- package/src/extension/language.ts +31 -0
- package/src/extension/lint.ts +60 -0
- package/src/extension/syntax.ts +62 -0
- package/src/index.ts +14 -0
- package/src/mermaid-engine.test.ts +6 -6
- package/src/mermaid-engine.ts +569 -119
- package/src/mermaid.test.ts +17 -1
- package/src/mermaid.ts +30 -18
- package/src/nudge.test.ts +155 -0
- package/src/nudge.ts +385 -0
- package/src/objective.test.ts +2 -0
- package/src/objective.ts +17 -1
- package/src/ortho-router.ts +240 -80
- package/src/ports.test.ts +157 -0
- package/src/ports.ts +186 -0
- package/src/relations.test.ts +98 -0
- package/src/rules.ts +117 -0
- package/src/scene.ts +64 -3
- package/src/score.test.ts +64 -0
- package/src/score.ts +130 -0
- package/src/semantic-engine.test.ts +635 -0
- package/src/semantic-engine.ts +888 -0
- package/src/semantic-place.ts +1508 -0
- package/src/semantic-route.ts +1136 -0
- package/src/semantic.ts +278 -0
- package/src/typings.d.ts +8 -0
- package/src/uml-grid.ts +6 -1
- package/src/uml.test.ts +5 -1
- package/src/uml.ts +24 -11
- package/src/view.test.ts +139 -0
- 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
|
+
});
|