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 = FuelStation(id: "s1", name: "OLD NAME", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil) 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].name, "Fresh Station") XCTAssertEqual(updated[0].prices[.e10], 132.9) } func testRefreshedFavouritesKeepUnmatchedSnapshot() { let fav = FuelStation(id: "s1", name: "Cached", brand: "X", address: "", postcode: "", lat: 0, lng: 0, prices: [.e10: 140.0], priceUpdated: nil) let updated = FuelStore.refreshedFavourites([fav], from: []) XCTAssertEqual(updated[0].name, "Cached", "unmatched favourite keeps its snapshot") XCTAssertEqual(updated[0].prices[.e10], 140.0) } }