1052 lines
57 KiB
Swift
1052 lines
57 KiB
Swift
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 (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() {
|
||
// 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 * 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: [:])
|
||
// "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 * 1.60934
|
||
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 * 1.60934,
|
||
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.0 mi return)")
|
||
XCTAssertEqual(confirmed.labelWithReturn(unit: .kilometers), "3.2 km (6.4 km return)")
|
||
XCTAssertEqual(confirmed.spokenLabel(unit: .miles), "2.0 miles")
|
||
XCTAssertEqual(confirmed.spokenLabelWithReturn(unit: .miles), "2.0 miles, 4.0 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)) (\(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)), \(DistanceUnit.kilometers.spoken(approximate.km * 2)) 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 * 1.60934,
|
||
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 * 1.60934,
|
||
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())
|
||
}
|
||
}
|