@osmix/router 0.0.11 → 0.1.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/CHANGELOG.md +78 -0
- package/LICENSE +21 -0
- package/README.md +138 -53
- package/dist/algorithms/bidirectional.d.ts +6 -9
- package/dist/algorithms/bidirectional.d.ts.map +1 -1
- package/dist/algorithms/bidirectional.js +84 -104
- package/dist/algorithms/bidirectional.js.map +1 -1
- package/dist/algorithms/index.d.ts +2 -2
- package/dist/algorithms/index.d.ts.map +1 -1
- package/dist/algorithms/index.js +1 -1
- package/dist/algorithms/index.js.map +1 -1
- package/dist/algorithms/shortest-path.d.ts +2 -1
- package/dist/algorithms/shortest-path.d.ts.map +1 -1
- package/dist/algorithms/shortest-path.js +9 -7
- package/dist/algorithms/shortest-path.js.map +1 -1
- package/dist/binary-heap.d.ts +5 -0
- package/dist/binary-heap.d.ts.map +1 -1
- package/dist/binary-heap.js +11 -4
- package/dist/binary-heap.js.map +1 -1
- package/dist/graph.d.ts +35 -7
- package/dist/graph.d.ts.map +1 -1
- package/dist/graph.js +161 -119
- package/dist/graph.js.map +1 -1
- package/dist/index.d.ts +9 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -4
- package/dist/index.js.map +1 -1
- package/dist/router.d.ts +1 -1
- package/dist/router.d.ts.map +1 -1
- package/dist/router.js +6 -3
- package/dist/router.js.map +1 -1
- package/dist/topology-stats.d.ts +37 -0
- package/dist/topology-stats.d.ts.map +1 -0
- package/dist/topology-stats.js +62 -0
- package/dist/topology-stats.js.map +1 -0
- package/dist/types.d.ts +15 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/utils.d.ts +1 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +1 -7
- package/dist/utils.js.map +1 -1
- package/package.json +32 -29
- package/src/algorithms/bidirectional.ts +124 -136
- package/src/algorithms/index.ts +10 -10
- package/src/algorithms/shortest-path.ts +96 -92
- package/src/binary-heap.ts +133 -129
- package/src/graph.ts +389 -355
- package/src/index.ts +14 -9
- package/src/router.ts +221 -233
- package/src/topology-stats.ts +84 -0
- package/src/types.ts +102 -87
- package/src/utils.ts +67 -74
- package/test/algorithms.test.ts +396 -353
- package/test/binary-heap.test.ts +27 -0
- package/test/limits.test.ts +82 -0
- package/test/no-shared-array-buffer.test.ts +37 -0
- package/test/router.bench.ts +171 -188
- package/test/router.test.ts +685 -726
- package/test/spatial-capability.test.ts +23 -0
- package/test/topology-stats.test.ts +82 -0
- package/tsconfig.build.json +6 -3
- package/tsconfig.json +7 -7
package/test/algorithms.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { describe, expect, it } from "
|
|
2
|
-
|
|
3
|
-
import
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { astar, bidirectional, dijkstra } from "../src/algorithms";
|
|
4
|
+
import type { GraphEdge } from "../src/types";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* Test suite for individual routing algorithms.
|
|
@@ -18,33 +19,56 @@ import type { GraphEdge } from "../src/types"
|
|
|
18
19
|
* Creates a simple graph function from an adjacency list.
|
|
19
20
|
* The adjacency list maps node IDs to their outgoing edges.
|
|
20
21
|
*/
|
|
21
|
-
function createGraph(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
22
|
+
function createGraph(adjacencyList: Map<number, GraphEdge[]>): (nodeId: number) => GraphEdge[] {
|
|
23
|
+
return (nodeId: number) => adjacencyList.get(nodeId) ?? [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Creates the incoming-edge function for an adjacency list, as bidirectional needs.
|
|
28
|
+
* In each returned edge, `targetNodeIndex` is the source node.
|
|
29
|
+
*/
|
|
30
|
+
function createReverseContext(adjacencyList: Map<number, GraphEdge[]>) {
|
|
31
|
+
const incoming = new Map<number, GraphEdge[]>();
|
|
32
|
+
for (const [source, edges] of adjacencyList) {
|
|
33
|
+
for (const edge of edges) {
|
|
34
|
+
const list = incoming.get(edge.targetNodeIndex) ?? [];
|
|
35
|
+
list.push({ ...edge, targetNodeIndex: source });
|
|
36
|
+
incoming.set(edge.targetNodeIndex, list);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return { reverseGraph: (nodeId: number) => incoming.get(nodeId) ?? [] };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Run bidirectional search over an adjacency list. */
|
|
43
|
+
function runBidirectional(adjacencyList: Map<number, GraphEdge[]>, start: number, end: number) {
|
|
44
|
+
return bidirectional(
|
|
45
|
+
createGraph(adjacencyList),
|
|
46
|
+
start,
|
|
47
|
+
end,
|
|
48
|
+
distanceWeight,
|
|
49
|
+
undefined,
|
|
50
|
+
undefined,
|
|
51
|
+
createReverseContext(adjacencyList),
|
|
52
|
+
);
|
|
25
53
|
}
|
|
26
54
|
|
|
27
55
|
/**
|
|
28
56
|
* Simple edge weight function that uses distance.
|
|
29
57
|
*/
|
|
30
58
|
function distanceWeight(edge: GraphEdge): number {
|
|
31
|
-
|
|
59
|
+
return edge.distance;
|
|
32
60
|
}
|
|
33
61
|
|
|
34
62
|
/**
|
|
35
63
|
* Creates a mock edge with default values.
|
|
36
64
|
*/
|
|
37
|
-
function mockEdge(
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
wayIndex,
|
|
45
|
-
distance,
|
|
46
|
-
time: distance / 10, // 10 m/s default speed
|
|
47
|
-
}
|
|
65
|
+
function mockEdge(targetNodeIndex: number, wayIndex: number, distance: number): GraphEdge {
|
|
66
|
+
return {
|
|
67
|
+
targetNodeIndex: targetNodeIndex,
|
|
68
|
+
wayIndex,
|
|
69
|
+
distance,
|
|
70
|
+
time: distance / 10, // 10 m/s default speed
|
|
71
|
+
};
|
|
48
72
|
}
|
|
49
73
|
|
|
50
74
|
/**
|
|
@@ -52,12 +76,12 @@ function mockEdge(
|
|
|
52
76
|
* Bidirectional edges for proper routing.
|
|
53
77
|
*/
|
|
54
78
|
function createLinearGraph(): Map<number, GraphEdge[]> {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
79
|
+
return new Map([
|
|
80
|
+
[1, [mockEdge(2, 100, 10)]],
|
|
81
|
+
[2, [mockEdge(1, 100, 10), mockEdge(3, 101, 10)]],
|
|
82
|
+
[3, [mockEdge(2, 101, 10), mockEdge(4, 102, 10)]],
|
|
83
|
+
[4, [mockEdge(3, 102, 10)]],
|
|
84
|
+
]);
|
|
61
85
|
}
|
|
62
86
|
|
|
63
87
|
/**
|
|
@@ -73,12 +97,12 @@ function createLinearGraph(): Map<number, GraphEdge[]> {
|
|
|
73
97
|
* Path 1->3->4 has distance 15
|
|
74
98
|
*/
|
|
75
99
|
function createSquareGraph(): Map<number, GraphEdge[]> {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
100
|
+
return new Map([
|
|
101
|
+
[1, [mockEdge(2, 100, 10), mockEdge(3, 101, 5)]],
|
|
102
|
+
[2, [mockEdge(1, 100, 10), mockEdge(4, 102, 5)]],
|
|
103
|
+
[3, [mockEdge(1, 101, 5), mockEdge(4, 103, 10)]],
|
|
104
|
+
[4, [mockEdge(2, 102, 5), mockEdge(3, 103, 10)]],
|
|
105
|
+
]);
|
|
82
106
|
}
|
|
83
107
|
|
|
84
108
|
/**
|
|
@@ -94,18 +118,18 @@ function createSquareGraph(): Map<number, GraphEdge[]> {
|
|
|
94
118
|
* Indirect path: 30 (should be chosen)
|
|
95
119
|
*/
|
|
96
120
|
function createIndirectShorterGraph(): Map<number, GraphEdge[]> {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
121
|
+
return new Map([
|
|
122
|
+
[
|
|
123
|
+
1,
|
|
124
|
+
[
|
|
125
|
+
mockEdge(4, 100, 100), // Direct but long
|
|
126
|
+
mockEdge(2, 101, 10), // Indirect start
|
|
127
|
+
],
|
|
128
|
+
],
|
|
129
|
+
[2, [mockEdge(1, 101, 10), mockEdge(3, 102, 10)]],
|
|
130
|
+
[3, [mockEdge(2, 102, 10), mockEdge(4, 103, 10)]],
|
|
131
|
+
[4, [mockEdge(1, 100, 100), mockEdge(3, 103, 10)]],
|
|
132
|
+
]);
|
|
109
133
|
}
|
|
110
134
|
|
|
111
135
|
/**
|
|
@@ -113,12 +137,12 @@ function createIndirectShorterGraph(): Map<number, GraphEdge[]> {
|
|
|
113
137
|
* Nodes 1-3 are connected bidirectionally, but 4 has no edges.
|
|
114
138
|
*/
|
|
115
139
|
function createDisconnectedGraph(): Map<number, GraphEdge[]> {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
140
|
+
return new Map([
|
|
141
|
+
[1, [mockEdge(2, 100, 10)]],
|
|
142
|
+
[2, [mockEdge(1, 100, 10), mockEdge(3, 101, 10)]],
|
|
143
|
+
[3, [mockEdge(2, 101, 10)]],
|
|
144
|
+
[4, []], // Unreachable - no edges to/from this node
|
|
145
|
+
]);
|
|
122
146
|
}
|
|
123
147
|
|
|
124
148
|
/**
|
|
@@ -135,13 +159,13 @@ function createDisconnectedGraph(): Map<number, GraphEdge[]> {
|
|
|
135
159
|
* Edge weights are 10+ units, so heuristic <= actual cost (admissible).
|
|
136
160
|
*/
|
|
137
161
|
function createCoordFn(): (nodeId: number) => [number, number] | undefined {
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
162
|
+
const coords = new Map<number, [number, number]>([
|
|
163
|
+
[1, [0, 0]],
|
|
164
|
+
[2, [0.00005, 0]], // ~5m east
|
|
165
|
+
[3, [0.0001, 0]], // ~10m east
|
|
166
|
+
[4, [0.00015, 0]], // ~15m east
|
|
167
|
+
]);
|
|
168
|
+
return (nodeId: number) => coords.get(nodeId);
|
|
145
169
|
}
|
|
146
170
|
|
|
147
171
|
// ---------------------------------------------------------------------------
|
|
@@ -149,318 +173,337 @@ function createCoordFn(): (nodeId: number) => [number, number] | undefined {
|
|
|
149
173
|
// ---------------------------------------------------------------------------
|
|
150
174
|
|
|
151
175
|
describe("dijkstra", () => {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
})
|
|
176
|
+
it("should find direct path between adjacent nodes", () => {
|
|
177
|
+
const graph = createGraph(createLinearGraph());
|
|
178
|
+
const path = dijkstra(graph, 1, 2, distanceWeight);
|
|
179
|
+
|
|
180
|
+
expect(path).not.toBeNull();
|
|
181
|
+
expect(path?.length).toBe(2);
|
|
182
|
+
expect(path?.[0]?.nodeIndex).toBe(1);
|
|
183
|
+
expect(path?.[1]?.nodeIndex).toBe(2);
|
|
184
|
+
expect(path?.[1]?.wayIndex).toBe(100);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it("should find path through multiple nodes", () => {
|
|
188
|
+
const graph = createGraph(createLinearGraph());
|
|
189
|
+
const path = dijkstra(graph, 1, 4, distanceWeight);
|
|
190
|
+
|
|
191
|
+
expect(path).not.toBeNull();
|
|
192
|
+
expect(path?.length).toBe(4);
|
|
193
|
+
|
|
194
|
+
// Verify node order
|
|
195
|
+
const nodeIds = path?.map((p) => p.nodeIndex);
|
|
196
|
+
expect(nodeIds).toEqual([1, 2, 3, 4]);
|
|
197
|
+
|
|
198
|
+
// Verify way indexes are tracked
|
|
199
|
+
expect(path?.[1]?.wayIndex).toBe(100);
|
|
200
|
+
expect(path?.[2]?.wayIndex).toBe(101);
|
|
201
|
+
expect(path?.[3]?.wayIndex).toBe(102);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("should return null when no path exists", () => {
|
|
205
|
+
const graph = createGraph(createDisconnectedGraph());
|
|
206
|
+
const path = dijkstra(graph, 1, 4, distanceWeight);
|
|
207
|
+
|
|
208
|
+
expect(path).toBeNull();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("should handle same start and end node", () => {
|
|
212
|
+
const graph = createGraph(createLinearGraph());
|
|
213
|
+
const path = dijkstra(graph, 1, 1, distanceWeight);
|
|
214
|
+
|
|
215
|
+
expect(path).not.toBeNull();
|
|
216
|
+
expect(path?.length).toBe(1);
|
|
217
|
+
expect(path?.[0]?.nodeIndex).toBe(1);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("should find shortest path when indirect is shorter", () => {
|
|
221
|
+
const graph = createGraph(createIndirectShorterGraph());
|
|
222
|
+
const path = dijkstra(graph, 1, 4, distanceWeight);
|
|
223
|
+
|
|
224
|
+
expect(path).not.toBeNull();
|
|
225
|
+
|
|
226
|
+
// Should take indirect route 1->2->3->4 (cost 30) not 1->4 (cost 100)
|
|
227
|
+
const nodeIds = path?.map((p) => p.nodeIndex);
|
|
228
|
+
expect(nodeIds).toEqual([1, 2, 3, 4]);
|
|
229
|
+
|
|
230
|
+
// Verify total cost
|
|
231
|
+
const lastSegment = path?.[path.length - 1];
|
|
232
|
+
expect(lastSegment?.cost).toBe(30);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("should calculate correct costs", () => {
|
|
236
|
+
const graph = createGraph(createLinearGraph());
|
|
237
|
+
const path = dijkstra(graph, 1, 4, distanceWeight);
|
|
238
|
+
|
|
239
|
+
expect(path).not.toBeNull();
|
|
240
|
+
|
|
241
|
+
// Each edge has distance 10
|
|
242
|
+
expect(path?.[0]?.cost).toBe(0); // Start node
|
|
243
|
+
expect(path?.[1]?.cost).toBe(10);
|
|
244
|
+
expect(path?.[2]?.cost).toBe(20);
|
|
245
|
+
expect(path?.[3]?.cost).toBe(30);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("should track correct way indexes in path", () => {
|
|
249
|
+
const graph = createGraph(createSquareGraph());
|
|
250
|
+
const path = dijkstra(graph, 1, 4, distanceWeight);
|
|
251
|
+
|
|
252
|
+
expect(path).not.toBeNull();
|
|
253
|
+
|
|
254
|
+
// Verify way indexes are present for traversed edges
|
|
255
|
+
const wayIndexes = path?.slice(1).map((p) => p.wayIndex);
|
|
256
|
+
expect(wayIndexes?.every((idx) => idx !== undefined)).toBe(true);
|
|
257
|
+
});
|
|
258
|
+
});
|
|
235
259
|
|
|
236
260
|
// ---------------------------------------------------------------------------
|
|
237
261
|
// A* Tests
|
|
238
262
|
// ---------------------------------------------------------------------------
|
|
239
263
|
|
|
240
264
|
describe("astar", () => {
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
})
|
|
265
|
+
it("should find direct path between adjacent nodes", () => {
|
|
266
|
+
const graph = createGraph(createLinearGraph());
|
|
267
|
+
const path = astar(graph, 1, 2, distanceWeight, createCoordFn());
|
|
268
|
+
|
|
269
|
+
expect(path).not.toBeNull();
|
|
270
|
+
expect(path?.length).toBe(2);
|
|
271
|
+
expect(path?.[0]?.nodeIndex).toBe(1);
|
|
272
|
+
expect(path?.[1]?.nodeIndex).toBe(2);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("should find path through multiple nodes", () => {
|
|
276
|
+
const graph = createGraph(createLinearGraph());
|
|
277
|
+
const path = astar(graph, 1, 4, distanceWeight, createCoordFn());
|
|
278
|
+
|
|
279
|
+
expect(path).not.toBeNull();
|
|
280
|
+
const nodeIds = path?.map((p) => p.nodeIndex);
|
|
281
|
+
expect(nodeIds).toEqual([1, 2, 3, 4]);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("should return null when no path exists", () => {
|
|
285
|
+
const graph = createGraph(createDisconnectedGraph());
|
|
286
|
+
const path = astar(graph, 1, 4, distanceWeight, createCoordFn());
|
|
287
|
+
|
|
288
|
+
expect(path).toBeNull();
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
it("should handle same start and end node", () => {
|
|
292
|
+
const graph = createGraph(createLinearGraph());
|
|
293
|
+
const path = astar(graph, 1, 1, distanceWeight, createCoordFn());
|
|
294
|
+
|
|
295
|
+
expect(path).not.toBeNull();
|
|
296
|
+
expect(path?.length).toBe(1);
|
|
297
|
+
expect(path?.[0]?.nodeIndex).toBe(1);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("should find shortest path when indirect is shorter", () => {
|
|
301
|
+
const graph = createGraph(createIndirectShorterGraph());
|
|
302
|
+
const path = astar(graph, 1, 4, distanceWeight, createCoordFn());
|
|
303
|
+
|
|
304
|
+
expect(path).not.toBeNull();
|
|
305
|
+
const nodeIds = path?.map((p) => p.nodeIndex);
|
|
306
|
+
expect(nodeIds).toEqual([1, 2, 3, 4]);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it("should use heuristic to guide search", () => {
|
|
310
|
+
// With heuristic, A* should explore fewer nodes than Dijkstra
|
|
311
|
+
// We can verify it finds the correct path
|
|
312
|
+
const graph = createGraph(createIndirectShorterGraph());
|
|
313
|
+
const path = astar(graph, 1, 4, distanceWeight, createCoordFn());
|
|
314
|
+
|
|
315
|
+
expect(path).not.toBeNull();
|
|
316
|
+
expect(path?.[path.length - 1]?.cost).toBe(30);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("should return null when coordinate function is missing", () => {
|
|
320
|
+
const graph = createGraph(createLinearGraph());
|
|
321
|
+
const path = astar(graph, 1, 4, distanceWeight, undefined);
|
|
322
|
+
|
|
323
|
+
expect(path).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("should handle missing coordinate for node gracefully", () => {
|
|
327
|
+
const graph = createGraph(createLinearGraph());
|
|
328
|
+
// Coordinate function that only knows about nodes 1 and 4
|
|
329
|
+
const partialCoordFn = (nodeId: number) => {
|
|
330
|
+
if (nodeId === 1) return [0, 0] as [number, number];
|
|
331
|
+
if (nodeId === 4) return [0.001, 0.001] as [number, number];
|
|
332
|
+
return undefined;
|
|
333
|
+
};
|
|
334
|
+
const path = astar(graph, 1, 4, distanceWeight, partialCoordFn);
|
|
335
|
+
|
|
336
|
+
// A* should still find a path, using 0 heuristic for unknown nodes
|
|
337
|
+
expect(path).not.toBeNull();
|
|
338
|
+
const nodeIds = path?.map((p) => p.nodeIndex);
|
|
339
|
+
expect(nodeIds).toEqual([1, 2, 3, 4]);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it("should track correct way indexes in path", () => {
|
|
343
|
+
const graph = createGraph(createSquareGraph());
|
|
344
|
+
const path = astar(graph, 1, 4, distanceWeight, createCoordFn());
|
|
345
|
+
|
|
346
|
+
expect(path).not.toBeNull();
|
|
347
|
+
const wayIndexes = path?.slice(1).map((p) => p.wayIndex);
|
|
348
|
+
expect(wayIndexes?.every((idx) => idx !== undefined)).toBe(true);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
327
351
|
|
|
328
352
|
// ---------------------------------------------------------------------------
|
|
329
353
|
// Bidirectional Tests
|
|
330
354
|
// ---------------------------------------------------------------------------
|
|
331
355
|
|
|
332
356
|
describe("bidirectional", () => {
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
357
|
+
it("follows one-way edges backward from the end", () => {
|
|
358
|
+
// 1 -> 2 -> 3 is one-way; 1 <-> 4 <-> 3 is a longer two-way detour.
|
|
359
|
+
// Going 3 to 1 must take the detour: the old search followed 3's outgoing
|
|
360
|
+
// edges from the end and could return 3 -> 2 -> 1 against the one-way.
|
|
361
|
+
const oneWay = new Map([
|
|
362
|
+
[1, [mockEdge(2, 100, 1), mockEdge(4, 101, 10)]],
|
|
363
|
+
[2, [mockEdge(3, 100, 1)]],
|
|
364
|
+
[3, [mockEdge(4, 102, 10)]],
|
|
365
|
+
[4, [mockEdge(1, 101, 10), mockEdge(3, 102, 10)]],
|
|
366
|
+
]);
|
|
367
|
+
expect(runBidirectional(oneWay, 3, 1)?.map((p) => p.nodeIndex)).toEqual([3, 4, 1]);
|
|
368
|
+
expect(runBidirectional(oneWay, 1, 3)?.map((p) => p.nodeIndex)).toEqual([1, 2, 3]);
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("reports costs from the start on both halves of the path", () => {
|
|
372
|
+
const path = runBidirectional(createIndirectShorterGraph(), 1, 4);
|
|
373
|
+
expect(path?.map((p) => p.cost)).toEqual([0, 10, 20, 30]);
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("throws without a reverse graph", () => {
|
|
377
|
+
expect(() => bidirectional(createGraph(createLinearGraph()), 1, 4, distanceWeight)).toThrow(
|
|
378
|
+
/reverseGraph/,
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
it("should find direct path between adjacent nodes", () => {
|
|
383
|
+
const path = runBidirectional(createLinearGraph(), 1, 2);
|
|
384
|
+
|
|
385
|
+
expect(path).not.toBeNull();
|
|
386
|
+
// Bidirectional may have different path structure but same nodes
|
|
387
|
+
const nodeIds = path?.map((p) => p.nodeIndex);
|
|
388
|
+
expect(nodeIds).toContain(1);
|
|
389
|
+
expect(nodeIds).toContain(2);
|
|
390
|
+
});
|
|
391
|
+
|
|
392
|
+
it("should find path through multiple nodes", () => {
|
|
393
|
+
const path = runBidirectional(createLinearGraph(), 1, 4);
|
|
394
|
+
|
|
395
|
+
expect(path).not.toBeNull();
|
|
396
|
+
const nodeIds = path?.map((p) => p.nodeIndex);
|
|
397
|
+
// Should contain all nodes in the path
|
|
398
|
+
expect(nodeIds).toContain(1);
|
|
399
|
+
expect(nodeIds).toContain(4);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("should return null when no path exists", () => {
|
|
403
|
+
const path = runBidirectional(createDisconnectedGraph(), 1, 4);
|
|
404
|
+
|
|
405
|
+
expect(path).toBeNull();
|
|
406
|
+
});
|
|
407
|
+
|
|
408
|
+
it("should handle same start and end node", () => {
|
|
409
|
+
const path = runBidirectional(createLinearGraph(), 1, 1);
|
|
410
|
+
|
|
411
|
+
expect(path).not.toBeNull();
|
|
412
|
+
expect(path?.some((p) => p.nodeIndex === 1)).toBe(true);
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
it("should meet in the middle correctly", () => {
|
|
416
|
+
// Create a symmetric graph where meeting in middle is clear
|
|
417
|
+
// 1 <-> 2 <-> 3 <-> 4 <-> 5
|
|
418
|
+
const symmetricGraph = new Map([
|
|
419
|
+
[1, [mockEdge(2, 100, 10)]],
|
|
420
|
+
[2, [mockEdge(1, 100, 10), mockEdge(3, 101, 10)]],
|
|
421
|
+
[3, [mockEdge(2, 101, 10), mockEdge(4, 102, 10)]],
|
|
422
|
+
[4, [mockEdge(3, 102, 10), mockEdge(5, 103, 10)]],
|
|
423
|
+
[5, [mockEdge(4, 103, 10)]],
|
|
424
|
+
]);
|
|
425
|
+
|
|
426
|
+
const path = runBidirectional(symmetricGraph, 1, 5);
|
|
427
|
+
|
|
428
|
+
expect(path).not.toBeNull();
|
|
429
|
+
// Should include start and end
|
|
430
|
+
const nodeIds = path?.map((p) => p.nodeIndex);
|
|
431
|
+
expect(nodeIds).toContain(1);
|
|
432
|
+
expect(nodeIds).toContain(5);
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
it("should combine forward and backward paths properly", () => {
|
|
436
|
+
const path = runBidirectional(createSquareGraph(), 1, 4);
|
|
437
|
+
|
|
438
|
+
expect(path).not.toBeNull();
|
|
439
|
+
|
|
440
|
+
// Path should go from 1 to 4
|
|
441
|
+
const nodeIds = path?.map((p) => p.nodeIndex);
|
|
442
|
+
expect(nodeIds?.[0]).toBe(1);
|
|
443
|
+
// Last node should be 4 or path should include 4
|
|
444
|
+
expect(nodeIds).toContain(4);
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it("should track way indexes in combined path", () => {
|
|
448
|
+
const path = runBidirectional(createSquareGraph(), 1, 4);
|
|
449
|
+
|
|
450
|
+
expect(path).not.toBeNull();
|
|
451
|
+
|
|
452
|
+
// Way indexes should be present (except possibly for start node)
|
|
453
|
+
const wayIndexes = path?.filter((p) => p.wayIndex !== undefined).map((p) => p.wayIndex);
|
|
454
|
+
expect(wayIndexes?.length).toBeGreaterThan(0);
|
|
455
|
+
});
|
|
456
|
+
});
|
|
417
457
|
|
|
418
458
|
// ---------------------------------------------------------------------------
|
|
419
459
|
// Cross-Algorithm Comparison Tests
|
|
420
460
|
// ---------------------------------------------------------------------------
|
|
421
461
|
|
|
422
462
|
describe("algorithm consistency", () => {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
463
|
+
it("should find same path length with all algorithms", () => {
|
|
464
|
+
const graph = createGraph(createIndirectShorterGraph());
|
|
465
|
+
|
|
466
|
+
const dijkstraPath = dijkstra(graph, 1, 4, distanceWeight);
|
|
467
|
+
const astarPath = astar(graph, 1, 4, distanceWeight, createCoordFn());
|
|
468
|
+
const biPath = runBidirectional(createIndirectShorterGraph(), 1, 4);
|
|
469
|
+
|
|
470
|
+
// All should find a path
|
|
471
|
+
expect(dijkstraPath).not.toBeNull();
|
|
472
|
+
expect(astarPath).not.toBeNull();
|
|
473
|
+
expect(biPath).not.toBeNull();
|
|
474
|
+
|
|
475
|
+
// All three are optimal, so they share the final cost
|
|
476
|
+
const dijkstraCost = dijkstraPath?.[dijkstraPath.length - 1]?.cost;
|
|
477
|
+
const astarCost = astarPath?.[astarPath.length - 1]?.cost;
|
|
478
|
+
const biCost = biPath?.[biPath.length - 1]?.cost;
|
|
479
|
+
|
|
480
|
+
expect(dijkstraCost).toBe(astarCost);
|
|
481
|
+
expect(biCost).toBe(dijkstraCost);
|
|
482
|
+
expect(biPath?.map((p) => p.nodeIndex)).toEqual([1, 2, 3, 4]);
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
it("all algorithms should return null for unreachable nodes", () => {
|
|
486
|
+
const graph = createGraph(createDisconnectedGraph());
|
|
487
|
+
|
|
488
|
+
expect(dijkstra(graph, 1, 4, distanceWeight)).toBeNull();
|
|
489
|
+
expect(astar(graph, 1, 4, distanceWeight, createCoordFn())).toBeNull();
|
|
490
|
+
expect(runBidirectional(createDisconnectedGraph(), 1, 4)).toBeNull();
|
|
491
|
+
});
|
|
492
|
+
|
|
493
|
+
it("all algorithms should handle trivial single-node path", () => {
|
|
494
|
+
const graph = createGraph(createLinearGraph());
|
|
495
|
+
|
|
496
|
+
const dijkstraPath = dijkstra(graph, 1, 1, distanceWeight);
|
|
497
|
+
const astarPath = astar(graph, 1, 1, distanceWeight, createCoordFn());
|
|
498
|
+
const biPath = runBidirectional(createLinearGraph(), 1, 1);
|
|
499
|
+
|
|
500
|
+
expect(dijkstraPath).not.toBeNull();
|
|
501
|
+
expect(astarPath).not.toBeNull();
|
|
502
|
+
expect(biPath).not.toBeNull();
|
|
503
|
+
|
|
504
|
+
// All should include node 1
|
|
505
|
+
expect(dijkstraPath?.some((p) => p.nodeIndex === 1)).toBe(true);
|
|
506
|
+
expect(astarPath?.some((p) => p.nodeIndex === 1)).toBe(true);
|
|
507
|
+
expect(biPath?.some((p) => p.nodeIndex === 1)).toBe(true);
|
|
508
|
+
});
|
|
509
|
+
});
|