import XCTest #if SWIFT_PACKAGE @testable import FuelBoardShared #endif /// Regression: a station's price age never removes it from Best Value. /// /// The relay publishes one `price_updated` per station — the newest /// `price_last_updated` across the grades it maps — which cannot vouch for the /// selected grade. It is therefore display-only: Best Value, Cheapest and /// Closest rank stations on their prices and distances exactly as before, /// whether the timestamp is minutes, months or absent. final class StationPriceAgePolicyTests: XCTestCase { private let now = Date(timeIntervalSince1970: 1_800_000_000) private let settings = FuelSavingsSettings( enabled: true, mpg: 30, preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds) ) private func station(_ id: String, price: Double, fuel: FuelType = .e10, ageDays: Double?, lat: Double = 53, lng: Double = -1) -> FuelStation { FuelStation( id: id, name: id, brand: "X", address: "", postcode: "", lat: lat, lng: lng, prices: [fuel: price], priceUpdated: ageDays.map { now.addingTimeInterval(-$0 * 86_400).timeIntervalSince1970 } ) } private func straightLine(_ miles: Double) -> FuelSavingsJourney { FuelSavingsJourney(miles: miles, basis: .straightLine) } private func distanceProvider(_ miles: [String: Double]) -> (FuelStation) -> FuelSavingsJourney? { { station in miles[station.id].map { self.straightLine($0) } } } // MARK: - The baseline is the closest seller, whatever its age func testClosestSellerIsTheBaselineEvenWhenItsTimestampIsAncient() { let ancientClosest = station("ancient-closest", price: 199.9, ageDays: 250) let freshFurther = station("fresh-further", price: 170.9, ageDays: 1) let journeys = distanceProvider(["ancient-closest": 0.8, "fresh-further": 3.0]) XCTAssertEqual( FuelSavingsEstimator.baselineStation([ancientClosest, freshFurther], fuel: .e10, journeyMiles: journeys)?.id, "ancient-closest", "the baseline is the closest station selling the selected fuel — age is not a criterion" ) } func testBaselineStillRequiresTheSelectedFuelAndAUsableJourney() { let wrongFuel = station("wrong-fuel", price: 140, fuel: .diesel, ageDays: 1) let unusableJourney = station("bad-journey", price: 141, ageDays: 1) let sellsFuel = station("sells-fuel", price: 145, ageDays: 1) let journeys = distanceProvider(["wrong-fuel": 0.1, "sells-fuel": 2.0]) // bad-journey has none XCTAssertEqual( FuelSavingsEstimator.baselineStation([wrongFuel, unusableJourney, sellsFuel], fuel: .e10, journeyMiles: journeys)?.id, "sells-fuel" ) } // MARK: - Old, missing and invalid timestamps still rank func testOldPriceTimestampsRankByNetSavingForEveryFuel() { for fuel in FuelType.allCases { let base = station("base", price: 199.9, fuel: fuel, ageDays: 250) let cheap = station("cheap", price: 129.9, fuel: fuel, ageDays: 250) let dear = station("dear", price: 198.9, fuel: fuel, ageDays: 250) let journeys = distanceProvider(["base": 0.8, "cheap": 1.2, "dear": 1.0]) let ranked = FuelSavingsEstimator.ranked([dear, cheap, base], fuel: fuel, baseline: base, settings: settings, amount: settings.preferredAmount, journeyMiles: journeys) XCTAssertEqual(ranked.map(\.id), ["cheap", "dear", "base"], fuel.rawValue) let saving = FuelSavingsEstimator.availability( station: cheap, fuel: fuel, baseline: base, settings: settings, amount: settings.preferredAmount, journey: straightLine(1.2), baselineJourney: straightLine(0.8) )?.result XCTAssertGreaterThan(saving?.netSavingGBP ?? -1, 0, "\(fuel.rawValue): a 250-day-old price still produces a saving") } } func testNetSavingStillOutranksPumpPriceWhenTimestampsAreOld() { // Net saving decides the order: the cheapest station is 30 miles away, // so a pricier one a mile from home wins. Nothing here consults the age. let base = station("base", price: 171.9, ageDays: 120) let cheapFar = station("cheap-far", price: 169.9, ageDays: 250) let pricierNear = station("pricier-near", price: 170.9, ageDays: 200) let journeys = distanceProvider(["base": 0.8, "cheap-far": 30, "pricier-near": 1.2]) let ranked = FuelSavingsEstimator.ranked([cheapFar, pricierNear, base], fuel: .e10, baseline: base, settings: settings, amount: settings.preferredAmount, journeyMiles: journeys) XCTAssertEqual(ranked.map(\.id), ["pricier-near", "base", "cheap-far"]) XCTAssertEqual(ranked.first?.prices[.e10], 170.9, "the winner is chosen on net saving, not pump price") } func testMissingFutureAndAbsurdTimestampsDoNotRemoveStations() { let invalid = FuelStation(id: "absurd", name: "Absurd", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [.e10: 150.9], priceUpdated: -Double.greatestFiniteMagnitude) let pool = [station("base", price: 199.9, ageDays: 250), station("cheap", price: 129.9, ageDays: 100), FuelStation(id: "future", name: "Future", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [.e10: 160.9], priceUpdated: now.addingTimeInterval(86_400).timeIntervalSince1970), station("undated", price: 170.9, ageDays: nil), invalid] let journeys = distanceProvider(["base": 0.8, "cheap": 1.2, "future": 1.6, "undated": 2.0, "absurd": 2.8]) let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: pool[0], settings: settings, amount: settings.preferredAmount, journeyMiles: journeys) XCTAssertEqual(Set(ranked.map(\.id)), Set(pool.map(\.id)), "every station stays in the pool: no timestamp can filter a row out") XCTAssertEqual(ranked.first?.id, "cheap", "the cheapest station still wins") } func testEveryPricedStationKeepsAResultWhateverItsTimestamp() { let pool = [ station("base", price: 199.9, ageDays: 1), station("fresh", price: 180.9, ageDays: 1), station("old", price: 175.9, ageDays: 40), station("ancient", price: 170.9, ageDays: 250), station("undated", price: 165.9, ageDays: nil) ] let journeys = distanceProvider(["base": 0.8, "fresh": 1.0, "old": 1.4, "ancient": 1.8, "undated": 2.2]) let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: pool[0], settings: settings, amount: settings.preferredAmount, journeyMiles: journeys) XCTAssertEqual(ranked.map(\.id).sorted(), ["ancient", "base", "fresh", "old", "undated"]) for station in pool { let result = FuelSavingsEstimator.availability( station: station, fuel: .e10, baseline: pool[0], settings: settings, amount: settings.preferredAmount, journey: journeys(station), baselineJourney: straightLine(0.8) )?.result XCTAssertNotNil(result, "\(station.id) still shows a Best Value row") } } func testCheapestAndClosestOrdersAreUnaffectedByTimestampAge() { // Cheapest sorts on pump price, Closest on distance — the only inputs are // prices and distances, so no age combination changes either order. let specs: [(String, Double, Double)] = [("a", 199.9, 0.8), ("b", 129.9, 30), ("c", 170.9, 1.2)] for ages in [[1.0, 1.0, 1.0], [250.0, 250.0, 250.0]] { let pool = zip(specs, ages).map { spec, age in station(spec.0, price: spec.1, ageDays: age) } let journeys = distanceProvider(Dictionary(uniqueKeysWithValues: zip(specs.map(\.0), specs.map(\.2)))) let cheapestFirst = pool.sorted { lhs, rhs in let left = lhs.prices[.e10] ?? .infinity let right = rhs.prices[.e10] ?? .infinity if left != right { return left < right } return (journeys(lhs)?.miles ?? .infinity) < (journeys(rhs)?.miles ?? .infinity) } XCTAssertEqual(cheapestFirst.map(\.id), ["b", "c", "a"]) let closestFirst = pool.sorted { lhs, rhs in let left = journeys(lhs)?.miles ?? .infinity let right = journeys(rhs)?.miles ?? .infinity if left != right { return left < right } return (lhs.prices[.e10] ?? .infinity) < (rhs.prices[.e10] ?? .infinity) } XCTAssertEqual(closestFirst.map(\.id), ["a", "c", "b"]) } } // MARK: - The bundled / offline dataset func testBundledDatasetWithMonthOldTimestampsStillRecommendsBestValue() { // The bundled dump ships one pull of the whole UK feed, so every station // arrives with the same months-old timestamp. Best Value must still // recommend — it used to read "unavailable" for the entire offline list. var specs: [(String, Double, Double, Double?)] = [("base", 171.9, 0.8, 90), ("pricier-near", 170.9, 1.2, 200), ("cheap-far", 149.9, 40, 250), ("dear-near", 179.9, 1.6, nil), ("old-undated", 160.9, 5.0, 240)] let pool = specs.map { station($0.0, price: $0.1, ageDays: $0.3) } let journeys = distanceProvider(Dictionary(uniqueKeysWithValues: specs.map { ($0.0, $0.2) })) let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: pool[0], settings: settings, amount: settings.preferredAmount, journeyMiles: journeys) let savings = pool.map { station in (station.id, FuelSavingsEstimator.availability( station: station, fuel: .e10, baseline: pool[0], settings: settings, amount: settings.preferredAmount, journey: journeys(station), baselineJourney: straightLine(0.8) )?.result?.netSavingGBP ?? -Double.infinity) } let expectedWinner = savings.max { $0.1 < $1.1 }?.0 XCTAssertEqual(ranked.first?.id, expectedWinner, "the offline list is still ordered by net saving") XCTAssertNotNil(ranked.first?.prices[.e10], "the top station has a price to show") XCTAssertGreaterThan(savings.first { $0.0 == expectedWinner }?.1 ?? -1, 0, "and that recommendation carries a real saving") } }