@dxos/app-graph 0.4.6 → 0.4.7-main.0aeacda

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/src/graph.test.ts CHANGED
@@ -2,219 +2,315 @@
2
2
  // Copyright 2023 DXOS.org
3
3
  //
4
4
 
5
+ import { effect } from '@preact/signals-core';
5
6
  import { expect } from 'chai';
6
7
 
7
8
  import { describe, test } from '@dxos/test';
8
9
 
9
- import { GraphBuilder } from './graph-builder';
10
- import { createTestNodeBuilder } from './testing';
10
+ import { Graph } from './graph';
11
+ import { type Node, type NodeFilter } from './node';
11
12
 
12
- describe('Graph', () => {
13
- test('returns root node', () => {
14
- const graph = new GraphBuilder().build();
15
- expect(graph.root).to.not.be.undefined;
16
- expect(graph.root.id).to.equal('root');
17
- });
13
+ const longestPaths = new Map<string, string[]>();
18
14
 
19
- test('root node unmodified without builders', () => {
20
- const graph = new GraphBuilder().build();
21
- expect(graph.root.properties).to.be.empty;
22
- expect(graph.root.children).to.be.empty;
23
- expect(graph.root.actions).to.be.empty;
24
- });
15
+ const filterLongestPath: NodeFilter = (node, connectedNode): node is Node => {
16
+ const longestPath = longestPaths.get(node.id);
17
+ if (!longestPath) {
18
+ return false;
19
+ }
25
20
 
26
- test('builder can add children to root node', () => {
27
- const builder = new GraphBuilder();
28
- const testNode = { id: 'test', label: 'Test', data: null };
29
- const id = 'test';
30
- builder.addNodeBuilder(id, (parent) => {
31
- if (parent.id === 'root') {
32
- parent.addNode(id, testNode);
33
- }
34
- });
21
+ if (longestPath[longestPath.length - 2] !== connectedNode.id) {
22
+ return false;
23
+ }
35
24
 
36
- const graph = builder.build();
37
- expect(graph.root.childrenMap.test.id).to.equal(testNode.id);
38
- expect(graph.root.childrenMap.test.parent!.id).to.equal(graph.root.id);
39
- });
25
+ return true;
26
+ };
27
+
28
+ // TODO(wittjosiah): Add tests for granularity of reactivity.
29
+ describe('Graph', () => {
30
+ test('add nodes', () => {
31
+ const graph = new Graph();
40
32
 
41
- test('builder can add actions to root node', () => {
42
- const builder = new GraphBuilder();
43
- const testAction = { id: 'test', label: 'Test', invoke: () => {} };
44
- builder.addNodeBuilder('test', (parent) => {
45
- if (parent.id === 'root') {
46
- parent.addAction(testAction);
47
- }
33
+ const [root] = graph.addNodes({
34
+ id: 'root',
35
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
48
36
  });
49
37
 
50
- const graph = builder.build();
51
- expect(graph.root.actionsMap.test.id).to.equal(testAction.id);
38
+ expect(root.id).to.equal('root');
39
+ expect(root.nodes()).to.have.length(2);
40
+ expect(graph.findNode('test1')?.id).to.equal('test1');
41
+ expect(graph.findNode('test2')?.id).to.equal('test2');
42
+ expect(graph.findNode('test1')?.nodes()).to.be.empty;
43
+ expect(graph.findNode('test2')?.nodes()).to.be.empty;
44
+ expect(graph.findNode('test1')?.nodes({ direction: 'inbound' })).to.have.length(1);
45
+ expect(graph.findNode('test2')?.nodes({ direction: 'inbound' })).to.have.length(1);
52
46
  });
53
47
 
54
- test('multiple builders can add children to root node', () => {
55
- const builder = new GraphBuilder();
56
- builder.addNodeBuilder('test1', createTestNodeBuilder('test1').nodeBuilder);
57
- builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
48
+ test('add nodes updates existing nodes', () => {
49
+ const graph = new Graph();
58
50
 
59
- const graph = builder.build();
60
- expect(Object.keys(graph.root.childrenMap)).to.deep.equal(['root-test1', 'root-test2']);
61
- for (const node of graph.root.children) {
62
- expect(graph.root.id).to.equal(node.parent!.id);
63
- }
51
+ graph.addNodes({
52
+ id: 'root',
53
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
54
+ });
55
+ graph.addNodes({
56
+ id: 'root',
57
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
58
+ });
59
+
60
+ expect(Object.keys(graph._nodes)).to.have.length(3);
61
+ expect(Object.keys(graph._edges)).to.have.length(3);
62
+ expect(graph.root.nodes()).to.have.length(2);
64
63
  });
65
64
 
66
- test('multiple builders can add actions to root node', () => {
67
- const builder = new GraphBuilder();
68
- builder.addNodeBuilder('test1', createTestNodeBuilder('test1').nodeBuilder);
69
- builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
65
+ test('remove node', () => {
66
+ const graph = new Graph();
70
67
 
71
- const graph = builder.build();
72
- expect(Object.keys(graph.root.actionsMap)).to.deep.equal(['root-test1', 'root-test2']);
73
- });
68
+ const [root] = graph.addNodes({
69
+ id: 'root',
70
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
71
+ });
74
72
 
75
- test('builders can add children to child node', () => {
76
- const builder = new GraphBuilder();
77
- builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
78
- builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
73
+ expect(root.id).to.equal('root');
74
+ expect(root.nodes()).to.have.length(2);
75
+ expect(graph.findNode('test1')?.id).to.equal('test1');
76
+ expect(graph.findNode('test2')?.id).to.equal('test2');
79
77
 
80
- const graph = builder.build();
81
- expect(Object.keys(graph.root.childrenMap['root-test1'].children)).to.be.empty;
82
- expect(Object.keys(graph.root.childrenMap['root-test2'].childrenMap)).to.deep.equal(['root-test2-test1']);
83
- for (const node of graph.root.childrenMap['root-test2'].children) {
84
- expect(graph.root.childrenMap['root-test2'].id).to.equal(node.parent!.id);
85
- }
78
+ graph.removeNode('test1');
79
+ expect(graph.findNode('test1')).to.be.undefined;
80
+ expect(root.nodes()).to.have.length(1);
86
81
  });
87
82
 
88
- test('builders can add actions to child node', () => {
89
- const builder = new GraphBuilder();
90
- builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
91
- builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
83
+ test('add edge', () => {
84
+ const graph = new Graph();
92
85
 
93
- const graph = builder.build();
94
- expect(Object.keys(graph.root.childrenMap['root-test1'].actionsMap)).to.be.empty;
95
- expect(Object.keys(graph.root.childrenMap['root-test2'].actionsMap)).to.deep.equal(['root-test2-test1']);
86
+ graph.addNodes({
87
+ id: 'root',
88
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
89
+ });
90
+ graph.addEdge({ source: 'test1', target: 'test2' });
91
+
92
+ expect(graph.findNode('test1')?.nodes()).to.have.length(1);
93
+ expect(graph.findNode('test2')?.nodes({ direction: 'inbound' })).to.have.length(2);
96
94
  });
97
95
 
98
- test('builders can add actions to action sets', () => {
99
- const builder = new GraphBuilder();
100
- builder.addNodeBuilder('test', (parent) => {
101
- const [set] = parent.addAction({ id: 'test-set', label: 'Test Set', invoke: () => {} });
102
- set.addAction({ id: 'test-action', label: 'Test', invoke: () => {} });
96
+ test('add edges is idempontent', () => {
97
+ const graph = new Graph();
98
+
99
+ graph.addNodes({
100
+ id: 'root',
101
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
103
102
  });
103
+ graph.addEdge({ source: 'test1', target: 'test2' });
104
+ graph.addEdge({ source: 'test1', target: 'test2' });
104
105
 
105
- const graph = builder.build();
106
- expect(Object.keys(graph.root.actionsMap['test-set'].actionsMap)).to.deep.equal(['test-action']);
106
+ expect(graph.findNode('test1')?.nodes()).to.have.length(1);
107
+ expect(graph.findNode('test2')?.nodes({ direction: 'inbound' })).to.have.length(2);
107
108
  });
108
109
 
109
- test('is updated when nodes change', async () => {
110
- const builder = new GraphBuilder();
110
+ test('sort edges', () => {
111
+ const graph = new Graph();
111
112
 
112
- // TODO(burdon): Return TestNodeBuilder object.
113
- const { nodeBuilder, addNode, removeNode, addAction, removeAction, addProperty, removeProperty } =
114
- createTestNodeBuilder('test1', 2);
115
- builder.addNodeBuilder('test1', nodeBuilder);
113
+ const [root] = graph.addNodes({
114
+ id: 'root',
115
+ nodes: [{ id: 'test1' }, { id: 'test3' }, { id: 'test2' }, { id: 'test4' }],
116
+ });
116
117
 
117
- const graph = builder.build();
118
- expect(graph.root.children).to.have.length(1);
119
- addNode('root', { id: 'root-test2', label: 'root-test2' });
120
- expect(graph.root.children).to.have.length(2);
121
- expect(graph.root.childrenMap['root-test2'].children).to.have.length(0);
118
+ expect(root.nodes().map((node) => node.id)).to.deep.equal(['test1', 'test3', 'test2', 'test4']);
122
119
 
123
- expect(graph.root.childrenMap['root-test1'].children).to.have.length(0);
124
- addNode('root-test1', { id: 'root-test1-test2', label: 'root-test1-test2' });
125
- expect(graph.root.childrenMap['root-test1'].children).to.have.length(1);
126
- expect(graph.root.childrenMap['root-test1'].childrenMap['root-test1-test2'].children).to.have.length(0);
120
+ graph.sortEdges('root', 'outbound', ['test4', 'test3']);
127
121
 
128
- expect(graph.root.actions).to.have.length(1);
129
- addAction('root', { id: 'root-test2', label: 'root-test2', invoke: () => {} });
130
- expect(graph.root.actions).to.have.length(2);
122
+ expect(root.nodes().map((node) => node.id)).to.deep.equal(['test4', 'test3', 'test1', 'test2']);
123
+ });
131
124
 
132
- expect(graph.root.childrenMap['root-test1'].properties).to.not.have.property('test');
133
- addProperty('root-test1', 'test', 'test');
134
- expect(graph.root.childrenMap['root-test1'].properties.test).to.equal('test');
125
+ test('remove edge', () => {
126
+ const graph = new Graph();
135
127
 
136
- expect(graph.root.children).to.have.length(2);
137
- removeNode('root', 'root-test2');
138
- expect(graph.root.children).to.have.length(1);
128
+ graph.addNodes({
129
+ id: 'root',
130
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
131
+ });
132
+ graph.removeEdge({ source: 'root', target: 'test1' });
139
133
 
140
- expect(graph.root.childrenMap['root-test1'].children).to.have.length(1);
141
- removeNode('root-test1', 'root-test1-test2');
142
- expect(graph.root.childrenMap['root-test1'].children).to.have.length(0);
134
+ expect(graph.root.nodes()).to.have.length(1);
135
+ expect(graph.findNode('test1')?.nodes({ direction: 'inbound' })).to.be.empty;
136
+ });
143
137
 
144
- expect(graph.root.actions).to.have.length(2);
145
- removeAction('root', 'root-test2');
146
- expect(graph.root.actions).to.have.length(1);
138
+ test('toJSON', () => {
139
+ const graph = new Graph();
147
140
 
148
- expect(graph.root.childrenMap['root-test1'].properties.test).to.equal('test');
149
- removeProperty('root-test1', 'test');
150
- expect(graph.root.childrenMap['root-test1'].properties).to.not.have.property('test');
151
- });
141
+ graph.addNodes({
142
+ id: 'root',
143
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
144
+ });
145
+ graph.addEdge({ source: 'test1', target: 'test2' });
152
146
 
153
- test.skip('node builder unsubscribe is called when builder is re-run', async () => {
154
- // TODO(wittjosiah): Implement.
147
+ const json = graph.toJSON();
148
+ expect(json).to.deep.equal({
149
+ id: 'root',
150
+ nodes: [{ id: 'test1', nodes: [{ id: 'test2' }] }, { id: 'test2' }],
151
+ });
155
152
  });
156
153
 
157
- test('can find nodes', () => {
158
- const builder = new GraphBuilder();
159
- builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
160
- builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
154
+ test('can be traversed', () => {
155
+ const graph = new Graph();
161
156
 
162
- const graph = builder.build();
163
- expect(graph.findNode('root-test1')?.id).to.equal('root-test1');
164
- expect(graph.findNode('root-test2-test1')?.id).to.equal('root-test2-test1');
157
+ const [root] = graph.addNodes({
158
+ id: 'root',
159
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
160
+ });
161
+
162
+ const nodes: string[] = [];
163
+ graph.traverse({ node: root, visitor: (node) => nodes.push(node.id) });
164
+ expect(nodes).to.deep.equal(['root', 'test1', 'test2']);
165
165
  });
166
166
 
167
- test('can be traversed', () => {
168
- const builder = new GraphBuilder();
169
- builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
170
- builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
167
+ test('traversal breaks cycles', () => {
168
+ const graph = new Graph();
169
+
170
+ const [root] = graph.addNodes({
171
+ id: 'root',
172
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
173
+ });
174
+ graph.addEdge({ source: 'test1', target: 'root' });
171
175
 
172
- const graph = builder.build();
173
176
  const nodes: string[] = [];
174
- graph.traverse({ visitor: (node) => nodes.push(node.id) });
175
- expect(nodes).to.deep.equal(['root', 'root-test1', 'root-test2', 'root-test2-test1']);
177
+ graph.traverse({ node: root, visitor: (node) => nodes.push(node.id) });
178
+ expect(nodes).to.deep.equal(['root', 'test1', 'test2']);
176
179
  });
177
180
 
178
181
  test('traversal can be limited by predicate', () => {
179
- const builder = new GraphBuilder();
180
- builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
181
- builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
182
+ const graph = new Graph();
183
+
184
+ const [root] = graph.addNodes({
185
+ id: 'root',
186
+ nodes: [{ id: 'test1' }, { id: 'test2' }, { id: 'test3' }, { id: 'test4' }],
187
+ });
182
188
 
183
- const graph = builder.build();
184
189
  const nodes: string[] = [];
185
190
  graph.traverse({
186
- filter: (node) => node.id.includes('test1'),
191
+ node: root,
187
192
  visitor: (node) => nodes.push(node.id),
193
+ filter: (node) => {
194
+ try {
195
+ const id = parseInt(node.id.replace('test', ''), 10);
196
+ return id % 2 === 0;
197
+ } catch (e) {
198
+ return false;
199
+ }
200
+ },
188
201
  });
189
- expect(nodes).to.deep.equal(['root-test1', 'root-test2-test1']);
202
+ expect(nodes).to.deep.equal(['test2', 'test4']);
190
203
  });
191
204
 
192
205
  test('traversal can be started from any node', () => {
193
- const builder = new GraphBuilder();
194
- builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
195
- builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
206
+ const graph = new Graph();
207
+
208
+ graph.addNodes({
209
+ id: 'root',
210
+ nodes: [{ id: 'test1', nodes: [{ id: 'test2', nodes: [{ id: 'test3' }] }] }],
211
+ });
196
212
 
197
- const graph = builder.build();
198
213
  const nodes: string[] = [];
199
214
  graph.traverse({
200
- node: graph.root.childrenMap['root-test2'],
215
+ node: graph.findNode('test2')!,
201
216
  visitor: (node) => nodes.push(node.id),
202
217
  });
203
- expect(nodes).to.deep.equal(['root-test2', 'root-test2-test1']);
218
+ expect(nodes).to.deep.equal(['test2', 'test3']);
204
219
  });
205
220
 
206
- test('can traverse up', () => {
207
- const builder = new GraphBuilder();
208
- builder.addNodeBuilder('test1', createTestNodeBuilder('test1', 2).nodeBuilder);
209
- builder.addNodeBuilder('test2', createTestNodeBuilder('test2').nodeBuilder);
221
+ test('traversal can follow inbound edges', () => {
222
+ const graph = new Graph();
223
+
224
+ graph.addNodes({
225
+ id: 'root',
226
+ nodes: [{ id: 'test1', nodes: [{ id: 'test2', nodes: [{ id: 'test3' }] }] }],
227
+ });
210
228
 
211
- const graph = builder.build();
212
229
  const nodes: string[] = [];
213
230
  graph.traverse({
214
- direction: 'up',
215
- node: graph.root.childrenMap['root-test2'].childrenMap['root-test2-test1'],
231
+ node: graph.findNode('test2')!,
232
+ direction: 'inbound',
216
233
  visitor: (node) => nodes.push(node.id),
217
234
  });
218
- expect(nodes).to.deep.equal(['root-test2-test1', 'root-test2', 'root']);
235
+ expect(nodes).to.deep.equal(['test2', 'test1', 'root']);
236
+ });
237
+
238
+ test('can filter to longest pathes', () => {
239
+ const graph = new Graph();
240
+
241
+ graph.addNodes({
242
+ id: 'root',
243
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
244
+ });
245
+ graph.addEdge({ source: 'test1', target: 'test2' });
246
+
247
+ graph.traverse({
248
+ visitor: (node, path) => {
249
+ if (!longestPaths.has(node.id) || longestPaths.get(node.id)!.length < path.length) {
250
+ longestPaths.set(node.id, path);
251
+ }
252
+ },
253
+ });
254
+
255
+ expect(longestPaths.get('root')).to.deep.equal(['root']);
256
+ expect(longestPaths.get('test1')).to.deep.equal(['root', 'test1']);
257
+ expect(longestPaths.get('test2')).to.deep.equal(['root', 'test1', 'test2']);
258
+ expect(graph.root.nodes({ filter: filterLongestPath })).to.have.length(1);
259
+ expect(graph.findNode('test1')?.nodes({ filter: filterLongestPath })).to.have.length(1);
260
+ expect(graph.findNode('test2')?.nodes({ filter: filterLongestPath })).to.be.empty;
261
+
262
+ longestPaths.clear();
263
+ });
264
+
265
+ test('traversing the graph subscribes to changes', () => {
266
+ const graph = new Graph();
267
+
268
+ graph.addNodes({
269
+ id: 'root',
270
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
271
+ });
272
+
273
+ const dispose = effect(() => {
274
+ graph.traverse({
275
+ visitor: (node, path) => {
276
+ if (!longestPaths.has(node.id) || longestPaths.get(node.id)!.length < path.length) {
277
+ longestPaths.set(node.id, path);
278
+ }
279
+ },
280
+ });
281
+ });
282
+
283
+ expect(longestPaths.get('root')).to.deep.equal(['root']);
284
+ expect(longestPaths.get('test1')).to.deep.equal(['root', 'test1']);
285
+ expect(longestPaths.get('test2')).to.deep.equal(['root', 'test2']);
286
+ expect(graph.root.nodes({ filter: filterLongestPath })).to.have.length(2);
287
+ expect(graph.findNode('test1')?.nodes({ filter: filterLongestPath })).to.be.empty;
288
+ expect(graph.findNode('test2')?.nodes({ filter: filterLongestPath })).to.be.empty;
289
+
290
+ graph.addEdge({ source: 'test1', target: 'test2' });
291
+
292
+ expect(longestPaths.get('root')).to.deep.equal(['root']);
293
+ expect(longestPaths.get('test1')).to.deep.equal(['root', 'test1']);
294
+ expect(longestPaths.get('test2')).to.deep.equal(['root', 'test1', 'test2']);
295
+ expect(graph.root.nodes({ filter: filterLongestPath })).to.have.length(1);
296
+ expect(graph.findNode('test1')?.nodes({ filter: filterLongestPath })).to.have.length(1);
297
+ expect(graph.findNode('test2')?.nodes({ filter: filterLongestPath })).to.be.empty;
298
+
299
+ dispose();
300
+ longestPaths.clear();
301
+ });
302
+
303
+ test('get path', () => {
304
+ const graph = new Graph();
305
+
306
+ graph.addNodes({
307
+ id: 'root',
308
+ nodes: [{ id: 'test1' }, { id: 'test2' }],
309
+ });
310
+ graph.addEdge({ source: 'test1', target: 'test2' });
311
+
312
+ expect(graph.getPath({ target: 'test2' })).to.deep.equal(['root', 'test1', 'test2']);
313
+ expect(graph.getPath({ source: 'test1', target: 'test2' })).to.deep.equal(['test1', 'test2']);
314
+ expect(graph.getPath({ source: 'test2', target: 'test1' })).to.be.undefined;
219
315
  });
220
316
  });