@ingissa/navcore-simulator 1.0.4 → 1.0.7

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 (173) hide show
  1. package/LICENSE +3 -0
  2. package/README.md +10 -18
  3. package/dist/core/src/NavCore.d.ts +142 -0
  4. package/dist/core/src/NavCore.d.ts.map +1 -0
  5. package/dist/core/src/NavCore.js +390 -0
  6. package/dist/core/src/bearing/BearingEngine.d.ts +24 -0
  7. package/dist/core/src/bearing/BearingEngine.d.ts.map +1 -0
  8. package/dist/core/src/bearing/BearingEngine.js +50 -0
  9. package/dist/core/src/constants.d.ts +34 -0
  10. package/dist/core/src/constants.d.ts.map +1 -0
  11. package/dist/core/src/constants.js +41 -0
  12. package/dist/core/src/corridor/DeviationDetector.d.ts +33 -0
  13. package/dist/core/src/corridor/DeviationDetector.d.ts.map +1 -0
  14. package/dist/core/src/corridor/DeviationDetector.js +83 -0
  15. package/dist/core/src/corridor/DynamicCorridor.d.ts +13 -0
  16. package/dist/core/src/corridor/DynamicCorridor.d.ts.map +1 -0
  17. package/dist/core/src/corridor/DynamicCorridor.js +26 -0
  18. package/dist/core/src/dead-reckoning/DeadReckoning.d.ts +35 -0
  19. package/dist/core/src/dead-reckoning/DeadReckoning.d.ts.map +1 -0
  20. package/dist/core/src/dead-reckoning/DeadReckoning.js +49 -0
  21. package/dist/core/src/directions/DirectionsProvider.d.ts +70 -0
  22. package/dist/core/src/directions/DirectionsProvider.d.ts.map +1 -0
  23. package/dist/core/src/directions/DirectionsProvider.js +7 -0
  24. package/dist/core/src/directions/OSRMDirectionsProvider.d.ts +26 -0
  25. package/dist/core/src/directions/OSRMDirectionsProvider.d.ts.map +1 -0
  26. package/dist/core/src/directions/OSRMDirectionsProvider.js +90 -0
  27. package/dist/core/src/directions/OpenRouteServiceProvider.d.ts +30 -0
  28. package/dist/core/src/directions/OpenRouteServiceProvider.d.ts.map +1 -0
  29. package/dist/core/src/directions/OpenRouteServiceProvider.js +111 -0
  30. package/dist/core/src/directions/ValhallaDirectionsProvider.d.ts +33 -0
  31. package/dist/core/src/directions/ValhallaDirectionsProvider.d.ts.map +1 -0
  32. package/dist/core/src/directions/ValhallaDirectionsProvider.js +155 -0
  33. package/dist/core/src/eta/ETAEngine.d.ts +61 -0
  34. package/dist/core/src/eta/ETAEngine.d.ts.map +1 -0
  35. package/dist/core/src/eta/ETAEngine.js +74 -0
  36. package/dist/core/src/features.d.ts +76 -0
  37. package/dist/core/src/features.d.ts.map +1 -0
  38. package/dist/core/src/features.js +76 -0
  39. package/dist/core/src/geo/bearing.d.ts +11 -0
  40. package/dist/core/src/geo/bearing.d.ts.map +1 -0
  41. package/dist/core/src/geo/bearing.js +25 -0
  42. package/dist/core/src/geo/cumulative.d.ts +9 -0
  43. package/dist/core/src/geo/cumulative.d.ts.map +1 -0
  44. package/dist/core/src/geo/cumulative.js +20 -0
  45. package/dist/core/src/geo/haversine.d.ts +13 -0
  46. package/dist/core/src/geo/haversine.d.ts.map +1 -0
  47. package/dist/core/src/geo/haversine.js +39 -0
  48. package/dist/core/src/geo/projection.d.ts +7 -0
  49. package/dist/core/src/geo/projection.d.ts.map +1 -0
  50. package/dist/core/src/geo/projection.js +17 -0
  51. package/dist/core/src/geofencing/GeofencingEngine.d.ts +93 -0
  52. package/dist/core/src/geofencing/GeofencingEngine.d.ts.map +1 -0
  53. package/dist/core/src/geofencing/GeofencingEngine.js +154 -0
  54. package/dist/core/src/index.d.ts +66 -0
  55. package/dist/core/src/index.d.ts.map +1 -0
  56. package/dist/core/src/index.js +62 -0
  57. package/dist/core/src/kalman/KalmanFilter2D.d.ts +46 -0
  58. package/dist/core/src/kalman/KalmanFilter2D.d.ts.map +1 -0
  59. package/dist/core/src/kalman/KalmanFilter2D.js +77 -0
  60. package/dist/core/src/license/LicenseManager.d.ts +40 -0
  61. package/dist/core/src/license/LicenseManager.d.ts.map +1 -0
  62. package/dist/core/src/license/LicenseManager.js +119 -0
  63. package/dist/core/src/license/verifyP256Signature.d.ts +2 -0
  64. package/dist/core/src/license/verifyP256Signature.d.ts.map +1 -0
  65. package/dist/core/src/license/verifyP256Signature.js +26 -0
  66. package/dist/core/src/off-route/NavigationFSM.d.ts +42 -0
  67. package/dist/core/src/off-route/NavigationFSM.d.ts.map +1 -0
  68. package/dist/core/src/off-route/NavigationFSM.js +56 -0
  69. package/dist/core/src/off-route/OffRouteFSM.d.ts +70 -0
  70. package/dist/core/src/off-route/OffRouteFSM.d.ts.map +1 -0
  71. package/dist/core/src/off-route/OffRouteFSM.js +151 -0
  72. package/dist/core/src/predictor/RouteOffsetPredictor.d.ts +24 -0
  73. package/dist/core/src/predictor/RouteOffsetPredictor.d.ts.map +1 -0
  74. package/dist/core/src/predictor/RouteOffsetPredictor.js +55 -0
  75. package/dist/core/src/progress/InstructionEditor.d.ts +77 -0
  76. package/dist/core/src/progress/InstructionEditor.d.ts.map +1 -0
  77. package/dist/core/src/progress/InstructionEditor.js +151 -0
  78. package/dist/core/src/progress/InstructionResolver.d.ts +25 -0
  79. package/dist/core/src/progress/InstructionResolver.d.ts.map +1 -0
  80. package/dist/core/src/progress/InstructionResolver.js +78 -0
  81. package/dist/core/src/progress/ProgressTracker.d.ts +12 -0
  82. package/dist/core/src/progress/ProgressTracker.d.ts.map +1 -0
  83. package/dist/core/src/progress/ProgressTracker.js +22 -0
  84. package/dist/core/src/renderer/MapRendererAdapter.d.ts +72 -0
  85. package/dist/core/src/renderer/MapRendererAdapter.d.ts.map +1 -0
  86. package/dist/core/src/renderer/MapRendererAdapter.js +19 -0
  87. package/dist/core/src/route-builder/CustomRouteBuilder.d.ts +83 -0
  88. package/dist/core/src/route-builder/CustomRouteBuilder.d.ts.map +1 -0
  89. package/dist/core/src/route-builder/CustomRouteBuilder.js +160 -0
  90. package/dist/core/src/route-plan/RoutePlan.d.ts +38 -0
  91. package/dist/core/src/route-plan/RoutePlan.d.ts.map +1 -0
  92. package/dist/core/src/route-plan/RoutePlan.js +102 -0
  93. package/dist/core/src/session/NavigationSession.d.ts +81 -0
  94. package/dist/core/src/session/NavigationSession.d.ts.map +1 -0
  95. package/dist/core/src/session/NavigationSession.js +115 -0
  96. package/dist/core/src/snapper/ParallelRoadResolver.d.ts +86 -0
  97. package/dist/core/src/snapper/ParallelRoadResolver.d.ts.map +1 -0
  98. package/dist/core/src/snapper/ParallelRoadResolver.js +83 -0
  99. package/dist/core/src/snapper/RouteSnapper.d.ts +38 -0
  100. package/dist/core/src/snapper/RouteSnapper.d.ts.map +1 -0
  101. package/dist/core/src/snapper/RouteSnapper.js +80 -0
  102. package/dist/core/src/voice/VoiceTriggerEngine.d.ts +80 -0
  103. package/dist/core/src/voice/VoiceTriggerEngine.d.ts.map +1 -0
  104. package/dist/core/src/voice/VoiceTriggerEngine.js +108 -0
  105. package/dist/simulator/src/core/NoiseEngine.d.ts +46 -0
  106. package/dist/simulator/src/core/NoiseEngine.d.ts.map +1 -0
  107. package/dist/simulator/src/core/NoiseEngine.js +106 -0
  108. package/dist/simulator/src/core/RouteWalker.d.ts +78 -0
  109. package/dist/simulator/src/core/RouteWalker.d.ts.map +1 -0
  110. package/dist/simulator/src/core/RouteWalker.js +180 -0
  111. package/dist/simulator/src/core/SimulatorEngine.d.ts +53 -0
  112. package/dist/simulator/src/core/SimulatorEngine.d.ts.map +1 -0
  113. package/dist/simulator/src/core/SimulatorEngine.js +298 -0
  114. package/dist/simulator/src/core/types.d.ts +157 -0
  115. package/dist/simulator/src/core/types.d.ts.map +1 -0
  116. package/dist/simulator/src/core/types.js +7 -0
  117. package/dist/simulator/src/fleet/FleetSimulator.d.ts +60 -0
  118. package/dist/simulator/src/fleet/FleetSimulator.d.ts.map +1 -0
  119. package/dist/simulator/src/fleet/FleetSimulator.js +114 -0
  120. package/dist/simulator/src/index.d.ts +28 -0
  121. package/dist/simulator/src/index.d.ts.map +1 -0
  122. package/dist/simulator/src/index.js +29 -0
  123. package/dist/simulator/src/node.d.ts +26 -0
  124. package/dist/simulator/src/node.d.ts.map +1 -0
  125. package/dist/simulator/src/node.js +25 -0
  126. package/dist/simulator/src/react-native/SimulatorControls.d.ts +35 -0
  127. package/dist/simulator/src/react-native/SimulatorControls.d.ts.map +1 -0
  128. package/dist/simulator/src/react-native/SimulatorControls.js +254 -0
  129. package/dist/simulator/src/react-native/useSimulator.d.ts +59 -0
  130. package/dist/simulator/src/react-native/useSimulator.d.ts.map +1 -0
  131. package/dist/simulator/src/react-native/useSimulator.js +100 -0
  132. package/dist/simulator/src/replay/GpxParser.d.ts +40 -0
  133. package/dist/simulator/src/replay/GpxParser.d.ts.map +1 -0
  134. package/dist/simulator/src/replay/GpxParser.js +95 -0
  135. package/dist/simulator/src/replay/SessionRecorder.d.ts +37 -0
  136. package/dist/simulator/src/replay/SessionRecorder.d.ts.map +1 -0
  137. package/dist/simulator/src/replay/SessionRecorder.js +120 -0
  138. package/dist/simulator/src/scenarios/ScenarioInjector.d.ts +55 -0
  139. package/dist/simulator/src/scenarios/ScenarioInjector.d.ts.map +1 -0
  140. package/dist/simulator/src/scenarios/ScenarioInjector.js +150 -0
  141. package/package.json +23 -23
  142. package/src/__tests__/integration/simulator-pipeline.test.ts +233 -0
  143. package/src/core/NoiseEngine.ts +137 -0
  144. package/src/core/RouteWalker.ts +227 -0
  145. package/src/core/SimulatorEngine.ts +364 -0
  146. package/src/core/__tests__/NoiseEngine.test.ts +131 -0
  147. package/src/core/__tests__/RouteWalker.test.ts +139 -0
  148. package/src/core/__tests__/SimulatorEngine.test.ts +317 -0
  149. package/src/core/types.ts +191 -0
  150. package/src/fleet/FleetSimulator.ts +162 -0
  151. package/src/index.ts +54 -0
  152. package/src/node.ts +48 -0
  153. package/src/react-native/SimulatorControls.tsx +473 -0
  154. package/src/react-native/useSimulator.ts +155 -0
  155. package/src/replay/GpxParser.ts +122 -0
  156. package/src/replay/SessionRecorder.ts +138 -0
  157. package/src/replay/__tests__/GpxParser.test.ts +136 -0
  158. package/src/scenarios/ScenarioInjector.ts +170 -0
  159. package/src/scenarios/__tests__/ScenarioInjector.test.ts +171 -0
  160. package/dist/index-i9TY71RY.d.mts +0 -494
  161. package/dist/index-i9TY71RY.d.ts +0 -494
  162. package/dist/index.d.mts +0 -1
  163. package/dist/index.d.ts +0 -1
  164. package/dist/index.js +0 -937
  165. package/dist/index.mjs +0 -929
  166. package/dist/node.d.mts +0 -1
  167. package/dist/node.d.ts +0 -1
  168. package/dist/node.js +0 -937
  169. package/dist/node.mjs +0 -929
  170. package/dist/react-native.d.mts +0 -82
  171. package/dist/react-native.d.ts +0 -82
  172. package/dist/react-native.js +0 -1370
  173. package/dist/react-native.mjs +0 -1360
