therms-device-tracker 1.0.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +145 -0
- package/CHANGELOG.md +26 -0
- package/LICENSE +21 -0
- package/README.md +386 -0
- package/android/build.gradle +25 -0
- package/android/src/main/AndroidManifest.xml +23 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/ActivityRecognitionProvider.kt +109 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/GeofenceProvider.kt +184 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/GeofenceTransitionReceiver.kt +34 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/LocationProvider.kt +84 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/LocationStore.kt +150 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/ScheduleProvider.kt +55 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/SyncProvider.kt +292 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/ThermsDeviceTrackerModule.kt +726 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/ThermsDeviceTrackerModuleSharedObject.kt +23 -0
- package/android/src/main/java/expo/modules/thermsdevicetracker/ThermsLocationService.kt +129 -0
- package/app.plugin.js +1 -0
- package/build/DeviceSettings.d.ts +14 -0
- package/build/DeviceSettings.d.ts.map +1 -0
- package/build/DeviceSettings.js +24 -0
- package/build/DeviceSettings.js.map +1 -0
- package/build/Logger.d.ts +13 -0
- package/build/Logger.d.ts.map +1 -0
- package/build/Logger.js +27 -0
- package/build/Logger.js.map +1 -0
- package/build/NativeModule.d.ts +51 -0
- package/build/NativeModule.d.ts.map +1 -0
- package/build/NativeModule.js +159 -0
- package/build/NativeModule.js.map +1 -0
- package/build/ThermsDeviceTracker.types.d.ts +204 -0
- package/build/ThermsDeviceTracker.types.d.ts.map +1 -0
- package/build/ThermsDeviceTracker.types.js +34 -0
- package/build/ThermsDeviceTracker.types.js.map +1 -0
- package/build/ThermsDeviceTrackerModule.d.ts +43 -0
- package/build/ThermsDeviceTrackerModule.d.ts.map +1 -0
- package/build/ThermsDeviceTrackerModule.js +3 -0
- package/build/ThermsDeviceTrackerModule.js.map +1 -0
- package/build/ThermsDeviceTrackerModule.web.d.ts +47 -0
- package/build/ThermsDeviceTrackerModule.web.d.ts.map +1 -0
- package/build/ThermsDeviceTrackerModule.web.js +132 -0
- package/build/ThermsDeviceTrackerModule.web.js.map +1 -0
- package/build/ThermsDeviceTrackerModuleSharedObject.d.ts +46 -0
- package/build/ThermsDeviceTrackerModuleSharedObject.d.ts.map +1 -0
- package/build/ThermsDeviceTrackerModuleSharedObject.js +24 -0
- package/build/ThermsDeviceTrackerModuleSharedObject.js.map +1 -0
- package/build/index.d.ts +101 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +221 -0
- package/build/index.js.map +1 -0
- package/build/plugin/index.d.ts +14 -0
- package/build/plugin/index.d.ts.map +1 -0
- package/build/plugin/index.js +83 -0
- package/build/plugin/index.js.map +1 -0
- package/build/tsconfig.tsbuildinfo +1 -0
- package/expo-module.config.json +9 -0
- package/ios/GeofenceManager.swift +221 -0
- package/ios/LocationProvider.swift +32 -0
- package/ios/LocationStore.swift +98 -0
- package/ios/MotionActivityProvider.swift +109 -0
- package/ios/ProviderMonitor.swift +33 -0
- package/ios/ScheduleManager.swift +33 -0
- package/ios/SyncManager.swift +186 -0
- package/ios/ThermsDeviceTracker.podspec +24 -0
- package/ios/ThermsDeviceTrackerModule.swift +632 -0
- package/ios/ThermsDeviceTrackerModuleSharedObject.swift +17 -0
- package/ios/ThermsGeofenceTests.swift +474 -0
- package/package.json +95 -0
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import CoreLocation
|
|
3
|
+
@testable import ThermsDeviceTracker
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Native XCTest cases exercising seams in GeofenceManager / LocationStore / ScheduleManager / MotionActivityProvider.
|
|
7
|
+
*
|
|
8
|
+
* Uses:
|
|
9
|
+
* - MockRegionMonitor implementing GeofenceRegionMonitoring to record start/stop (no real CLLocationManager).
|
|
10
|
+
* - LocationStore injected with isolated UserDefaults(suiteName:) (no .standard side effects).
|
|
11
|
+
* - lastLocationProvider closure, on* callbacks, handle* entry points.
|
|
12
|
+
* - Direct MotionActivityProvider() entrypoint + onActivityUpdate/onPedometerUpdate/onError callback capture for seam tests (no internal manager injection).
|
|
13
|
+
*
|
|
14
|
+
* Tests cover:
|
|
15
|
+
* - addGeofence/remove/getGeofences + monitor calls + persist via store.
|
|
16
|
+
* - addGeofences (bulk) / removeGeofences (list or all) + persistence + onGeofencesChange + monitor calls.
|
|
17
|
+
* - Bad inputs: invalid geofence dicts (missing id/lat/lon/radius, nulls, wrong types), bad remove ids (non-existing + nil for all); graceful no-op no crash no persist (note: empty string identifier is accepted as valid degenerate key by current seam guards).
|
|
18
|
+
* - handleDidEnterRegion / handleDidExitRegion (action, identifier, location from provider, extras).
|
|
19
|
+
* - LocationStore: id gen on insert, remove(byId), geofence save/load roundtrip (core + extras), clear.
|
|
20
|
+
* - ScheduleManager start with config interval (exercises seam).
|
|
21
|
+
* - Direct restorePersistedGeofences() (iOS pure; Android via startMonitoring entrypoint) exercising "populates memory only (no side effects / do not notify or start)" + getGeofences() roundtrip + no-callback asserts.
|
|
22
|
+
* - MotionActivityProvider: configure (enable flags), start/stop, callback assignment/capture for onActivityUpdate, onPedometerUpdate, onError; disabled paths and multi start/stop (direct entrypoints, no crash).
|
|
23
|
+
*
|
|
24
|
+
* Isolation: no real monitoring, correct event payloads, durability roundtrips.
|
|
25
|
+
* Running (Xcode): Add/include ThermsGeofenceTests.swift in an XCTest target (for @testable import ThermsDeviceTracker access to the module). Use `npm run open:ios` (after prebuild), then Product > Test (Cmd+U) in Xcode. On macOS host some MotionActivityProvider paths may be no-op due to hardware availability (tests focus on seam wiring/no-crash). `swift test` requires additional SPM configuration (not provided here).
|
|
26
|
+
*/
|
|
27
|
+
final class ThermsGeofenceTests: XCTestCase {
|
|
28
|
+
|
|
29
|
+
// Simple mock implementing the protocol seam (records calls only).
|
|
30
|
+
class MockRegionMonitor: GeofenceRegionMonitoring {
|
|
31
|
+
var started: [CLRegion] = []
|
|
32
|
+
var stopped: [CLRegion] = []
|
|
33
|
+
func startMonitoring(for region: CLRegion) { started.append(region) }
|
|
34
|
+
func stopMonitoring(for region: CLRegion) { stopped.append(region) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func testAddGeofence_populatesGetAndPersistsToStore() {
|
|
38
|
+
let suite = "test.add.\(UUID().uuidString)"
|
|
39
|
+
let defaults = UserDefaults(suiteName: suite)!
|
|
40
|
+
let store = LocationStore(defaults: defaults)
|
|
41
|
+
let mock = MockRegionMonitor()
|
|
42
|
+
let mgr = GeofenceManager(monitor: mock, store: store)
|
|
43
|
+
|
|
44
|
+
var changes: [[String: Any]] = []
|
|
45
|
+
mgr.onGeofencesChange = { p in
|
|
46
|
+
if let on = p["on"] as? [[String: Any]] { changes = on }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let dict: [String: Any] = [
|
|
50
|
+
"identifier": "f1",
|
|
51
|
+
"latitude": 37.7749,
|
|
52
|
+
"longitude": -122.4194,
|
|
53
|
+
"radius": 150.0,
|
|
54
|
+
"notifyOnEntry": true,
|
|
55
|
+
"notifyOnExit": false
|
|
56
|
+
]
|
|
57
|
+
mgr.addGeofence(from: dict)
|
|
58
|
+
|
|
59
|
+
// Memory population (getGeofences) and persist/notify always happen (deterministic).
|
|
60
|
+
// startMonitoring on monitor is conditional on canMonitorRegions() (true in iOS test targets; may be false on macOS host XCTest).
|
|
61
|
+
// This uses public seam; no env-branching in assertions.
|
|
62
|
+
XCTAssertEqual(mgr.getGeofences().count, 1)
|
|
63
|
+
XCTAssertEqual(mgr.getGeofences()[0]["identifier"] as? String, "f1")
|
|
64
|
+
let saved = store.loadGeofences()
|
|
65
|
+
XCTAssertEqual(saved.count, 1)
|
|
66
|
+
XCTAssertEqual(saved[0]["identifier"] as? String, "f1")
|
|
67
|
+
XCTAssertEqual(saved[0]["radius"] as? Double, 150.0)
|
|
68
|
+
XCTAssertEqual(changes.count, 1)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
func testRemoveGeofence_stopsMonitorAndClearsStore() {
|
|
72
|
+
let suite = "test.remove.\(UUID().uuidString)"
|
|
73
|
+
let defaults = UserDefaults(suiteName: suite)!
|
|
74
|
+
let store = LocationStore(defaults: defaults)
|
|
75
|
+
let mock = MockRegionMonitor()
|
|
76
|
+
let mgr = GeofenceManager(monitor: mock, store: store)
|
|
77
|
+
|
|
78
|
+
let dict: [String: Any] = ["identifier": "r1", "latitude": 1.0, "longitude": 2.0, "radius": 10.0]
|
|
79
|
+
mgr.addGeofence(from: dict)
|
|
80
|
+
mgr.removeGeofence(identifier: "r1")
|
|
81
|
+
|
|
82
|
+
XCTAssertEqual(mock.stopped.count, 1)
|
|
83
|
+
XCTAssertEqual(mock.stopped.first?.identifier, "r1")
|
|
84
|
+
XCTAssertEqual(store.loadGeofences().count, 0)
|
|
85
|
+
XCTAssertEqual(mgr.getGeofences().count, 0)
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
func testGetGeofences_returnsCoreFieldsAndExtras() {
|
|
89
|
+
let mock = MockRegionMonitor()
|
|
90
|
+
let mgr = GeofenceManager(monitor: mock, store: nil)
|
|
91
|
+
let dict: [String: Any] = [
|
|
92
|
+
"identifier": "g1",
|
|
93
|
+
"latitude": 10.0,
|
|
94
|
+
"longitude": 20.0,
|
|
95
|
+
"radius": 30.0,
|
|
96
|
+
"extras": ["foo": "bar"]
|
|
97
|
+
]
|
|
98
|
+
mgr.addGeofence(from: dict)
|
|
99
|
+
let list = mgr.getGeofences()
|
|
100
|
+
XCTAssertEqual(list.count, 1)
|
|
101
|
+
XCTAssertEqual(list[0]["identifier"] as? String, "g1")
|
|
102
|
+
XCTAssertEqual(list[0]["radius"] as? Double, 30.0)
|
|
103
|
+
XCTAssertEqual((list[0]["extras"] as? [String: Any])?["foo"] as? String, "bar")
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
func testHandleEnterExit_invokesOnGeofenceWithActionAndLocationProvider() {
|
|
107
|
+
let mock = MockRegionMonitor()
|
|
108
|
+
let mgr = GeofenceManager(monitor: mock, store: nil)
|
|
109
|
+
|
|
110
|
+
var entered: [String: Any]?
|
|
111
|
+
var exited: [String: Any]?
|
|
112
|
+
mgr.onGeofence = { evt in
|
|
113
|
+
if (evt["action"] as? String) == "ENTER" { entered = evt }
|
|
114
|
+
if (evt["action"] as? String) == "EXIT" { exited = evt }
|
|
115
|
+
}
|
|
116
|
+
mgr.lastLocationProvider = { ["latitude": 99.0, "longitude": 88.0] }
|
|
117
|
+
|
|
118
|
+
let region = CLCircularRegion(
|
|
119
|
+
center: CLLocationCoordinate2D(latitude: 0, longitude: 0),
|
|
120
|
+
radius: 5,
|
|
121
|
+
identifier: "h1"
|
|
122
|
+
)
|
|
123
|
+
mgr.handleDidEnterRegion(region)
|
|
124
|
+
mgr.handleDidExitRegion(region)
|
|
125
|
+
|
|
126
|
+
XCTAssertEqual(entered?["identifier"] as? String, "h1")
|
|
127
|
+
XCTAssertEqual(entered?["action"] as? String, "ENTER")
|
|
128
|
+
XCTAssertEqual((entered?["location"] as? [String: Any])?["latitude"] as? Double, 99.0)
|
|
129
|
+
|
|
130
|
+
XCTAssertEqual(exited?["identifier"] as? String, "h1")
|
|
131
|
+
XCTAssertEqual(exited?["action"] as? String, "EXIT")
|
|
132
|
+
XCTAssertNotNil(exited?["location"])
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
func testLocationStore_generatesIdOnInsert_removesById() {
|
|
136
|
+
let suite = "test.storeloc.\(UUID().uuidString)"
|
|
137
|
+
let defaults = UserDefaults(suiteName: suite)!
|
|
138
|
+
let store = LocationStore(defaults: defaults)
|
|
139
|
+
|
|
140
|
+
store.insert(["latitude": 12.3, "longitude": 45.6, "timestamp": 123456.0])
|
|
141
|
+
let all = store.getAll()
|
|
142
|
+
XCTAssertEqual(all.count, 1)
|
|
143
|
+
let id = all[0]["id"] as? String
|
|
144
|
+
XCTAssertNotNil(id)
|
|
145
|
+
XCTAssertFalse(id!.isEmpty)
|
|
146
|
+
|
|
147
|
+
store.remove(byId: id)
|
|
148
|
+
XCTAssertEqual(store.getAll().count, 0)
|
|
149
|
+
XCTAssertEqual(store.count(), 0)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
func testLocationStore_geofenceSaveLoadRoundtrip_clear() {
|
|
153
|
+
let suite = "test.storegeo.\(UUID().uuidString)"
|
|
154
|
+
let defaults = UserDefaults(suiteName: suite)!
|
|
155
|
+
let store = LocationStore(defaults: defaults)
|
|
156
|
+
|
|
157
|
+
let gfs: [[String: Any]] = [
|
|
158
|
+
[
|
|
159
|
+
"identifier": "rt1",
|
|
160
|
+
"latitude": 1.1,
|
|
161
|
+
"longitude": 2.2,
|
|
162
|
+
"radius": 50.5,
|
|
163
|
+
"notifyOnEntry": true,
|
|
164
|
+
"notifyOnExit": false,
|
|
165
|
+
"extras": ["key": "val"]
|
|
166
|
+
]
|
|
167
|
+
]
|
|
168
|
+
store.saveGeofences(gfs)
|
|
169
|
+
let loaded = store.loadGeofences()
|
|
170
|
+
XCTAssertEqual(loaded.count, 1)
|
|
171
|
+
XCTAssertEqual(loaded[0]["identifier"] as? String, "rt1")
|
|
172
|
+
XCTAssertEqual(loaded[0]["radius"] as? Double, 50.5)
|
|
173
|
+
XCTAssertEqual(loaded[0]["notifyOnEntry"] as? Bool, true)
|
|
174
|
+
XCTAssertEqual(loaded[0]["notifyOnExit"] as? Bool, false)
|
|
175
|
+
XCTAssertEqual((loaded[0]["extras"] as? [String: Any])?["key"] as? String, "val")
|
|
176
|
+
|
|
177
|
+
store.clearGeofences()
|
|
178
|
+
XCTAssertEqual(store.loadGeofences().count, 0)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func testScheduleManager_startWithConfigInterval_emitsStartedAndHeartbeat() {
|
|
182
|
+
let mgr = ScheduleManager()
|
|
183
|
+
var states: [String] = []
|
|
184
|
+
let exp = expectation(description: "heartbeat")
|
|
185
|
+
mgr.onSchedule = { payload in
|
|
186
|
+
if let s = payload["state"] as? String {
|
|
187
|
+
states.append(s)
|
|
188
|
+
if s == "heartbeat" { exp.fulfill() }
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
mgr.start(config: ["interval": 0.05])
|
|
193
|
+
XCTAssertEqual(states.first, "started")
|
|
194
|
+
|
|
195
|
+
wait(for: [exp], timeout: 1.0)
|
|
196
|
+
XCTAssertTrue(states.contains("heartbeat"))
|
|
197
|
+
|
|
198
|
+
mgr.stop()
|
|
199
|
+
// last may be heartbeat or stopped; just ensure no crash and stopped was sent at least once before
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func testRestorePersistedGeofences_populatesFromStore_noSideEffectsOrCallbacks() {
|
|
203
|
+
let suite = "test.restore.\(UUID().uuidString)"
|
|
204
|
+
let defaults = UserDefaults(suiteName: suite)!
|
|
205
|
+
let store = LocationStore(defaults: defaults)
|
|
206
|
+
let gfs: [[String: Any]] = [[
|
|
207
|
+
"identifier": "rest1",
|
|
208
|
+
"latitude": 10.0,
|
|
209
|
+
"longitude": 20.0,
|
|
210
|
+
"radius": 30.0,
|
|
211
|
+
"notifyOnEntry": false,
|
|
212
|
+
"notifyOnExit": true,
|
|
213
|
+
"extras": ["x": 42]
|
|
214
|
+
]]
|
|
215
|
+
store.saveGeofences(gfs)
|
|
216
|
+
|
|
217
|
+
let mock = MockRegionMonitor()
|
|
218
|
+
let mgr = GeofenceManager(monitor: mock, store: store)
|
|
219
|
+
var changeCalled = false
|
|
220
|
+
mgr.onGeofencesChange = { _ in changeCalled = true }
|
|
221
|
+
// no onGeofence assignment needed; restore path does not emit it (see buildEvent)
|
|
222
|
+
|
|
223
|
+
XCTAssertEqual(mgr.getGeofences().count, 0)
|
|
224
|
+
mgr.restorePersistedGeofences()
|
|
225
|
+
let restored = mgr.getGeofences()
|
|
226
|
+
XCTAssertEqual(restored.count, 1)
|
|
227
|
+
XCTAssertEqual(restored[0]["identifier"] as? String, "rest1")
|
|
228
|
+
XCTAssertEqual(restored[0]["notifyOnEntry"] as? Bool, false)
|
|
229
|
+
XCTAssertEqual(restored[0]["notifyOnExit"] as? Bool, true)
|
|
230
|
+
XCTAssertEqual((restored[0]["extras"] as? [String: Any])?["x"] as? Int, 42)
|
|
231
|
+
|
|
232
|
+
// restore-only: no monitor calls, no callbacks (per "populates memory only"; "do not notify or start here")
|
|
233
|
+
XCTAssertEqual(mock.started.count, 0)
|
|
234
|
+
XCTAssertEqual(mock.stopped.count, 0)
|
|
235
|
+
XCTAssertFalse(changeCalled)
|
|
236
|
+
// Note: iOS provides public pure restorePersistedGeofences() (unlike Android startMonitoring entrypoint)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Bulk operations tests
|
|
240
|
+
func testAddGeofences_bulk_populatesPersistsAndMonitorAndEvents() {
|
|
241
|
+
let suite = "test.bulkadd.\(UUID().uuidString)"
|
|
242
|
+
let defaults = UserDefaults(suiteName: suite)!
|
|
243
|
+
let store = LocationStore(defaults: defaults)
|
|
244
|
+
let mock = MockRegionMonitor()
|
|
245
|
+
let mgr = GeofenceManager(monitor: mock, store: store)
|
|
246
|
+
|
|
247
|
+
var changes: [[String: Any]] = []
|
|
248
|
+
mgr.onGeofencesChange = { p in
|
|
249
|
+
if let on = p["on"] as? [[String: Any]] { changes = on }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
let gfs: [[String: Any]] = [
|
|
253
|
+
["identifier": "b1", "latitude": 1.0, "longitude": 2.0, "radius": 10.0],
|
|
254
|
+
["identifier": "b2", "latitude": 3.0, "longitude": 4.0, "radius": 20.0, "extras": ["k": "v"]]
|
|
255
|
+
]
|
|
256
|
+
mgr.addGeofences(gfs)
|
|
257
|
+
|
|
258
|
+
XCTAssertEqual(mgr.getGeofences().count, 2)
|
|
259
|
+
XCTAssertEqual(store.loadGeofences().count, 2)
|
|
260
|
+
// monitor starts recorded (may be 0/2 depending on canMonitor in host; use public get for count)
|
|
261
|
+
XCTAssertEqual(changes.count, 2) // last change after second add
|
|
262
|
+
XCTAssertEqual(mgr.getGeofences()[1]["identifier"] as? String, "b2")
|
|
263
|
+
XCTAssertEqual((mgr.getGeofences()[1]["extras"] as? [String: Any])?["k"] as? String, "v")
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
func testRemoveGeofences_list_and_all_stopsMonitorsClearsStoreAndEvents() {
|
|
267
|
+
let suite = "test.bulkrem.\(UUID().uuidString)"
|
|
268
|
+
let defaults = UserDefaults(suiteName: suite)!
|
|
269
|
+
let store = LocationStore(defaults: defaults)
|
|
270
|
+
let mock = MockRegionMonitor()
|
|
271
|
+
let mgr = GeofenceManager(monitor: mock, store: store)
|
|
272
|
+
|
|
273
|
+
var changes: [[String: Any]] = []
|
|
274
|
+
mgr.onGeofencesChange = { p in
|
|
275
|
+
if let on = p["on"] as? [[String: Any]] { changes = on }
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
mgr.addGeofences([
|
|
279
|
+
["identifier": "ra", "latitude": 10.0, "longitude": 20.0, "radius": 5.0],
|
|
280
|
+
["identifier": "rb", "latitude": 30.0, "longitude": 40.0, "radius": 5.0]
|
|
281
|
+
])
|
|
282
|
+
XCTAssertEqual(mgr.getGeofences().count, 2)
|
|
283
|
+
|
|
284
|
+
// remove list
|
|
285
|
+
mgr.removeGeofences(identifiers: ["ra"])
|
|
286
|
+
XCTAssertEqual(mgr.getGeofences().count, 1)
|
|
287
|
+
XCTAssertEqual(store.loadGeofences().count, 1)
|
|
288
|
+
XCTAssertEqual(mock.stopped.count, 1)
|
|
289
|
+
XCTAssertEqual(changes.count, 1) // onGeofencesChange fired with remaining
|
|
290
|
+
|
|
291
|
+
// remove all (nil)
|
|
292
|
+
mgr.removeGeofences(identifiers: nil)
|
|
293
|
+
XCTAssertEqual(mgr.getGeofences().count, 0)
|
|
294
|
+
XCTAssertEqual(store.loadGeofences().count, 0)
|
|
295
|
+
XCTAssertEqual(changes.count, 0)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Bad inputs tests (graceful handling: no crash, no invalid persist/monitor)
|
|
299
|
+
func testAddGeofence_badDicts_gracefulNoPersistNoMonitor() {
|
|
300
|
+
let suite = "test.badadd.\(UUID().uuidString)"
|
|
301
|
+
let defaults = UserDefaults(suiteName: suite)!
|
|
302
|
+
let store = LocationStore(defaults: defaults)
|
|
303
|
+
let mock = MockRegionMonitor()
|
|
304
|
+
let mgr = GeofenceManager(monitor: mock, store: store)
|
|
305
|
+
|
|
306
|
+
// missing id, missing lat/lon/rad, wrong types, nulls via Any.
|
|
307
|
+
// (empty id string "" is accepted as valid degenerate identifier by seam; not treated as reject here)
|
|
308
|
+
let bads: [[String: Any]] = [
|
|
309
|
+
[:],
|
|
310
|
+
["latitude": 1.0, "longitude": 2.0, "radius": 3.0], // no id
|
|
311
|
+
["identifier": "bad1", "latitude": "notnum", "longitude": 2.0, "radius": 3.0],
|
|
312
|
+
["identifier": "bad2", "latitude": 1.0], // missing lon/rad
|
|
313
|
+
["identifier": "bad3", "latitude": nil, "longitude": 2.0, "radius": 3.0]
|
|
314
|
+
]
|
|
315
|
+
for b in bads { mgr.addGeofence(from: b) }
|
|
316
|
+
|
|
317
|
+
XCTAssertEqual(mgr.getGeofences().count, 0)
|
|
318
|
+
XCTAssertEqual(store.loadGeofences().count, 0)
|
|
319
|
+
XCTAssertEqual(mock.started.count, 0)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
func testRemoveGeofence_badIds_noCrashNoOp() {
|
|
323
|
+
let suite = "test.badrem.\(UUID().uuidString)"
|
|
324
|
+
let defaults = UserDefaults(suiteName: suite)!
|
|
325
|
+
let store = LocationStore(defaults: defaults)
|
|
326
|
+
let mock = MockRegionMonitor()
|
|
327
|
+
let mgr = GeofenceManager(monitor: mock, store: store)
|
|
328
|
+
|
|
329
|
+
mgr.addGeofence(from: ["identifier": "good", "latitude": 1.0, "longitude": 2.0, "radius": 3.0])
|
|
330
|
+
|
|
331
|
+
// non-existing ids + remove-all via nil
|
|
332
|
+
mgr.removeGeofence(identifier: "nope")
|
|
333
|
+
XCTAssertEqual(mgr.getGeofences().count, 1)
|
|
334
|
+
|
|
335
|
+
// removeGeofences with bad list (includes nonexist)
|
|
336
|
+
mgr.removeGeofences(identifiers: ["nope2"])
|
|
337
|
+
XCTAssertEqual(mgr.getGeofences().count, 1)
|
|
338
|
+
|
|
339
|
+
// all via nil should work on existing
|
|
340
|
+
mgr.removeGeofences(identifiers: nil)
|
|
341
|
+
XCTAssertEqual(mgr.getGeofences().count, 0)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Tests for SyncManager emissions (onSync callback paths for batch/immediate/background sync feature)
|
|
345
|
+
func testSyncManager_startStopAndSyncEmits_onSyncStates_forBatchAndImmediate() {
|
|
346
|
+
let suite = "test.sync.\(UUID().uuidString)"
|
|
347
|
+
let defaults = UserDefaults(suiteName: suite)!
|
|
348
|
+
let store = LocationStore(defaults: defaults)
|
|
349
|
+
let mgr = SyncManager(store: store)
|
|
350
|
+
|
|
351
|
+
var emitted: [[String: Any]] = []
|
|
352
|
+
mgr.onSync = { payload in
|
|
353
|
+
emitted.append(payload)
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// disabled path (no url or disabled)
|
|
357
|
+
mgr.start(config: ["enabled": false])
|
|
358
|
+
XCTAssertEqual(emitted.last?["state"] as? String, "disabled")
|
|
359
|
+
|
|
360
|
+
// started path
|
|
361
|
+
mgr.start(config: ["enabled": true, "url": "https://example.com/api", "batch": true, "interval": 30])
|
|
362
|
+
XCTAssertEqual(emitted.last?["state"] as? String, "started")
|
|
363
|
+
XCTAssertEqual(emitted.last?["url"] as? String, "https://example.com/api")
|
|
364
|
+
|
|
365
|
+
// noop on syncNow (batch mode, empty store) - covers batch sync trigger path w/o http
|
|
366
|
+
mgr.syncNow()
|
|
367
|
+
XCTAssertEqual(emitted.last?["state"] as? String, "noop")
|
|
368
|
+
XCTAssertEqual(emitted.last?["count"] as? Int, 0)
|
|
369
|
+
|
|
370
|
+
// stop
|
|
371
|
+
mgr.stop()
|
|
372
|
+
XCTAssertEqual(emitted.last?["state"] as? String, "stopped")
|
|
373
|
+
|
|
374
|
+
// immediate (non-batch) mode also covers syncNow path (called from location updates when !batch)
|
|
375
|
+
mgr.start(config: ["enabled": true, "url": "https://ex.com", "batch": false])
|
|
376
|
+
XCTAssertEqual(emitted.last?["state"] as? String, "started")
|
|
377
|
+
mgr.syncNow() // triggers immediate path emission
|
|
378
|
+
XCTAssertEqual(emitted.last?["state"] as? String, "noop")
|
|
379
|
+
|
|
380
|
+
mgr.stop()
|
|
381
|
+
XCTAssertEqual(emitted.last?["state"] as? String, "stopped")
|
|
382
|
+
|
|
383
|
+
// ensure variety of states exercised
|
|
384
|
+
let states = emitted.compactMap { $0["state"] as? String }
|
|
385
|
+
XCTAssertTrue(states.contains("started"))
|
|
386
|
+
XCTAssertTrue(states.contains("stopped"))
|
|
387
|
+
XCTAssertTrue(states.contains("noop"))
|
|
388
|
+
XCTAssertTrue(states.contains("disabled"))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Tests for MotionActivityProvider (seam tests for configure/start/stop + callbacks).
|
|
392
|
+
// Direct mocking of CMMotionActivityManager/CMPedometer internals is not injected (unlike Android fusedClient);
|
|
393
|
+
// use seam tests exercising public API + callbacks (consistent with other providers tested here).
|
|
394
|
+
// On macOS host, availability checks typically prevent actual updates firing (similar notes for geofence monitor);
|
|
395
|
+
// test focuses on call paths, wiring, no-crash, and config.
|
|
396
|
+
func testMotionActivityProvider_configureStartStopCallbacks_noCrash() {
|
|
397
|
+
let provider = MotionActivityProvider()
|
|
398
|
+
|
|
399
|
+
var activityUpdates: [[String: Any]] = []
|
|
400
|
+
var pedometerUpdates: [[String: Any]] = []
|
|
401
|
+
var errors: [String] = []
|
|
402
|
+
|
|
403
|
+
provider.onActivityUpdate = { activityUpdates.append($0) }
|
|
404
|
+
provider.onPedometerUpdate = { pedometerUpdates.append($0) }
|
|
405
|
+
provider.onError = { _, msg in errors.append(msg) }
|
|
406
|
+
|
|
407
|
+
provider.configure(enableActivity: true, enablePedometer: true)
|
|
408
|
+
provider.start()
|
|
409
|
+
provider.stop()
|
|
410
|
+
|
|
411
|
+
// Seams: callbacks assigned, configure/start/stop called without throwing.
|
|
412
|
+
XCTAssertNotNil(provider.onActivityUpdate)
|
|
413
|
+
XCTAssertNotNil(provider.onPedometerUpdate)
|
|
414
|
+
XCTAssertNotNil(provider.onError)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
func testMotionActivityProvider_configureDisableSkips() {
|
|
418
|
+
let provider = MotionActivityProvider()
|
|
419
|
+
var called = false
|
|
420
|
+
provider.onActivityUpdate = { _ in called = true }
|
|
421
|
+
provider.configure(enableActivity: false, enablePedometer: false)
|
|
422
|
+
provider.start()
|
|
423
|
+
provider.stop()
|
|
424
|
+
XCTAssertFalse(called)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// Expanded seam tests per task: more coverage of configure variants, callback capture, direct entrypoints.
|
|
428
|
+
func testMotionActivityProvider_configureActivityOnly_usesDirectEntrypoint_capturesCallbacks() {
|
|
429
|
+
let provider = MotionActivityProvider()
|
|
430
|
+
var activity: [String: Any]?
|
|
431
|
+
var pedo: [String: Any]?
|
|
432
|
+
provider.onActivityUpdate = { activity = $0 }
|
|
433
|
+
provider.onPedometerUpdate = { pedo = $0 }
|
|
434
|
+
provider.onError = { _, _ in }
|
|
435
|
+
|
|
436
|
+
// direct entrypoint call
|
|
437
|
+
provider.configure(enableActivity: true, enablePedometer: false)
|
|
438
|
+
provider.start()
|
|
439
|
+
provider.stop()
|
|
440
|
+
|
|
441
|
+
// callback capture seams wired
|
|
442
|
+
XCTAssertNotNil(provider.onActivityUpdate)
|
|
443
|
+
XCTAssertNotNil(provider.onPedometerUpdate)
|
|
444
|
+
// no actual fire expected on test host but wiring verified
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
func testMotionActivityProvider_configurePedometerOnly_andErrorCallback() {
|
|
448
|
+
let provider = MotionActivityProvider()
|
|
449
|
+
var pedometerUpdates: [[String: Any]] = []
|
|
450
|
+
var errors: [String] = []
|
|
451
|
+
provider.onPedometerUpdate = { pedometerUpdates.append($0) }
|
|
452
|
+
provider.onError = { code, msg in errors.append("\(code):\(msg)") }
|
|
453
|
+
|
|
454
|
+
provider.configure(enableActivity: false, enablePedometer: true)
|
|
455
|
+
provider.start()
|
|
456
|
+
provider.stop()
|
|
457
|
+
|
|
458
|
+
XCTAssertNotNil(provider.onPedometerUpdate)
|
|
459
|
+
XCTAssertNotNil(provider.onError)
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
func testMotionActivityProvider_multipleStartStop_noCrash_direct() {
|
|
463
|
+
let provider = MotionActivityProvider()
|
|
464
|
+
var updates: [[String: Any]] = []
|
|
465
|
+
provider.onActivityUpdate = { updates.append($0) }
|
|
466
|
+
provider.configure(enableActivity: true, enablePedometer: true)
|
|
467
|
+
provider.start()
|
|
468
|
+
provider.stop()
|
|
469
|
+
provider.start()
|
|
470
|
+
provider.stop()
|
|
471
|
+
// callback capture pattern (assign before start)
|
|
472
|
+
XCTAssertNotNil(provider.onActivityUpdate)
|
|
473
|
+
}
|
|
474
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "therms-device-tracker",
|
|
3
|
+
"version": "1.0.0-rc.1",
|
|
4
|
+
"description": "THERMS software expo app device physical activity and geo location tracking",
|
|
5
|
+
"main": "build/index.js",
|
|
6
|
+
"types": "build/index.d.ts",
|
|
7
|
+
"files": [
|
|
8
|
+
"build",
|
|
9
|
+
"ios",
|
|
10
|
+
"android/src/main",
|
|
11
|
+
"android/build.gradle",
|
|
12
|
+
"app.plugin.js",
|
|
13
|
+
"expo-module.config.json",
|
|
14
|
+
"README.md",
|
|
15
|
+
"CHANGELOG.md",
|
|
16
|
+
"ARCHITECTURE.md",
|
|
17
|
+
"LICENSE"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "node internal/module_scripts/build.js",
|
|
21
|
+
"build:expo": "npm run build",
|
|
22
|
+
"clean": "node internal/module_scripts/clean.js",
|
|
23
|
+
"lint": "eslint src/",
|
|
24
|
+
"test": "node internal/module_scripts/test.js",
|
|
25
|
+
"prepare": "node internal/module_scripts/prepare.js",
|
|
26
|
+
"open:ios": "node internal/module_scripts/open-ios.js",
|
|
27
|
+
"open:android": "node internal/module_scripts/open-android.js",
|
|
28
|
+
"test:native:ios": "echo 'Native iOS provider tests: (1) npm run build (2) (cd example && npx expo prebuild) (3) npm run open:ios (4) include ios/ThermsGeofenceTests.swift in XCTest target if needed (5) Cmd+U'",
|
|
29
|
+
"test:native:android": "echo 'Native Android provider tests: (1) npm run build (2) (cd example && npx expo prebuild) (3) cd example/android && ./gradlew test (GeofenceTest.kt + seams)'"
|
|
30
|
+
},
|
|
31
|
+
"keywords": [
|
|
32
|
+
"react-native",
|
|
33
|
+
"expo",
|
|
34
|
+
"background-geolocation",
|
|
35
|
+
"geolocation",
|
|
36
|
+
"location-tracking",
|
|
37
|
+
"activity-recognition",
|
|
38
|
+
"motion-detection",
|
|
39
|
+
"therms-device-tracker",
|
|
40
|
+
"ThermsDeviceTracker"
|
|
41
|
+
],
|
|
42
|
+
"repository": "https://www.therms.io",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://www.therms.io/issues"
|
|
45
|
+
},
|
|
46
|
+
"author": "Cory Robinson <cory@therms.io>",
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"homepage": "https://www.therms.io#readme",
|
|
49
|
+
"expo": {
|
|
50
|
+
"plugins": [
|
|
51
|
+
"./app.plugin.js"
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@babel/core": "^7.26.0",
|
|
57
|
+
"@types/jest": "^29.2.1",
|
|
58
|
+
"@types/react": "~19.1.1",
|
|
59
|
+
"babel-preset-expo": "~56.0.8",
|
|
60
|
+
"eslint": "~9.39.4",
|
|
61
|
+
"eslint-config-universe": "^15.0.3",
|
|
62
|
+
"expo": "^56.0.11",
|
|
63
|
+
"jest": "^29.7.0",
|
|
64
|
+
"jest-expo": "~56.0.5",
|
|
65
|
+
"prettier": "^3.0.0",
|
|
66
|
+
"react-native": "0.82.1",
|
|
67
|
+
"typescript": "^5.9.2"
|
|
68
|
+
},
|
|
69
|
+
"jest": {
|
|
70
|
+
"preset": "jest-expo",
|
|
71
|
+
"roots": [
|
|
72
|
+
"<rootDir>/src",
|
|
73
|
+
"<rootDir>/tests"
|
|
74
|
+
],
|
|
75
|
+
"moduleNameMapper": {
|
|
76
|
+
"^therms-device-tracker$": "<rootDir>/src"
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
"peerDependencies": {
|
|
80
|
+
"expo": "*",
|
|
81
|
+
"react": "*",
|
|
82
|
+
"react-native": "*"
|
|
83
|
+
},
|
|
84
|
+
"codegenConfig": {
|
|
85
|
+
"name": "ThermsDeviceTracker",
|
|
86
|
+
"type": "modules",
|
|
87
|
+
"jsSrcsDir": "./src/specs",
|
|
88
|
+
"android": {
|
|
89
|
+
"javaPackageName": "expo.modules.thermsdevicetracker"
|
|
90
|
+
},
|
|
91
|
+
"ios": {
|
|
92
|
+
"swiftModuleName": "ThermsDeviceTracker"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|