Debug: 100 m fence delivery test + test-button trace logging

Settings → Debug → 'Debug fence (delivery test)': registers a 100 m
CLCircularRegion at the current fix that exists solely to prove iOS
delivers region events on this install. Entering it ticks 'Region events
this session', logs 'debug fence entered' in the Alert trace, and fires a
plain notification — zero alert gates. Cleared via button; survives the
station re-registration loop (update() now skips debug-fence regions);
cleared when alerts are disabled.

The Test alert / Plain test buttons now also write to the Alert trace
('test alert scheduled — …'), so the trace reflects every probe, not just
live geofence entries. 83 tests, Release build green.
This commit is contained in:
FuelBoard Contributor
2026-08-15 11:46:00 +01:00
parent 494412ba99
commit 6cbf1dfc8f
3 changed files with 91 additions and 2 deletions
+60 -1
View File
@@ -96,6 +96,11 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
/// How many `didEnterRegion` callbacks this session proof the geofences
/// actually fire (vs. a gate silently blocking every alert).
@Published private(set) var regionEventCount = 0
/// Identifier of the armed debug fence (Settings Debug "Register
/// 100 m fence"), nil when not armed. The fence is a 100 m circle at the
/// current location that exists only to prove iOS delivers region events
/// on this install; entering it fires a plain notification, no gates.
@Published private(set) var debugFenceIdentifier: String?
private let manager = CLLocationManager()
private var stations: [FuelStation] = []
@@ -146,7 +151,10 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
refreshDebugStatus()
for region in manager.monitoredRegions {
// Drop every region EXCEPT the debug fence (if armed) the fence is
// a fixed 100 m circle at a fixed point that must survive re-registration
// churn; stations re-register from scratch every pass.
for region in manager.monitoredRegions where !region.identifier.hasPrefix("debug-fence") {
manager.stopMonitoring(for: region)
}
monitoredStationIDs = []
@@ -200,6 +208,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
manager.stopMonitoring(for: region)
}
monitoredStationIDs = []
debugFenceIdentifier = nil
} else {
requestPermissions()
// Re-register geofences from restored cache immediately.
@@ -215,6 +224,42 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
if alertLog.count > 8 { alertLog.removeLast() }
}
// MARK: - Debug fence
/// Debug-only (Settings Debug "Register 100 m fence"): arms a 100 m
/// circle around the last known fix purely to prove iOS delivers region
/// events on this install. Stepping 100 m away and back should tick
/// "Region events this session" and fire a plain notification no alert
/// gates apply. If that produces nothing, region delivery is broken at the
/// system level and no amount of alert-logic fixing will help.
func registerDebugFenceAroundMe() {
guard let fix = FuelStore.loadLocationWithDate() else {
logAlert(.error, "debug fence — no location fix yet (wait for one, then retry)")
return
}
clearDebugFence()
let id = "debug-fence-\(UUID().uuidString.prefix(8))"
let region = CLCircularRegion(
center: CLLocationCoordinate2D(latitude: fix.coordinate.lat, longitude: fix.coordinate.lng),
radius: 100,
identifier: id
)
region.notifyOnEntry = true
region.notifyOnExit = false
manager.startMonitoring(for: region)
debugFenceIdentifier = id
logAlert(.entry, "debug fence armed — 100 m circle at current location (step away and back in)")
}
func clearDebugFence() {
guard let id = debugFenceIdentifier else { return }
for region in manager.monitoredRegions where region.identifier == id {
manager.stopMonitoring(for: region)
}
debugFenceIdentifier = nil
logAlert(.gate, "debug fence cleared")
}
/// Recomputes the Settings Debug location snapshot from current state.
/// "In range" uses the SAME criteria as live alerts: stations selling the
/// monitored fuel within the alert radius of the last known location.
@@ -288,6 +333,15 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
// "delivery fine, a gate blocked the alert".
self.regionEventCount += 1
self.logAlert(.entry, "didEnterRegion — \(region.identifier)")
// Debug fence (Settings Debug): exists only to prove iOS
// delivers region events on this install. Entering it fires a
// plain notification with no alert gates if the counter ticks
// and a banner shows after stepping out and back, delivery works.
if region.identifier.hasPrefix("debug-fence") {
self.logAlert(.fired, "debug fence entered — iOS delivered the region event (100 m)")
self.sendPlainTestNotification()
return
}
self.handleEntry(region)
}
}
@@ -585,6 +639,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.body = "\(choices.count) stations at \(String(format: "%.1fp", price)) within \(unit.format(radiusKM)). Pick one for directions."
let baseResult = "\(fuel.displayName) · \(choices.count) tied at \(String(format: "%.1fp", price)) · radius \(unit.format(radiusKM))"
lastTestResult = baseResult
logAlert(.fired, "test alert scheduled — \(choices.count) tied at \(String(format: "%.1fp", price))")
addAlertRequest(content: content, station: candidate, ties: choices) { status in
self.lastTestResult = "\(baseResult) · \(status)"
}
@@ -593,6 +648,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.body = "\(candidate.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
let baseResult = "\(fuel.displayName) · \(brand) · \(String(format: "%.1fp", price)) · \(distanceText) away · radius \(unit.format(radiusKM))"
lastTestResult = baseResult
logAlert(.fired, "test alert scheduled — \(brand) · \(String(format: "%.1fp", price))")
addAlertRequest(content: content, station: candidate) { status in
self.lastTestResult = "\(baseResult) · \(status)"
}
@@ -609,6 +665,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
logAlert(.fired, "test alert scheduled — no \(fuel.displayName.lowercased()) candidate, fallback copy sent")
lastTestResult = sellers.isEmpty
? "No stations loaded — open the Stations tab first"
: "No \(fuel.displayName.lowercased()) seller within \(unit.format(radiusKM))"
@@ -642,6 +699,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.title = "\(brand) — plain test notification"
content.body = "\(nearest.name) · \(distanceText) away · \(String(format: "%.1fp", price)). Tap for directions."
content.sound = .default
logAlert(.fired, "plain test notification scheduled — \(brand)")
addAlertRequest(content: content, station: nearest)
} else {
content.title = "FuelBoard test notification"
@@ -649,6 +707,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.sound = .default
let request = UNNotificationRequest(identifier: UUID().uuidString, content: content, trigger: nil)
UNUserNotificationCenter.current().add(request)
logAlert(.fired, "plain test notification scheduled — no station data")
}
}