Files
fuelboard/FuelBoardTests/Tests/FuelBoardSharedTests/FuelBoardTests.swift
T
FuelBoard Contributor 0b27d3d4f6 road distance: pin-fingerprint guard so embedded/live/cached never cross-pollute
The road-distance cache was keyed purely by station ID with no coordinate, so
a value routed to one pin could be served for a same-ID station whose stored
coordinate came from a different source (live fetch vs bundled offline dump
vs a corrected pin), reproducing Maps mismatches.
- RoadDistanceCache now stores each routed pin (CachedRoadDistance{meters,
  lat, lng}) and roadDistanceMeters() only serves a value when the displayed
  station's coordinate matches the pinned one (within ~11 m).
- Kept the existing origin-distance staleness guard.
- Verified embedded sample data (ids y1/se1/...) never collides with real
  relay IDs, and the widget STRICT radius filter drops far-offline samples, so
  no actual leak existed in practice — this closes the theoretical stale-pin
  channel and future-proofs against coordinate fixes.
2026-08-20 17:26:33 +01:00

702 lines
34 KiB
Swift
Raw 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
@testable import FuelBoardShared
// 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 (app uses × 1.60934)
XCTAssertEqual(5 * 1.60934, 8.0467, accuracy: 0.001)
// 262 km ≈ 162.8 miles (display uses × 0.621371)
XCTAssertEqual(262 * 0.621371, 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.60934, accuracy: 0.00001)
XCTAssertEqual(DistanceUnit.kilometers.toKM(5), 5)
XCTAssertEqual(DistanceUnit.miles.fromKM(1.60934), 1, accuracy: 0.00001)
XCTAssertEqual(DistanceUnit.kilometers.fromKM(2.5), 2.5)
}
func testDistanceUnitFormat() {
XCTAssertEqual(DistanceUnit.miles.format(1.60934), "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)
}
}
final class AlertRadiusFollowTests: XCTestCase {
func testAlertRadiusOptionsAreMileFriendly() {
// 12 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 * 1.60934, 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: [:])
}
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() {
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() {
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.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() {
// 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")
}
}
// 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())
}
}