@dxos/diagram 0.11.1

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 (100) hide show
  1. package/LICENSE +105 -0
  2. package/README.md +10 -0
  3. package/dist/lib/index.mjs +3975 -0
  4. package/dist/lib/index.mjs.map +1 -0
  5. package/dist/lib/testing.mjs +68 -0
  6. package/dist/lib/testing.mjs.map +1 -0
  7. package/dist/types/src/content.d.ts +60 -0
  8. package/dist/types/src/content.d.ts.map +1 -0
  9. package/dist/types/src/diagnostics.d.ts +56 -0
  10. package/dist/types/src/diagnostics.d.ts.map +1 -0
  11. package/dist/types/src/diagnostics.test.d.ts +2 -0
  12. package/dist/types/src/diagnostics.test.d.ts.map +1 -0
  13. package/dist/types/src/dialect.d.ts +19 -0
  14. package/dist/types/src/dialect.d.ts.map +1 -0
  15. package/dist/types/src/index.d.ts +16 -0
  16. package/dist/types/src/index.d.ts.map +1 -0
  17. package/dist/types/src/layout.d.ts +26 -0
  18. package/dist/types/src/layout.d.ts.map +1 -0
  19. package/dist/types/src/mermaid-engine.d.ts +83 -0
  20. package/dist/types/src/mermaid-engine.d.ts.map +1 -0
  21. package/dist/types/src/mermaid-engine.test.d.ts +2 -0
  22. package/dist/types/src/mermaid-engine.test.d.ts.map +1 -0
  23. package/dist/types/src/mermaid.d.ts +56 -0
  24. package/dist/types/src/mermaid.d.ts.map +1 -0
  25. package/dist/types/src/mermaid.test.d.ts +3 -0
  26. package/dist/types/src/mermaid.test.d.ts.map +1 -0
  27. package/dist/types/src/objective.d.ts +75 -0
  28. package/dist/types/src/objective.d.ts.map +1 -0
  29. package/dist/types/src/objective.test.d.ts +2 -0
  30. package/dist/types/src/objective.test.d.ts.map +1 -0
  31. package/dist/types/src/ortho-router.d.ts +13 -0
  32. package/dist/types/src/ortho-router.d.ts.map +1 -0
  33. package/dist/types/src/scene.d.ts +1006 -0
  34. package/dist/types/src/scene.d.ts.map +1 -0
  35. package/dist/types/src/scene.test.d.ts +2 -0
  36. package/dist/types/src/scene.test.d.ts.map +1 -0
  37. package/dist/types/src/svg-handler.d.ts +5 -0
  38. package/dist/types/src/svg-handler.d.ts.map +1 -0
  39. package/dist/types/src/svg-handler.test.d.ts +2 -0
  40. package/dist/types/src/svg-handler.test.d.ts.map +1 -0
  41. package/dist/types/src/testing.d.ts +11 -0
  42. package/dist/types/src/testing.d.ts.map +1 -0
  43. package/dist/types/src/ui.d.ts +67 -0
  44. package/dist/types/src/ui.d.ts.map +1 -0
  45. package/dist/types/src/ui.test.d.ts +2 -0
  46. package/dist/types/src/ui.test.d.ts.map +1 -0
  47. package/dist/types/src/uml-engine.d.ts +14 -0
  48. package/dist/types/src/uml-engine.d.ts.map +1 -0
  49. package/dist/types/src/uml-engine.test.d.ts +2 -0
  50. package/dist/types/src/uml-engine.test.d.ts.map +1 -0
  51. package/dist/types/src/uml-grid.d.ts +99 -0
  52. package/dist/types/src/uml-grid.d.ts.map +1 -0
  53. package/dist/types/src/uml-grid.test.d.ts +2 -0
  54. package/dist/types/src/uml-grid.test.d.ts.map +1 -0
  55. package/dist/types/src/uml-rules.d.ts +56 -0
  56. package/dist/types/src/uml-rules.d.ts.map +1 -0
  57. package/dist/types/src/uml-rules.test.d.ts +2 -0
  58. package/dist/types/src/uml-rules.test.d.ts.map +1 -0
  59. package/dist/types/src/uml-search.d.ts +26 -0
  60. package/dist/types/src/uml-search.d.ts.map +1 -0
  61. package/dist/types/src/uml-search.test.d.ts +2 -0
  62. package/dist/types/src/uml-search.test.d.ts.map +1 -0
  63. package/dist/types/src/uml.d.ts +60 -0
  64. package/dist/types/src/uml.d.ts.map +1 -0
  65. package/dist/types/src/uml.test.d.ts +2 -0
  66. package/dist/types/src/uml.test.d.ts.map +1 -0
  67. package/dist/types/tsconfig.tsbuildinfo +1 -0
  68. package/package.json +48 -0
  69. package/src/__snapshots__/diagnostics.test.ts.snap +36 -0
  70. package/src/__snapshots__/mermaid-engine.test.ts.snap +22 -0
  71. package/src/content.ts +180 -0
  72. package/src/diagnostics.test.ts +173 -0
  73. package/src/diagnostics.ts +393 -0
  74. package/src/dialect.ts +53 -0
  75. package/src/index.ts +19 -0
  76. package/src/layout.ts +99 -0
  77. package/src/mermaid-engine.test.ts +244 -0
  78. package/src/mermaid-engine.ts +863 -0
  79. package/src/mermaid.test.ts +138 -0
  80. package/src/mermaid.ts +326 -0
  81. package/src/objective.test.ts +82 -0
  82. package/src/objective.ts +157 -0
  83. package/src/ortho-router.ts +372 -0
  84. package/src/scene.test.ts +36 -0
  85. package/src/scene.ts +281 -0
  86. package/src/svg-handler.test.ts +75 -0
  87. package/src/svg-handler.ts +119 -0
  88. package/src/testing.ts +68 -0
  89. package/src/ui.test.ts +183 -0
  90. package/src/ui.ts +398 -0
  91. package/src/uml-engine.test.ts +63 -0
  92. package/src/uml-engine.ts +146 -0
  93. package/src/uml-grid.test.ts +175 -0
  94. package/src/uml-grid.ts +559 -0
  95. package/src/uml-rules.test.ts +109 -0
  96. package/src/uml-rules.ts +319 -0
  97. package/src/uml-search.test.ts +108 -0
  98. package/src/uml-search.ts +266 -0
  99. package/src/uml.test.ts +172 -0
  100. package/src/uml.ts +390 -0
