564 lines
27 KiB
Swift
564 lines
27 KiB
Swift
import XCTest
|
||
@testable import FuelBoardShared
|
||
|
||
// FuelBoard shared-logic tests. Run from FuelBoardTests/ with `swift test`.
|
||
// Covers the pure logic that drives the app + widget: title sanitizer, price
|
||
// guard, RAG, sorting baselines, distance, brand normalization, fuel labels.
|
||
|
||
final class TitleSanitizerTests: XCTestCase {
|
||
func testAllCapsToTitleCase() {
|
||
XCTAssertEqual("SHELL SALTERHEBBLE".sanitizedStationTitle, "Shell Salterhebble")
|
||
XCTAssertEqual("TESCO EXPRESS YORK".sanitizedStationTitle, "Tesco Express York")
|
||
}
|
||
|
||
func testSainsburysApostrophe() {
|
||
XCTAssertEqual("SAINSBURYS HALIFAX".sanitizedStationTitle, "Sainsbury's Halifax")
|
||
XCTAssertEqual("SAINSBURYS".sanitizedStationTitle, "Sainsbury's")
|
||
}
|
||
|
||
func testAcronymsStayUppercase() {
|
||
XCTAssertEqual("BP HESSLE".sanitizedStationTitle, "BP Hessle")
|
||
XCTAssertEqual("MFG BRIGHOUSE".sanitizedStationTitle, "MFG Brighouse")
|
||
XCTAssertEqual("ASDA WAKEFIELD".sanitizedStationTitle, "ASDA Wakefield")
|
||
XCTAssertEqual("MOTO WETHERBY".sanitizedStationTitle, "MOTO Wetherby")
|
||
}
|
||
|
||
func testConnectorsLowercase() {
|
||
XCTAssertEqual("GULF OIL OF MANCHESTER".sanitizedStationTitle, "Gulf Oil of Manchester")
|
||
}
|
||
|
||
func testLtd() {
|
||
XCTAssertEqual("SMITHS GARAGES LTD".sanitizedStationTitle, "Smiths Garages Ltd")
|
||
}
|
||
|
||
func testHyphenParenChunks() {
|
||
XCTAssertEqual("NEWCASTLE-UNDER-LYME SERVICES".sanitizedStationTitle, "Newcastle-Under-Lyme Services")
|
||
XCTAssertEqual("SPAR (MEADOWHALL)".sanitizedStationTitle, "SPAR (Meadowhall)")
|
||
}
|
||
|
||
func testMixedCasePassthrough() {
|
||
XCTAssertEqual("Shell Salterhebble".sanitizedStationTitle, "Shell Salterhebble")
|
||
XCTAssertEqual("Esso M62 EASTBOUND".sanitizedStationTitle, "Esso M62 Eastbound")
|
||
}
|
||
|
||
func testNoLettersPassthrough() {
|
||
XCTAssertEqual("M62 J25".sanitizedStationTitle, "M62 J25")
|
||
}
|
||
|
||
func testAmpersandChunkReset() {
|
||
XCTAssertEqual("SHELL & BP SERVICES".sanitizedStationTitle, "Shell & BP Services")
|
||
}
|
||
}
|
||
|
||
final class PriceGuardTests: XCTestCase {
|
||
func testInBandPricesKept() throws {
|
||
let json = """
|
||
{"stations":[{"id":"s1","name":"SHELL LEEDS","brand":"Shell","address":"A1","postcode":"LS1 1AA","lat":53.8,"lng":-1.5,"prices":{"E10":137.9,"E5":144.9,"DIESEL":142.9}}]}
|
||
"""
|
||
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
|
||
XCTAssertEqual(stations.count, 1)
|
||
XCTAssertEqual(stations[0].prices[.e10], 137.9)
|
||
XCTAssertEqual(stations[0].prices[.e5], 144.9)
|
||
XCTAssertEqual(stations[0].prices[.diesel], 142.9)
|
||
}
|
||
|
||
func testRelayMetaDecoded() throws {
|
||
let json = """
|
||
{"source":"api","stations_count":8010,"data_updated":"2026-08-12T10:23:00.000Z","stations":[{"id":"s1","name":"SHELL LEEDS","brand":"Shell","address":"A1","postcode":"LS1 1AA","lat":53.8,"lng":-1.5,"prices":{"E10":137.9}}]}
|
||
"""
|
||
let meta = FuelPriceProvider.decodeRelayMeta(from: Data(json.utf8))
|
||
XCTAssertEqual(meta?.source, "api")
|
||
XCTAssertEqual(meta?.stationCount, 8010)
|
||
XCTAssertEqual(meta?.dataUpdated, "2026-08-12T10:23:00.000Z")
|
||
}
|
||
|
||
func testRelayMetaAbsentIsNil() throws {
|
||
// Older relay without envelope metadata must not fail decode — nil meta.
|
||
let json = """
|
||
{"stations":[{"id":"s1","name":"SHELL LEEDS","brand":"Shell","address":"A1","postcode":"LS1 1AA","lat":53.8,"lng":-1.5,"prices":{"E10":137.9}}]}
|
||
"""
|
||
let meta = FuelPriceProvider.decodeRelayMeta(from: Data(json.utf8))
|
||
XCTAssertNil(meta?.source)
|
||
XCTAssertNil(meta?.stationCount)
|
||
XCTAssertNil(meta?.dataUpdated)
|
||
}
|
||
|
||
func testOutOfBandPricesDropped() throws {
|
||
let json = """
|
||
{"stations":[{"id":"s1","name":"GARBAGE","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":1.3,"E5":1589.0,"DIESEL":137.9}}]}
|
||
"""
|
||
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
|
||
XCTAssertEqual(stations.count, 1)
|
||
XCTAssertNil(stations[0].prices[.e10], "1.3p garbage must be dropped")
|
||
XCTAssertNil(stations[0].prices[.e5], "1589p garbage must be dropped")
|
||
XCTAssertEqual(stations[0].prices[.diesel], 137.9, "in-band price kept")
|
||
}
|
||
|
||
func testBoundaryValues() throws {
|
||
let json = """
|
||
{"stations":[{"id":"s1","name":"BOUNDARY","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":50.0,"E5":500.0,"DIESEL":49.9}}]}
|
||
"""
|
||
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
|
||
XCTAssertEqual(stations[0].prices[.e10], 50.0, "lower bound inclusive")
|
||
XCTAssertEqual(stations[0].prices[.e5], 500.0, "upper bound inclusive")
|
||
XCTAssertNil(stations[0].prices[.diesel], "49.9 below band dropped")
|
||
}
|
||
|
||
func testB7VariantsMapToDiesel() throws {
|
||
// A station sells ONE diesel grade — test each variant individually.
|
||
for (grade, price) in [("B7S", 141.9), ("B7P", 143.9), ("B10", 140.9)] {
|
||
let json = """
|
||
{"stations":[{"id":"s1","name":"DIESEL MAP","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"\(grade)":\(price)}}]}
|
||
"""
|
||
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
|
||
XCTAssertEqual(stations[0].prices[.diesel], price, "\(grade) maps to diesel")
|
||
}
|
||
}
|
||
|
||
func testLegacyPriceFallback() throws {
|
||
let json = """
|
||
{"stations":[{"id":"s1","name":"LEGACY","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"price":136.5}]}
|
||
"""
|
||
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
|
||
XCTAssertEqual(stations[0].prices[.e10], 136.5, "legacy price maps to E10")
|
||
}
|
||
|
||
func testLegacyGarbagePriceDropped() throws {
|
||
let json = """
|
||
{"stations":[{"id":"s1","name":"LEGACY BAD","brand":"X","address":"A","postcode":"L","lat":0,"lng":0,"price":1589.0}]}
|
||
"""
|
||
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
|
||
XCTAssertTrue(stations[0].prices.isEmpty, "garbage legacy price dropped")
|
||
}
|
||
|
||
func testRelayNameSanitizedAtDecode() throws {
|
||
let json = """
|
||
{"stations":[{"id":"s1","name":"TESCO EXPRESS LEEDS","brand":"Tesco","address":"A","postcode":"L","lat":0,"lng":0,"prices":{"E10":137.9}}]}
|
||
"""
|
||
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
|
||
XCTAssertEqual(stations[0].name, "Tesco Express Leeds")
|
||
}
|
||
}
|
||
|
||
final class RAGTests: XCTestCase {
|
||
func testGreenWithinOneAndHalf() {
|
||
XCTAssertEqual(RAGRating.rating(price: 138.0, cheapest: 137.0), .green)
|
||
XCTAssertEqual(RAGRating.rating(price: 138.5, cheapest: 137.0), .green, "exactly 1.5p is green")
|
||
}
|
||
|
||
func testAmberWithinFour() {
|
||
XCTAssertEqual(RAGRating.rating(price: 141.0, cheapest: 137.0), .amber)
|
||
XCTAssertEqual(RAGRating.rating(price: 141.0, cheapest: 137.0), .amber)
|
||
XCTAssertEqual(RAGRating.rating(price: 141.0, cheapest: 137.0), .amber, "exactly 4p is amber")
|
||
}
|
||
|
||
func testRedBeyondFour() {
|
||
XCTAssertEqual(RAGRating.rating(price: 141.1, cheapest: 137.0), .red)
|
||
}
|
||
}
|
||
|
||
final class SortBaselineTests: XCTestCase {
|
||
// Baseline = cheapest within the chosen radius (both Cheapest and Closest).
|
||
// TOP badge goes to the cheapest station in the pool.
|
||
func testCheapestFirstSorting() {
|
||
let a = FuelStation(id: "a", name: "A", brand: "X", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [.e10: 140.0], priceUpdated: nil)
|
||
let b = FuelStation(id: "b", name: "B", brand: "X", address: "", postcode: "", lat: 53.71, lng: -1.81, prices: [.e10: 137.0], priceUpdated: nil)
|
||
let c = FuelStation(id: "c", name: "C", brand: "X", address: "", postcode: "", lat: 53.72, lng: -1.82, prices: [.e10: 139.0], priceUpdated: nil)
|
||
let sorted = [a, b, c].sorted { $0.prices[.e10]! < $1.prices[.e10]! }
|
||
XCTAssertEqual(sorted.map(\.id), ["b", "c", "a"])
|
||
}
|
||
|
||
// Ties: stations at the same cheapest price are found (within 0.01p) and
|
||
// sorted nearest-first from the reference location.
|
||
func testTiedStations() {
|
||
func station(_ id: String, _ lat: Double, _ lng: Double, _ price: Double) -> FuelStation {
|
||
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
|
||
lat: lat, lng: lng, prices: [.e10: price], priceUpdated: nil)
|
||
}
|
||
let far = station("far", 53.72, -1.82, 137.0)
|
||
let near = station("near", 53.71, -1.81, 137.0)
|
||
let other = station("other", 53.70, -1.80, 141.0)
|
||
let tied = FuelStore.tiedStations(in: [far, other, near], fuel: .e10, price: 137.0,
|
||
fromLat: 53.7, lng: -1.8)
|
||
XCTAssertEqual(tied.map(\.id), ["near", "far"], "tied stations sorted nearest-first")
|
||
}
|
||
|
||
// A price 0.005p away still counts as a tie; 0.05p away does not.
|
||
func testTiedStationsTolerance() {
|
||
func station(_ id: String, _ price: Double) -> FuelStation {
|
||
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
|
||
lat: 53.7, lng: -1.8, prices: [.e10: price], priceUpdated: nil)
|
||
}
|
||
let a = station("a", 137.005)
|
||
let b = station("b", 137.05)
|
||
XCTAssertEqual(FuelStore.tiedStations(in: [a, b], fuel: .e10, price: 137.0,
|
||
fromLat: 53.7, lng: -1.8).map(\.id), ["a"])
|
||
}
|
||
}
|
||
|
||
final class DistanceTests: XCTestCase {
|
||
func testZeroDistance() {
|
||
let s = FuelStation(id: "a", name: "A", brand: "X", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [:], priceUpdated: nil)
|
||
XCTAssertEqual(s.distanceKM(to: 53.7, lng2: -1.8), 0, accuracy: 0.001)
|
||
}
|
||
|
||
func testKnownDistance() {
|
||
// London (51.5074, -0.1278) → Manchester (53.4808, -2.2426) ≈ 262 km
|
||
let s = FuelStation(id: "a", name: "A", brand: "X", address: "", postcode: "", lat: 51.5074, lng: -0.1278, prices: [:], priceUpdated: nil)
|
||
XCTAssertEqual(s.distanceKM(to: 53.4808, lng2: -2.2426), 262, accuracy: 5)
|
||
}
|
||
|
||
func testMilesConversion() {
|
||
// 5 miles ≈ 8.05 km (app uses × 1.60934)
|
||
XCTAssertEqual(5 * 1.60934, 8.0467, accuracy: 0.001)
|
||
// 262 km ≈ 162.8 miles (display uses × 0.621371)
|
||
XCTAssertEqual(262 * 0.621371, 162.8, accuracy: 0.1)
|
||
}
|
||
}
|
||
|
||
final class BrandTests: XCTestCase {
|
||
private func station(brand: String) -> FuelStation {
|
||
FuelStation(id: UUID().uuidString, name: "N", brand: brand, address: "", postcode: "", lat: 0, lng: 0, prices: [:], priceUpdated: nil)
|
||
}
|
||
|
||
func testKnownBrands() {
|
||
XCTAssertEqual(station(brand: "SHELL LEEDS ROAD").brandImageName, "brand_shell")
|
||
XCTAssertEqual(station(brand: "SAINSBURYS").brandImageName, "brand_sainsburys")
|
||
XCTAssertEqual(station(brand: "MORRISONS").brandImageName, "brand_morrisons")
|
||
XCTAssertEqual(station(brand: "TESCO EXPRESS").brandImageName, "brand_tesco")
|
||
XCTAssertEqual(station(brand: "BP").brandImageName, "brand_bp")
|
||
XCTAssertEqual(station(brand: "ESSO").brandImageName, "brand_esso")
|
||
XCTAssertEqual(station(brand: "ASDA").brandImageName, "brand_asda")
|
||
XCTAssertEqual(station(brand: "GULF").brandImageName, "brand_gulf")
|
||
XCTAssertEqual(station(brand: "JET").brandImageName, "brand_jet")
|
||
XCTAssertEqual(station(brand: "TEXACO").brandImageName, "brand_texaco")
|
||
XCTAssertEqual(station(brand: "APPLEGREEN").brandImageName, "brand_applegreen")
|
||
}
|
||
|
||
func testUnknownBrand() {
|
||
XCTAssertNil(station(brand: "WELCOME BREAK").brandImageName)
|
||
XCTAssertNil(station(brand: "VALERO").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")
|
||
}
|
||
}
|