Files
fuelboard/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift
FuelBoard Contributor 965a506a43 Store: save one Fuel Savings field per edit, and update keychain items in place
The Settings sliders called persistFuelSavingsSettings() on every value change,
which rewrote all four keys — four UserDefaults writes and four keychain round
trips per step, on the main thread, up to 90 times per drag. That is what made
them stutter.

saveFuelSavingsField(_:from:) writes just the field that moved, so a slider
gesture touches one key.

writeKeychain now uses SecItemUpdate and only falls back to SecItemAdd on
errSecItemNotFound. Delete-then-add cost two round trips to securityd and
briefly left the key holding no value at all — a window where the keychain, the
authoritative mirror on read, could disagree with the app-group defaults.

Tests pin the store half: a single-field save leaves the other three keys alone,
and the whole-settings save still writes all four.
2026-10-16 20:33:10 +01:00

1153 lines
62 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
// FuelBoard shared-logic tests. Run from FuelBoardTests/ with `swift test`.
// Covers the pure logic that drives the app + widget: title sanitizer, price
// guard, RAG, sorting baselines, distance, brand normalization, fuel labels.
final class TitleSanitizerTests: XCTestCase {
func testAllCapsToTitleCase() {
XCTAssertEqual("SHELL SALTERHEBBLE".sanitizedStationTitle, "Shell Salterhebble")
XCTAssertEqual("TESCO EXPRESS YORK".sanitizedStationTitle, "Tesco Express York")
}
func testSainsburysApostrophe() {
XCTAssertEqual("SAINSBURYS HALIFAX".sanitizedStationTitle, "Sainsbury's Halifax")
XCTAssertEqual("SAINSBURYS".sanitizedStationTitle, "Sainsbury's")
}
func testAcronymsStayUppercase() {
XCTAssertEqual("BP HESSLE".sanitizedStationTitle, "BP Hessle")
XCTAssertEqual("MFG BRIGHOUSE".sanitizedStationTitle, "MFG Brighouse")
XCTAssertEqual("ASDA WAKEFIELD".sanitizedStationTitle, "ASDA Wakefield")
XCTAssertEqual("MOTO WETHERBY".sanitizedStationTitle, "MOTO Wetherby")
}
func testConnectorsLowercase() {
XCTAssertEqual("GULF OIL OF MANCHESTER".sanitizedStationTitle, "Gulf Oil of Manchester")
}
func testLtd() {
XCTAssertEqual("SMITHS GARAGES LTD".sanitizedStationTitle, "Smiths Garages Ltd")
}
func testHyphenParenChunks() {
XCTAssertEqual("NEWCASTLE-UNDER-LYME SERVICES".sanitizedStationTitle, "Newcastle-Under-Lyme Services")
XCTAssertEqual("SPAR (MEADOWHALL)".sanitizedStationTitle, "SPAR (Meadowhall)")
}
func testMixedCasePassthrough() {
XCTAssertEqual("Shell Salterhebble".sanitizedStationTitle, "Shell Salterhebble")
XCTAssertEqual("Esso M62 EASTBOUND".sanitizedStationTitle, "Esso M62 Eastbound")
}
func testNoLettersPassthrough() {
XCTAssertEqual("M62 J25".sanitizedStationTitle, "M62 J25")
}
func testAmpersandChunkReset() {
XCTAssertEqual("SHELL & BP SERVICES".sanitizedStationTitle, "Shell & BP Services")
}
}
final class PriceGuardTests: XCTestCase {
func testInBandPricesKept() throws {
let json = """
{"stations":[{"id":"s1","name":"SHELL LEEDS","brand":"Shell","address":"A1","postcode":"LS1 1AA","lat":53.8,"lng":-1.5,"prices":{"E10":137.9,"E5":144.9,"DIESEL":142.9}}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertEqual(stations.count, 1)
XCTAssertEqual(stations[0].prices[.e10], 137.9)
XCTAssertEqual(stations[0].prices[.e5], 144.9)
XCTAssertEqual(stations[0].prices[.diesel], 142.9)
}
func testRelayMetaDecoded() throws {
let json = """
{"source":"api","stations_count":8010,"data_updated":"2026-08-12T10:23:00.000Z","stations":[{"id":"s1","name":"SHELL LEEDS","brand":"Shell","address":"A1","postcode":"LS1 1AA","lat":53.8,"lng":-1.5,"prices":{"E10":137.9}}]}
"""
let meta = FuelPriceProvider.decodeRelayMeta(from: Data(json.utf8))
XCTAssertEqual(meta?.source, "api")
XCTAssertEqual(meta?.stationCount, 8010)
XCTAssertEqual(meta?.dataUpdated, "2026-08-12T10:23:00.000Z")
}
func testRelayMetaAbsentIsNil() throws {
// Older relay without envelope metadata must not fail decode — nil meta.
let json = """
{"stations":[{"id":"s1","name":"SHELL LEEDS","brand":"Shell","address":"A1","postcode":"LS1 1AA","lat":53.8,"lng":-1.5,"prices":{"E10":137.9}}]}
"""
let meta = FuelPriceProvider.decodeRelayMeta(from: Data(json.utf8))
XCTAssertNil(meta?.source)
XCTAssertNil(meta?.stationCount)
XCTAssertNil(meta?.dataUpdated)
}
func testOutOfBandPricesDropped() throws {
let json = """
{"stations":[{"id":"s1","name":"GARBAGE","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":1.3,"E5":1589.0,"DIESEL":137.9}}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertEqual(stations.count, 1)
XCTAssertNil(stations[0].prices[.e10], "1.3p garbage must be dropped")
XCTAssertNil(stations[0].prices[.e5], "1589p garbage must be dropped")
XCTAssertEqual(stations[0].prices[.diesel], 137.9, "in-band price kept")
}
func testBoundaryValues() throws {
let json = """
{"stations":[{"id":"s1","name":"BOUNDARY","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":50.0,"E5":500.0,"DIESEL":49.9}}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertEqual(stations[0].prices[.e10], 50.0, "lower bound inclusive")
XCTAssertEqual(stations[0].prices[.e5], 500.0, "upper bound inclusive")
XCTAssertNil(stations[0].prices[.diesel], "49.9 below band dropped")
}
func testB7VariantsMapToDiesel() throws {
// A station sells ONE diesel grade — test each variant individually.
for (grade, price) in [("B7S", 141.9), ("B7P", 143.9), ("B10", 140.9)] {
let json = """
{"stations":[{"id":"s1","name":"DIESEL MAP","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"\(grade)":\(price)}}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertEqual(stations[0].prices[.diesel], price, "\(grade) maps to diesel")
}
}
func testLegacyPriceFallback() throws {
let json = """
{"stations":[{"id":"s1","name":"LEGACY","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"price":136.5}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertEqual(stations[0].prices[.e10], 136.5, "legacy price maps to E10")
}
func testLegacyGarbagePriceDropped() throws {
let json = """
{"stations":[{"id":"s1","name":"LEGACY BAD","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"price":1589.0}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertTrue(stations[0].prices.isEmpty, "garbage legacy price dropped")
}
func testRelayNameSanitizedAtDecode() throws {
let json = """
{"stations":[{"id":"s1","name":"TESCO EXPRESS LEEDS","brand":"Tesco","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":137.9}}]}
"""
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
XCTAssertEqual(stations[0].name, "Tesco Express Leeds")
}
}
final class RAGTests: XCTestCase {
func testGreenWithinOneAndHalf() {
XCTAssertEqual(RAGRating.rating(price: 138.0, cheapest: 137.0), .green)
XCTAssertEqual(RAGRating.rating(price: 138.5, cheapest: 137.0), .green, "exactly 1.5p is green")
}
func testAmberWithinFour() {
XCTAssertEqual(RAGRating.rating(price: 141.0, cheapest: 137.0), .amber)
XCTAssertEqual(RAGRating.rating(price: 141.0, cheapest: 137.0), .amber)
XCTAssertEqual(RAGRating.rating(price: 141.0, cheapest: 137.0), .amber, "exactly 4p is amber")
}
func testRedBeyondFour() {
XCTAssertEqual(RAGRating.rating(price: 141.1, cheapest: 137.0), .red)
}
}
final class SortBaselineTests: XCTestCase {
// Baseline = cheapest within the chosen radius (both Cheapest and Closest).
// TOP badge goes to the cheapest station in the pool.
func testCheapestFirstSorting() {
let a = FuelStation(id: "a", name: "A", brand: "X", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [.e10: 140.0], priceUpdated: nil)
let b = FuelStation(id: "b", name: "B", brand: "X", address: "", postcode: "", lat: 53.71, lng: -1.81, prices: [.e10: 137.0], priceUpdated: nil)
let c = FuelStation(id: "c", name: "C", brand: "X", address: "", postcode: "", lat: 53.72, lng: -1.82, prices: [.e10: 139.0], priceUpdated: nil)
let sorted = [a, b, c].sorted { $0.prices[.e10]! < $1.prices[.e10]! }
XCTAssertEqual(sorted.map(\.id), ["b", "c", "a"])
}
// Ties: stations at the same cheapest price are found (within 0.01p) and
// sorted nearest-first from the reference location.
func testTiedStations() {
func station(_ id: String, _ lat: Double, _ lng: Double, _ price: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
lat: lat, lng: lng, prices: [.e10: price], priceUpdated: nil)
}
let far = station("far", 53.72, -1.82, 137.0)
let near = station("near", 53.71, -1.81, 137.0)
let other = station("other", 53.70, -1.80, 141.0)
let tied = FuelStore.tiedStations(in: [far, other, near], fuel: .e10, price: 137.0,
fromLat: 53.7, lng: -1.8)
XCTAssertEqual(tied.map(\.id), ["near", "far"], "tied stations sorted nearest-first")
}
// A price 0.005p away still counts as a tie; 0.05p away does not.
func testTiedStationsTolerance() {
func station(_ id: String, _ price: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
lat: 53.7, lng: -1.8, prices: [.e10: price], priceUpdated: nil)
}
let a = station("a", 137.005)
let b = station("b", 137.05)
XCTAssertEqual(FuelStore.tiedStations(in: [a, b], fuel: .e10, price: 137.0,
fromLat: 53.7, lng: -1.8).map(\.id), ["a"])
}
}
final class DistanceTests: XCTestCase {
func testZeroDistance() {
let s = FuelStation(id: "a", name: "A", brand: "X", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [:], priceUpdated: nil)
XCTAssertEqual(s.distanceKM(to: 53.7, lng2: -1.8), 0, accuracy: 0.001)
}
func testKnownDistance() {
// London (51.5074, -0.1278) → Manchester (53.4808, -2.2426) ≈ 262 km
let s = FuelStation(id: "a", name: "A", brand: "X", address: "", postcode: "", lat: 51.5074, lng: -0.1278, prices: [:], priceUpdated: nil)
XCTAssertEqual(s.distanceKM(to: 53.4808, lng2: -2.2426), 262, accuracy: 5)
}
func testMilesConversion() {
// 5 miles ≈ 8.05 km using the shared exact conversion
XCTAssertEqual(5 * FuelSavingsCalculator.kilometresPerMile, 8.04672, accuracy: 0.001)
// 262 km ≈ 162.8 miles using the shared exact conversion
XCTAssertEqual(262 / FuelSavingsCalculator.kilometresPerMile, 162.8, accuracy: 0.1)
}
}
final class BrandTests: XCTestCase {
private func station(brand: String) -> FuelStation {
FuelStation(id: UUID().uuidString, name: "N", brand: brand, address: "", postcode: "", lat: 0, lng: 0, prices: [:], priceUpdated: nil)
}
func testKnownBrands() {
XCTAssertEqual(station(brand: "SHELL LEEDS ROAD").brandImageName, "brand_shell")
XCTAssertEqual(station(brand: "SAINSBURYS").brandImageName, "brand_sainsburys")
XCTAssertEqual(station(brand: "MORRISONS").brandImageName, "brand_morrisons")
XCTAssertEqual(station(brand: "TESCO EXPRESS").brandImageName, "brand_tesco")
XCTAssertEqual(station(brand: "BP").brandImageName, "brand_bp")
XCTAssertEqual(station(brand: "ESSO").brandImageName, "brand_esso")
XCTAssertEqual(station(brand: "ASDA").brandImageName, "brand_asda")
XCTAssertEqual(station(brand: "GULF").brandImageName, "brand_gulf")
XCTAssertEqual(station(brand: "JET").brandImageName, "brand_jet")
XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco")
XCTAssertEqual(station(brand: "APPLEGREEN").brandImageName, "brand_applegreen")
XCTAssertEqual(station(brand: "VALERO").brandImageName, "brand_valero")
XCTAssertEqual(station(brand: "WELCOME BREAK").brandImageName, "brand_welcome_break")
XCTAssertEqual(station(brand: "THE CO-OPERATIVE").brandImageName, "brand_the_co_operative")
XCTAssertEqual(station(brand: "CO-OP").brandImageName, "brand_the_co_operative")
XCTAssertEqual(station(brand: "MURCO").brandImageName, "brand_murco")
XCTAssertEqual(station(brand: "GLEANER").brandImageName, "brand_gleaner")
XCTAssertEqual(station(brand: "HIGHLAND FUELS").brandImageName, "brand_highland_fuels")
XCTAssertEqual(station(brand: "CIRCLE K").brandImageName, "brand_circle_k")
XCTAssertEqual(station(brand: "MAXOL").brandImageName, "brand_maxol")
XCTAssertEqual(station(brand: "SPAR").brandImageName, "brand_spar")
XCTAssertEqual(station(brand: "ESSAR").brandImageName, "brand_essar")
XCTAssertEqual(station(brand: "EG ON THE MOVE").brandImageName, "brand_esso")
XCTAssertEqual(station(brand: "GO FORECOURT").brandImageName, "brand_gulf")
}
func testUnknownBrandUsesGenericFuelPumpFallbackInUI() {
XCTAssertNil(station(brand: "TOTAL HARVEST ENERGY").brandImageName)
XCTAssertNil(station(brand: "SOLO").brandImageName)
XCTAssertNil(station(brand: "PACE").brandImageName)
XCTAssertNil(station(brand: "").brandImageName)
}
}
final class FuelTypeLabelTests: XCTestCase {
func testDisplayNames() {
// E10/E5 grades are intentionally not part of user-facing labels.
XCTAssertEqual(FuelType.e10.displayName, "Unleaded")
XCTAssertEqual(FuelType.e5.displayName, "Premium")
XCTAssertEqual(FuelType.diesel.displayName, "Diesel")
}
func testDistanceUnitConversion() {
XCTAssertEqual(DistanceUnit.miles.toKM(1), 1.609344, accuracy: 0.00001)
XCTAssertEqual(DistanceUnit.kilometers.toKM(5), 5)
XCTAssertEqual(DistanceUnit.miles.fromKM(1.609344), 1, accuracy: 0.00001)
XCTAssertEqual(DistanceUnit.kilometers.fromKM(2.5), 2.5)
}
func testDistanceUnitFormat() {
XCTAssertEqual(DistanceUnit.miles.format(1.609344), "1.0 mi")
XCTAssertEqual(DistanceUnit.kilometers.format(3.4), "3.4 km")
}
}
final class FavouriteRefreshTests: XCTestCase {
func testRefreshedFavouritesApplyFreshPrices() {
let fav = FavouriteEntry(station: FuelStation(id: "s1", name: "OLD NAME", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil), fuel: .e10)
let fresh = FuelStation(id: "s1", name: "Fresh Station", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 132.9], priceUpdated: nil)
let updated = FuelStore.refreshedFavourites([fav], from: [fresh])
XCTAssertEqual(updated.count, 1)
XCTAssertEqual(updated[0].station.name, "Fresh Station")
XCTAssertEqual(updated[0].station.prices[.e10], 132.9)
XCTAssertEqual(updated[0].fuel, .e10, "fuel scoping survives refresh")
}
func testRefreshedFavouritesKeepUnmatchedSnapshot() {
let fav = FavouriteEntry(station: FuelStation(id: "s1", name: "Cached", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil), fuel: .diesel)
let updated = FuelStore.refreshedFavourites([fav], from: [])
XCTAssertEqual(updated[0].station.name, "Cached", "unmatched favourite keeps its snapshot")
XCTAssertEqual(updated[0].station.prices[.e10], 140.0)
XCTAssertEqual(updated[0].fuel, .diesel, "fuel scoping survives unmatched refresh")
}
func testFavouriteEntryIDIsFuelScoped() {
let station = FuelStation(id: "s1", name: "X", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0, .diesel: 150.0], priceUpdated: nil)
let unleaded = FavouriteEntry(station: station, fuel: .e10)
let diesel = FavouriteEntry(station: station, fuel: .diesel)
XCTAssertNotEqual(unleaded.id, diesel.id, "same station favourited for two fuels is two distinct favourites")
XCTAssertTrue(unleaded.id.hasSuffix("|s1"))
XCTAssertTrue(diesel.id.hasPrefix("diesel|"))
}
}
final class FavouriteReorderTests: XCTestCase {
private func entry(_ id: String, _ fuel: FuelType) -> FavouriteEntry {
FavouriteEntry(station: FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [fuel: 140.0], priceUpdated: nil), fuel: fuel)
}
func testReorderMovesWithinFuelAndKeepsOthersRelativeOrder() {
// e10: a, b, c | diesel: d
let list = [entry("a", .e10), entry("b", .e10), entry("c", .e10), entry("d", .diesel)]
// Move c (index 2) to the front (offset 0) of the e10 list.
let moved = FuelStore.reorderedFavourites(list, fuel: .e10, fromOffsets: [2], toOffset: 0)
XCTAssertEqual(moved.map(\.station.id), ["c", "a", "b", "d"], "c moves to front; diesel stays last")
}
func testReorderPreservesFuelBlockPosition() {
// diesel first block, then e10 block: d, e | a, b, c
let list = [entry("d", .diesel), entry("e", .diesel), entry("a", .e10), entry("b", .e10), entry("c", .e10)]
// Move a (e10 index 0) to the e10 block end (offset 3 within e10).
let moved = FuelStore.reorderedFavourites(list, fuel: .e10, fromOffsets: [0], toOffset: 3)
XCTAssertEqual(moved.map(\.station.id), ["d", "e", "b", "c", "a"], "e10 block stays in place after diesel block")
}
func testReorderFirstBecomesSingleWidgetFavourite() {
let list = [entry("x", .e10), entry("y", .e10), entry("z", .e10)]
let moved = FuelStore.reorderedFavourites(list, fuel: .e10, fromOffsets: [2], toOffset: 0)
XCTAssertEqual(moved.first?.station.id, "z", "first stored favourite = single-widget favourite")
}
func testReorderMultipleFuelsUntouched() {
let list = [entry("a", .e10), entry("b", .e10), entry("d", .diesel), entry("p", .e5)]
let moved = FuelStore.reorderedFavourites(list, fuel: .e10, fromOffsets: [0], toOffset: 2)
XCTAssertEqual(moved.map(\.station.id), ["b", "a", "d", "p"], "e10 reorder leaves other fuels' relative order intact")
}
}
final class AlertsFuelTests: XCTestCase {
func testAlertsFuelRoundTrips() {
FuelStore.saveAlertsFuel(.diesel)
XCTAssertEqual(FuelStore.loadAlertsFuel(), .diesel, "alerts fuel persists independently")
}
func testAlertsFuelDefaultsToUnleaded() {
// Fresh state (test isolation) defaults to Unleaded like the app's
// other fuel selections.
FuelStore.saveAlertsFuel(.e5)
FuelStore.saveAlertsFuel(.e10)
XCTAssertEqual(FuelStore.loadAlertsFuel(), .e10)
}
}
/// The Settings sliders save ONE field per gesture instead of rewriting all four
/// keys (four UserDefaults writes plus four keychain round trips per drag is what
/// made them stutter). These pin the store half of that: a single-field save must
/// leave the other three keys exactly as they were.
final class FuelSavingsFieldSaveTests: XCTestCase {
private func seed() {
FuelStore.saveFuelSavingsSettings(FuelSavingsSettings(
enabled: true,
mpg: 40,
preferredAmount: FuelSavingsFillAmount(value: 50, unit: .pounds)
))
}
func testSavingMPGLeavesTheOtherFieldsAlone() {
seed()
var edited = FuelStore.loadFuelSavingsSettings()
edited.mpg = 62
FuelStore.saveFuelSavingsField(.mpg, from: edited)
let reloaded = FuelStore.loadFuelSavingsSettings()
XCTAssertEqual(reloaded.mpg, 62, "the edited field is written")
XCTAssertTrue(reloaded.enabled, "enabled survives an MPG save")
XCTAssertEqual(reloaded.preferredAmount.value, 50, "the fill-up amount survives an MPG save")
XCTAssertEqual(reloaded.preferredAmount.unit, .pounds, "the unit survives an MPG save")
}
func testSavingFillAmountLeavesMPGAndUnitAlone() {
seed()
var edited = FuelStore.loadFuelSavingsSettings()
edited.preferredAmount.value = 75
FuelStore.saveFuelSavingsField(.amount, from: edited)
let reloaded = FuelStore.loadFuelSavingsSettings()
XCTAssertEqual(reloaded.preferredAmount.value, 75, "the edited field is written")
XCTAssertEqual(reloaded.mpg, 40, "MPG survives a fill-up save")
XCTAssertEqual(reloaded.preferredAmount.unit, .pounds, "the unit survives a fill-up save")
}
func testSavingEnabledLeavesTheNumbersAlone() {
seed()
var edited = FuelStore.loadFuelSavingsSettings()
edited.enabled = false
FuelStore.saveFuelSavingsField(.enabled, from: edited)
let reloaded = FuelStore.loadFuelSavingsSettings()
XCTAssertFalse(reloaded.enabled, "the edited field is written")
XCTAssertEqual(reloaded.mpg, 40, "MPG survives an enabled save")
XCTAssertEqual(reloaded.preferredAmount.value, 50, "the fill-up amount survives an enabled save")
}
func testWholeSettingsSaveStillWritesEveryField() {
seed()
FuelStore.saveFuelSavingsSettings(FuelSavingsSettings(
enabled: false,
mpg: 55,
preferredAmount: FuelSavingsFillAmount(value: 30, unit: .litres)
))
let reloaded = FuelStore.loadFuelSavingsSettings()
XCTAssertFalse(reloaded.enabled)
XCTAssertEqual(reloaded.mpg, 55)
XCTAssertEqual(reloaded.preferredAmount.value, 30)
XCTAssertEqual(reloaded.preferredAmount.unit, .litres)
}
}
final class AlertRadiusFollowTests: XCTestCase {
func testAlertRadiusOptionsAreMileFriendly() {
// 1–2 city, 3 town, 5 default, 8 motorway — the geofence ceiling.
XCTAssertEqual(FuelStore.alertRadiusOptions, [1, 2, 3, 5, 8])
}
func testLegacyRadiusClampsToCap() {
// Old options went to 20 km; stored values must not exceed the 8-mi cap.
FuelStore.saveAlertsRadius(30)
XCTAssertEqual(FuelStore.loadAlertsRadius(), FuelStore.alertFollowCapKM, accuracy: 0.001)
FuelStore.saveAlertsRadius(5) // still inside the cap — untouched
XCTAssertEqual(FuelStore.loadAlertsRadius(), 5, accuracy: 0.001)
}
func testFollowSearchRoundTrips() {
FuelStore.saveAlertsFollowsSearch(true)
XCTAssertTrue(FuelStore.loadAlertsFollowsSearch())
FuelStore.saveAlertsFollowsSearch(false)
XCTAssertFalse(FuelStore.loadAlertsFollowsSearch())
FuelStore.saveLiveActivityFollowsSearch(true)
XCTAssertTrue(FuelStore.loadLiveActivityFollowsSearch())
FuelStore.saveLiveActivityFollowsSearch(false)
XCTAssertFalse(FuelStore.loadLiveActivityFollowsSearch())
}
func testEffectiveRadiusManualWhenNotFollowing() {
let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: false, manualKM: 3)
XCTAssertEqual(eff, 3, "manual radius passes through when not following")
}
func testEffectiveRadiusFollowsSearchCapped() {
// 15-mile search must cap at the 8-mile geofence ceiling.
FuelStore.saveDistanceUnit(.miles)
FuelStore.saveStationLimit(15)
let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: true, manualKM: 3)
XCTAssertEqual(eff, FuelStore.alertFollowCapKM, accuracy: 0.001)
}
func testEffectiveRadiusFollowsSmallSearch() {
// 5-mile search follows exactly (8.05 km), under the cap.
FuelStore.saveDistanceUnit(.miles)
FuelStore.saveStationLimit(5)
let eff = FuelStore.effectiveAlertsRadiusKM(followsSearch: true, manualKM: 3)
XCTAssertEqual(eff, 5 * FuelSavingsCalculator.kilometresPerMile, accuracy: 0.001)
}
}
final class DistanceLabelTests: XCTestCase {
func testUnitLabelPluralizesMiles() {
XCTAssertEqual(DistanceUnit.miles.label(for: 1), "mile")
XCTAssertEqual(DistanceUnit.miles.label(for: 2), "miles")
XCTAssertEqual(DistanceUnit.miles.label(for: 5), "miles")
XCTAssertEqual(DistanceUnit.kilometers.label(for: 1), "km")
XCTAssertEqual(DistanceUnit.kilometers.label(for: 8), "km")
}
func testDisplayMilesShowsTrueDistance() {
// Picker labels must match what the option really means.
XCTAssertEqual(DistanceUnit.miles.displayMiles(5), 5)
XCTAssertEqual(DistanceUnit.miles.displayMiles(15), 15)
XCTAssertEqual(DistanceUnit.kilometers.displayMiles(5), 8) // 5 mi = 8.05 km
XCTAssertEqual(DistanceUnit.kilometers.displayMiles(10), 16) // 10 mi = 16.1 km
XCTAssertEqual(DistanceUnit.kilometers.displayMiles(15), 24) // 15 mi = 24.1 km
}
}
final class SiriCheapestLookupTests: XCTestCase {
private func station(_ id: String, lat: Double, lng: Double, _ prices: [FuelType: Double]) -> FuelStation {
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", lat: lat, lng: lng, prices: prices, priceUpdated: nil)
}
// Origin (53.01, -1.01). near ≈ 0.8 mi away; mid ≈ 3.2 mi away; far ≈ 36 mi away.
private let nearLat = 53.0, nearLng = -1.0
private let midLat = 53.05, midLng = -1.05
private let farLat = 53.5, farLng = -1.5
func testCheapestPicksLowestPriceNotNearestWithinRadius() {
// Both inside the 5 mi radius; near station is pricier — price wins.
let stations = [
station("near", lat: nearLat, lng: nearLng, [.e10: 145.9]),
station("mid", lat: midLat, lng: midLng, [.e10: 139.9]),
]
let result = SiriCheapestLookup.cheapest(in: stations, fuel: .e10, fromLat: 53.01, lng: -1.01, withinMiles: 5)
XCTAssertEqual(result?.id, "mid", "cheapest by price within the radius")
}
func testCheapestTieBreaksByDistance() {
let stations = [
station("near", lat: nearLat, lng: nearLng, [.e10: 140.0]),
station("mid", lat: midLat, lng: midLng, [.e10: 140.0]),
]
let result = SiriCheapestLookup.cheapest(in: stations, fuel: .e10, fromLat: 53.01, lng: -1.01, withinMiles: 5)
XCTAssertEqual(result?.id, "near", "equal prices resolve to the nearest station")
}
func testCheapestSkipsStationsWithoutThatFuel() {
let stations = [
station("noDiesel", lat: nearLat, lng: nearLng, [.e10: 139.9]),
station("sellsDiesel", lat: midLat, lng: midLng, [.diesel: 149.9]),
]
let result = SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.01, lng: -1.01, withinMiles: 5)
XCTAssertEqual(result?.id, "sellsDiesel", "stations without the fuel are skipped")
}
func testCheapestIgnoresCheaperStationOutsideRadius() {
// The reported bug: the UK-wide minimum (100.9p, 36 mi away) must NOT
// beat a closer 129.9p station when the radius is 5 mi.
let stations = [
station("near", lat: nearLat, lng: nearLng, [.diesel: 129.9]),
station("farCheap", lat: farLat, lng: farLng, [.diesel: 100.9]),
]
let result = SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.01, lng: -1.01, withinMiles: 5)
XCTAssertEqual(result?.id, "near", "cheaper station outside the radius is excluded")
}
func testCheapestReturnsNilWhenOnlyStationsOutsideRadius() {
let stations = [station("far", lat: farLat, lng: farLng, [.diesel: 100.9])]
XCTAssertNil(SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.01, lng: -1.01, withinMiles: 5))
}
func testCheapestReturnsNilWhenNoStationSellsFuel() {
let stations = [station("a", lat: nearLat, lng: nearLng, [.e10: 139.9])]
XCTAssertNil(SiriCheapestLookup.cheapest(in: stations, fuel: .diesel, fromLat: 53.01, lng: -1.01, withinMiles: 5))
}
func testCheapestReturnsNilForEmptyInput() {
XCTAssertNil(SiriCheapestLookup.cheapest(in: [], fuel: .e10, fromLat: 53.01, lng: -1.01, withinMiles: 5))
}
func testMapsURLIsUniversalAppleMapsLink() {
let url = SiriCheapestLookup.mapsURL(latitude: 53.7538, longitude: -1.8177)
XCTAssertEqual(url.scheme, "https")
XCTAssertEqual(url.host, "maps.apple.com")
XCTAssertEqual(url.query, "daddr=53.7538,-1.8177")
}
func testMapsURLUsesUniversalLinkNotCustomScheme() {
// OpenURLIntent only opens universal links — a maps:// custom scheme
// would be rejected at runtime, so the https form is mandatory.
let url = SiriCheapestLookup.mapsURL(latitude: 0, longitude: 0)
XCTAssertEqual(url.scheme, "https", "custom schemes like maps:// must not be used")
XCTAssertTrue(url.absoluteString.hasPrefix("https://maps.apple.com/"))
}
// MARK: Favourite selection
private func favourite(_ id: String, _ fuel: FuelType, _ prices: [FuelType: Double]) -> FavouriteEntry {
FavouriteEntry(station: station(id, lat: 53.7, lng: -1.8, prices), fuel: fuel)
}
// The top favourite for a fuel is the FIRST entry in the manual favourites
// order for that fuel (the drag-and-drop order from the Favourites tab;
// per-fuel blocks keep their position, so array order = per-fuel order).
func testTopFavouriteIsFirstInManualOrderPerFuel() {
let favs = [
favourite("a", .e10, [.e10: 130]),
favourite("b", .diesel, [.diesel: 145]),
favourite("c", .e10, [.e10: 120]),
]
XCTAssertEqual(SiriCheapestLookup.topFavourite(in: favs, from: [], fuel: .e10)?.station.id, "a")
XCTAssertEqual(SiriCheapestLookup.topFavourite(in: favs, from: [], fuel: .diesel)?.station.id, "b")
XCTAssertNil(SiriCheapestLookup.topFavourite(in: favs, from: [], fuel: .e5))
}
// The favourite's cached price snapshot is refreshed from the current
// station dump when the station is present.
func testTopFavouriteRefreshesPriceFromDump() {
let favs = [favourite("a", .e10, [.e10: 130])]
let dump = [station("a", lat: 53.7, lng: -1.8, [.e10: 122.9])]
let top = SiriCheapestLookup.topFavourite(in: favs, from: dump, fuel: .e10)
XCTAssertEqual(top?.station.prices[.e10], 122.9)
}
// Stations missing from the dump keep their cached snapshot, so the
// favourite still answers offline or when the station isn't in the
// GOV.UK set.
func testTopFavouriteKeepsCachedSnapshotWhenMissingFromDump() {
let favs = [favourite("a", .e10, [.e10: 130])]
let top = SiriCheapestLookup.topFavourite(in: favs, from: [], fuel: .e10)
XCTAssertEqual(top?.station.prices[.e10], 130)
}
// Manual reorder (the Favourites tab's drag-and-drop) changes which
// favourite is "top" — the Siri answer follows the widget order.
func testTopFavouriteFollowsManualReorder() {
let favs = [
favourite("a", .e10, [.e10: 130]),
favourite("c", .e10, [.e10: 120]),
]
let reordered = FuelStore.reorderedFavourites(favs, fuel: .e10, fromOffsets: IndexSet(integer: 0), toOffset: 2)
XCTAssertEqual(SiriCheapestLookup.topFavourite(in: reordered, from: [], fuel: .e10)?.station.id, "c")
}
func testFreshnessLabelUsesDataUpdatedStamp() {
let label = SiriCheapestLookup.freshnessLabel(
updated: "2026-08-14T10:31:36.000Z",
lastRefresh: nil
)
XCTAssertTrue(label.hasPrefix("as of "), "stamp must be prefixed for the dialog")
XCTAssertFalse(label.contains("10:31"), "stamp is converted to the user's local time, not raw UTC")
}
func testFreshnessLabelFallsBackToLastRefresh() {
let label = SiriCheapestLookup.freshnessLabel(updated: nil, lastRefresh: Date(timeIntervalSince1970: 0))
XCTAssertTrue(label.hasPrefix("as of "), "local refresh date is used when no GOV.UK stamp exists")
}
func testFreshnessLabelEmptyWithoutAnyDate() {
XCTAssertEqual(SiriCheapestLookup.freshnessLabel(updated: nil, lastRefresh: nil), "")
}
func testFreshnessLabelToleratesPlainISODate() {
// Some relays emit no fractional seconds.
let label = SiriCheapestLookup.freshnessLabel(
updated: "2026-08-14T10:31:36Z",
lastRefresh: nil
)
XCTAssertTrue(label.hasPrefix("as of "), "plain ISO 8601 still parses")
}
}
// MARK: - Price display style
final class PriceDisplayTests: XCTestCase {
func testStationSignStyle() {
// Bare pence figure, no £ — exactly the roadside sign.
XCTAssertEqual(FuelStore.priceText(129.9, style: .stationSign), "129.9")
XCTAssertEqual(FuelStore.priceText(135.0, style: .stationSign), "135.0")
XCTAssertEqual(FuelStore.priceText(249.9, style: .stationSign), "249.9")
}
func testPoundsPenceStyle() {
// Forecourt superscript style: £ + 2dp + small superscript third digit + /L.
XCTAssertEqual(FuelStore.priceText(129.9, style: .poundsPence), "£1.29⁹/L")
XCTAssertEqual(FuelStore.priceText(135.0, style: .poundsPence), "£1.35⁰/L")
XCTAssertEqual(FuelStore.priceText(100.9, style: .poundsPence), "£1.00⁹/L")
XCTAssertEqual(FuelStore.priceText(199.9, style: .poundsPence), "£1.99⁹/L")
}
func testSpokenAlwaysPounds() {
// Siri would read "£129.9" as "one hundred and twenty-nine pounds" —
// the spoken form must always be pounds regardless of display style.
XCTAssertEqual(FuelStore.priceTextSpoken(129.9), "£1.299")
XCTAssertEqual(FuelStore.priceTextSpoken(100.9), "£1.009")
}
}
// MARK: - Offline data banner label
final class OfflineDataLabelTests: XCTestCase {
func testOfflineDataLabelFormatsStampWithFractionalSeconds() {
// The bundled dump's real envelope stamp.
XCTAssertEqual(FuelStore.offlineDataLabel(from: "2026-08-15T08:46:33.000Z"), "15 Aug")
}
func testOfflineDataLabelToleratesPlainISODate() {
XCTAssertEqual(FuelStore.offlineDataLabel(from: "2026-08-15T08:46:33Z"), "15 Aug")
}
func testOfflineDataLabelNilWhenMissingOrUnparseable() {
XCTAssertNil(FuelStore.offlineDataLabel(from: nil))
XCTAssertNil(FuelStore.offlineDataLabel(from: ""))
XCTAssertNil(FuelStore.offlineDataLabel(from: "not-a-date"))
}
}
// MARK: - Road distance cache
final class RoadDistanceCacheTests: XCTestCase {
override func setUp() {
super.setUp()
// Keychain persists across invocations, so a cache left by an earlier
// test or run would pollute these. Overwrite with an empty, far-away
// cache (source at (0,0)) so every test starts from a clean slate.
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
// "Accurate distances" is a persisted setting, so a previous run (or a
// test that turned it on) would leak into these. Off is the default.
FuelStore.setAccurateDistancesEnabled(false)
}
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
lat: lat, lng: lng, prices: [:], priceUpdated: nil)
}
func testDisplayDistanceFallsBackToStraightLineWhenNoCache() {
// London user, station ~ London -> no cache -> straight-line haversine.
let s = station("a", 51.5074, -0.1278)
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
}
func testRoadDistanceUsedWhenCachedNear() {
FuelStore.setAccurateDistancesEnabled(true)
let s = station("a", 51.5074, -0.1278)
// Cache a road distance of 3.2 km for this station from the user's fix.
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, 3.2, accuracy: 0.0001)
}
func testRoadDistanceNilWhenOriginFar() {
FuelStore.setAccurateDistancesEnabled(true)
let s = station("a", 51.5074, -0.1278)
// Cache built in London, but the user is now ~200 km away -> stale.
FuelStore.saveRoadDistances(sourceLat: 51.5074, sourceLng: -0.1278,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
let meters = FuelStore.roadDistanceMeters(for: s, userLat: 53.4808, userLng: -2.2426)
XCTAssertNil(meters)
// And display falls back to straight-line.
let km = FuelStore.displayDistanceKM(station: s, userLat: 53.4808, userLng: -2.2426)
XCTAssertEqual(km, s.distanceKM(to: 53.4808, lng2: -2.2426), accuracy: 0.0001)
}
func testRoadDistanceUsedForOtherStationNotFound() {
FuelStore.setAccurateDistancesEnabled(true)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
// A station that isn't in the cache falls back to straight-line.
let s = station("z", 51.51, -0.13)
let km = FuelStore.displayDistanceKM(station: s, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, s.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
}
func testRoadDistanceNotServedWhenStationPinDiffers() {
FuelStore.setAccurateDistancesEnabled(true)
// Route a road distance to station "a" at pin P1.
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["a": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
// The SAME station id appears with a moved pin (corrected coordinate /
// different embedded vs live source): the cached route to P1 must NOT
// be served — it belongs to a different location.
let moved = station("a", 51.5400, -0.1600)
let km = FuelStore.displayDistanceKM(station: moved, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(km, moved.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
"road value routed to the old pin leaked onto a different coordinate")
}
/// A partial cache legitimately mixes road and straight-line distances for
/// the SAVINGS maths (Best Value marks the estimated ones itself), but the
/// browse layer only ever serves a COMPLETE set — see the accurate-distances
/// tests below.
func testPartialRoadCacheCanMixRoadAndStraightLineDistances() {
FuelStore.setAccurateDistancesEnabled(true)
let routed = station("routed", 51.5074, -0.1278)
let estimated = station("estimated", 51.5100, -0.1300)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 3200, lat: 51.5074, lng: -0.1278, returnMeters: 3600)])
XCTAssertEqual(FuelStore.roadDistanceMeters(for: routed, userLat: 51.6, userLng: -0.1), 3200)
XCTAssertNil(FuelStore.roadDistanceMeters(for: estimated, userLat: 51.6, userLng: -0.1))
XCTAssertEqual(FuelStore.displayDistanceKM(station: routed, userLat: 51.6, userLng: -0.1), 3.2, accuracy: 0.0001)
XCTAssertEqual(FuelStore.displayDistanceKM(station: estimated, userLat: 51.6, userLng: -0.1),
estimated.distanceKM(to: 51.6, lng2: -0.1),
accuracy: 0.0001)
XCTAssertEqual(FuelStore.roadJourneyMeters(for: routed, userLat: 51.6, userLng: -0.1), 6800,
"outward and return road routes are combined once, not doubled")
XCTAssertTrue(FuelStore.hasMeasuredReturnRoadDistance(for: routed, userLat: 51.6, userLng: -0.1))
XCTAssertFalse(FuelStore.hasMeasuredReturnRoadDistance(for: estimated, userLat: 51.6, userLng: -0.1))
}
/// The reported bug: "10 miles cheapest" listed Jet Chidswell at 14.6 mi.
/// Its straight line was inside 10 miles, its routed distance was not — the
/// filter tested the former while the row displayed the latter.
func testRadiusFilterUsesTheDisplayedDistanceNotTheStraightLine() {
FuelStore.setAccurateDistancesEnabled(true)
let sourceLat = 51.6, sourceLng = -0.1
let far = station("far", 51.5074, -0.1278) // in by straight line…
let near = station("near", 51.5400, -0.1000)
FuelStore.saveRoadDistances(
sourceLat: sourceLat, sourceLng: sourceLng,
entries: ["far": .init(meters: 23_500, lat: 51.5074, lng: -0.1278, returnMeters: 23_500)])
let tenMilesKM = 10 * FuelSavingsCalculator.kilometresPerMile
XCTAssertLessThan(far.distanceKM(to: sourceLat, lng2: sourceLng), tenMilesKM,
"fixture: the straight line must sit INSIDE the 10-mile radius")
XCTAssertGreaterThan(FuelStore.displayDistanceKM(station: far, userLat: sourceLat, userLng: sourceLng),
tenMilesKM, "fixture: the routed distance must sit OUTSIDE it")
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: near, radiusKM: tenMilesKM,
userLat: sourceLat, userLng: sourceLng))
XCTAssertFalse(FuelStore.isWithinDisplayRadius(station: far, radiusKM: tenMilesKM,
userLat: sourceLat, userLng: sourceLng),
"a station whose row reads 14.6 mi must not appear in a 10-mile list")
// The same station is back once the radius actually covers its distance.
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: far, radiusKM: 15 * FuelSavingsCalculator.kilometresPerMile,
userLat: sourceLat, userLng: sourceLng))
// …and an unknown station (no cached route) still tests its displayed
// straight line, so nothing changes for the bulk of the list.
let unrouted = station("unrouted", 51.5400, -0.1000)
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: unrouted, radiusKM: tenMilesKM,
userLat: sourceLat, userLng: sourceLng))
}
func testLegacyOneWayRoadCacheIsUsableButNotConfirmed() {
let legacy = station("legacy", 51.5074, -0.1278)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["legacy": .init(meters: 3200, lat: 51.5074, lng: -0.1278)])
XCTAssertEqual(FuelStore.roadJourneyMeters(for: legacy, userLat: 51.6, userLng: -0.1), 6400)
XCTAssertFalse(FuelStore.hasMeasuredReturnRoadDistance(for: legacy, userLat: 51.6, userLng: -0.1),
"legacy one-way cache entries infer the return leg and must remain estimated")
let diagnostics = FuelStore.roadDistanceDiagnostics(for: legacy, userLat: 51.6, userLng: -0.1)
XCTAssertEqual(diagnostics.displaySource, .roadRoute)
XCTAssertEqual(diagnostics.outwardMeters, 3200)
XCTAssertNil(diagnostics.returnMeters)
XCTAssertEqual(diagnostics.totalJourneyMeters, 6400)
XCTAssertFalse(diagnostics.hasMeasuredReturn)
XCTAssertTrue(diagnostics.hasValidCacheEntry)
}
/// A station distance the user can see is marked "Approx." until a routed road
/// distance exists for it. Figure and marker come out of ONE cache read, so a
/// surface can never show a routed value labelled approximate (or the reverse).
func testStationDistanceIsMarkedApproximateUntilRoutingLands() {
FuelStore.setAccurateDistancesEnabled(true)
let routed = station("routed", 51.5074, -0.1278)
let unrouted = station("unrouted", 51.5200, -0.1400)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 3200, lat: 51.5074, lng: -0.1278, returnMeters: 3600)])
let confirmed = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
XCTAssertFalse(confirmed.isApproximate, "a cached road distance is not an approximation")
XCTAssertEqual(confirmed.km, 3.2, accuracy: 0.0001)
XCTAssertEqual(confirmed.label(unit: .miles), "2.0 mi")
XCTAssertEqual(confirmed.labelWithReturn(unit: .miles), "2.0 mi (4.2 mi return)")
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.8 km return)")
XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles")
XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.2 miles return")
// The return figure split out on its own — a surface that prints the
// one-way and the return distance on separate lines still gets BOTH from
// this type, marker included.
XCTAssertEqual(confirmed.returnLabel(unit: .miles), "4.2 mi return")
XCTAssertEqual(confirmed.returnLabel(unit: .kilometers), "6.8 km return")
XCTAssertEqual(confirmed.spokenReturnLabel(unit: .miles), "4.2 miles return")
let approximate = FuelStore.stationDistanceDisplay(station: unrouted, userLat: 51.6, userLng: -0.1)
XCTAssertTrue(approximate.isApproximate, "the straight-line fallback must be identified as one")
XCTAssertEqual(approximate.km, unrouted.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
XCTAssertEqual(approximate.label(unit: .miles),
"Approx. " + DistanceUnit.miles.format(approximate.km))
XCTAssertEqual(approximate.labelWithReturn(unit: .miles),
"Approx. " + DistanceUnit.miles.format(approximate.km) + " (Approx. " + DistanceUnit.miles.format(approximate.km * 2) + " return)")
XCTAssertEqual(approximate.spokenLabel(unit: .kilometers),
"Approximately " + DistanceUnit.kilometers.spoken(approximate.km))
XCTAssertEqual(approximate.spokenLabelWithReturn(unit: .kilometers),
"Approximately \(DistanceUnit.kilometers.spoken(approximate.km)), Approximately \(DistanceUnit.kilometers.spoken(approximate.km * 2)) return")
}
func testDisplayedReturnUsesTheIndependentlyMeasuredReturnRoute() {
FuelStore.setAccurateDistancesEnabled(true)
let station = station("separate-legs", 51.5074, -0.1278)
FuelStore.saveRoadDistances(
sourceLat: 51.6,
sourceLng: -0.1,
entries: ["separate-legs": .init(
meters: 4_650,
lat: station.lat,
lng: station.lng,
returnMeters: 4_820
)],
complete: true
)
let display = FuelStore.stationDistanceDisplay(
station: station,
userLat: 51.6,
userLng: -0.1
)
XCTAssertEqual(display.km, 4.65, accuracy: 1e-12)
XCTAssertEqual(display.returnKM, 9.47, accuracy: 1e-12)
XCTAssertFalse(display.isApproximate)
XCTAssertFalse(display.returnIsApproximate)
XCTAssertEqual(display.labelWithReturn(unit: .miles), "2.9 mi (5.9 mi return)")
XCTAssertEqual(display.spokenLabelWithReturn(unit: .miles), "2.9 miles, 5.9 miles return")
// The split-out return figure must use the INDEPENDENT return route
// (9.47 km), not a doubled one-way (2 × 4.65 = 9.30 km → 5.8 mi).
XCTAssertEqual(display.returnLabel(unit: .miles), "5.9 mi return")
XCTAssertEqual(display.spokenReturnLabel(unit: .miles), "5.9 miles return")
}
/// The marker tracks the cache on its own: same station, bare figure once its
/// route lands, "Approx." again once the origin moves out of tolerance.
func testStationDistanceLabelFollowsTheRoadCache() {
FuelStore.setAccurateDistancesEnabled(true)
let moved = station("moved", 51.5074, -0.1278)
XCTAssertTrue(FuelStore.stationDistanceDisplay(station: moved, userLat: 51.6, userLng: -0.1).isApproximate,
"nothing has been routed yet")
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["moved": .init(meters: 3200, lat: 51.5074, lng: -0.1278, returnMeters: 3600)])
XCTAssertFalse(FuelStore.stationDistanceDisplay(station: moved, userLat: 51.6, userLng: -0.1).isApproximate,
"route data landed, so the marker must drop without any other change")
XCTAssertTrue(FuelStore.stationDistanceDisplay(station: moved, userLat: 59.0, userLng: -0.1).isApproximate,
"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
/// route requests and every device shows the same straight-line figures.
func testAccurateDistancesDefaultsToOffWhenUnset() {
UserDefaults(suiteName: FuelStore.appGroupSuite)?.removeObject(forKey: FuelStore.accurateDistancesKey)
XCTAssertFalse(FuelStore.accurateDistancesEnabled)
}
/// Mode OFF: a COMPLETE routed cache exists and the browse layer must still
/// show the straight line, marked Approx., for every row — while the savings
/// maths keeps using the measured routes. That boundary is the feature.
func testAccurateDistancesOffIgnoresCachedRoutes() {
let routed = station("routed", 51.5074, -0.1278)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
lng: -0.1278, returnMeters: 23_500)],
complete: true)
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
XCTAssertTrue(display.isApproximate, "the browse layer must never serve routes with the mode off")
XCTAssertEqual(display.km, routed.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001)
XCTAssertTrue(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * FuelSavingsCalculator.kilometresPerMile,
userLat: 51.6, userLng: -0.1),
"with the mode off the radius is a straight-line radius, agreeing with the rows")
XCTAssertEqual(FuelStore.roadJourneyMeters(for: routed, userLat: 51.6, userLng: -0.1), 47_000,
"Best Value keeps its measured routes whichever way the toggle is set")
XCTAssertTrue(FuelStore.hasMeasuredReturnRoadDistance(for: routed, userLat: 51.6, userLng: -0.1))
}
/// An incomplete fill must never be half-served: the row that HAS a route
/// falls back too, so a list can't read "14.6 mi" beside rows that its radius
/// filter measured as straight lines.
func testAccurateDistancesOnWithIncompleteSetServesStraightLineForEveryRow() {
FuelStore.setAccurateDistancesEnabled(true)
let routed = station("routed", 51.5074, -0.1278)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
lng: -0.1278, returnMeters: 23_500)],
complete: false)
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
XCTAssertTrue(display.isApproximate)
XCTAssertEqual(display.km, routed.distanceKM(to: 51.6, lng2: -0.1), accuracy: 0.0001,
"a partial set must not be served, even for a station that has a route")
}
/// Mode ON with a complete set from a location we are standing at: routed
/// values, unmarked, and the radius means miles of driving.
func testAccurateDistancesOnServesRoutedValuesWhenCompleteAndNear() {
FuelStore.setAccurateDistancesEnabled(true)
let routed = station("routed", 51.5074, -0.1278)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
lng: -0.1278, returnMeters: 23_500)],
complete: true)
let display = FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1)
XCTAssertFalse(display.isApproximate)
XCTAssertEqual(display.km, 23.5, accuracy: 0.0001)
XCTAssertFalse(FuelStore.isWithinDisplayRadius(station: routed, radiusKM: 10 * FuelSavingsCalculator.kilometresPerMile,
userLat: 51.6, userLng: -0.1),
"in this mode a 10-mile radius is 10 miles of driving, matching the routed rows")
}
/// Same complete cache, but the user has since driven away: nothing is served
/// (every row falls back together) rather than showing routes measured from a
/// place the user has left. Walking back inside tolerance serves it again
/// with no refill.
func testAccurateDistancesOnFallsBackTogetherWhenTheAnchorMoves() {
FuelStore.setAccurateDistancesEnabled(true)
let routed = station("routed", 51.5074, -0.1278)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
lng: -0.1278, returnMeters: 23_500)],
complete: true)
let movedLat = 51.6 + 0.018 // ~2 km north, outside the 600 m tolerance
XCTAssertTrue(FuelStore.stationDistanceDisplay(station: routed, userLat: movedLat, userLng: -0.1).isApproximate)
XCTAssertFalse(FuelStore.stationDistanceDisplay(station: routed, userLat: 51.6, userLng: -0.1).isApproximate)
}
/// The toggle is a browse-layer switch, not a data switch: Best Value's
/// journey metres and its measured-return confidence must be identical in
/// both positions.
func testAccurateDistancesSavingsMathsIsUnaffectedByTheToggle() {
let routed = station("routed", 51.5074, -0.1278)
FuelStore.saveRoadDistances(sourceLat: 51.6, sourceLng: -0.1,
entries: ["routed": .init(meters: 23_500, lat: 51.5074,
lng: -0.1278, returnMeters: 23_600)],
complete: true)
for mode in [false, true] {
FuelStore.setAccurateDistancesEnabled(mode)
XCTAssertEqual(FuelStore.roadJourneyMeters(for: routed, userLat: 51.6, userLng: -0.1), 47_100)
XCTAssertTrue(FuelStore.hasMeasuredReturnRoadDistance(for: routed, userLat: 51.6, userLng: -0.1))
}
}
}
// MARK: - Install identity
final class InstallIdentityTests: XCTestCase {
func testFreshInstallIsStableAfterFirstCall() {
// The first call seeds the common install id (local == keychain), so
// any subsequent call in the same process must report NOT-fresh. This
// holds regardless of persisted keychain/defaults state from prior runs.
_ = FuelStore.isFreshInstall()
XCTAssertFalse(FuelStore.isFreshInstall())
}
}