package/package.json ADDED
@@ -0,0 +1,48 @@
1
+ {
2
+ "name": "@dxos/diagram",
3
+ "version": "0.11.1",
4
+ "description": "Renderer-neutral scene DSL, diagram dialects, and layout engines.",
5
+ "homepage": "https://dxos.org",
6
+ "bugs": "https://github.com/dxos/dxos/issues",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/dxos/dxos"
10
+ },
11
+ "license": "FSL-1.1-Apache-2.0",
12
+ "author": "DXOS.org",
13
+ "sideEffects": false,
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "source": "./src/index.ts",
18
+ "types": "./dist/types/src/index.d.ts",
19
+ "import": "./dist/lib/index.mjs"
20
+ },
21
+ "./testing": {
22
+ "source": "./src/testing.ts",
23
+ "types": "./dist/types/src/testing.d.ts",
24
+ "import": "./dist/lib/testing.mjs"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "types": "dist/types/src/index.d.ts",
29
+ "files": [
30
+ "dist",
31
+ "src"
32
+ ],
33
+ "dependencies": {
34
+ "@dagrejs/dagre": "^3.1.1",
35
+ "elkjs": "^0.12.0",
36
+ "@dxos/invariant": "0.11.1",
37
+ "@dxos/util": "0.11.1"
38
+ },
39
+ "devDependencies": {
40
+ "effect": "4.0.0-rc.115"
41
+ },
42
+ "peerDependencies": {
43
+ "effect": "4.0.0-rc.115"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
47
+ }
48
+ }
@@ -0,0 +1,36 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`layout quality (dagre) > soft metrics 1`] = `
4
+ {
5
+ "bends": 2,
6
+ "crossings": 0,
7
+ }
8
+ `;
9
+
10
+ exports[`layout quality (elk) > soft metrics 1`] = `
11
+ {
12
+ "bends": 3,
13
+ "crossings": 0,
14
+ }
15
+ `;
16
+
17
+ exports[`layout quality (grid) > soft metrics 1`] = `
18
+ {
19
+ "bends": 3,
20
+ "crossings": 0,
21
+ }
22
+ `;
23
+
24
+ exports[`layout quality (rules) > soft metrics 1`] = `
25
+ {
26
+ "bends": 6,
27
+ "crossings": 0,
28
+ }
29
+ `;
30
+
31
+ exports[`layout quality (search) > soft metrics 1`] = `
32
+ {
33
+ "bends": 2,
34
+ "crossings": 0,
35
+ }
36
+ `;
@@ -0,0 +1,22 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`mermaid-engine > basic: no hard defects, every connector straight or one bend 1`] = `
4
+ {
5
+ "arrangement": "columns",
6
+ "cost": 4.96,
7
+ }
8
+ `;
9
+
10
+ exports[`mermaid-engine > basic: no hard defects, every connector straight or one bend 2`] = `
11
+ {
12
+ "bends": 1,
13
+ "crossings": 0,
14
+ }
15
+ `;
16
+
17
+ exports[`mermaid-engine > has no hard defects and records its soft metrics 1`] = `
18
+ {
19
+ "bends": 3,
20
+ "crossings": 0,
21
+ }
22
+ `;
package/src/content.ts ADDED
@@ -0,0 +1,180 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ //
6
+ // Renderer-neutral command application over a canvas content map. Every renderer stores its
7
+ // canvas as `Record<recordId, record>` and stamps DSL identity onto each managed record, so the
8
+ // upsert/remove/move bookkeeping is identical; only the encoding differs. Renderers supply that
9
+ // encoding via `ContentHandler` and inherit the command semantics.
10
+ //
11
+
12
+ import { invariant } from '@dxos/invariant';
13
+
14
+ import type * as Scene from './scene.ts';
15
+
16
+ /** Opaque map of record id → renderer record. */
17
+ export type ContentMap = Record<string, any>;
18
+
19
+ /** DSL identity stamped onto a managed record. */
20
+ export type Identity = {
21
+ object: string;
22
+ element: string;
23
+ /** Set on companion records that belong to an element rather than being one (e.g. arrow labels). */
24
+ part?: string;
25
+ };
26
+
27
+ /** Where an object sits on the canvas: `canvas = origin + local * scale`. */
28
+ export type Placement = {
29
+ origin: Scene.Point;
30
+ scale: number;
31
+ };
32
+
33
+ /** An object as accepted by the renderer's compiler; `upsert-elements` supplies only a subset. */
34
+ export type ObjectInput = Scene.WorldObject | { id: string; elements: readonly Scene.Element[] };
35
+
36
+ /** A world object as derived from the canvas: placement is always resolved. */
37
+ export type ReadWorldObject = Scene.WorldObject & Placement;
38
+
39
+ export type ReadResult = {
40
+ scene: { objects: ReadWorldObject[] };
41
+ /** Records on the canvas not managed by the DSL (drawn by hand). */
42
+ unmanaged: number;
43
+ };
44
+
45
+ export type ApplyResult = {
46
+ /** Object ids created or modified. */
47
+ upserted: string[];
48
+ /** Records removed. */
49
+ removed: number;
50
+ };
51
+
52
+ /**
53
+ * The renderer-specific half of command application: how records carry identity, how objects
54
+ * compile to records, and any bookkeeping the renderer's store requires.
55
+ */
56
+ export type ContentHandler = {
57
+ /** DSL identity for a record, or undefined when the record isn't managed by the DSL. */
58
+ identify: (record: any) => Identity | undefined;
59
+ /** Compile an object's elements into records keyed by record id. */
60
+ render: (object: ObjectInput, placement: Placement, content: ContentMap) => ContentMap;
61
+ /** Derive the scene from the content map. */
62
+ read: (content: ContentMap) => ReadResult;
63
+ /** Translate an object move into a per-record mutation. */
64
+ translate: (record: any, delta: Scene.Point) => void;
65
+ /** Create any scaffolding the renderer needs before its first record (e.g. a page). */
66
+ scaffold?: (content: ContentMap) => void;
67
+ /** Merge compiled records into the content map; defaults to a plain assign. */
68
+ merge?: (content: ContentMap, records: ContentMap) => void;
69
+ /** Clean up after deletions (e.g. drop bindings left dangling). */
70
+ prune?: (content: ContentMap) => void;
71
+ };
72
+
73
+ /** Apply a batch of edit commands to the canvas content (mutates `content`). */
74
+ export const applyCommands = (
75
+ content: ContentMap,
76
+ commands: readonly Scene.Command[],
77
+ handler: ContentHandler,
78
+ ): ApplyResult => {
79
+ handler.scaffold?.(content);
80
+ const upserted = new Set<string>();
81
+ let removed = 0;
82
+
83
+ const find = (objectId: string) => handler.read(content).scene.objects.find((object) => object.id === objectId);
84
+
85
+ const merge = (object: ObjectInput, placement: Placement) => {
86
+ const records = handler.render(object, placement, content);
87
+ if (handler.merge) {
88
+ handler.merge(content, records);
89
+ } else {
90
+ Object.assign(content, records);
91
+ }
92
+ };
93
+
94
+ // Companion records (labels) are deleted alongside their element but aren't themselves
95
+ // elements, so they don't count toward the reported total.
96
+ const remove = (predicate: (identity: Identity) => boolean): number => {
97
+ let count = 0;
98
+ for (const [key, record] of Object.entries(content)) {
99
+ const identity = handler.identify(record);
100
+ if (identity && predicate(identity)) {
101
+ // Read before delete: ECHO proxies dereference lazily, so fields vanish once removed.
102
+ const isPart = identity.part !== undefined;
103
+ delete content[key];
104
+ if (!isPart) {
105
+ count++;
106
+ }
107
+ }
108
+ }
109
+ handler.prune?.(content);
110
+ return count;
111
+ };
112
+
113
+ for (const command of commands) {
114
+ switch (command.op) {
115
+ case 'upsert-object': {
116
+ const previous = find(command.object.id);
117
+ removed += remove((identity) => identity.object === command.object.id);
118
+ merge(command.object, {
119
+ origin: command.object.origin ?? previous?.origin ?? { x: 0, y: 0 },
120
+ scale: command.object.scale ?? previous?.scale ?? 1,
121
+ });
122
+ upserted.add(command.object.id);
123
+ break;
124
+ }
125
+
126
+ case 'upsert-elements': {
127
+ const previous = find(command.objectId);
128
+ invariant(previous, `unknown object: ${command.objectId} (use upsert-object to create it)`);
129
+ const ids = new Set(command.elements.map((element) => element.id));
130
+ removed += remove((identity) => identity.object === command.objectId && ids.has(identity.element));
131
+ merge({ id: command.objectId, elements: command.elements }, previous);
132
+ upserted.add(command.objectId);
133
+ break;
134
+ }
135
+
136
+ case 'remove-elements': {
137
+ const ids = new Set(command.elementIds);
138
+ removed += remove((identity) => identity.object === command.objectId && ids.has(identity.element));
139
+ break;
140
+ }
141
+
142
+ case 'remove-object': {
143
+ removed += remove((identity) => identity.object === command.objectId);
144
+ break;
145
+ }
146
+
147
+ case 'move-object': {
148
+ const previous = find(command.objectId);
149
+ invariant(previous, `unknown object: ${command.objectId}`);
150
+ const delta = { x: command.origin.x - previous.origin.x, y: command.origin.y - previous.origin.y };
151
+ for (const record of Object.values(content)) {
152
+ if (handler.identify(record)?.object === command.objectId) {
153
+ handler.translate(record, delta);
154
+ }
155
+ }
156
+ upserted.add(command.objectId);
157
+ break;
158
+ }
159
+ }
160
+ }
161
+
162
+ return { upserted: [...upserted], removed };
163
+ };
164
+
165
+ /** Seed a fractional-index counter past any existing `a<n>` indexes. */
166
+ export const nextIndex = (content: ContentMap, isCounted: (record: any) => boolean = () => true): number => {
167
+ let max = 0;
168
+ let count = 0;
169
+ for (const record of Object.values(content)) {
170
+ if (!record || !isCounted(record)) {
171
+ continue;
172
+ }
173
+ count++;
174
+ const match = /^a(\d+)$/.exec(record.index ?? '');
175
+ if (match) {
176
+ max = Math.max(max, Number(match[1]));
177
+ }
178
+ }
179
+ return Math.max(max, count);
180
+ };
@@ -0,0 +1,173 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { analyze, errors } from './diagnostics.ts';
8
+ import type * as Scene from './scene.ts';
9
+ import { CLASS_DIAGRAM } from './testing.ts';
10
+ import * as UmlEngine from './uml-engine.ts';
11
+ import * as UmlGrid from './uml-grid.ts';
12
+ import * as UmlRules from './uml-rules.ts';
13
+ import * as UmlSearch from './uml-search.ts';
14
+
15
+ const objectsOf = (commands: readonly Scene.Command[]) =>
16
+ commands.flatMap((command) => (command.op === 'upsert-object' ? [command.object] : []));
17
+
18
+ const box = (id: string, x: number, y: number, w = 64, h = 64, text?: string): Scene.WorldObject => ({
19
+ id,
20
+ origin: { x, y },
21
+ elements: [{ kind: 'rect', id: 'frame', x: 0, y: 0, w, h, text }],
22
+ });
23
+
24
+ const arrow = (id: string, start: Scene.Point, end: Scene.Point): Scene.Element => ({ kind: 'arrow', id, start, end });
25
+
26
+ describe('diagnostics', () => {
27
+ test('a clean scene reports nothing', ({ expect }) => {
28
+ const report = analyze([
29
+ box('a', 0, 0),
30
+ box('b', 0, 200),
31
+ { id: 'edges', elements: [arrow('a-b', { x: 32, y: 64 }, { x: 32, y: 200 })] },
32
+ ]);
33
+
34
+ expect(report.diagnostics).toEqual([]);
35
+ expect(report.metrics).toMatchObject({ nodes: 2, connectors: 1, crossings: 0, bends: 0 });
36
+ });
37
+
38
+ test('detects partially overlapping nodes, but not containment', ({ expect }) => {
39
+ const overlapping = analyze([box('a', 0, 0), box('b', 32, 32)]);
40
+ expect(overlapping.diagnostics.map(({ code }) => code)).toEqual(['node-overlap']);
41
+
42
+ // Exactly stacked nodes are the strongest overlap, not a container and its member.
43
+ const stacked = analyze([box('a', 0, 0), box('b', 0, 0)]);
44
+ expect(stacked.diagnostics.map(({ code }) => code)).toEqual(['node-overlap']);
45
+
46
+ // A subgraph frame enclosing its members is a container, not a collision.
47
+ const contained = analyze([box('frame', 0, 0, 400, 400), box('a', 32, 32)]);
48
+ expect(contained.diagnostics).toEqual([]);
49
+ });
50
+
51
+ test('detects a connector drawn across a node it does not terminate at', ({ expect }) => {
52
+ const report = analyze([
53
+ box('a', 0, 0),
54
+ box('blocker', 0, 128),
55
+ box('b', 0, 320),
56
+ { id: 'edges', elements: [arrow('a-b', { x: 32, y: 64 }, { x: 32, y: 320 })] },
57
+ ]);
58
+
59
+ expect(report.diagnostics.map(({ code }) => code)).toEqual(['route-through-node']);
60
+ expect(report.diagnostics[0].refs).toEqual(['edges/a-b', 'blocker/frame']);
61
+ });
62
+
63
+ test('a connector ending on a border is not a crossing', ({ expect }) => {
64
+ const report = analyze([
65
+ box('a', 0, 0),
66
+ box('b', 0, 200),
67
+ { id: 'edges', elements: [arrow('a-b', { x: 32, y: 64 }, { x: 32, y: 200 })] },
68
+ ]);
69
+
70
+ expect(report.metrics.routesThroughNodes).toBe(0);
71
+ });
72
+
73
+ test('detects a label that cannot fit its shape', ({ expect }) => {
74
+ const report = analyze([box('a', 0, 0, 64, 32, 'A very long label that will never fit')]);
75
+
76
+ expect(report.diagnostics.map(({ code }) => code)).toEqual(['label-overflow']);
77
+ });
78
+
79
+ test('counts proper crossings between distinct connectors, ignoring shared ports', ({ expect }) => {
80
+ const crossing = analyze([
81
+ {
82
+ id: 'edges',
83
+ elements: [arrow('h', { x: 0, y: 50 }, { x: 100, y: 50 }), arrow('v', { x: 50, y: 0 }, { x: 50, y: 100 })],
84
+ },
85
+ ]);
86
+ expect(crossing.metrics.crossings).toBe(1);
87
+ expect(crossing.diagnostics[0].severity).toBe('warning');
88
+
89
+ const fanIn = analyze([
90
+ {
91
+ id: 'edges',
92
+ elements: [arrow('a', { x: 0, y: 0 }, { x: 50, y: 50 }), arrow('b', { x: 100, y: 0 }, { x: 50, y: 50 })],
93
+ },
94
+ ]);
95
+ expect(fanIn.metrics.crossings).toBe(0);
96
+ });
97
+
98
+ test('rejoins a routed path with its arrow head and counts its bends', ({ expect }) => {
99
+ const report = analyze([
100
+ {
101
+ id: 'edges',
102
+ elements: [
103
+ {
104
+ kind: 'line',
105
+ id: 'a-b-path',
106
+ points: [
107
+ { x: 0, y: 0 },
108
+ { x: 0, y: 50 },
109
+ { x: 100, y: 50 },
110
+ ],
111
+ },
112
+ arrow('a-b', { x: 100, y: 50 }, { x: 100, y: 100 }),
113
+ ],
114
+ },
115
+ ]);
116
+
117
+ expect(report.metrics.connectors).toBe(1);
118
+ expect(report.metrics.bends).toBe(2);
119
+ expect(analyze(objectsOf([]), { maxBends: 1 }).diagnostics).toEqual([]);
120
+ expect(
121
+ analyze(
122
+ [
123
+ {
124
+ id: 'edges',
125
+ elements: [
126
+ {
127
+ kind: 'line',
128
+ id: 'z-path',
129
+ points: [
130
+ { x: 0, y: 0 },
131
+ { x: 0, y: 10 },
132
+ { x: 10, y: 10 },
133
+ { x: 10, y: 20 },
134
+ ],
135
+ },
136
+ arrow('z', { x: 10, y: 20 }, { x: 20, y: 20 }),
137
+ ],
138
+ },
139
+ ],
140
+ { maxBends: 2 },
141
+ ).diagnostics.map(({ code }) => code),
142
+ ).toEqual(['excessive-bends']);
143
+ });
144
+ });
145
+
146
+ //
147
+ // Tier 1: every placement strategy must produce a scene free of hard defects for the shared
148
+ // fixture. Soft metrics are recorded in the snapshot so a layout change reads as a diff.
149
+ //
150
+
151
+ const strategies: Record<string, (source: string) => Promise<readonly Scene.Command[]>> = {
152
+ grid: async (source) => UmlGrid.compile(source),
153
+ dagre: (source) => UmlEngine.compile(source, { engine: 'dagre' }),
154
+ elk: (source) => UmlEngine.compile(source, { engine: 'elk' }),
155
+ rules: async (source) => UmlRules.compile(source),
156
+ search: async (source) => UmlSearch.compile(source),
157
+ };
158
+
159
+ describe.each(Object.entries(strategies))('layout quality (%s)', (_name, compile) => {
160
+ test('the class-diagram fixture has no hard defects', async ({ expect }) => {
161
+ const report = analyze(objectsOf(await compile(CLASS_DIAGRAM)));
162
+
163
+ expect(errors(report).map(({ message }) => message)).toEqual([]);
164
+ expect(report.metrics.nodes).toBeGreaterThan(0);
165
+ expect(report.metrics.connectors).toBe(5);
166
+ });
167
+
168
+ test('soft metrics', async ({ expect }) => {
169
+ const { crossings, bends } = analyze(objectsOf(await compile(CLASS_DIAGRAM))).metrics;
170
+
171
+ expect({ crossings, bends }).toMatchSnapshot();
172
+ });
173
+ });