Stop Approx. distances beside routed values; add Best Value alert mode
Distances (the "Approx. 1.5 mi" report): - Refuse junk CoreLocation fixes (invalid accuracy, or a coarse >300 m fix within 3 km of the position already held; a coarse fix in another city is real travel and is still accepted). - Resolve ONE display basis per render through FuelStore.distanceDisplayOrigin, used by rows, radius filter, sorting, savings figures and the Live Activity, so a stray fix can no longer re-base the list outside the route cache's 600 m ring while the routed values sit unused in the cache. - Treat an incomplete route set as a state: retry it after 30 s rather than the full 10-minute throttle, and repair a stale incomplete flag once every in-radius station has a route (nothing left to route, flag stuck down). - Widget extension resolves the same origin for fetch, radius, ordering and labels, and keeps its own fix in fuelboard.widgetLocation instead of overwriting the app's GPS fix. Alerts: - Best Value as a distinct alert mode on the existing geofence/notification pipeline, with a persisted threshold; reverts to cheapest when the feature is unconfigured and is not selectable while disabled. Diagnostics (DEBUG, gated): FB_DIST_FOCUS now prints FB-DIST-ORIGIN (candidate, accuracy, anchor, offset, resolved branch, served origin), FB_FAKE_FIX feeds a synthetic fix through the real guard, FB_STALE_INCOMPLETE forces the stale-flag state, and the route-fill status is surfaced in Settings behind debug mode.
This commit is contained in:
@@ -809,6 +809,129 @@ final class RoadDistanceCacheTests: XCTestCase {
|
||||
"a cache built from another origin must not confirm this one")
|
||||
}
|
||||
|
||||
// MARK: - Display origin across processes (widget extension fix vs app fix)
|
||||
|
||||
/// The reported bug: the iPhone SE widget kept reading "Approx. 1.5 mi" for
|
||||
/// the nearest stations while the app showed the SAME figure as a routed road
|
||||
/// distance. The widget extension gets its own fix from its own
|
||||
/// CLLocationManager and it is usually coarse — hundreds of metres from the
|
||||
/// app's GPS fix that the routes were anchored to. That must not be read as
|
||||
/// "the user moved away from the cache" and collapse every row.
|
||||
func testCoarseWidgetFixDoesNotDowngradeRoutedDistances() {
|
||||
FuelStore.setAccurateDistancesEnabled(true)
|
||||
let appLat = 51.6, appLng = -0.1
|
||||
FuelStore.saveLocation(lat: appLat, lng: appLng)
|
||||
FuelStore.saveRoadDistances(sourceLat: appLat, sourceLng: appLng,
|
||||
entries: ["a": .init(meters: 2344, lat: 51.5074, lng: -0.1278,
|
||||
returnMeters: 2400)],
|
||||
complete: true)
|
||||
|
||||
// The extension's own fix: ~700 m from the app's anchor, reported coarse.
|
||||
let widgetLat = 51.6063
|
||||
let offsetKM = station("anchor", appLat, appLng).distanceKM(to: widgetLat, lng2: appLng)
|
||||
XCTAssertGreaterThan(offsetKM, FuelStore.roadDistanceOriginToleranceMeters / 1000,
|
||||
"fixture: the widget fix must sit outside the origin ring")
|
||||
XCTAssertLessThan(offsetKM, FuelStore.roadDistanceCoarseFixRadiusMeters / 1000,
|
||||
"fixture: and inside the coarse-fix radius")
|
||||
|
||||
let origin = FuelStore.distanceDisplayOrigin(candidateLat: widgetLat, candidateLng: appLng,
|
||||
candidateAccuracyMeters: 500)
|
||||
XCTAssertTrue(origin.routed, "a coarse widget fix must not invalidate the routed set")
|
||||
XCTAssertEqual(origin.source, "appFix")
|
||||
XCTAssertEqual(origin.lat, appLat, accuracy: 0.000001)
|
||||
XCTAssertEqual(origin.lng, appLng, accuracy: 0.000001)
|
||||
|
||||
let shown = FuelStore.stationDistanceDisplay(station: station("a", 51.5074, -0.1278),
|
||||
userLat: origin.lat, userLng: origin.lng)
|
||||
XCTAssertFalse(shown.isApproximate, "the nearest station must not read Approx. on the widget")
|
||||
XCTAssertEqual(shown.km, 2.344, accuracy: 0.0001)
|
||||
XCTAssertEqual(shown.label(unit: .miles), "1.5 mi")
|
||||
}
|
||||
|
||||
/// Sub-kilometre disagreement is fix noise whatever the reported accuracy:
|
||||
/// two honest fixes of the same place can differ that much, and it is far
|
||||
/// too small to justify re-labelling a whole list.
|
||||
func testSubKilometreDisagreementKeepsRoutedDistances() {
|
||||
FuelStore.setAccurateDistancesEnabled(true)
|
||||
FuelStore.saveLocation(lat: 51.6, lng: -0.1)
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
|
||||
complete: true)
|
||||
|
||||
let origin = FuelStore.distanceDisplayOrigin(candidateLat: 51.6063, candidateLng: -0.1,
|
||||
candidateAccuracyMeters: 10)
|
||||
XCTAssertTrue(origin.routed)
|
||||
XCTAssertEqual(origin.source, "appFix")
|
||||
}
|
||||
|
||||
/// A PRECISE fix far from the anchor IS the user having moved. The honest
|
||||
/// answer stays the straight line, marked Approx., until the app re-routes
|
||||
/// from the new position.
|
||||
func testPreciseFixAwayFromTheAnchorStillFallsBackToApproximate() {
|
||||
FuelStore.setAccurateDistancesEnabled(true)
|
||||
FuelStore.saveLocation(lat: 51.6, lng: -0.1)
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
|
||||
complete: true)
|
||||
|
||||
let origin = FuelStore.distanceDisplayOrigin(candidateLat: 51.78, candidateLng: -0.1,
|
||||
candidateAccuracyMeters: 10)
|
||||
XCTAssertFalse(origin.routed)
|
||||
XCTAssertEqual(origin.source, "straightLine")
|
||||
XCTAssertEqual(origin.lat, 51.78, accuracy: 0.000001)
|
||||
|
||||
let shown = FuelStore.stationDistanceDisplay(station: station("a", 51.5074, -0.1278),
|
||||
userLat: origin.lat, userLng: origin.lng)
|
||||
XCTAssertTrue(shown.isApproximate)
|
||||
}
|
||||
|
||||
/// The rescue only applies where the stored app fix actually backs the cache:
|
||||
/// an app fix somewhere else cannot confirm routes for this neighbourhood.
|
||||
func testCoarseFixIsNotRescuedWhenTheStoredAppFixIsElsewhere() {
|
||||
FuelStore.setAccurateDistancesEnabled(true)
|
||||
FuelStore.saveLocation(lat: 53.4808, lng: -2.2426) // app fix: Manchester
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
|
||||
complete: true)
|
||||
|
||||
let origin = FuelStore.distanceDisplayOrigin(candidateLat: 51.6063, candidateLng: -0.1,
|
||||
candidateAccuracyMeters: 500)
|
||||
XCTAssertFalse(origin.routed)
|
||||
XCTAssertEqual(origin.source, "straightLine")
|
||||
}
|
||||
|
||||
/// Neither an incomplete fill nor the mode being off may resolve a routed
|
||||
/// origin from any fix — same all-or-nothing rule as the rows.
|
||||
func testRoutedOriginRequiresACompleteCacheAndTheModeOn() {
|
||||
FuelStore.setAccurateDistancesEnabled(true)
|
||||
FuelStore.saveLocation(lat: 51.6, lng: -0.1)
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
|
||||
complete: false)
|
||||
XCTAssertFalse(FuelStore.distanceDisplayOrigin(candidateLat: 51.6, candidateLng: -0.1,
|
||||
candidateAccuracyMeters: 10).routed,
|
||||
"an incomplete fill must never serve routed distances")
|
||||
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
|
||||
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)],
|
||||
complete: true)
|
||||
FuelStore.setAccurateDistancesEnabled(false)
|
||||
XCTAssertFalse(FuelStore.distanceDisplayOrigin(candidateLat: 51.6, candidateLng: -0.1,
|
||||
candidateAccuracyMeters: 10).routed,
|
||||
"with the mode off the browse layer never serves routes")
|
||||
}
|
||||
|
||||
/// The widget must record its own fix under its own key: overwriting the
|
||||
/// app's GPS fix with a Wi-Fi-grade one left the app seeded with the wrong
|
||||
/// position and broke the anchor comparison above.
|
||||
func testWidgetFixIsStoredSeparatelyFromTheAppFix() {
|
||||
FuelStore.saveLocation(lat: 51.6, lng: -0.1)
|
||||
FuelStore.saveWidgetLocation(lat: 51.6063, lng: -0.1)
|
||||
XCTAssertEqual(FuelStore.loadLocation()?.lat ?? 0, 51.6, accuracy: 0.000001,
|
||||
"the app's GPS fix must survive a widget timeline")
|
||||
XCTAssertEqual(FuelStore.loadWidgetLocation()?.coordinate.lat ?? 0, 51.6063, accuracy: 0.000001)
|
||||
}
|
||||
|
||||
// MARK: - Accurate distances toggle (browse layer only)
|
||||
|
||||
/// The toggle must be opt-in: unset means off, so a fresh install makes no
|
||||
|
||||
@@ -81,6 +81,65 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
||||
let c = FuelSavingsRankedStation(stationID: "c", result: .unavailable("No route"), distanceMiles: 1, pricePencePerLitre: 140)
|
||||
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([a, c, b]).map(\.stationID), ["b", "a", "c"])
|
||||
}
|
||||
|
||||
func testBestValueAlertCandidateRequiresConfiguredSavingsAndThreshold() {
|
||||
let stations = [
|
||||
station("near", lat: 0, lng: 0, price: 160),
|
||||
station("cheap", lat: 0, lng: 0, price: 150),
|
||||
]
|
||||
let journeys = ["near": 2.0, "cheap": 4.0]
|
||||
let off = FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
|
||||
XCTAssertNil(FuelSavingsCalculator.bestValueAlertCandidate(
|
||||
stations: stations,
|
||||
fuel: .e10,
|
||||
settings: off,
|
||||
amount: off.preferredAmount,
|
||||
minimumNetSavingGBP: 1.0,
|
||||
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } }
|
||||
))
|
||||
|
||||
let on = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
|
||||
XCTAssertNil(FuelSavingsCalculator.bestValueAlertCandidate(
|
||||
stations: stations,
|
||||
fuel: .e10,
|
||||
settings: on,
|
||||
amount: on.preferredAmount,
|
||||
minimumNetSavingGBP: 5.0,
|
||||
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } }
|
||||
))
|
||||
}
|
||||
|
||||
func testBestValueAlertCandidatePicksHighestNetSavingAboveThreshold() throws {
|
||||
let stations = [
|
||||
station("baseline", lat: 0, lng: 0, price: 160),
|
||||
station("small", lat: 0, lng: 0, price: 154),
|
||||
station("best", lat: 0, lng: 0, price: 148),
|
||||
]
|
||||
let journeys = ["baseline": 2.0, "small": 4.0, "best": 6.0]
|
||||
let settings = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
|
||||
let candidate = FuelSavingsCalculator.bestValueAlertCandidate(
|
||||
stations: stations,
|
||||
fuel: .e10,
|
||||
settings: settings,
|
||||
amount: settings.preferredAmount,
|
||||
minimumNetSavingGBP: 1.0,
|
||||
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } }
|
||||
)
|
||||
XCTAssertEqual(candidate?.station.id, "best")
|
||||
XCTAssertEqual(candidate?.result.status, .worthTheDrive)
|
||||
XCTAssertGreaterThan(candidate?.result.netSavingGBP ?? 0, 1.0)
|
||||
}
|
||||
|
||||
func testBestValueAlertCandidateBreaksTiesByDistanceThenPrice() throws {
|
||||
let first = FuelSavingsResult(fillLitres: 25, grossSavingGBP: 3, extraDrivingCostGBP: 1, extraDistanceMiles: 2, netSavingGBP: 2, status: .worthTheDrive)
|
||||
let nearer = FuelSavingsRankedStation(stationID: "nearer", result: .available(first), distanceMiles: 4, pricePencePerLitre: 149)
|
||||
let farther = FuelSavingsRankedStation(stationID: "farther", result: .available(first), distanceMiles: 8, pricePencePerLitre: 145)
|
||||
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([farther, nearer]).first?.stationID, "nearer")
|
||||
}
|
||||
|
||||
private func station(_ id: String, lat: Double, lng: Double, price: Double) -> FuelStation {
|
||||
FuelStation(id: id, name: id, brand: id, address: "", postcode: "", lat: lat, lng: lng, prices: [.e10: price], priceUpdated: nil)
|
||||
}
|
||||
}
|
||||
|
||||
final class FuelSavingsSettingsTests: XCTestCase {
|
||||
|
||||
Reference in New Issue
Block a user