@@ -0,0 +1,317 @@
1
+ /**
2
+ * SimulatorEngine unit tests
3
+ * Uses vi.useFakeTimers() to control setInterval without real waiting.
4
+ */
5
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
6
+ import { SimulatorEngine } from '../SimulatorEngine';
7
+ import { NoiseEngine } from '../NoiseEngine';
8
+ import { ScenarioInjector } from '../../scenarios/ScenarioInjector';
9
+ import type { SimPosition, Coordinate } from '../types';
10
+
11
+ // 10-point straight eastward route
12
+ const ROUTE: Coordinate[] = Array.from({ length: 10 }, (_, i) => [i * 0.001, 0.0]);
13
+
14
+ function makeEngine(overrides?: Partial<ConstructorParameters<typeof SimulatorEngine>[0]>) {
15
+ return new SimulatorEngine({
16
+ routeGeometry: ROUTE,
17
+ speedKmh: 50,
18
+ speedMultiplier: 1,
19
+ noiseProfile: NoiseEngine.NONE, // deterministic for tests
20
+ loop: false,
21
+ ...overrides,
22
+ });
23
+ }
24
+
25
+ describe('SimulatorEngine', () => {
26
+ beforeEach(() => {
27
+ vi.useFakeTimers();
28
+ });
29
+
30
+ afterEach(() => {
31
+ vi.useRealTimers();
32
+ });
33
+
34
+ // ── Construction ────────────────────────────────────────────────────────────
35
+ describe('construction', () => {
36
+ it('throws with fewer than 2 geometry points', () => {
37
+ expect(() => makeEngine({ routeGeometry: [[0, 0]] })).toThrow();
38
+ });
39
+
40
+ it('starts in idle state', () => {
41
+ const engine = makeEngine();
42
+ expect(engine.state).toBe('idle');
43
+ expect(engine.position).toBeNull();
44
+ engine.destroy();
45
+ });
46
+
47
+ it('autoStart=true calls start automatically', () => {
48
+ const engine = makeEngine({ autoStart: true });
49
+ expect(engine.state).toBe('running');
50
+ engine.destroy();
51
+ });
52
+ });
53
+
54
+ // ── Start / Stop ────────────────────────────────────────────────────────────
55
+ describe('start / stop', () => {
56
+ it('transitions idle → running', () => {
57
+ const engine = makeEngine();
58
+ engine.start();
59
+ expect(engine.state).toBe('running');
60
+ engine.destroy();
61
+ });
62
+
63
+ it('stop transitions to idle and clears position', () => {
64
+ const engine = makeEngine();
65
+ engine.start();
66
+ vi.advanceTimersByTime(1000);
67
+ engine.stop();
68
+ expect(engine.state).toBe('idle');
69
+ expect(engine.position).toBeNull();
70
+ engine.destroy();
71
+ });
72
+
73
+ it('double start does not throw', () => {
74
+ const engine = makeEngine();
75
+ engine.start();
76
+ engine.start();
77
+ expect(engine.state).toBe('running');
78
+ engine.destroy();
79
+ });
80
+ });
81
+
82
+ // ── Callbacks ───────────────────────────────────────────────────────────────
83
+ describe('onPositionUpdate', () => {
84
+ it('fires on each tick and emits standard LocationObject coords', () => {
85
+ const positions: SimPosition[] = [];
86
+ const engine = makeEngine({ onPositionUpdate: (p) => positions.push(p) });
87
+ engine.start();
88
+ vi.advanceTimersByTime(3000); // 3 ticks at 1× speed
89
+ expect(positions.length).toBe(3);
90
+ const p = positions[0]!;
91
+ expect(p.coords).toBeDefined();
92
+ expect(typeof p.coords.latitude).toBe('number');
93
+ expect(typeof p.coords.longitude).toBe('number');
94
+ expect(typeof p.coords.heading).toBe('number');
95
+ expect(typeof p.coords.speed).toBe('number');
96
+ expect(typeof p.coords.accuracy).toBe('number');
97
+ expect(p.location).toBeDefined();
98
+ expect(p.location?.coords.latitude).toBe(p.coords.latitude);
99
+ expect(p.location?.timestamp).toBe(p.timestamp);
100
+ engine.destroy();
101
+ });
102
+
103
+ it('receives correct bearing for eastward route', () => {
104
+ const positions: SimPosition[] = [];
105
+ const engine = makeEngine({ onPositionUpdate: (p) => positions.push(p) });
106
+ engine.start();
107
+ vi.advanceTimersByTime(1000);
108
+ const bearing = positions[0]!.bearing;
109
+ expect(bearing).toBeGreaterThan(85);
110
+ expect(bearing).toBeLessThan(95);
111
+ engine.destroy();
112
+ });
113
+
114
+ it('never throws even if callback throws', () => {
115
+ const engine = makeEngine({
116
+ onPositionUpdate: () => { throw new Error('boom'); },
117
+ });
118
+ engine.start();
119
+ expect(() => vi.advanceTimersByTime(1000)).not.toThrow();
120
+ engine.destroy();
121
+ });
122
+ });
123
+
124
+ describe('onRouteComplete', () => {
125
+ it('fires when route is finished', () => {
126
+ const done = vi.fn();
127
+ const engine = makeEngine({ onRouteComplete: done });
128
+ engine.start();
129
+ // ROUTE has 10 points → 9 advances to complete
130
+ vi.advanceTimersByTime(10000);
131
+ expect(done).toHaveBeenCalledOnce();
132
+ expect(engine.state).toBe('complete');
133
+ engine.destroy();
134
+ });
135
+ });
136
+
137
+ // ── Pause / Resume ──────────────────────────────────────────────────────────
138
+ describe('pause / resume', () => {
139
+ it('pause freezes position, resume continues', () => {
140
+ const positions: SimPosition[] = [];
141
+ const engine = makeEngine({ onPositionUpdate: (p) => positions.push(p) });
142
+ engine.start();
143
+ vi.advanceTimersByTime(2000);
144
+ const countBeforePause = positions.length;
145
+
146
+ engine.pause();
147
+ expect(engine.state).toBe('paused');
148
+
149
+ vi.advanceTimersByTime(3000); // nothing should fire
150
+ expect(positions.length).toBe(countBeforePause);
151
+
152
+ engine.resume();
153
+ vi.advanceTimersByTime(2000);
154
+ expect(positions.length).toBeGreaterThan(countBeforePause);
155
+ engine.destroy();
156
+ });
157
+ });
158
+
159
+ // ── Speed multiplier ────────────────────────────────────────────────────────
160
+ describe('setSpeed', () => {
161
+ it('4× speed fires 4 ticks per second', () => {
162
+ const positions: SimPosition[] = [];
163
+ const engine = makeEngine({
164
+ speedMultiplier: 4,
165
+ onPositionUpdate: (p) => positions.push(p),
166
+ });
167
+ engine.start();
168
+ vi.advanceTimersByTime(1000); // 1000ms / (1000/4) = 4 ticks
169
+ expect(positions.length).toBe(4);
170
+ engine.destroy();
171
+ });
172
+
173
+ it('setSpeed changes tick rate mid-simulation', () => {
174
+ const positions: SimPosition[] = [];
175
+ const engine = makeEngine({ onPositionUpdate: (p) => positions.push(p) });
176
+ engine.start();
177
+ vi.advanceTimersByTime(1000); // 1 tick at 1×
178
+
179
+ engine.setSpeed(4);
180
+ vi.advanceTimersByTime(1000); // 4 ticks at 4×
181
+ // Total should be 1 + 4 = 5 (approximately)
182
+ expect(positions.length).toBeGreaterThanOrEqual(5);
183
+ engine.destroy();
184
+ });
185
+ });
186
+
187
+ // ── Seek ────────────────────────────────────────────────────────────────────
188
+ describe('seek', () => {
189
+ it('jumps to specified index', () => {
190
+ const engine = makeEngine();
191
+ engine.seek(5);
192
+ expect(engine.progress).toBeCloseTo(5 / 9, 2);
193
+ engine.destroy();
194
+ });
195
+
196
+ it('recalculates bearing correctly after seek', () => {
197
+ const engine = makeEngine();
198
+ engine.seek(3);
199
+ const pos = engine.position;
200
+ expect(pos).not.toBeNull();
201
+ // Eastward route → bearing ~90°
202
+ expect(pos!.bearing).toBeGreaterThan(85);
203
+ expect(pos!.bearing).toBeLessThan(95);
204
+ engine.destroy();
205
+ });
206
+ });
207
+
208
+ // ── Progress ────────────────────────────────────────────────────────────────
209
+ describe('progress', () => {
210
+ it('is 0 at start', () => {
211
+ const engine = makeEngine();
212
+ expect(engine.progress).toBe(0);
213
+ engine.destroy();
214
+ });
215
+
216
+ it('is 1 when route completes', () => {
217
+ const engine = makeEngine();
218
+ engine.start();
219
+ vi.advanceTimersByTime(10000);
220
+ expect(engine.progress).toBe(1);
221
+ engine.destroy();
222
+ });
223
+ });
224
+
225
+ // ── Scenarios ───────────────────────────────────────────────────────────────
226
+ describe('scenarios', () => {
227
+ it('addScenario fires at correct index', () => {
228
+ const onScenarioStart = vi.fn();
229
+ const engine = makeEngine({ onScenarioStart });
230
+ engine.addScenario(ScenarioInjector.gpsBlackout(3, 1000));
231
+ engine.start();
232
+ vi.advanceTimersByTime(4000); // 4 ticks, scenario at index 3
233
+ expect(onScenarioStart).toHaveBeenCalledOnce();
234
+ expect(onScenarioStart.mock.calls[0]![0].type).toBe('GPS_BLACKOUT');
235
+ engine.destroy();
236
+ });
237
+
238
+ it('clearScenarios prevents firing', () => {
239
+ const onScenarioStart = vi.fn();
240
+ const engine = makeEngine({ onScenarioStart });
241
+ engine.addScenario(ScenarioInjector.gpsBlackout(2, 1000));
242
+ engine.clearScenarios();
243
+ engine.start();
244
+ vi.advanceTimersByTime(5000);
245
+ expect(onScenarioStart).not.toHaveBeenCalled();
246
+ engine.destroy();
247
+ });
248
+ });
249
+
250
+ // ── Loop ────────────────────────────────────────────────────────────────────
251
+ describe('loop', () => {
252
+ it('restarts from beginning when loop=true', () => {
253
+ const complete = vi.fn();
254
+ const engine = makeEngine({ loop: true, onRouteComplete: complete });
255
+ engine.start();
256
+ vi.advanceTimersByTime(10000); // completes once
257
+ expect(complete).toHaveBeenCalledOnce();
258
+ expect(engine.state).toBe('running'); // still running
259
+ engine.destroy();
260
+ });
261
+ });
262
+
263
+ // ── Recording ───────────────────────────────────────────────────────────────
264
+ describe('recording', () => {
265
+ it('startRecording / stopRecording captures positions', () => {
266
+ const engine = makeEngine();
267
+ engine.startRecording();
268
+ engine.start();
269
+ vi.advanceTimersByTime(4000);
270
+ const session = engine.stopRecording();
271
+
272
+ expect(session.positions.length).toBeGreaterThan(0);
273
+ expect(session.stats.scenariosFired).toBe(0);
274
+ expect(session.startedAt).toBeTruthy();
275
+ expect(session.endedAt).toBeTruthy();
276
+ engine.destroy();
277
+ });
278
+
279
+ it('records scenario fires', () => {
280
+ const engine = makeEngine();
281
+ engine.addScenario(ScenarioInjector.gpsBlackout(2, 1000));
282
+ engine.startRecording();
283
+ engine.start();
284
+ vi.advanceTimersByTime(5000);
285
+ const session = engine.stopRecording();
286
+
287
+ expect(session.scenarios.length).toBe(1);
288
+ expect(session.scenarios[0]!.scenario.type).toBe('GPS_BLACKOUT');
289
+ expect(session.stats.scenariosFired).toBe(1);
290
+ engine.destroy();
291
+ });
292
+
293
+ it('stats.distanceTravelledM is positive', () => {
294
+ const engine = makeEngine();
295
+ engine.startRecording();
296
+ engine.start();
297
+ vi.advanceTimersByTime(5000);
298
+ const session = engine.stopRecording();
299
+ expect(session.stats.distanceTravelledM).toBeGreaterThan(0);
300
+ engine.destroy();
301
+ });
302
+ });
303
+
304
+ // ── Destroy ─────────────────────────────────────────────────────────────────
305
+ describe('destroy', () => {
306
+ it('clears position and stops firing', () => {
307
+ const positions: SimPosition[] = [];
308
+ const engine = makeEngine({ onPositionUpdate: (p) => positions.push(p) });
309
+ engine.start();
310
+ vi.advanceTimersByTime(2000);
311
+ engine.destroy();
312
+ const countAtDestroy = positions.length;
313
+ vi.advanceTimersByTime(5000); // timer should be dead
314
+ expect(positions.length).toBe(countAtDestroy);
315
+ });
316
+ });
317
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * @navcore/simulator — Core Types
3
+ *
4
+ * These types are the foundation of the entire simulator package.
5
+ * All other modules build on top of these definitions.
6
+ */
7
+
8
+ /** [longitude, latitude] — Mapbox/GeoJSON order */
9
+ export type Coordinate = [number, number];
10
+
11
+ // ─── LocationObject Alignment ──────────────────────────────────────────────────
12
+
13
+ /**
14
+ * Standard coords conforming to expo-location LocationObject['coords']
15
+ * and @react-native-community/geolocation.
16
+ */
17
+ export interface SimLocationCoords {
18
+ latitude: number;
19
+ longitude: number;
20
+ altitude: number | null;
21
+ accuracy: number | null;
22
+ altitudeAccuracy: number | null;
23
+ heading: number | null;
24
+ speed: number | null;
25
+ }
26
+
27
+ /**
28
+ * Standard LocationObject conforming to expo-location.
29
+ */
30
+ export interface SimLocationObject {
31
+ coords: SimLocationCoords;
32
+ timestamp: number;
33
+ }
34
+
35
+ // ─── SimPosition ──────────────────────────────────────────────────────────────
36
+
37
+ /**
38
+ * A single simulated GPS position, emitted by SimulatorEngine on every tick.
39
+ * Shape includes standard LocationObject coords for drop-in Expo/React Native usage.
40
+ */
41
+ export interface SimPosition {
42
+ /** [longitude, latitude] */
43
+ coord: Coordinate;
44
+ /** Heading in degrees 0–360 */
45
+ bearing: number;
46
+ /** Speed in metres per second */
47
+ speed: number;
48
+ /** Simulated GPS accuracy radius in metres */
49
+ accuracy: number;
50
+ /** Unix timestamp in milliseconds */
51
+ timestamp: number;
52
+ /**
53
+ * Standard LocationObject coords conforming to expo-location & React Native geolocation.
54
+ */
55
+ coords: SimLocationCoords;
56
+ /**
57
+ * Full standard LocationObject conforming to expo-location.
58
+ */
59
+ location?: SimLocationObject;
60
+ /** Optional debug metadata */
61
+ metadata?: {
62
+ /** Which geometry segment the vehicle is currently on */
63
+ segmentIndex: number;
64
+ /** Total metres travelled from route start */
65
+ distanceTravelled: number;
66
+ /** Whether noise was applied on this tick */
67
+ isNoisy: boolean;
68
+ /** Name of the active scenario, if any */
69
+ scenarioActive?: string;
70
+ };
71
+ }
72
+
73
+ // ─── NoiseProfile ─────────────────────────────────────────────────────────────
74
+
75
+ /**
76
+ * Noise model applied to simulated GPS coordinates each tick.
77
+ */
78
+ export interface NoiseProfile {
79
+ /** Noise type */
80
+ type: 'NONE' | 'GAUSSIAN' | 'URBAN' | 'TUNNEL' | 'CUSTOM';
81
+ /** Standard deviation of the Gaussian noise in metres (default: 12) */
82
+ stdDevM: number;
83
+ /**
84
+ * Optional seed for deterministic noise generation.
85
+ * Same seed + same route = identical output (critical for regression tests).
86
+ */
87
+ seed?: number;
88
+ /**
89
+ * For CUSTOM type: return [lngOffset, latOffset] for each tick.
90
+ * @param index - current geometry segment index
91
+ */
92
+ customFn?: (index: number) => [number, number];
93
+ }
94
+
95
+ // ─── Scenario ─────────────────────────────────────────────────────────────────
96
+
97
+ /**
98
+ * A scenario that is injected at a specific geometry index during simulation.
99
+ */
100
+ export interface Scenario {
101
+ /** Unique name for this scenario */
102
+ name: string;
103
+ /** Geometry index at which the scenario fires */
104
+ triggerIndex: number;
105
+ /** Scenario type */
106
+ type:
107
+ | 'GPS_BLACKOUT' // dead reckoning stress test
108
+ | 'WRONG_SIDE_ROAD' // parallel road resolver stress test
109
+ | 'UTURN' // U-turn protection stress test
110
+ | 'SPEED_SPIKE' // sudden speed change
111
+ | 'ACCURACY_DROP' // GPS accuracy degrades
112
+ | 'CUSTOM';
113
+ /** How long the scenario lasts in milliseconds */
114
+ durationMs?: number;
115
+ /** Scenario-specific configuration */
116
+ params?: Record<string, unknown>;
117
+ }
118
+
119
+ // ─── SimulatorConfig ──────────────────────────────────────────────────────────
120
+
121
+ /**
122
+ * Full configuration object passed to SimulatorEngine.
123
+ * All callback fields are optional — the engine is fully usable without them
124
+ * (useful for CI/headless tests).
125
+ */
126
+ export interface SimulatorConfig {
127
+ // ── Route ────────────────────────────────────────────────────────────────
128
+ /** The route geometry to simulate along */
129
+ routeGeometry: Coordinate[];
130
+
131
+ // ── Speed ────────────────────────────────────────────────────────────────
132
+ /** Simulated vehicle speed in km/h (default: 50) */
133
+ speedKmh?: number;
134
+ /** Playback speed multiplier: 1×, 2×, 4×, 8× (default: 1) */
135
+ speedMultiplier?: 1 | 2 | 4 | 8;
136
+
137
+ // ── Noise ────────────────────────────────────────────────────────────────
138
+ /** Noise model applied to each position (default: NoiseEngine.STANDARD) */
139
+ noiseProfile?: NoiseProfile;
140
+
141
+ // ── Playback ─────────────────────────────────────────────────────────────
142
+ /** Whether to restart from the beginning when the route ends (default: false) */
143
+ loop?: boolean;
144
+ /** Start simulation from this geometry index (default: 0) */
145
+ startIndex?: number;
146
+ /** If true, start() is called automatically after construction (default: false) */
147
+ autoStart?: boolean;
148
+
149
+ // ── Callbacks ─────────────────────────────────────────────────────────────
150
+ /** Called on every tick with the new simulated position */
151
+ onPositionUpdate?: (pos: SimPosition) => void;
152
+ /** Called when the route end is reached (or loop restarts) */
153
+ onRouteComplete?: () => void;
154
+ /** Called when a scenario begins */
155
+ onScenarioStart?: (scenario: Scenario) => void;
156
+ /** Called when a scenario ends */
157
+ onScenarioEnd?: (scenario: Scenario) => void;
158
+ }
159
+
160
+ // ─── SimSession ───────────────────────────────────────────────────────────────
161
+
162
+ /**
163
+ * A complete recorded simulation session.
164
+ * Produced by SimulatorEngine.stopRecording() / SessionRecorder.stop().
165
+ */
166
+ export interface SimSession {
167
+ /** UUID v4 session identifier */
168
+ id: string;
169
+ /** ISO 8601 start timestamp */
170
+ startedAt: string;
171
+ /** ISO 8601 end timestamp */
172
+ endedAt: string;
173
+ /** The route geometry used */
174
+ route: Coordinate[];
175
+ /** All SimPositions emitted during the session */
176
+ positions: SimPosition[];
177
+ /** Scenarios that fired, with their tick timestamps */
178
+ scenarios: Array<{
179
+ scenario: Scenario;
180
+ /** Unix ms when the scenario fired */
181
+ firedAt: number;
182
+ }>;
183
+ /** Aggregate statistics for this session */
184
+ stats: {
185
+ durationMs: number;
186
+ distanceTravelledM: number;
187
+ avgSpeedKmh: number;
188
+ noiseSamplesAdded: number;
189
+ scenariosFired: number;
190
+ };
191
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * FleetSimulator — Phase 2 skeleton.
3
+ *
4
+ * Spawns N SimulatorEngine instances with optional per-vehicle profile
5
+ * overrides, runs them in parallel, and produces aggregate reports.
6
+ *
7
+ * @experimental — fully typed but not tested in Phase 1.
8
+ * Activate in a future iteration once SimulatorEngine is validated in CI.
9
+ */
10
+
11
+ import { SimulatorEngine } from '../core/SimulatorEngine';
12
+ import type { SimulatorConfig, SimSession } from '../core/types';
13
+
14
+ export interface VehicleProfile extends Partial<SimulatorConfig> {
15
+ /** Human-readable label for this vehicle profile */
16
+ label?: string;
17
+ }
18
+
19
+ export interface FleetReport {
20
+ vehicles: Array<{
21
+ id: number;
22
+ label: string;
23
+ session: SimSession;
24
+ profile: VehicleProfile;
25
+ }>;
26
+ comparison: {
27
+ /** Average stats keyed by vehicle label */
28
+ avgScoreByProfile: Record<string, number>;
29
+ /** Per-segment noise deviation across all vehicles (heatmap data) */
30
+ deviationsBySegment: number[];
31
+ };
32
+ }
33
+
34
+ export class FleetSimulator {
35
+ private readonly engines: SimulatorEngine[];
36
+ private readonly profiles: VehicleProfile[];
37
+ private readonly baseConfig: Omit<SimulatorConfig, 'onPositionUpdate'>;
38
+
39
+ private constructor(
40
+ engines: SimulatorEngine[],
41
+ profiles: VehicleProfile[],
42
+ baseConfig: Omit<SimulatorConfig, 'onPositionUpdate'>,
43
+ ) {
44
+ this.engines = engines;
45
+ this.profiles = profiles;
46
+ this.baseConfig = baseConfig;
47
+ }
48
+
49
+ /**
50
+ * Spawn N SimulatorEngine instances.
51
+ *
52
+ * @param count - number of vehicles
53
+ * @param config - base config (onPositionUpdate is per-vehicle, excluded here)
54
+ */
55
+ static spawn(
56
+ count: number,
57
+ config: Omit<SimulatorConfig, 'onPositionUpdate'> & {
58
+ profiles?: VehicleProfile[];
59
+ },
60
+ ): FleetSimulator {
61
+ const { profiles = [], ...baseConfig } = config;
62
+
63
+ const engines: SimulatorEngine[] = [];
64
+ const resolvedProfiles: VehicleProfile[] = [];
65
+
66
+ for (let i = 0; i < count; i++) {
67
+ const profile = profiles[i] ?? {};
68
+ resolvedProfiles.push(profile);
69
+
70
+ const engine = new SimulatorEngine({
71
+ ...baseConfig,
72
+ ...profile,
73
+ onPositionUpdate: undefined,
74
+ });
75
+
76
+ engines.push(engine);
77
+ }
78
+
79
+ return new FleetSimulator(engines, resolvedProfiles, baseConfig);
80
+ }
81
+
82
+ /** Start all engines simultaneously */
83
+ start(): void {
84
+ for (const engine of this.engines) {
85
+ engine.startRecording();
86
+ engine.start();
87
+ }
88
+ }
89
+
90
+ /** Stop all engines */
91
+ stop(): void {
92
+ for (const engine of this.engines) {
93
+ engine.stop();
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Get the engine at the given index.
99
+ * Use this for per-vehicle control (seek, setSpeed, addScenario, etc.)
100
+ */
101
+ vehicle(index: number): SimulatorEngine {
102
+ const engine = this.engines[index];
103
+ if (!engine) throw new Error(`FleetSimulator: no vehicle at index ${index}`);
104
+ return engine;
105
+ }
106
+
107
+ /**
108
+ * Stop all engines, collect sessions, and return an aggregate report.
109
+ */
110
+ report(): FleetReport {
111
+ const vehicleReports = this.engines.map((engine, i) => {
112
+ const session = engine.stopRecording();
113
+ const profile = this.profiles[i] ?? {};
114
+ return {
115
+ id: i,
116
+ label: profile.label ?? `vehicle-${i}`,
117
+ session,
118
+ profile,
119
+ };
120
+ });
121
+
122
+ // Compute average speed per profile label
123
+ const avgScoreByProfile: Record<string, number> = {};
124
+ for (const v of vehicleReports) {
125
+ const key = v.label;
126
+ const existing = avgScoreByProfile[key];
127
+ if (existing === undefined) {
128
+ avgScoreByProfile[key] = v.session.stats.avgSpeedKmh;
129
+ } else {
130
+ avgScoreByProfile[key] = (existing + v.session.stats.avgSpeedKmh) / 2;
131
+ }
132
+ }
133
+
134
+ // Per-segment noise deviation: average accuracy across all vehicles per segment
135
+ const segmentCount = this.baseConfig.routeGeometry.length;
136
+ const deviationsBySegment: number[] = new Array<number>(segmentCount).fill(0);
137
+ for (const v of vehicleReports) {
138
+ for (const pos of v.session.positions) {
139
+ const segIdx = pos.metadata?.segmentIndex ?? 0;
140
+ if (segIdx < segmentCount) {
141
+ deviationsBySegment[segIdx] = (deviationsBySegment[segIdx] ?? 0) + pos.accuracy;
142
+ }
143
+ }
144
+ }
145
+ // Average
146
+ for (let i = 0; i < deviationsBySegment.length; i++) {
147
+ deviationsBySegment[i] = (deviationsBySegment[i] ?? 0) / Math.max(1, vehicleReports.length);
148
+ }
149
+
150
+ return {
151
+ vehicles: vehicleReports,
152
+ comparison: { avgScoreByProfile, deviationsBySegment },
153
+ };
154
+ }
155
+
156
+ /** Destroy all engines and clean up */
157
+ destroy(): void {
158
+ for (const engine of this.engines) {
159
+ engine.destroy();
160
+ }
161
+ }
162
+ }