@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.
Files changed (62) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/LICENSE +21 -0
  3. package/README.md +138 -53
  4. package/dist/algorithms/bidirectional.d.ts +6 -9
  5. package/dist/algorithms/bidirectional.d.ts.map +1 -1
  6. package/dist/algorithms/bidirectional.js +84 -104
  7. package/dist/algorithms/bidirectional.js.map +1 -1
  8. package/dist/algorithms/index.d.ts +2 -2
  9. package/dist/algorithms/index.d.ts.map +1 -1
  10. package/dist/algorithms/index.js +1 -1
  11. package/dist/algorithms/index.js.map +1 -1
  12. package/dist/algorithms/shortest-path.d.ts +2 -1
  13. package/dist/algorithms/shortest-path.d.ts.map +1 -1
  14. package/dist/algorithms/shortest-path.js +9 -7
  15. package/dist/algorithms/shortest-path.js.map +1 -1
  16. package/dist/binary-heap.d.ts +5 -0
  17. package/dist/binary-heap.d.ts.map +1 -1
  18. package/dist/binary-heap.js +11 -4
  19. package/dist/binary-heap.js.map +1 -1
  20. package/dist/graph.d.ts +35 -7
  21. package/dist/graph.d.ts.map +1 -1
  22. package/dist/graph.js +161 -119
  23. package/dist/graph.js.map +1 -1
  24. package/dist/index.d.ts +9 -4
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +9 -4
  27. package/dist/index.js.map +1 -1
  28. package/dist/router.d.ts +1 -1
  29. package/dist/router.d.ts.map +1 -1
  30. package/dist/router.js +6 -3
  31. package/dist/router.js.map +1 -1
  32. package/dist/topology-stats.d.ts +37 -0
  33. package/dist/topology-stats.d.ts.map +1 -0
  34. package/dist/topology-stats.js +62 -0
  35. package/dist/topology-stats.js.map +1 -0
  36. package/dist/types.d.ts +15 -2
  37. package/dist/types.d.ts.map +1 -1
  38. package/dist/utils.d.ts +1 -1
  39. package/dist/utils.d.ts.map +1 -1
  40. package/dist/utils.js +1 -7
  41. package/dist/utils.js.map +1 -1
  42. package/package.json +32 -29
  43. package/src/algorithms/bidirectional.ts +124 -136
  44. package/src/algorithms/index.ts +10 -10
  45. package/src/algorithms/shortest-path.ts +96 -92
  46. package/src/binary-heap.ts +133 -129
  47. package/src/graph.ts +389 -355
  48. package/src/index.ts +14 -9
  49. package/src/router.ts +221 -233
  50. package/src/topology-stats.ts +84 -0
  51. package/src/types.ts +102 -87
  52. package/src/utils.ts +67 -74
  53. package/test/algorithms.test.ts +396 -353
  54. package/test/binary-heap.test.ts +27 -0
  55. package/test/limits.test.ts +82 -0
  56. package/test/no-shared-array-buffer.test.ts +37 -0
  57. package/test/router.bench.ts +171 -188
  58. package/test/router.test.ts +685 -726
  59. package/test/spatial-capability.test.ts +23 -0
  60. package/test/topology-stats.test.ts +82 -0
  61. package/tsconfig.build.json +6 -3
  62. package/tsconfig.json +7 -7
