@kafitra/react-native-live-tracking 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 (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +396 -0
  3. package/android/build.gradle +71 -0
  4. package/android/gradle.properties +7 -0
  5. package/android/src/main/AndroidManifest.xml +40 -0
  6. package/android/src/main/java/com/livetracking/LiveTrackingModuleImpl.kt +728 -0
  7. package/android/src/main/java/com/livetracking/LiveTrackingPackage.kt +16 -0
  8. package/android/src/main/java/com/livetracking/location/LocationEngine.kt +93 -0
  9. package/android/src/main/java/com/livetracking/network/NetworkListener.kt +127 -0
  10. package/android/src/main/java/com/livetracking/optimizer/ActivityRecognitionHandler.kt +248 -0
  11. package/android/src/main/java/com/livetracking/optimizer/MotionSleepManager.kt +130 -0
  12. package/android/src/main/java/com/livetracking/permissions/PermissionHandler.kt +145 -0
  13. package/android/src/main/java/com/livetracking/queue/QueueEngine.kt +167 -0
  14. package/android/src/main/java/com/livetracking/queue/QueuedLocation.kt +16 -0
  15. package/android/src/main/java/com/livetracking/queue/TrackingDatabase.kt +239 -0
  16. package/android/src/main/java/com/livetracking/receiver/BootReceiver.kt +53 -0
  17. package/android/src/main/java/com/livetracking/service/TrackingForegroundService.kt +145 -0
  18. package/android/src/main/java/com/livetracking/sync/FirebaseSyncEngine.kt +277 -0
  19. package/android/src/main/java/com/livetracking/sync/LocationDataPoint.kt +31 -0
  20. package/android/src/main/java/com/livetracking/sync/SyncEngineController.kt +220 -0
  21. package/android/src/main/java/com/livetracking/sync/SyncTargetConfig.kt +20 -0
  22. package/android/src/main/java/com/livetracking/sync/TargetHandler.kt +601 -0
  23. package/android/src/newarch/java/com/livetracking/LiveTrackingModule.kt +64 -0
  24. package/android/src/oldarch/java/com/livetracking/LiveTrackingModule.kt +70 -0
  25. package/android/src/test/java/com/livetracking/BackoffCalculationTest.kt +216 -0
  26. package/android/src/test/java/com/livetracking/BatchAccumulatorTest.kt +391 -0
  27. package/android/src/test/java/com/livetracking/BootReceiverTest.kt +247 -0
  28. package/android/src/test/java/com/livetracking/FirebaseSyncEngineTest.kt +337 -0
  29. package/android/src/test/java/com/livetracking/LocationEngineTest.kt +202 -0
  30. package/android/src/test/java/com/livetracking/MotionSleepManagerTest.kt +420 -0
  31. package/android/src/test/java/com/livetracking/OfflineQueueTest.kt +462 -0
  32. package/android/src/test/java/com/livetracking/PermissionHandlerTest.kt +200 -0
  33. package/android/src/test/java/com/livetracking/QueueEngineTest.kt +335 -0
  34. package/android/src/test/java/com/livetracking/SyncEngineControllerTest.kt +855 -0
  35. package/ios/ActivityRecognitionHandler.swift +196 -0
  36. package/ios/BackgroundModeHelper.swift +132 -0
  37. package/ios/FirebaseSyncEngine.swift +276 -0
  38. package/ios/LiveTracking-Bridging-Header.h +2 -0
  39. package/ios/LiveTracking.m +37 -0
  40. package/ios/LiveTracking.swift +773 -0
  41. package/ios/LocationDataPoint.swift +56 -0
  42. package/ios/LocationEngine.swift +160 -0
  43. package/ios/MotionSleepManager.swift +151 -0
  44. package/ios/NetworkListener.swift +105 -0
  45. package/ios/OfflineQueueManager.swift +503 -0
  46. package/ios/PermissionHandler.swift +148 -0
  47. package/ios/QueueEngine.swift +249 -0
  48. package/ios/SyncEngineController.swift +396 -0
  49. package/ios/SyncTargetConfig.swift +36 -0
  50. package/ios/TargetHandler.swift +715 -0
  51. package/ios/Tests/ActivityRecognitionHandlerTests.swift +259 -0
  52. package/ios/Tests/FirebaseSyncEngineTests.swift +303 -0
  53. package/ios/Tests/LocationEngineTests.swift +244 -0
  54. package/ios/Tests/MotionSleepManagerTests.swift +355 -0
  55. package/ios/Tests/NetworkListenerTests.swift +188 -0
  56. package/ios/Tests/OfflineQueueFlushTests.swift +375 -0
  57. package/ios/Tests/PermissionHandlerTests.swift +238 -0
  58. package/ios/Tests/QueueEngineTests.swift +346 -0
  59. package/ios/TrackingCleanup.swift +93 -0
  60. package/ios/TrackingNotificationManager.swift +187 -0
  61. package/lib/commonjs/EventEmitter.js +113 -0
  62. package/lib/commonjs/EventEmitter.js.map +1 -0
  63. package/lib/commonjs/LiveTracking.js +134 -0
  64. package/lib/commonjs/LiveTracking.js.map +1 -0
  65. package/lib/commonjs/NativeLiveTracking.js +21 -0
  66. package/lib/commonjs/NativeLiveTracking.js.map +1 -0
  67. package/lib/commonjs/filters/distanceTimeFilter.js +63 -0
  68. package/lib/commonjs/filters/distanceTimeFilter.js.map +1 -0
  69. package/lib/commonjs/index.js +103 -0
  70. package/lib/commonjs/index.js.map +1 -0
  71. package/lib/commonjs/serialization/locationSerializer.js +51 -0
  72. package/lib/commonjs/serialization/locationSerializer.js.map +1 -0
  73. package/lib/commonjs/types.js +77 -0
  74. package/lib/commonjs/types.js.map +1 -0
  75. package/lib/commonjs/utils/distance.js +63 -0
  76. package/lib/commonjs/utils/distance.js.map +1 -0
  77. package/lib/commonjs/utils/retry.js +80 -0
  78. package/lib/commonjs/utils/retry.js.map +1 -0
  79. package/lib/commonjs/validation.js +463 -0
  80. package/lib/commonjs/validation.js.map +1 -0
  81. package/lib/module/EventEmitter.js +105 -0
  82. package/lib/module/EventEmitter.js.map +1 -0
  83. package/lib/module/LiveTracking.js +127 -0
  84. package/lib/module/LiveTracking.js.map +1 -0
  85. package/lib/module/NativeLiveTracking.js +16 -0
  86. package/lib/module/NativeLiveTracking.js.map +1 -0
  87. package/lib/module/filters/distanceTimeFilter.js +58 -0
  88. package/lib/module/filters/distanceTimeFilter.js.map +1 -0
  89. package/lib/module/index.js +32 -0
  90. package/lib/module/index.js.map +1 -0
  91. package/lib/module/serialization/locationSerializer.js +45 -0
  92. package/lib/module/serialization/locationSerializer.js.map +1 -0
  93. package/lib/module/types.js +94 -0
  94. package/lib/module/types.js.map +1 -0
  95. package/lib/module/utils/distance.js +56 -0
  96. package/lib/module/utils/distance.js.map +1 -0
  97. package/lib/module/utils/retry.js +72 -0
  98. package/lib/module/utils/retry.js.map +1 -0
  99. package/lib/module/validation.js +456 -0
  100. package/lib/module/validation.js.map +1 -0
  101. package/lib/typescript/EventEmitter.d.ts +65 -0
  102. package/lib/typescript/EventEmitter.d.ts.map +1 -0
  103. package/lib/typescript/LiveTracking.d.ts +23 -0
  104. package/lib/typescript/LiveTracking.d.ts.map +1 -0
  105. package/lib/typescript/NativeLiveTracking.d.ts +25 -0
  106. package/lib/typescript/NativeLiveTracking.d.ts.map +1 -0
  107. package/lib/typescript/filters/distanceTimeFilter.d.ts +44 -0
  108. package/lib/typescript/filters/distanceTimeFilter.d.ts.map +1 -0
  109. package/lib/typescript/index.d.ts +21 -0
  110. package/lib/typescript/index.d.ts.map +1 -0
  111. package/lib/typescript/serialization/locationSerializer.d.ts +39 -0
  112. package/lib/typescript/serialization/locationSerializer.d.ts.map +1 -0
  113. package/lib/typescript/types.d.ts +217 -0
  114. package/lib/typescript/types.d.ts.map +1 -0
  115. package/lib/typescript/utils/distance.d.ts +38 -0
  116. package/lib/typescript/utils/distance.d.ts.map +1 -0
  117. package/lib/typescript/utils/retry.d.ts +60 -0
  118. package/lib/typescript/utils/retry.d.ts.map +1 -0
  119. package/lib/typescript/validation.d.ts +26 -0
  120. package/lib/typescript/validation.d.ts.map +1 -0
  121. package/package.json +126 -0
  122. package/react-native-live-tracking.podspec +47 -0
  123. package/src/EventEmitter.ts +118 -0
  124. package/src/LiveTracking.ts +159 -0
  125. package/src/NativeLiveTracking.ts +29 -0
  126. package/src/filters/distanceTimeFilter.ts +75 -0
  127. package/src/index.ts +51 -0
  128. package/src/serialization/locationSerializer.ts +57 -0
  129. package/src/types.ts +252 -0
  130. package/src/utils/distance.ts +68 -0
  131. package/src/utils/retry.ts +75 -0
  132. package/src/validation.ts +552 -0
@@ -0,0 +1,259 @@
1
+ /**
2
+ * ActivityRecognitionHandlerTests.swift
3
+ *
4
+ * Unit tests for ActivityRecognitionHandler — testing activity detection state and defaults.
5
+ * Tests focus on enum cases, default values, and initial state.
6
+ *
7
+ * HOW TO ADD TO XCODE PROJECT:
8
+ * 1. In Xcode, select your project in the navigator
9
+ * 2. Add a new Unit Test target (File > New > Target > Unit Testing Bundle) or use existing
10
+ * 3. Drag this file into the test target's folder in the project navigator
11
+ * 4. Ensure @testable import LiveTracking matches your module/target name
12
+ * 5. Build and run tests with Cmd+U
13
+ *
14
+ * NOTE: CMMotionActivityManager requires device hardware (M-series coprocessor).
15
+ * These tests focus on logic that can be tested without hardware:
16
+ * - ActivityType enum cases
17
+ * - Default threshold values
18
+ * - Initial state of the handler
19
+ * - ActivityStateDelegate protocol conformance
20
+ */
21
+
22
+ import XCTest
23
+ @testable import LiveTracking
24
+
25
+ // MARK: - Mock ActivityStateDelegate
26
+
27
+ class MockActivityStateDelegate: ActivityStateDelegate {
28
+ var activityChangedCount = 0
29
+ var lastActivity: ActivityType?
30
+ var stationaryDurationExceededCount = 0
31
+ var lastDurationMs: Int64?
32
+
33
+ func onActivityChanged(activity: ActivityType) {
34
+ activityChangedCount += 1
35
+ lastActivity = activity
36
+ }
37
+
38
+ func onStationaryDurationExceeded(durationMs: Int64) {
39
+ stationaryDurationExceededCount += 1
40
+ lastDurationMs = durationMs
41
+ }
42
+ }
43
+
44
+ // MARK: - ActivityRecognitionHandler Tests
45
+
46
+ class ActivityRecognitionHandlerTests: XCTestCase {
47
+
48
+ var handler: ActivityRecognitionHandler!
49
+ var mockDelegate: MockActivityStateDelegate!
50
+
51
+ override func setUp() {
52
+ super.setUp()
53
+ handler = ActivityRecognitionHandler()
54
+ mockDelegate = MockActivityStateDelegate()
55
+ handler.delegate = mockDelegate
56
+ }
57
+
58
+ override func tearDown() {
59
+ handler = nil
60
+ mockDelegate = nil
61
+ super.tearDown()
62
+ }
63
+
64
+ // MARK: - Test: ActivityType enum has correct cases
65
+
66
+ func testActivityTypeHasStationaryCase() {
67
+ let activity: ActivityType = .stationary
68
+ switch activity {
69
+ case .stationary:
70
+ // Expected
71
+ break
72
+ default:
73
+ XCTFail("Should match .stationary case")
74
+ }
75
+ }
76
+
77
+ func testActivityTypeHasWalkingCase() {
78
+ let activity: ActivityType = .walking
79
+ switch activity {
80
+ case .walking:
81
+ // Expected
82
+ break
83
+ default:
84
+ XCTFail("Should match .walking case")
85
+ }
86
+ }
87
+
88
+ func testActivityTypeHasAutomotiveCase() {
89
+ let activity: ActivityType = .automotive
90
+ switch activity {
91
+ case .automotive:
92
+ // Expected
93
+ break
94
+ default:
95
+ XCTFail("Should match .automotive case")
96
+ }
97
+ }
98
+
99
+ func testActivityTypeHasUnknownCase() {
100
+ let activity: ActivityType = .unknown
101
+ switch activity {
102
+ case .unknown:
103
+ // Expected
104
+ break
105
+ default:
106
+ XCTFail("Should match .unknown case")
107
+ }
108
+ }
109
+
110
+ func testActivityTypeAllCasesAreDistinct() {
111
+ // Verify all cases can be distinguished via switch
112
+ let activities: [ActivityType] = [.stationary, .walking, .automotive, .unknown]
113
+
114
+ var stationaryCount = 0
115
+ var walkingCount = 0
116
+ var automotiveCount = 0
117
+ var unknownCount = 0
118
+
119
+ for activity in activities {
120
+ switch activity {
121
+ case .stationary: stationaryCount += 1
122
+ case .walking: walkingCount += 1
123
+ case .automotive: automotiveCount += 1
124
+ case .unknown: unknownCount += 1
125
+ }
126
+ }
127
+
128
+ XCTAssertEqual(stationaryCount, 1, "Should have exactly one .stationary")
129
+ XCTAssertEqual(walkingCount, 1, "Should have exactly one .walking")
130
+ XCTAssertEqual(automotiveCount, 1, "Should have exactly one .automotive")
131
+ XCTAssertEqual(unknownCount, 1, "Should have exactly one .unknown")
132
+ }
133
+
134
+ // MARK: - Test: stationaryThresholdMs default is 180_000
135
+
136
+ func testStationaryThresholdMsDefaultIs180000() {
137
+ XCTAssertEqual(handler.stationaryThresholdMs, 180_000,
138
+ "Default stationaryThresholdMs should be 180,000 ms (3 minutes)")
139
+ }
140
+
141
+ func testStationaryThresholdMsCanBeModified() {
142
+ handler.stationaryThresholdMs = 60_000 // 1 minute
143
+
144
+ XCTAssertEqual(handler.stationaryThresholdMs, 60_000,
145
+ "stationaryThresholdMs should be modifiable")
146
+ }
147
+
148
+ // MARK: - Test: isDeviceStationary returns false initially
149
+
150
+ func testIsDeviceStationaryReturnsFalseInitially() {
151
+ XCTAssertFalse(handler.isDeviceStationary(),
152
+ "isDeviceStationary should return false before any activity updates")
153
+ }
154
+
155
+ // MARK: - Test: getCurrentActivity returns unknown initially
156
+
157
+ func testGetCurrentActivityReturnsUnknownInitially() {
158
+ let activity = handler.getCurrentActivity()
159
+
160
+ switch activity {
161
+ case .unknown:
162
+ // Expected
163
+ break
164
+ default:
165
+ XCTFail("getCurrentActivity should return .unknown initially, got \(activity)")
166
+ }
167
+ }
168
+
169
+ // MARK: - Test: getStationaryDurationMs returns 0 initially
170
+
171
+ func testGetStationaryDurationMsReturnsZeroInitially() {
172
+ let duration = handler.getStationaryDurationMs()
173
+ XCTAssertEqual(duration, 0,
174
+ "getStationaryDurationMs should return 0 when not stationary")
175
+ }
176
+
177
+ // MARK: - Test: ActivityStateDelegate protocol has correct methods
178
+
179
+ func testActivityStateDelegateOnActivityChangedMethod() {
180
+ // Verify the delegate protocol method exists and can be called
181
+ mockDelegate.onActivityChanged(activity: .walking)
182
+
183
+ XCTAssertEqual(mockDelegate.activityChangedCount, 1)
184
+ XCTAssertNotNil(mockDelegate.lastActivity)
185
+
186
+ if let lastActivity = mockDelegate.lastActivity {
187
+ switch lastActivity {
188
+ case .walking:
189
+ // Expected
190
+ break
191
+ default:
192
+ XCTFail("Delegate should receive .walking activity")
193
+ }
194
+ }
195
+ }
196
+
197
+ func testActivityStateDelegateOnStationaryDurationExceededMethod() {
198
+ // Verify the delegate protocol method exists and can be called
199
+ mockDelegate.onStationaryDurationExceeded(durationMs: 200_000)
200
+
201
+ XCTAssertEqual(mockDelegate.stationaryDurationExceededCount, 1)
202
+ XCTAssertEqual(mockDelegate.lastDurationMs, 200_000)
203
+ }
204
+
205
+ // MARK: - Test: Handler can be instantiated
206
+
207
+ func testHandlerCanBeInstantiated() {
208
+ let newHandler = ActivityRecognitionHandler()
209
+ XCTAssertNotNil(newHandler, "ActivityRecognitionHandler should be instantiable")
210
+ }
211
+
212
+ // MARK: - Test: stopActivityRecognition resets state
213
+
214
+ func testStopActivityRecognitionResetsState() {
215
+ // Stop should reset internal state without crashing
216
+ handler.stopActivityRecognition()
217
+
218
+ XCTAssertFalse(handler.isDeviceStationary(),
219
+ "isDeviceStationary should be false after stop")
220
+ XCTAssertEqual(handler.getStationaryDurationMs(), 0,
221
+ "Stationary duration should be 0 after stop")
222
+
223
+ let activity = handler.getCurrentActivity()
224
+ switch activity {
225
+ case .unknown:
226
+ // Expected after reset
227
+ break
228
+ default:
229
+ XCTFail("getCurrentActivity should return .unknown after stop")
230
+ }
231
+ }
232
+
233
+ // MARK: - Test: Multiple stop calls don't crash
234
+
235
+ func testMultipleStopCallsDontCrash() {
236
+ handler.stopActivityRecognition()
237
+ handler.stopActivityRecognition()
238
+ handler.stopActivityRecognition()
239
+
240
+ // Should not crash
241
+ XCTAssertFalse(handler.isDeviceStationary())
242
+ }
243
+
244
+ // MARK: - Test: Delegate is weak reference
245
+
246
+ func testDelegateIsWeakReference() {
247
+ var delegate: MockActivityStateDelegate? = MockActivityStateDelegate()
248
+ handler.delegate = delegate
249
+
250
+ // Verify delegate is set
251
+ XCTAssertNotNil(handler.delegate)
252
+
253
+ // Release the delegate
254
+ delegate = nil
255
+
256
+ // Delegate should be nil (weak reference)
257
+ XCTAssertNil(handler.delegate, "Delegate should be a weak reference")
258
+ }
259
+ }
@@ -0,0 +1,303 @@
1
+ /**
2
+ * FirebaseSyncEngineTests.swift
3
+ *
4
+ * Unit tests for FirebaseSyncEngine — focusing on retry/backoff logic and path validation.
5
+ * The calculateBackoffDelay method is `internal` and accessible within the same module via @testable import.
6
+ *
7
+ * HOW TO ADD TO XCODE PROJECT:
8
+ * 1. In Xcode, select your project in the navigator
9
+ * 2. Add a new Unit Test target (File > New > Target > Unit Testing Bundle) or use existing
10
+ * 3. Drag this file into the test target's folder in the project navigator
11
+ * 4. Ensure @testable import LiveTracking matches your module/target name
12
+ * 5. Build and run tests with Cmd+U
13
+ *
14
+ * NOTE: Firebase SDK calls are NOT mocked here — these tests focus on the backoff calculation
15
+ * and path validation logic which don't require Firebase connectivity.
16
+ */
17
+
18
+ import XCTest
19
+ @testable import LiveTracking
20
+
21
+ // MARK: - Mock SyncCallback
22
+
23
+ class MockSyncCallback: SyncCallback {
24
+ var successCount = 0
25
+ var errorCount = 0
26
+ var lastErrorCode: String?
27
+ var lastErrorMessage: String?
28
+
29
+ var onSuccessExpectation: XCTestExpectation?
30
+ var onErrorExpectation: XCTestExpectation?
31
+
32
+ func onSuccess() {
33
+ successCount += 1
34
+ onSuccessExpectation?.fulfill()
35
+ }
36
+
37
+ func onError(errorCode: String, message: String) {
38
+ errorCount += 1
39
+ lastErrorCode = errorCode
40
+ lastErrorMessage = message
41
+ onErrorExpectation?.fulfill()
42
+ }
43
+ }
44
+
45
+ // MARK: - FirebaseSyncEngine Tests
46
+
47
+ class FirebaseSyncEngineTests: XCTestCase {
48
+
49
+ var syncEngine: FirebaseSyncEngine!
50
+ var mockCallback: MockSyncCallback!
51
+
52
+ override func setUp() {
53
+ super.setUp()
54
+ mockCallback = MockSyncCallback()
55
+ }
56
+
57
+ override func tearDown() {
58
+ syncEngine = nil
59
+ mockCallback = nil
60
+ super.tearDown()
61
+ }
62
+
63
+ // MARK: - Backoff Delay Tests
64
+
65
+ /**
66
+ * Test: calculateBackoffDelay(attempt: 1) ≈ 1000ms ±200ms
67
+ *
68
+ * Formula: baseDelay(1000) × 2^(1-1) ± jitter(200)
69
+ * = 1000 × 1 ± 200
70
+ * = 800 to 1200
71
+ */
72
+ func testCalculateBackoffDelayAttempt1() {
73
+ syncEngine = FirebaseSyncEngine(service: "RTDB", currentLocationPath: "/test", historyPath: "/history")
74
+
75
+ let delay = syncEngine.calculateBackoffDelay(attempt: 1)
76
+
77
+ XCTAssertGreaterThanOrEqual(delay, 800,
78
+ "Attempt 1 delay should be >= 800ms (1000 - 200 jitter)")
79
+ XCTAssertLessThanOrEqual(delay, 1200,
80
+ "Attempt 1 delay should be <= 1200ms (1000 + 200 jitter)")
81
+ }
82
+
83
+ /**
84
+ * Test: calculateBackoffDelay(attempt: 2) ≈ 2000ms ±200ms
85
+ *
86
+ * Formula: baseDelay(1000) × 2^(2-1) ± jitter(200)
87
+ * = 1000 × 2 ± 200
88
+ * = 1800 to 2200
89
+ */
90
+ func testCalculateBackoffDelayAttempt2() {
91
+ syncEngine = FirebaseSyncEngine(service: "RTDB", currentLocationPath: "/test", historyPath: "/history")
92
+
93
+ let delay = syncEngine.calculateBackoffDelay(attempt: 2)
94
+
95
+ XCTAssertGreaterThanOrEqual(delay, 1800,
96
+ "Attempt 2 delay should be >= 1800ms (2000 - 200 jitter)")
97
+ XCTAssertLessThanOrEqual(delay, 2200,
98
+ "Attempt 2 delay should be <= 2200ms (2000 + 200 jitter)")
99
+ }
100
+
101
+ /**
102
+ * Test: calculateBackoffDelay(attempt: 3) ≈ 4000ms ±200ms
103
+ *
104
+ * Formula: baseDelay(1000) × 2^(3-1) ± jitter(200)
105
+ * = 1000 × 4 ± 200
106
+ * = 3800 to 4200
107
+ */
108
+ func testCalculateBackoffDelayAttempt3() {
109
+ syncEngine = FirebaseSyncEngine(service: "RTDB", currentLocationPath: "/test", historyPath: "/history")
110
+
111
+ let delay = syncEngine.calculateBackoffDelay(attempt: 3)
112
+
113
+ XCTAssertGreaterThanOrEqual(delay, 3800,
114
+ "Attempt 3 delay should be >= 3800ms (4000 - 200 jitter)")
115
+ XCTAssertLessThanOrEqual(delay, 4200,
116
+ "Attempt 3 delay should be <= 4200ms (4000 + 200 jitter)")
117
+ }
118
+
119
+ /**
120
+ * Test: delay is never negative
121
+ *
122
+ * The implementation uses max(0, ...) to ensure non-negative delays.
123
+ * Even with negative jitter on attempt 1, the result should be >= 0.
124
+ */
125
+ func testDelayIsNeverNegative() {
126
+ syncEngine = FirebaseSyncEngine(service: "RTDB", currentLocationPath: "/test", historyPath: "/history")
127
+
128
+ // Run multiple times to account for random jitter
129
+ for attempt in 1...10 {
130
+ for _ in 0..<100 {
131
+ let delay = syncEngine.calculateBackoffDelay(attempt: attempt)
132
+ XCTAssertGreaterThanOrEqual(delay, 0,
133
+ "Delay should never be negative for attempt \(attempt)")
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Test: updateCurrentLocation with nil path calls onError
140
+ *
141
+ * When currentLocationPath is nil, the engine should immediately call onError
142
+ * with "NO_PATH" error code.
143
+ */
144
+ func testUpdateCurrentLocationWithNilPathCallsOnError() {
145
+ syncEngine = FirebaseSyncEngine(service: "RTDB", currentLocationPath: nil, historyPath: "/history")
146
+
147
+ let expectation = self.expectation(description: "onError should be called")
148
+ mockCallback.onErrorExpectation = expectation
149
+
150
+ syncEngine.updateCurrentLocation(
151
+ latitude: 37.7749,
152
+ longitude: -122.4194,
153
+ timestamp: 1700000000000,
154
+ accuracy: 10.0,
155
+ speed: 5.0,
156
+ callback: mockCallback
157
+ )
158
+
159
+ waitForExpectations(timeout: 1.0) { error in
160
+ XCTAssertNil(error)
161
+ }
162
+
163
+ XCTAssertEqual(mockCallback.errorCount, 1, "onError should be called exactly once")
164
+ XCTAssertEqual(mockCallback.lastErrorCode, "NO_PATH", "Error code should be NO_PATH")
165
+ XCTAssertEqual(mockCallback.successCount, 0, "onSuccess should not be called")
166
+ }
167
+
168
+ /**
169
+ * Test: pushHistoryBatch with empty array calls onSuccess
170
+ *
171
+ * When locations array is empty, the engine should immediately call onSuccess
172
+ * without attempting a Firebase write.
173
+ */
174
+ func testPushHistoryBatchWithEmptyArrayCallsOnSuccess() {
175
+ syncEngine = FirebaseSyncEngine(service: "RTDB", currentLocationPath: "/current", historyPath: "/history")
176
+
177
+ let expectation = self.expectation(description: "onSuccess should be called")
178
+ mockCallback.onSuccessExpectation = expectation
179
+
180
+ syncEngine.pushHistoryBatch(locations: [], callback: mockCallback)
181
+
182
+ waitForExpectations(timeout: 1.0) { error in
183
+ XCTAssertNil(error)
184
+ }
185
+
186
+ XCTAssertEqual(mockCallback.successCount, 1, "onSuccess should be called for empty batch")
187
+ XCTAssertEqual(mockCallback.errorCount, 0, "onError should not be called for empty batch")
188
+ }
189
+
190
+ /**
191
+ * Test: pushHistoryBatch with nil path calls onError
192
+ *
193
+ * When historyPath is nil, the engine should immediately call onError
194
+ * with "NO_PATH" error code.
195
+ */
196
+ func testPushHistoryBatchWithNilPathCallsOnError() {
197
+ syncEngine = FirebaseSyncEngine(service: "RTDB", currentLocationPath: "/current", historyPath: nil)
198
+
199
+ let expectation = self.expectation(description: "onError should be called")
200
+ mockCallback.onErrorExpectation = expectation
201
+
202
+ let locations: [[String: Any]] = [
203
+ ["latitude": 37.7749, "longitude": -122.4194, "timestamp": 1700000000000]
204
+ ]
205
+
206
+ syncEngine.pushHistoryBatch(locations: locations, callback: mockCallback)
207
+
208
+ waitForExpectations(timeout: 1.0) { error in
209
+ XCTAssertNil(error)
210
+ }
211
+
212
+ XCTAssertEqual(mockCallback.errorCount, 1, "onError should be called exactly once")
213
+ XCTAssertEqual(mockCallback.lastErrorCode, "NO_PATH", "Error code should be NO_PATH")
214
+ XCTAssertEqual(mockCallback.successCount, 0, "onSuccess should not be called")
215
+ }
216
+
217
+ /**
218
+ * Test: jitter produces variation across multiple calls
219
+ *
220
+ * Running calculateBackoffDelay multiple times for the same attempt should produce
221
+ * different values due to random jitter (±200ms range).
222
+ */
223
+ func testJitterProducesVariation() {
224
+ syncEngine = FirebaseSyncEngine(service: "RTDB", currentLocationPath: "/test", historyPath: "/history")
225
+
226
+ var delays: Set<Int> = []
227
+
228
+ // Run 50 times — with ±200ms jitter range (401 possible values), we should see variation
229
+ for _ in 0..<50 {
230
+ let delay = syncEngine.calculateBackoffDelay(attempt: 1)
231
+ delays.insert(delay)
232
+ }
233
+
234
+ // With 401 possible values and 50 samples, we should get at least 2 distinct values
235
+ XCTAssertGreaterThan(delays.count, 1,
236
+ "Jitter should produce variation across multiple calls. Got \(delays.count) unique values from 50 samples")
237
+ }
238
+
239
+ /**
240
+ * Test: backoff grows exponentially
241
+ *
242
+ * Verify that higher attempts produce larger delays (on average).
243
+ */
244
+ func testBackoffGrowsExponentially() {
245
+ syncEngine = FirebaseSyncEngine(service: "RTDB", currentLocationPath: "/test", historyPath: "/history")
246
+
247
+ // Calculate average delay for each attempt over multiple samples
248
+ let samples = 100
249
+ var avgDelay1: Double = 0
250
+ var avgDelay2: Double = 0
251
+ var avgDelay3: Double = 0
252
+
253
+ for _ in 0..<samples {
254
+ avgDelay1 += Double(syncEngine.calculateBackoffDelay(attempt: 1))
255
+ avgDelay2 += Double(syncEngine.calculateBackoffDelay(attempt: 2))
256
+ avgDelay3 += Double(syncEngine.calculateBackoffDelay(attempt: 3))
257
+ }
258
+
259
+ avgDelay1 /= Double(samples)
260
+ avgDelay2 /= Double(samples)
261
+ avgDelay3 /= Double(samples)
262
+
263
+ XCTAssertLessThan(avgDelay1, avgDelay2, "Attempt 2 should have higher average delay than attempt 1")
264
+ XCTAssertLessThan(avgDelay2, avgDelay3, "Attempt 3 should have higher average delay than attempt 2")
265
+
266
+ // Verify approximate 2x growth
267
+ let ratio2to1 = avgDelay2 / avgDelay1
268
+ let ratio3to2 = avgDelay3 / avgDelay2
269
+
270
+ XCTAssertGreaterThan(ratio2to1, 1.5, "Delay should roughly double between attempts 1 and 2")
271
+ XCTAssertLessThan(ratio2to1, 2.5, "Delay should roughly double between attempts 1 and 2")
272
+ XCTAssertGreaterThan(ratio3to2, 1.5, "Delay should roughly double between attempts 2 and 3")
273
+ XCTAssertLessThan(ratio3to2, 2.5, "Delay should roughly double between attempts 2 and 3")
274
+ }
275
+
276
+ /**
277
+ * Test: attempt 4 delay ≈ 8000ms ±200ms
278
+ *
279
+ * Formula: 1000 × 2^(4-1) = 8000 ± 200
280
+ */
281
+ func testCalculateBackoffDelayAttempt4() {
282
+ syncEngine = FirebaseSyncEngine(service: "RTDB", currentLocationPath: "/test", historyPath: "/history")
283
+
284
+ let delay = syncEngine.calculateBackoffDelay(attempt: 4)
285
+
286
+ XCTAssertGreaterThanOrEqual(delay, 7800, "Attempt 4 delay should be >= 7800ms")
287
+ XCTAssertLessThanOrEqual(delay, 8200, "Attempt 4 delay should be <= 8200ms")
288
+ }
289
+
290
+ /**
291
+ * Test: attempt 5 delay ≈ 16000ms ±200ms
292
+ *
293
+ * Formula: 1000 × 2^(5-1) = 16000 ± 200
294
+ */
295
+ func testCalculateBackoffDelayAttempt5() {
296
+ syncEngine = FirebaseSyncEngine(service: "RTDB", currentLocationPath: "/test", historyPath: "/history")
297
+
298
+ let delay = syncEngine.calculateBackoffDelay(attempt: 5)
299
+
300
+ XCTAssertGreaterThanOrEqual(delay, 15800, "Attempt 5 delay should be >= 15800ms")
301
+ XCTAssertLessThanOrEqual(delay, 16200, "Attempt 5 delay should be <= 16200ms")
302
+ }
303
+ }