Fixing the bug where favouriting an Unleaded entry also created a Diesel favourite. Favourites are now [FavouriteEntry] = (station, fuel) pairs: - Starring a row pins it only for the fuel being viewed - Favourites tabs show only fuels that actually have favourites - Geofence monitor gives priority slots only to favourites matching the monitored fuel - Tests updated + new fuel-scoping test (31/31 passing)
246 lines
12 KiB
Swift
246 lines
12 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 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"])
|
||
}
|
||
}
|
||
|
||
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|"))
|
||
}
|
||
}
|