@@ -1,6 +1,7 @@
1
- import { describe, expect, it } from "bun:test"
2
- import { astar, bidirectional, dijkstra } from "../src/algorithms"
3
- import type { GraphEdge } from "../src/types"
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
- adjacencyList: Map<number, GraphEdge[]>,
23
- ): (nodeId: number) => GraphEdge[] {
24
- return (nodeId: number) => adjacencyList.get(nodeId) ?? []
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
- return edge.distance
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
- targetNodeIndex: number,
39
- wayIndex: number,
40
- distance: number,
41
- ): GraphEdge {
42
- return {
43
- targetNodeIndex: targetNodeIndex,
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
- return new Map([
56
- [1, [mockEdge(2, 100, 10)]],
57
- [2, [mockEdge(1, 100, 10), mockEdge(3, 101, 10)]],
58
- [3, [mockEdge(2, 101, 10), mockEdge(4, 102, 10)]],
59
- [4, [mockEdge(3, 102, 10)]],
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
- return new Map([
77
- [1, [mockEdge(2, 100, 10), mockEdge(3, 101, 5)]],
78
- [2, [mockEdge(1, 100, 10), mockEdge(4, 102, 5)]],
79
- [3, [mockEdge(1, 101, 5), mockEdge(4, 103, 10)]],
80
- [4, [mockEdge(2, 102, 5), mockEdge(3, 103, 10)]],
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
- return new Map([
98
- [
99
- 1,
100
- [
101
- mockEdge(4, 100, 100), // Direct but long
102
- mockEdge(2, 101, 10), // Indirect start
103
- ],
104
- ],
105
- [2, [mockEdge(1, 101, 10), mockEdge(3, 102, 10)]],
106
- [3, [mockEdge(2, 102, 10), mockEdge(4, 103, 10)]],
107
- [4, [mockEdge(1, 100, 100), mockEdge(3, 103, 10)]],
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
- return new Map([
117
- [1, [mockEdge(2, 100, 10)]],
118
- [2, [mockEdge(1, 100, 10), mockEdge(3, 101, 10)]],
119
- [3, [mockEdge(2, 101, 10)]],
120
- [4, []], // Unreachable - no edges to/from this node
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
- const coords = new Map<number, [number, number]>([
139
- [1, [0, 0]],
140
- [2, [0.00005, 0]], // ~5m east
141
- [3, [0.0001, 0]], // ~10m east
142
- [4, [0.00015, 0]], // ~15m east
143
- ])
144
- return (nodeId: number) => coords.get(nodeId)
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
- it("should find direct path between adjacent nodes", () => {
153
- const graph = createGraph(createLinearGraph())
154
- const path = dijkstra(graph, 1, 2, distanceWeight)
155
-
156
- expect(path).not.toBeNull()
157
- expect(path?.length).toBe(2)
158
- expect(path?.[0]?.nodeIndex).toBe(1)
159
- expect(path?.[1]?.nodeIndex).toBe(2)
160
- expect(path?.[1]?.wayIndex).toBe(100)
161
- })
162
-
163
- it("should find path through multiple nodes", () => {
164
- const graph = createGraph(createLinearGraph())
165
- const path = dijkstra(graph, 1, 4, distanceWeight)
166
-
167
- expect(path).not.toBeNull()
168
- expect(path?.length).toBe(4)
169
-
170
- // Verify node order
171
- const nodeIds = path?.map((p) => p.nodeIndex)
172
- expect(nodeIds).toEqual([1, 2, 3, 4])
173
-
174
- // Verify way indexes are tracked
175
- expect(path?.[1]?.wayIndex).toBe(100)
176
- expect(path?.[2]?.wayIndex).toBe(101)
177
- expect(path?.[3]?.wayIndex).toBe(102)
178
- })
179
-
180
- it("should return null when no path exists", () => {
181
- const graph = createGraph(createDisconnectedGraph())
182
- const path = dijkstra(graph, 1, 4, distanceWeight)
183
-
184
- expect(path).toBeNull()
185
- })
186
-
187
- it("should handle same start and end node", () => {
188
- const graph = createGraph(createLinearGraph())
189
- const path = dijkstra(graph, 1, 1, distanceWeight)
190
-
191
- expect(path).not.toBeNull()
192
- expect(path?.length).toBe(1)
193
- expect(path?.[0]?.nodeIndex).toBe(1)
194
- })
195
-
196
- it("should find shortest path when indirect is shorter", () => {
197
- const graph = createGraph(createIndirectShorterGraph())
198
- const path = dijkstra(graph, 1, 4, distanceWeight)
199
-
200
- expect(path).not.toBeNull()
201
-
202
- // Should take indirect route 1->2->3->4 (cost 30) not 1->4 (cost 100)
203
- const nodeIds = path?.map((p) => p.nodeIndex)
204
- expect(nodeIds).toEqual([1, 2, 3, 4])
205
-
206
- // Verify total cost
207
- const lastSegment = path?.[path.length - 1]
208
- expect(lastSegment?.cost).toBe(30)
209
- })
210
-
211
- it("should calculate correct costs", () => {
212
- const graph = createGraph(createLinearGraph())
213
- const path = dijkstra(graph, 1, 4, distanceWeight)
214
-
215
- expect(path).not.toBeNull()
216
-
217
- // Each edge has distance 10
218
- expect(path?.[0]?.cost).toBe(0) // Start node
219
- expect(path?.[1]?.cost).toBe(10)
220
- expect(path?.[2]?.cost).toBe(20)
221
- expect(path?.[3]?.cost).toBe(30)
222
- })
223
-
224
- it("should track correct way indexes in path", () => {
225
- const graph = createGraph(createSquareGraph())
226
- const path = dijkstra(graph, 1, 4, distanceWeight)
227
-
228
- expect(path).not.toBeNull()
229
-
230
- // Verify way indexes are present for traversed edges
231
- const wayIndexes = path?.slice(1).map((p) => p.wayIndex)
232
- expect(wayIndexes?.every((idx) => idx !== undefined)).toBe(true)
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
- it("should find direct path between adjacent nodes", () => {
242
- const graph = createGraph(createLinearGraph())
243
- const path = astar(graph, 1, 2, distanceWeight, createCoordFn())
244
-
245
- expect(path).not.toBeNull()
246
- expect(path?.length).toBe(2)
247
- expect(path?.[0]?.nodeIndex).toBe(1)
248
- expect(path?.[1]?.nodeIndex).toBe(2)
249
- })
250
-
251
- it("should find path through multiple nodes", () => {
252
- const graph = createGraph(createLinearGraph())
253
- const path = astar(graph, 1, 4, distanceWeight, createCoordFn())
254
-
255
- expect(path).not.toBeNull()
256
- const nodeIds = path?.map((p) => p.nodeIndex)
257
- expect(nodeIds).toEqual([1, 2, 3, 4])
258
- })
259
-
260
- it("should return null when no path exists", () => {
261
- const graph = createGraph(createDisconnectedGraph())
262
- const path = astar(graph, 1, 4, distanceWeight, createCoordFn())
263
-
264
- expect(path).toBeNull()
265
- })
266
-
267
- it("should handle same start and end node", () => {
268
- const graph = createGraph(createLinearGraph())
269
- const path = astar(graph, 1, 1, distanceWeight, createCoordFn())
270
-
271
- expect(path).not.toBeNull()
272
- expect(path?.length).toBe(1)
273
- expect(path?.[0]?.nodeIndex).toBe(1)
274
- })
275
-
276
- it("should find shortest path when indirect is shorter", () => {
277
- const graph = createGraph(createIndirectShorterGraph())
278
- const path = astar(graph, 1, 4, distanceWeight, createCoordFn())
279
-
280
- expect(path).not.toBeNull()
281
- const nodeIds = path?.map((p) => p.nodeIndex)
282
- expect(nodeIds).toEqual([1, 2, 3, 4])
283
- })
284
-
285
- it("should use heuristic to guide search", () => {
286
- // With heuristic, A* should explore fewer nodes than Dijkstra
287
- // We can verify it finds the correct path
288
- const graph = createGraph(createIndirectShorterGraph())
289
- const path = astar(graph, 1, 4, distanceWeight, createCoordFn())
290
-
291
- expect(path).not.toBeNull()
292
- expect(path?.[path.length - 1]?.cost).toBe(30)
293
- })
294
-
295
- it("should return null when coordinate function is missing", () => {
296
- const graph = createGraph(createLinearGraph())
297
- const path = astar(graph, 1, 4, distanceWeight, undefined)
298
-
299
- expect(path).toBeNull()
300
- })
301
-
302
- it("should handle missing coordinate for node gracefully", () => {
303
- const graph = createGraph(createLinearGraph())
304
- // Coordinate function that only knows about nodes 1 and 4
305
- const partialCoordFn = (nodeId: number) => {
306
- if (nodeId === 1) return [0, 0] as [number, number]
307
- if (nodeId === 4) return [0.001, 0.001] as [number, number]
308
- return undefined
309
- }
310
- const path = astar(graph, 1, 4, distanceWeight, partialCoordFn)
311
-
312
- // A* should still find a path, using 0 heuristic for unknown nodes
313
- expect(path).not.toBeNull()
314
- const nodeIds = path?.map((p) => p.nodeIndex)
315
- expect(nodeIds).toEqual([1, 2, 3, 4])
316
- })
317
-
318
- it("should track correct way indexes in path", () => {
319
- const graph = createGraph(createSquareGraph())
320
- const path = astar(graph, 1, 4, distanceWeight, createCoordFn())
321
-
322
- expect(path).not.toBeNull()
323
- const wayIndexes = path?.slice(1).map((p) => p.wayIndex)
324
- expect(wayIndexes?.every((idx) => idx !== undefined)).toBe(true)
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
- it("should find direct path between adjacent nodes", () => {
334
- const graph = createGraph(createLinearGraph())
335
- const path = bidirectional(graph, 1, 2, distanceWeight)
336
-
337
- expect(path).not.toBeNull()
338
- // Bidirectional may have different path structure but same nodes
339
- const nodeIds = path?.map((p) => p.nodeIndex)
340
- expect(nodeIds).toContain(1)
341
- expect(nodeIds).toContain(2)
342
- })
343
-
344
- it("should find path through multiple nodes", () => {
345
- const graph = createGraph(createLinearGraph())
346
- const path = bidirectional(graph, 1, 4, distanceWeight)
347
-
348
- expect(path).not.toBeNull()
349
- const nodeIds = path?.map((p) => p.nodeIndex)
350
- // Should contain all nodes in the path
351
- expect(nodeIds).toContain(1)
352
- expect(nodeIds).toContain(4)
353
- })
354
-
355
- it("should return null when no path exists", () => {
356
- const graph = createGraph(createDisconnectedGraph())
357
- const path = bidirectional(graph, 1, 4, distanceWeight)
358
-
359
- expect(path).toBeNull()
360
- })
361
-
362
- it("should handle same start and end node", () => {
363
- const graph = createGraph(createLinearGraph())
364
- const path = bidirectional(graph, 1, 1, distanceWeight)
365
-
366
- expect(path).not.toBeNull()
367
- expect(path?.some((p) => p.nodeIndex === 1)).toBe(true)
368
- })
369
-
370
- it("should meet in the middle correctly", () => {
371
- // Create a symmetric graph where meeting in middle is clear
372
- // 1 <-> 2 <-> 3 <-> 4 <-> 5
373
- const symmetricGraph = new Map([
374
- [1, [mockEdge(2, 100, 10)]],
375
- [2, [mockEdge(1, 100, 10), mockEdge(3, 101, 10)]],
376
- [3, [mockEdge(2, 101, 10), mockEdge(4, 102, 10)]],
377
- [4, [mockEdge(3, 102, 10), mockEdge(5, 103, 10)]],
378
- [5, [mockEdge(4, 103, 10)]],
379
- ])
380
-
381
- const graph = createGraph(symmetricGraph)
382
- const path = bidirectional(graph, 1, 5, distanceWeight)
383
-
384
- expect(path).not.toBeNull()
385
- // Should include start and end
386
- const nodeIds = path?.map((p) => p.nodeIndex)
387
- expect(nodeIds).toContain(1)
388
- expect(nodeIds).toContain(5)
389
- })
390
-
391
- it("should combine forward and backward paths properly", () => {
392
- const graph = createGraph(createSquareGraph())
393
- const path = bidirectional(graph, 1, 4, distanceWeight)
394
-
395
- expect(path).not.toBeNull()
396
-
397
- // Path should go from 1 to 4
398
- const nodeIds = path?.map((p) => p.nodeIndex)
399
- expect(nodeIds?.[0]).toBe(1)
400
- // Last node should be 4 or path should include 4
401
- expect(nodeIds).toContain(4)
402
- })
403
-
404
- it("should track way indexes in combined path", () => {
405
- const graph = createGraph(createSquareGraph())
406
- const path = bidirectional(graph, 1, 4, distanceWeight)
407
-
408
- expect(path).not.toBeNull()
409
-
410
- // Way indexes should be present (except possibly for start node)
411
- const wayIndexes = path
412
- ?.filter((p) => p.wayIndex !== undefined)
413
- .map((p) => p.wayIndex)
414
- expect(wayIndexes?.length).toBeGreaterThan(0)
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
- it("should find same path length with all algorithms", () => {
424
- const graph = createGraph(createIndirectShorterGraph())
425
-
426
- const dijkstraPath = dijkstra(graph, 1, 4, distanceWeight)
427
- const astarPath = astar(graph, 1, 4, distanceWeight, createCoordFn())
428
- const biPath = bidirectional(graph, 1, 4, distanceWeight)
429
-
430
- // All should find a path
431
- expect(dijkstraPath).not.toBeNull()
432
- expect(astarPath).not.toBeNull()
433
- expect(biPath).not.toBeNull()
434
-
435
- // Dijkstra and A* should have same final cost
436
- const dijkstraCost = dijkstraPath?.[dijkstraPath.length - 1]?.cost
437
- const astarCost = astarPath?.[astarPath.length - 1]?.cost
438
-
439
- expect(dijkstraCost).toBe(astarCost)
440
- })
441
-
442
- it("all algorithms should return null for unreachable nodes", () => {
443
- const graph = createGraph(createDisconnectedGraph())
444
-
445
- expect(dijkstra(graph, 1, 4, distanceWeight)).toBeNull()
446
- expect(astar(graph, 1, 4, distanceWeight, createCoordFn())).toBeNull()
447
- expect(bidirectional(graph, 1, 4, distanceWeight)).toBeNull()
448
- })
449
-
450
- it("all algorithms should handle trivial single-node path", () => {
451
- const graph = createGraph(createLinearGraph())
452
-
453
- const dijkstraPath = dijkstra(graph, 1, 1, distanceWeight)
454
- const astarPath = astar(graph, 1, 1, distanceWeight, createCoordFn())
455
- const biPath = bidirectional(graph, 1, 1, distanceWeight)
456
-
457
- expect(dijkstraPath).not.toBeNull()
458
- expect(astarPath).not.toBeNull()
459
- expect(biPath).not.toBeNull()
460
-
461
- // All should include node 1
462
- expect(dijkstraPath?.some((p) => p.nodeIndex === 1)).toBe(true)
463
- expect(astarPath?.some((p) => p.nodeIndex === 1)).toBe(true)
464
- expect(biPath?.some((p) => p.nodeIndex === 1)).toBe(true)
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
+ });