Skip to content

Commit 564ac5f

Browse files
committed
test(geofence): add unit tests for the waypoint geofence alert engine
Covers the deterministic core of MeshPackets+Geofence (previously untested): - GeofenceCrossingStore: first observation establishes a baseline (returns nil, never alerts — so an app relaunch can't produce spurious alerts); enter/exit/ enter transition reporting; per-key independence; and — using the production key format — that changing the geofence geometry (radius or bounding box) re-establishes a fresh baseline instead of firing a spurious enter/exit from stale state. Plus a concurrency hammer test for the serial-queue thread safety. - WaypointEntity.contains(location:): circle inside/outside, bounding-box containment (interior/exterior/near-edge), circle+box union semantics, the no-geofence nil case, and boundingBoxCoordinates. - WaypointEntity.applyGeofence(from:): proto→entity mapping including the branch that clears a stale bounding box when the incoming proto has none. The full evaluateGeofences path (actor + LocalNotificationManager) is glue over these units and isn't exercised here to keep the suite deterministic. 18 tests, all passing.
1 parent 8e0eaaf commit 564ac5f

2 files changed

Lines changed: 269 additions & 0 deletions

File tree

Meshtastic.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,7 @@
334334
DD0000CHSETST0000000001 /* ChannelSetSaveTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0000CHSETST0000000000 /* ChannelSetSaveTests.swift */; };
335335
DD0000MQTTFWDFLTTST00001 /* MqttForwardFilterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0000MQTTFWDFLTTST00000 /* MqttForwardFilterTests.swift */; };
336336
DD0000DEVLNKTST000000001 /* DeviceLinkTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD0000DEVLNKTST000000000 /* DeviceLinkTests.swift */; };
337+
E5E5E5E501000000000000A1 /* GeofenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5E5E5E501000000000000A0 /* GeofenceTests.swift */; };
337338
DD1827FB0000000000000002 /* RegionCodesFirmwareLocaleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD1827FA0000000000000002 /* RegionCodesFirmwareLocaleTests.swift */; };
338339
DD007BAE2AA4E91200F5FA12 /* MyInfoEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */; };
339340
DD007BB02AA5981000F5FA12 /* NodeInfoEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD007BAF2AA5981000F5FA12 /* NodeInfoEntityExtension.swift */; };
@@ -947,6 +948,7 @@
947948
DD0000CHSETST0000000000 /* ChannelSetSaveTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChannelSetSaveTests.swift; sourceTree = "<group>"; };
948949
DD0000MQTTFWDFLTTST00000 /* MqttForwardFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MqttForwardFilterTests.swift; sourceTree = "<group>"; };
949950
DD0000DEVLNKTST000000000 /* DeviceLinkTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceLinkTests.swift; sourceTree = "<group>"; };
951+
E5E5E5E501000000000000A0 /* GeofenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeofenceTests.swift; sourceTree = "<group>"; };
950952
DD1827FA0000000000000002 /* RegionCodesFirmwareLocaleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegionCodesFirmwareLocaleTests.swift; sourceTree = "<group>"; };
951953
DD007BAD2AA4E91200F5FA12 /* MyInfoEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyInfoEntityExtension.swift; sourceTree = "<group>"; };
952954
DD007BAF2AA5981000F5FA12 /* NodeInfoEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityExtension.swift; sourceTree = "<group>"; };
@@ -1693,6 +1695,7 @@
16931695
DD7E5740000000000000008A /* LockdownCoordinatorTests.swift */,
16941696
DD7E5740000000000000008B /* LockdownPassphraseStoreTests.swift */,
16951697
DD0000DEVLNKTST000000000 /* DeviceLinkTests.swift */,
1698+
E5E5E5E501000000000000A0 /* GeofenceTests.swift */,
16961699
BEEF00102F00000100000003 /* MeshtasticChannelURLTests.swift */,
16971700
DD0000CHSETST0000000000 /* ChannelSetSaveTests.swift */,
16981701
DD0000MQTTFWDFLTTST00000 /* MqttForwardFilterTests.swift */,
@@ -2778,6 +2781,7 @@
27782781
DD7E5750000000000000008A /* LockdownCoordinatorTests.swift in Sources */,
27792782
DD7E5750000000000000008B /* LockdownPassphraseStoreTests.swift in Sources */,
27802783
DD0000DEVLNKTST000000001 /* DeviceLinkTests.swift in Sources */,
2784+
E5E5E5E501000000000000A1 /* GeofenceTests.swift in Sources */,
27812785
DD0000CHSETST0000000001 /* ChannelSetSaveTests.swift in Sources */,
27822786
DD0000MQTTFWDFLTTST00001 /* MqttForwardFilterTests.swift in Sources */,
27832787
DD1827FB0000000000000002 /* RegionCodesFirmwareLocaleTests.swift in Sources */,
Lines changed: 265 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,265 @@
1+
//
2+
// GeofenceTests.swift
3+
// MeshtasticTests
4+
//
5+
// Tests for the waypoint-geofence alert engine (MeshPackets+Geofence):
6+
// the crossing-state store (baseline / transition / geometry-key rotation /
7+
// thread-safety), the WaypointEntity.contains(location:) geometry, and
8+
// applyGeofence(from:) proto→entity mapping.
9+
//
10+
11+
import Testing
12+
import Foundation
13+
import CoreLocation
14+
import MeshtasticProtobufs
15+
@testable import Meshtastic
16+
17+
// MARK: - GeofenceCrossingStore
18+
19+
@Suite("Geofence crossing store")
20+
struct GeofenceCrossingStoreTests {
21+
22+
@Test("First observation of a pair establishes a baseline (returns nil, never notifies)")
23+
func firstObservationIsBaseline() {
24+
let store = GeofenceCrossingStore()
25+
#expect(store.update(key: "wp-1", isInside: true) == nil)
26+
#expect(store.update(key: "wp-2", isInside: false) == nil)
27+
}
28+
29+
@Test("Second observation returns the previous state")
30+
func secondObservationReturnsPrevious() {
31+
let store = GeofenceCrossingStore()
32+
_ = store.update(key: "k", isInside: false)
33+
#expect(store.update(key: "k", isInside: true) == false)
34+
}
35+
36+
@Test("Repeated same state returns that state — the engine treats it as no transition")
37+
func repeatedStateIsNoTransition() {
38+
let store = GeofenceCrossingStore()
39+
_ = store.update(key: "k", isInside: true)
40+
// previous == current -> evaluateGeofences skips (no enter/exit alert).
41+
#expect(store.update(key: "k", isInside: true) == true)
42+
}
43+
44+
@Test("Different keys track independent state")
45+
func keysAreIndependent() {
46+
let store = GeofenceCrossingStore()
47+
_ = store.update(key: "a", isInside: true)
48+
#expect(store.update(key: "b", isInside: false) == nil) // "b" never seen -> baseline
49+
#expect(store.update(key: "a", isInside: false) == true) // "a" transitions inside->outside
50+
}
51+
52+
@Test("Enter / exit / enter sequence reports the correct transitions")
53+
func enterExitEnterSequence() {
54+
let store = GeofenceCrossingStore()
55+
#expect(store.update(key: "k", isInside: true) == nil) // baseline (no alert)
56+
#expect(store.update(key: "k", isInside: false) == true) // was inside -> exit
57+
#expect(store.update(key: "k", isInside: true) == false) // was outside -> enter
58+
#expect(store.update(key: "k", isInside: true) == true) // unchanged -> no alert
59+
}
60+
61+
/// Mirrors the production key format from `evaluateGeofences`: the geofence geometry
62+
/// (radius + bounding box corners) is part of the key, so changing it re-establishes a
63+
/// baseline instead of firing a spurious enter/exit from stale inside/outside state.
64+
private func geometryKey(
65+
waypointId: Int64, nodeNum: Int64, radius: Int,
66+
north: Int32 = 0, south: Int32 = 0, east: Int32 = 0, west: Int32 = 0
67+
) -> String {
68+
"\(waypointId)-\(nodeNum)-\(radius)-\(north)-\(south)-\(east)-\(west)"
69+
}
70+
71+
@Test("Changing the geofence radius resets the baseline (no spurious alert)")
72+
func radiusChangeResetsBaseline() {
73+
let store = GeofenceCrossingStore()
74+
let inside100 = geometryKey(waypointId: 1, nodeNum: 42, radius: 100)
75+
#expect(store.update(key: inside100, isInside: true) == nil) // baseline inside @100m
76+
77+
// Radius enlarged to 200m -> different key -> brand-new baseline even though the node's
78+
// inside/outside may differ under the new geometry. Must NOT be reported as a transition.
79+
let outside200 = geometryKey(waypointId: 1, nodeNum: 42, radius: 200)
80+
#expect(store.update(key: outside200, isInside: false) == nil)
81+
}
82+
83+
@Test("Changing the bounding box resets the baseline (no spurious alert)")
84+
func boundingBoxChangeResetsBaseline() {
85+
let store = GeofenceCrossingStore()
86+
let box1 = geometryKey(waypointId: 7, nodeNum: 9, radius: 0, north: 100, south: -100, east: 100, west: -100)
87+
#expect(store.update(key: box1, isInside: true) == nil)
88+
89+
let box2 = geometryKey(waypointId: 7, nodeNum: 9, radius: 0, north: 200, south: -200, east: 200, west: -200)
90+
#expect(store.update(key: box2, isInside: false) == nil) // new box -> fresh baseline
91+
}
92+
93+
@Test("Concurrent updates are serialized without crashing or losing state")
94+
func concurrentUpdatesAreThreadSafe() {
95+
let store = GeofenceCrossingStore()
96+
// Hammer the store from many threads at once; the internal serial queue must keep this
97+
// crash-free and leave every touched key with a recorded state.
98+
DispatchQueue.concurrentPerform(iterations: 1_000) { i in
99+
_ = store.update(key: "shared", isInside: i % 2 == 0)
100+
_ = store.update(key: "k-\(i)", isInside: true)
101+
}
102+
// A previously-seen key now has a baseline, so a follow-up returns non-nil.
103+
#expect(store.update(key: "shared", isInside: true) != nil)
104+
#expect(store.update(key: "k-0", isInside: true) != nil)
105+
#expect(store.update(key: "k-999", isInside: true) != nil)
106+
}
107+
}
108+
109+
// MARK: - WaypointEntity.contains(location:) geometry
110+
111+
@Suite("Waypoint geofence geometry")
112+
struct WaypointGeofenceGeometryTests {
113+
114+
/// Center used by the circular-geofence cases: 37.0, -122.0.
115+
private static let centerLatI: Int32 = 370000000
116+
private static let centerLonI: Int32 = -1220000000
117+
118+
private func waypointWithCircle(radius: Int) -> WaypointEntity {
119+
let wp = WaypointEntity()
120+
wp.latitudeI = Self.centerLatI
121+
wp.longitudeI = Self.centerLonI
122+
wp.geofenceRadius = radius
123+
return wp
124+
}
125+
126+
private func waypointWithBox() -> WaypointEntity {
127+
let wp = WaypointEntity()
128+
wp.hasBoundingBox = true
129+
wp.boundingBoxLatitudeNorthI = 370010000 // 37.001
130+
wp.boundingBoxLatitudeSouthI = 369990000 // 36.999
131+
wp.boundingBoxLongitudeEastI = -1219990000 // -121.999
132+
wp.boundingBoxLongitudeWestI = -1220010000 // -122.001
133+
return wp
134+
}
135+
136+
@Test("No geofence -> contains returns nil")
137+
func noGeofenceReturnsNil() {
138+
let wp = WaypointEntity()
139+
wp.latitudeI = Self.centerLatI
140+
wp.longitudeI = Self.centerLonI
141+
#expect(wp.hasGeofence == false)
142+
#expect(wp.contains(location: CLLocation(latitude: 37.0, longitude: -122.0)) == nil)
143+
}
144+
145+
@Test("Circle contains a point inside the radius")
146+
func circleContainsInsidePoint() {
147+
let wp = waypointWithCircle(radius: 100)
148+
// ~50 m north of center (0.000449° ≈ 50 m)
149+
let point = CLLocation(latitude: 37.000449, longitude: -122.0)
150+
#expect(wp.contains(location: point) == true)
151+
}
152+
153+
@Test("Circle excludes a point beyond the radius")
154+
func circleExcludesOutsidePoint() {
155+
let wp = waypointWithCircle(radius: 100)
156+
// ~200 m north of center — well outside the 100 m radius
157+
let point = CLLocation(latitude: 37.0018, longitude: -122.0)
158+
#expect(wp.contains(location: point) == false)
159+
}
160+
161+
@Test("Bounding box includes interior points and excludes exterior ones")
162+
func boundingBoxContainment() {
163+
let wp = waypointWithBox()
164+
#expect(wp.contains(location: CLLocation(latitude: 37.0, longitude: -122.0)) == true) // center
165+
#expect(wp.contains(location: CLLocation(latitude: 37.0009, longitude: -122.0)) == true) // just inside north
166+
#expect(wp.contains(location: CLLocation(latitude: 37.0011, longitude: -122.0)) == false) // just north of box
167+
#expect(wp.contains(location: CLLocation(latitude: 37.0, longitude: -121.9989)) == false) // just east of box
168+
}
169+
170+
@Test("Circle and box combine as a union — inside either counts")
171+
func circleAndBoxAreUnion() {
172+
let wp = waypointWithBox()
173+
wp.latitudeI = Self.centerLatI
174+
wp.longitudeI = Self.centerLonI
175+
wp.geofenceRadius = 10 // tiny circle; the box is the larger region
176+
// ~90 m north: outside the 10 m circle but inside the box -> union == inside
177+
let point = CLLocation(latitude: 37.0008, longitude: -122.0)
178+
#expect(wp.contains(location: point) == true)
179+
}
180+
181+
@Test("Point outside both the circle and the box -> false")
182+
func outsideBothReturnsFalse() {
183+
let wp = waypointWithBox()
184+
wp.latitudeI = Self.centerLatI
185+
wp.longitudeI = Self.centerLonI
186+
wp.geofenceRadius = 50
187+
let point = CLLocation(latitude: 37.5, longitude: -122.0) // far away
188+
#expect(wp.contains(location: point) == false)
189+
}
190+
191+
@Test("boundingBoxCoordinates: nil without a box, four corners with one")
192+
func boundingBoxCoordinates() {
193+
let plain = WaypointEntity()
194+
#expect(plain.boundingBoxCoordinates == nil)
195+
196+
let wp = waypointWithBox()
197+
let corners = wp.boundingBoxCoordinates
198+
#expect(corners?.count == 4)
199+
}
200+
}
201+
202+
// MARK: - WaypointEntity.applyGeofence(from:)
203+
204+
@Suite("Waypoint applyGeofence(from:)")
205+
struct WaypointApplyGeofenceTests {
206+
207+
@Test("Copies circular geofence + notification flags from the proto")
208+
func copiesCircleAndFlags() {
209+
let wp = WaypointEntity()
210+
var proto = Waypoint()
211+
proto.geofenceRadius = 250
212+
proto.notifyOnEnter = true
213+
proto.notifyOnExit = false
214+
proto.notifyFavoritesOnly = true
215+
216+
wp.applyGeofence(from: proto)
217+
218+
#expect(wp.geofenceRadius == 250)
219+
#expect(wp.notifyOnEnter == true)
220+
#expect(wp.notifyOnExit == false)
221+
#expect(wp.notifyFavoritesOnly == true)
222+
#expect(wp.hasBoundingBox == false)
223+
}
224+
225+
@Test("Copies the bounding box when the proto has one")
226+
func copiesBoundingBox() {
227+
let wp = WaypointEntity()
228+
var proto = Waypoint()
229+
// Setting a field on the computed `boundingBox` materializes it (hasBoundingBox == true).
230+
proto.boundingBox.latitudeNorthI = 370010000
231+
proto.boundingBox.latitudeSouthI = 369990000
232+
proto.boundingBox.longitudeEastI = -1219990000
233+
proto.boundingBox.longitudeWestI = -1220010000
234+
#expect(proto.hasBoundingBox == true)
235+
236+
wp.applyGeofence(from: proto)
237+
238+
#expect(wp.hasBoundingBox == true)
239+
#expect(wp.boundingBoxLatitudeNorthI == 370010000)
240+
#expect(wp.boundingBoxLatitudeSouthI == 369990000)
241+
#expect(wp.boundingBoxLongitudeEastI == -1219990000)
242+
#expect(wp.boundingBoxLongitudeWestI == -1220010000)
243+
}
244+
245+
@Test("Clears a stale bounding box when the proto has none")
246+
func clearsStaleBoundingBox() {
247+
let wp = WaypointEntity()
248+
// Pre-existing stale bounding box on the entity.
249+
wp.hasBoundingBox = true
250+
wp.boundingBoxLatitudeNorthI = 12345
251+
wp.boundingBoxLatitudeSouthI = 6789
252+
wp.boundingBoxLongitudeEastI = 111
253+
wp.boundingBoxLongitudeWestI = 222
254+
255+
var proto = Waypoint()
256+
proto.geofenceRadius = 100 // circle only, no bounding box
257+
wp.applyGeofence(from: proto)
258+
259+
#expect(wp.hasBoundingBox == false)
260+
#expect(wp.boundingBoxLatitudeNorthI == 0)
261+
#expect(wp.boundingBoxLatitudeSouthI == 0)
262+
#expect(wp.boundingBoxLongitudeEastI == 0)
263+
#expect(wp.boundingBoxLongitudeWestI == 0)
264+
}
265+
}

0 commit comments

Comments
 (0)