The relay's price_updated is the maximum price_last_updated across the grades it maps, so it cannot vouch for the selected grade: stop using it to score, gate or warn about Best Value. - FuelPriceFreshness is now station-level and display-only: one age per station, fresh/outdated/unknown/invalid, "May be outdated" past 30 days, with future-tolerance and non-finite guards. - Best Value restores the closest-station-selling-the-selected-fuel baseline and its savings ranking; eligibility gating, eligible-first sorting and the unavailable banner are gone. - Price age appears in the More info sheet only - no listing-row warnings, no extra banners. - Copy reverted from "closest eligible station" to "closest station"; stale price-confidence strings removed, "May be outdated" added. - Dormant per-grade timestamp plumbing removed, and regression tests added for every fuel type, missing/invalid/future stamps and the bundled offline dump.
220 lines
12 KiB
Swift
220 lines
12 KiB
Swift
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")
|
|
}
|
|
}
|