@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
package/package.json CHANGED
@@ -1,56 +1,56 @@
1
1
  {
2
2
  "name": "@ingissa/navcore-simulator",
3
- "version": "1.0.4",
3
+ "version": "1.0.7",
4
4
  "description": "Headless GPS simulator for NavCore. Works in Node.js, React Native, and CI.",
5
5
  "author": "Ingissa <8abnav@gmail.com>",
6
- "license": "Commercial",
7
- "homepage": "https://navcore-web.vercel.app/",
6
+ "license": "SEE LICENSE IN LICENSE",
7
+ "homepage": "https://navcore.dev",
8
8
  "repository": {
9
9
  "type": "git",
10
- "url": "git+https://github.com/ingissa/ingissa-navcore-examples.git"
10
+ "url": "git+https://github.com/ingissa/navcore-sdk.git"
11
11
  },
12
12
  "bugs": {
13
- "url": "https://github.com/ingissa/ingissa-navcore-examples/issues"
13
+ "url": "https://github.com/ingissa/navcore-sdk/issues"
14
14
  },
15
15
  "main": "dist/index.js",
16
16
  "module": "dist/index.mjs",
17
17
  "types": "dist/index.d.ts",
18
18
  "exports": {
19
19
  ".": {
20
- "types": "./dist/index.d.ts",
21
20
  "import": "./dist/index.mjs",
22
- "require": "./dist/index.js"
23
- },
24
- "./node": {
25
- "types": "./dist/node.d.ts",
26
- "import": "./dist/node.mjs",
27
- "require": "./dist/node.js"
28
- },
29
- "./react-native": {
30
- "types": "./dist/react-native.d.ts",
31
- "import": "./dist/react-native.mjs",
32
- "require": "./dist/react-native.js"
21
+ "require": "./dist/index.js",
22
+ "types": "./dist/index.d.ts"
33
23
  }
34
24
  },
35
- "files": ["dist"],
25
+ "files": [
26
+ "dist",
27
+ "src",
28
+ "LICENSE"
29
+ ],
36
30
  "sideEffects": false,
37
31
  "publishConfig": {
38
32
  "access": "public"
39
33
  },
40
- "engines": { "node": ">=18" },
34
+ "engines": {
35
+ "node": ">=16"
36
+ },
41
37
  "scripts": {
42
- "build": "tsup",
38
+ "build": "tsc -p tsconfig.build.json",
43
39
  "lint": "tsc --noEmit",
44
40
  "prepublishOnly": "npm run build"
45
41
  },
46
42
  "peerDependencies": {
47
- "@ingissa/navcore-core": "^1.0.4",
43
+ "@ingissa/navcore-core": "^1.0.7",
48
44
  "react": ">=18",
49
45
  "react-native": ">=0.72"
50
46
  },
51
47
  "peerDependenciesMeta": {
52
- "react": { "optional": true },
53
- "react-native": { "optional": true }
48
+ "react": {
49
+ "optional": true
50
+ },
51
+ "react-native": {
52
+ "optional": true
53
+ }
54
54
  },
55
55
  "devDependencies": {
56
56
  "@types/node": "^25.8.0"
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Integration test: Full simulator pipeline
3
+ *
4
+ * GPX file → parse → simulate → record → export GPX → re-parse → verify
5
+ *
6
+ * This is the validation test specified in the brief.
7
+ */
8
+ import { describe, it, expect, vi, afterEach } from 'vitest';
9
+ import { SimulatorEngine } from '../../core/SimulatorEngine';
10
+ import { NoiseEngine } from '../../core/NoiseEngine';
11
+ import { ScenarioInjector } from '../../scenarios/ScenarioInjector';
12
+ import { GpxParser } from '../../replay/GpxParser';
13
+ import { SessionRecorder } from '../../replay/SessionRecorder';
14
+ import type { Coordinate } from '@ingissa/navcore-core';
15
+ import type { SimPosition } from '../../core/types';
16
+
17
+ // ─── Agadir Circuit — 137 points (as defined in NavCoreTestScreen) ─────────────
18
+ const CIRCUIT_ANCHORS: [number, number][] = [
19
+ [-9.6271, 30.4112],
20
+ [-9.6295, 30.4170],
21
+ [-9.6340, 30.4240],
22
+ [-9.6371, 30.4310],
23
+ [-9.6360, 30.4390],
24
+ [-9.6280, 30.4460],
25
+ [-9.6150, 30.4530],
26
+ [-9.5980, 30.4590],
27
+ [-9.5800, 30.4560],
28
+ [-9.5640, 30.4490],
29
+ [-9.5490, 30.4380],
30
+ [-9.5510, 30.4240],
31
+ [-9.5620, 30.4130],
32
+ [-9.5780, 30.4060],
33
+ [-9.5960, 30.4020],
34
+ [-9.6100, 30.4040],
35
+ [-9.6210, 30.4075],
36
+ [-9.6271, 30.4112],
37
+ ];
38
+
39
+ function interpolate(a: [number, number], b: [number, number], steps: number): Coordinate[] {
40
+ const pts: Coordinate[] = [];
41
+ for (let i = 0; i < steps; i++) {
42
+ const t = i / steps;
43
+ pts.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);
44
+ }
45
+ return pts;
46
+ }
47
+
48
+ function buildCircuit(): Coordinate[] {
49
+ const STEPS = 8; // 17 segments × 8 = 136 + 1 closing = 137
50
+ const points: Coordinate[] = [];
51
+ for (let i = 0; i < CIRCUIT_ANCHORS.length - 1; i++) {
52
+ points.push(...interpolate(CIRCUIT_ANCHORS[i]!, CIRCUIT_ANCHORS[i + 1]!, STEPS));
53
+ }
54
+ points.push(CIRCUIT_ANCHORS[CIRCUIT_ANCHORS.length - 1]! as Coordinate);
55
+ return points;
56
+ }
57
+
58
+ const AGADIR_CIRCUIT = buildCircuit();
59
+
60
+ describe('Simulator Integration Pipeline', () => {
61
+ afterEach(() => {
62
+ vi.useRealTimers();
63
+ });
64
+
65
+ it('AGADIR_CIRCUIT has 137 points', () => {
66
+ expect(AGADIR_CIRCUIT.length).toBe(137);
67
+ });
68
+
69
+ it('full validation test from spec: simulate → record → export GPX → re-parse', async () => {
70
+ vi.useFakeTimers();
71
+
72
+ const positions: SimPosition[] = [];
73
+ let done = false;
74
+
75
+ const engine = new SimulatorEngine({
76
+ routeGeometry: AGADIR_CIRCUIT,
77
+ speedKmh: 50,
78
+ speedMultiplier: 4,
79
+ noiseProfile: NoiseEngine.STANDARD,
80
+ loop: false,
81
+ onPositionUpdate: (pos) => positions.push(pos),
82
+ onRouteComplete: () => { done = true; },
83
+ });
84
+
85
+ engine.addScenario(ScenarioInjector.gpsBlackout(42, 3000));
86
+ engine.startRecording();
87
+ engine.start();
88
+
89
+ // Drain all timers — at 4× speed, 136 ticks × 250ms = 34000ms of real time
90
+ await vi.runAllTimersAsync();
91
+
92
+ expect(done).toBe(true);
93
+
94
+ const session = engine.stopRecording();
95
+
96
+ // ── Assert session correctness ──
97
+ expect(session.positions.length).toBeGreaterThan(0);
98
+ expect(session.positions.length).toBe(positions.length);
99
+ expect(session.scenarios).toHaveLength(1);
100
+ expect(session.scenarios[0]!.scenario.type).toBe('GPS_BLACKOUT');
101
+ expect(session.stats.scenariosFired).toBe(1);
102
+ expect(session.stats.distanceTravelledM).toBeGreaterThan(0);
103
+
104
+ // ── Export as GPX ──
105
+ const recorder = new SessionRecorder();
106
+ recorder.stop(session);
107
+ const gpx = recorder.export('gpx');
108
+
109
+ expect(gpx).toContain('<gpx');
110
+ expect(gpx).toContain('<trkpt');
111
+ expect(gpx).toContain('</gpx>');
112
+
113
+ // ── Re-parse GPX → verify coordinate count matches ──
114
+ const reparsed = GpxParser.parse(gpx);
115
+ expect(reparsed.coordinates.length).toBe(session.positions.length);
116
+
117
+ // ── Coordinates are [lng, lat] and within Morocco ──
118
+ for (const coord of reparsed.coordinates) {
119
+ expect(coord[0]).toBeLessThan(-9.0); // lng in Morocco
120
+ expect(coord[1]).toBeGreaterThan(30.0); // lat in Morocco
121
+ }
122
+
123
+ engine.destroy();
124
+ });
125
+
126
+ it('CSV export produces correct header and row count', async () => {
127
+ vi.useFakeTimers();
128
+
129
+ const engine = new SimulatorEngine({
130
+ routeGeometry: AGADIR_CIRCUIT.slice(0, 10),
131
+ speedKmh: 50,
132
+ speedMultiplier: 4,
133
+ noiseProfile: NoiseEngine.NONE,
134
+ loop: false,
135
+ });
136
+
137
+ engine.startRecording();
138
+ engine.start();
139
+ await vi.runAllTimersAsync();
140
+
141
+ const session = engine.stopRecording();
142
+ const recorder = new SessionRecorder();
143
+ recorder.stop(session);
144
+ const csv = recorder.export('csv');
145
+
146
+ const lines = csv.split('\n');
147
+ expect(lines[0]).toBe('timestamp,lng,lat,bearing,speed,accuracy');
148
+ expect(lines.length).toBe(session.positions.length + 1); // header + rows
149
+
150
+ engine.destroy();
151
+ });
152
+
153
+ it('JSON export is valid and re-parseable', async () => {
154
+ vi.useFakeTimers();
155
+
156
+ const engine = new SimulatorEngine({
157
+ routeGeometry: AGADIR_CIRCUIT.slice(0, 5),
158
+ speedKmh: 50,
159
+ speedMultiplier: 4,
160
+ noiseProfile: NoiseEngine.NONE,
161
+ loop: false,
162
+ });
163
+
164
+ engine.startRecording('test-session-123');
165
+ engine.start();
166
+ await vi.runAllTimersAsync();
167
+
168
+ const session = engine.stopRecording();
169
+ const recorder = new SessionRecorder();
170
+ recorder.stop(session);
171
+ const json = recorder.export('json');
172
+
173
+ const parsed = JSON.parse(json);
174
+ expect(parsed.id).toBe('test-session-123');
175
+ expect(Array.isArray(parsed.positions)).toBe(true);
176
+ expect(parsed.stats.scenariosFired).toBe(0);
177
+
178
+ engine.destroy();
179
+ });
180
+
181
+ it('GPX round-trip: exported GPX re-parsed has matching coordinate count', async () => {
182
+ vi.useFakeTimers();
183
+
184
+ const route: Coordinate[] = Array.from({ length: 20 }, (_, i) => [i * 0.001, 30.0] as Coordinate);
185
+
186
+ const engine = new SimulatorEngine({
187
+ routeGeometry: route,
188
+ speedKmh: 50,
189
+ speedMultiplier: 8,
190
+ noiseProfile: NoiseEngine.NONE,
191
+ loop: false,
192
+ });
193
+
194
+ engine.startRecording();
195
+ engine.start();
196
+ await vi.runAllTimersAsync();
197
+
198
+ const session = engine.stopRecording();
199
+ const recorder = new SessionRecorder();
200
+ recorder.stop(session);
201
+ const gpx = recorder.export('gpx');
202
+ const reparsed = GpxParser.parse(gpx);
203
+
204
+ expect(reparsed.coordinates.length).toBe(session.positions.length);
205
+ engine.destroy();
206
+ });
207
+
208
+ it('scenario metadata is attached to positions during GPS_BLACKOUT', async () => {
209
+ vi.useFakeTimers();
210
+
211
+ const route: Coordinate[] = Array.from({ length: 20 }, (_, i) => [i * 0.001, 30.0] as Coordinate);
212
+ const captured: SimPosition[] = [];
213
+
214
+ const engine = new SimulatorEngine({
215
+ routeGeometry: route,
216
+ speedKmh: 50,
217
+ speedMultiplier: 8,
218
+ noiseProfile: NoiseEngine.NONE,
219
+ loop: false,
220
+ onPositionUpdate: (p) => captured.push(p),
221
+ });
222
+
223
+ engine.addScenario(ScenarioInjector.gpsBlackout(5, 5000));
224
+ engine.start();
225
+ await vi.runAllTimersAsync();
226
+
227
+ // At least one position should have metadata.scenarioActive set
228
+ const scenarioPositions = captured.filter((p) => p.metadata?.scenarioActive);
229
+ expect(scenarioPositions.length).toBeGreaterThan(0);
230
+
231
+ engine.destroy();
232
+ });
233
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * NoiseEngine — GPS noise simulation.
3
+ *
4
+ * Formalises the ±0.00011° noise from the original useGpsSimulator
5
+ * into a testable, seeded, swappable noise model.
6
+ *
7
+ * Box-Muller transform for Gaussian sampling — ZERO external dependencies.
8
+ *
9
+ * Degrees-per-metre constants:
10
+ * 1° latitude ≈ 110540 m
11
+ * 1° longitude ≈ 111320 m (at equator; good enough for noise simulation)
12
+ */
13
+
14
+ import type { Coordinate } from '@ingissa/navcore-core';
15
+ import type { NoiseProfile } from './types';
16
+
17
+ // Conversion factors
18
+ const LAT_DEG_PER_M = 1 / 110540;
19
+ const LNG_DEG_PER_M = 1 / 111320;
20
+
21
+ /**
22
+ * Simple seeded LCG (Linear Congruential Generator).
23
+ * Same seed guarantees identical noise sequences — critical for CI regression.
24
+ */
25
+ class SeededRandom {
26
+ private state: number;
27
+
28
+ constructor(seed: number) {
29
+ this.state = seed >>> 0; // ensure unsigned 32-bit
30
+ }
31
+
32
+ /** Returns a float in [0, 1) */
33
+ next(): number {
34
+ // LCG parameters from Numerical Recipes
35
+ this.state = (Math.imul(1664525, this.state) + 1013904223) >>> 0;
36
+ return this.state / 0x100000000;
37
+ }
38
+ }
39
+
40
+ export class NoiseEngine {
41
+ private profile: NoiseProfile;
42
+ private rng: SeededRandom | null;
43
+ private _tickIndex = 0;
44
+
45
+ constructor(profile: NoiseProfile) {
46
+ this.profile = profile;
47
+ this.rng = profile.seed !== undefined ? new SeededRandom(profile.seed) : null;
48
+ }
49
+
50
+ // ── Static presets ─────────────────────────────────────────────────────────
51
+
52
+ /** No noise — returns the exact coordinate */
53
+ static readonly NONE: NoiseProfile = { type: 'NONE', stdDevM: 0 };
54
+
55
+ /** Good urban GPS — ~3m standard deviation */
56
+ static readonly CLEAN: NoiseProfile = { type: 'GAUSSIAN', stdDevM: 3 };
57
+
58
+ /** Standard Go4Exams value — ~12m standard deviation (matches original ±0.00011°) */
59
+ static readonly STANDARD: NoiseProfile = { type: 'GAUSSIAN', stdDevM: 12 };
60
+
61
+ /** Dense city canyon — ~25m standard deviation */
62
+ static readonly URBAN: NoiseProfile = { type: 'URBAN', stdDevM: 25 };
63
+
64
+ /** Bad conditions — ~50m standard deviation */
65
+ static readonly DEGRADED: NoiseProfile = { type: 'GAUSSIAN', stdDevM: 50 };
66
+
67
+ // ── Public API ─────────────────────────────────────────────────────────────
68
+
69
+ /**
70
+ * Apply noise to a coordinate.
71
+ * Returns a new coordinate — never mutates the input.
72
+ */
73
+ apply(coord: Coordinate): Coordinate {
74
+ const idx = this._tickIndex++;
75
+
76
+ switch (this.profile.type) {
77
+ case 'NONE':
78
+ return coord;
79
+
80
+ case 'GAUSSIAN':
81
+ case 'URBAN': {
82
+ const [dLng, dLat] = this._gaussianOffset(this.profile.stdDevM);
83
+ return [coord[0] + dLng, coord[1] + dLat];
84
+ }
85
+
86
+ case 'TUNNEL':
87
+ // Signal loss: return a fixed position (last known would be handled by engine)
88
+ // Here we just return the exact coord (engine handles blackout via scenario)
89
+ return coord;
90
+
91
+ case 'CUSTOM': {
92
+ if (!this.profile.customFn) return coord;
93
+ const [lngOff, latOff] = this.profile.customFn(idx);
94
+ return [coord[0] + lngOff, coord[1] + latOff];
95
+ }
96
+
97
+ default:
98
+ return coord;
99
+ }
100
+ }
101
+
102
+ /** Hot-swap the noise profile mid-simulation */
103
+ setProfile(profile: NoiseProfile): void {
104
+ this.profile = profile;
105
+ this.rng = profile.seed !== undefined ? new SeededRandom(profile.seed) : null;
106
+ this._tickIndex = 0;
107
+ }
108
+
109
+ get currentProfile(): NoiseProfile {
110
+ return this.profile;
111
+ }
112
+
113
+ // ── Private helpers ─────────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Box-Muller transform: produces Gaussian N(0, stdDev²) offset.
117
+ * Returns [lngOffset, latOffset] in degrees.
118
+ */
119
+ private _gaussianOffset(stdDevM: number): [number, number] {
120
+ if (stdDevM === 0) return [0, 0];
121
+
122
+ const u1 = this._rand();
123
+ const u2 = this._rand();
124
+
125
+ // Box-Muller
126
+ const mag = stdDevM * Math.sqrt(-2 * Math.log(Math.max(u1, 1e-10)));
127
+ const z0 = mag * Math.cos(2 * Math.PI * u2);
128
+ const z1 = mag * Math.sin(2 * Math.PI * u2);
129
+
130
+ return [z0 * LNG_DEG_PER_M, z1 * LAT_DEG_PER_M];
131
+ }
132
+
133
+ /** Returns a float in [0, 1) — uses seeded RNG if available, else Math.random */
134
+ private _rand(): number {
135
+ return this.rng ? this.rng.next() : Math.random();
136
+ }
137
+ }
@@ -0,0 +1,227 @@
1
+ /**
2
+ * RouteWalker — Manages traversal of a route geometry array.
3
+ *
4
+ * Extracted from the inline useGpsSimulator hook so the traversal logic
5
+ * is testable independently of React, timers, or noise.
6
+ *
7
+ * ZERO dependencies beyond @navcore/core types.
8
+ */
9
+
10
+ import {
11
+ getBearingBetweenPoints,
12
+ haversineDistance,
13
+ } from '@ingissa/navcore-core';
14
+ import type { Coordinate } from '@ingissa/navcore-core';
15
+
16
+ export interface WalkerPosition {
17
+ /** Current floating-point index (e.g. 3.75 = between points 3 and 4) */
18
+ index: number;
19
+ /** Interpolated coordinate at the current index */
20
+ coord: Coordinate;
21
+ /** Forward-looking bearing at the current segment */
22
+ bearing: number;
23
+ /** Total metres travelled from route start */
24
+ distanceTravelled: number;
25
+ /** True if the walker has reached or passed the last point */
26
+ isComplete: boolean;
27
+ }
28
+
29
+ export class RouteWalker {
30
+ private readonly geometry: Coordinate[];
31
+ private _index: number;
32
+ private _distanceTravelled: number;
33
+ private readonly _cumulativeDistances: number[]; // length = geometry.length
34
+ private _isComplete: boolean;
35
+
36
+ constructor(geometry: Coordinate[], startIndex = 0) {
37
+ if (geometry.length < 2) {
38
+ throw new Error('RouteWalker requires at least 2 geometry points');
39
+ }
40
+ this.geometry = geometry;
41
+ this._index = Math.max(0, Math.min(startIndex, geometry.length - 1));
42
+ this._isComplete = false;
43
+
44
+ // Pre-compute cumulative distances for O(1) distanceToIndex()
45
+ this._cumulativeDistances = new Array<number>(geometry.length).fill(0);
46
+ for (let i = 1; i < geometry.length; i++) {
47
+ const a = geometry[i - 1]!;
48
+ const b = geometry[i]!;
49
+ this._cumulativeDistances[i] =
50
+ (this._cumulativeDistances[i - 1] ?? 0) +
51
+ haversineDistance(a[1], a[0], b[1], b[0]);
52
+ }
53
+
54
+ this._distanceTravelled = this._cumulativeDistances[this._index] ?? 0;
55
+ }
56
+
57
+ // ── Public API ─────────────────────────────────────────────────────────────
58
+
59
+ /**
60
+ * Advance the walker by `steps` geometry points.
61
+ * Fractional steps are supported for sub-second precision.
62
+ */
63
+ advance(steps: number): WalkerPosition {
64
+ if (this._isComplete) return this._currentPosition();
65
+ this._index += steps;
66
+ return this._finalizeAdvance();
67
+ }
68
+
69
+ /**
70
+ * Advance the walker by a physical distance in metres.
71
+ * This is the preferred way for smooth constant-speed simulation.
72
+ */
73
+ advanceByDistance(meters: number): WalkerPosition {
74
+ if (this._isComplete) return this._currentPosition();
75
+
76
+ this._distanceTravelled += meters;
77
+ if (this._distanceTravelled >= this.totalDistanceM) {
78
+ this._distanceTravelled = this.totalDistanceM;
79
+ this._index = this.geometry.length - 1;
80
+ this._isComplete = true;
81
+ } else {
82
+ this._index = this._distanceToIndex(this._distanceTravelled);
83
+ }
84
+
85
+ return this._currentPosition();
86
+ }
87
+
88
+ private _finalizeAdvance(): WalkerPosition {
89
+ if (this._index >= this.geometry.length - 1) {
90
+ this._index = this.geometry.length - 1;
91
+ this._isComplete = true;
92
+ }
93
+
94
+ this._distanceTravelled = this._interpolatedDistance(this._index);
95
+ return this._currentPosition();
96
+ }
97
+
98
+ /**
99
+ * Seek directly to a geometry index (integer or fractional).
100
+ * Recalculates bearing from the new position.
101
+ */
102
+ seek(index: number): WalkerPosition {
103
+ this._index = Math.max(0, Math.min(index, this.geometry.length - 1));
104
+ this._isComplete = this._index >= this.geometry.length - 1;
105
+ this._distanceTravelled = this._interpolatedDistance(this._index);
106
+ return this._currentPosition();
107
+ }
108
+
109
+ /**
110
+ * Get an interpolated coordinate between geometry[index] and geometry[index+1].
111
+ * @param index - integer index
112
+ * @param fraction - 0..1 interpolation factor
113
+ */
114
+ interpolate(index: number, fraction: number): Coordinate {
115
+ const clampedIdx = Math.max(0, Math.min(index, this.geometry.length - 2));
116
+ const a = this.geometry[clampedIdx]!;
117
+ const b = this.geometry[clampedIdx + 1]!;
118
+ const f = Math.max(0, Math.min(1, fraction));
119
+ return [a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f];
120
+ }
121
+
122
+ /**
123
+ * Compute the forward-looking bearing at a given integer index.
124
+ * At the last point, returns the bearing of the final segment.
125
+ */
126
+ bearingAt(index: number): number {
127
+ const clampedIdx = Math.max(0, Math.min(Math.floor(index), this.geometry.length - 2));
128
+ const a = this.geometry[clampedIdx]!;
129
+ const b = this.geometry[clampedIdx + 1]!;
130
+ return getBearingBetweenPoints(a, b);
131
+ }
132
+
133
+ /**
134
+ * Total haversine distance from the route start to the given index.
135
+ * Uses the pre-computed cumulative distances (O(1)).
136
+ */
137
+ distanceAtIndex(index: number): number {
138
+ const clampedIdx = Math.max(0, Math.min(Math.floor(index), this.geometry.length - 1));
139
+ return this._cumulativeDistances[clampedIdx] ?? 0;
140
+ }
141
+
142
+ /**
143
+ * Alias for distanceAtIndex.
144
+ */
145
+ distanceToIndex(index: number): number {
146
+ return this.distanceAtIndex(index);
147
+ }
148
+
149
+ /**
150
+ * Convert a distance in metres back to a fractional index.
151
+ * Uses binary search for O(log N) performance.
152
+ */
153
+ private _distanceToIndex(targetDistance: number): number {
154
+ if (targetDistance <= 0) return 0;
155
+ if (targetDistance >= this.totalDistanceM) return this.geometry.length - 1;
156
+
157
+ // Binary search for the segment [i, i+1] containing targetDistance
158
+ let low = 0;
159
+ let high = this._cumulativeDistances.length - 1;
160
+ let i = 0;
161
+
162
+ while (low <= high) {
163
+ const mid = Math.floor((low + high) / 2);
164
+ if (this._cumulativeDistances[mid]! <= targetDistance) {
165
+ i = mid;
166
+ low = mid + 1;
167
+ } else {
168
+ high = mid - 1;
169
+ }
170
+ }
171
+
172
+ // Now targetDistance is between i and i+1
173
+ const dStart = this._cumulativeDistances[i]!;
174
+ const dEnd = this._cumulativeDistances[i + 1]!;
175
+ const segmentDist = dEnd - dStart;
176
+
177
+ if (segmentDist <= 0) return i;
178
+
179
+ const fraction = (targetDistance - dStart) / segmentDist;
180
+ return i + fraction;
181
+ }
182
+
183
+ // ── Readonly state ──────────────────────────────────────────────────────────
184
+
185
+ get totalDistanceM(): number {
186
+ return this._cumulativeDistances[this.geometry.length - 1] ?? 0;
187
+ }
188
+
189
+ get totalPoints(): number {
190
+ return this.geometry.length;
191
+ }
192
+
193
+ get isComplete(): boolean {
194
+ return this._isComplete;
195
+ }
196
+
197
+ get currentIndex(): number {
198
+ return this._index;
199
+ }
200
+
201
+ // ── Private helpers ─────────────────────────────────────────────────────────
202
+
203
+ private _currentPosition(): WalkerPosition {
204
+ const floorIdx = Math.min(Math.floor(this._index), this.geometry.length - 2);
205
+ const fraction = this._index - floorIdx;
206
+ const coord = this.interpolate(floorIdx, fraction);
207
+ const bearing = this.bearingAt(floorIdx);
208
+
209
+ return {
210
+ index: this._index,
211
+ coord,
212
+ bearing,
213
+ distanceTravelled: this._distanceTravelled,
214
+ isComplete: this._isComplete,
215
+ };
216
+ }
217
+
218
+ /** Interpolate cumulative distance for fractional index */
219
+ private _interpolatedDistance(index: number): number {
220
+ const floor = Math.min(Math.floor(index), this.geometry.length - 1);
221
+ const ceil = Math.min(floor + 1, this.geometry.length - 1);
222
+ const fraction = index - floor;
223
+ const dFloor = this._cumulativeDistances[floor] ?? 0;
224
+ const dCeil = this._cumulativeDistances[ceil] ?? 0;
225
+ return dFloor + (dCeil - dFloor) * fraction;
226
+ }
227
+ }