Distance confidence - Every distance surface takes the figure AND its "Approx." marker from one FuelStore.stationDistanceDisplay call (station rows, detail sheet, widget, Live Activity, watch, Siri), so a surface can never mark one figure approximate while showing a routed one. Radius filters and Closest ordering test that same displayed distance, so no row can read a distance outside the radius that filtered it (the reported "14.6 mi inside a 10-mile list"). Accurate distances (Settings -> Distances, default off) - Off: the browse layer serves the straight line, marked Approx., for every row, so two installs agree to the metre and the app makes no route requests at all. - On: routed distances for the whole search area, unmarked, served only from a COMPLETE set anchored within 600 m — a partial fill reverts every row at once rather than mixing road and straight-line bases. - Best Value maths sits deliberately outside the toggle: it keeps using measured road routes and its own confidence marking, so money figures never move with the switch. - Fills run only while the toggle is on and the app is frontmost; capacity raised to 600 stations per pass; completeness tolerates pins Apple Maps has no route for, which otherwise left an area permanently incomplete and the whole list stuck on estimates. Performance (the reported slowdown switching to Closest / launching with it) - FuelStore.accurateDistancesEnabled was a UserDefaults(suiteName:) read per call: a synchronous cfprefsd round-trip measured at ~1.9 us, ~200k calls per Closest sort. Memoised, with the setter keeping it in step. - Both keyed sorts decorate their keys — one distance per station, carried as an index instead of copying whole stations into the decorated array — and the comparison baseline plus per-station journeys now come from ONE pass instead of two full scans per tab. The Stations tab also reuses its single sorted list for the count and the TOP badge instead of sorting the country three times. - Measured on iPhone SE (7,914-station Closest pool): sort 36.6 ms -> 17.1 ms per list, Cheapest 6 ms, Best Value 11 ms. - DEBUG probes: FB_PERF list timing, FB_PERF_BENCH toggle-read benchmark, and FB_DIST_FOCUS now reports the served value, marker and mode (and re-prints when a fill lands). FB_ACCURATE_DISTANCES / FB_SORT_MODE env forms for devices, where devicectl eats a leading-dash argument. Tests: 198, 0 failures. Device and simulator Debug builds green.
922 lines
49 KiB
Swift
922 lines
49 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.spokenLabel(unit: .miles), "2.0 miles")
|
||
|
||
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.spokenLabel(unit: .kilometers),
|
||
"Approximately " + DistanceUnit.kilometers.spoken(approximate.km))
|
||
}
|
||
|
||
/// 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: - 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())
|
||
}
|
||
}
|