- Add FuelPriceFreshness: selected-fuel-aware eligibility plus a shared comparison confidence, so a saving is presented as verified only when both the candidate and the baseline pass the gate for the fuel on screen. - Rank against the closest station with an eligible selected-fuel price instead of the physically closest station, so a single stale neighbour can no longer invalidate every comparison in the list. Ordinary Closest sorting still includes every priced station. - Keep ineligible prices visible, fall back to pump-price order, and label Best Value unavailable when no comparison qualifies. - Widget: same baseline and confidence path as the app. - Tests: stale-closest baseline, alert baseline, eligibility policy, boundary and missing-timestamp regressions.
834 lines
38 KiB
Swift
834 lines
38 KiB
Swift
import XCTest
|
||
#if SWIFT_PACKAGE
|
||
@testable import FuelBoardShared
|
||
#endif
|
||
|
||
/// Ranking rules for the Worth the Drive sort.
|
||
///
|
||
/// Regression: the sort used to rank only a shortlist of the nearest stations
|
||
/// whose ROAD ROUTES had been measured, and everything else was listed as
|
||
/// unavailable. Cheaper stations a few miles further out — the exact stations
|
||
/// the sort exists to surface — were dropped from the top of the list, and
|
||
/// stations on later pages showed no savings at all.
|
||
final class FuelSavingsEstimateTests: XCTestCase {
|
||
private let settings = FuelSavingsSettings(
|
||
enabled: true,
|
||
mpg: 45,
|
||
preferredAmount: FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||
)
|
||
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||
|
||
private func station(_ id: String,
|
||
price: Double?,
|
||
lat: Double = 51.5,
|
||
lng: Double = -0.1) -> FuelStation {
|
||
FuelStation(
|
||
id: id,
|
||
name: id,
|
||
brand: "X",
|
||
address: "",
|
||
postcode: "",
|
||
lat: lat,
|
||
lng: lng,
|
||
prices: price.map { [FuelType.e10: $0] } ?? [:],
|
||
priceUpdated: nil
|
||
)
|
||
}
|
||
|
||
/// Straight-line out-and-back estimate, as the app builds it when no road
|
||
/// route has been measured yet.
|
||
private func straightLine(_ miles: Double) -> FuelSavingsJourney {
|
||
FuelSavingsJourney(miles: miles, basis: .straightLine)
|
||
}
|
||
|
||
func testDifferentFuelNeverUsesAnotherGradeAsItsBaseline() {
|
||
let petrolOnly = FuelStation(id: "petrol", name: "Petrol", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||
prices: [.e10: 199.9], priceUpdated: nil)
|
||
let dieselOnly = FuelStation(id: "diesel", name: "Diesel", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||
prices: [.diesel: 129.9], priceUpdated: nil)
|
||
let nearest = FuelSavingsEstimator.baselineStation([petrolOnly, dieselOnly], fuel: .diesel) { station in
|
||
self.straightLine(station.id == "petrol" ? 0.5 : 1)
|
||
}
|
||
XCTAssertEqual(nearest?.id, "diesel")
|
||
let unavailable = FuelSavingsEstimator.availability(station: dieselOnly, fuel: .diesel, baseline: petrolOnly,
|
||
settings: settings, amount: amount, journey: straightLine(1), baselineJourney: straightLine(0.5))
|
||
XCTAssertEqual(unavailable, .unavailable("No comparison station"))
|
||
}
|
||
|
||
func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() {
|
||
// Nearest station: 1 mile away at 145.9p. The station the user can see
|
||
// in the Cheapest tab: 3.5 miles away at 138.9p.
|
||
let baseline = station("baseline", price: 145.9)
|
||
let candidate = station("cheaper-3.5", price: 138.9, lat: 51.55)
|
||
let filler = (0..<14).map { station("filler-\($0)", price: 150 + Double($0)) }
|
||
let pool = [baseline, candidate] + filler
|
||
|
||
let ranked = FuelSavingsEstimator.ranked(
|
||
pool,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journeyMiles: { s in
|
||
switch s.id {
|
||
case "baseline": return self.straightLine(2.0)
|
||
case "cheaper-3.5": return self.straightLine(7.0)
|
||
default: return self.straightLine(20.0)
|
||
}
|
||
}
|
||
)
|
||
|
||
XCTAssertEqual(ranked.count, pool.count)
|
||
XCTAssertEqual(ranked.first?.id, "cheaper-3.5",
|
||
"a cheaper station 3.5 miles away must rank above the nearest station")
|
||
|
||
let result = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: straightLine(7.0),
|
||
baselineJourney: straightLine(2.0)
|
||
)?.result
|
||
|
||
XCTAssertNotNil(result, "the station must show a saving, not an unavailable state")
|
||
XCTAssertGreaterThan(result?.netSavingGBP ?? -1, 0)
|
||
XCTAssertEqual(result?.status, .worthTheDrive)
|
||
XCTAssertEqual(result?.distanceBasis, .straightLine,
|
||
"unmeasured routes are reported as straight-line estimates")
|
||
}
|
||
|
||
func testBaselineOutsideFilteredPoolStillRanksByNetSaving() {
|
||
let baseline = station("nearest-outside-radius", price: 199.9)
|
||
let cheap = station("z-cheap", price: 129.9)
|
||
let expensive = station("a-expensive", price: 198.9)
|
||
let ranked = FuelSavingsEstimator.ranked(
|
||
[expensive, cheap], fuel: .e10, baseline: baseline,
|
||
settings: settings, amount: amount,
|
||
journeyMiles: { s in self.straightLine(s.id == baseline.id ? 0.8 : 4.0) }
|
||
)
|
||
XCTAssertEqual(ranked.map(\.id), ["z-cheap", "a-expensive"])
|
||
let expected = FuelSavingsEstimator.availability(
|
||
station: cheap, fuel: .e10, baseline: baseline,
|
||
settings: settings, amount: amount,
|
||
journey: straightLine(4.0), baselineJourney: straightLine(0.8)
|
||
)?.result
|
||
XCTAssertGreaterThan(expected?.netSavingGBP ?? -1, 0)
|
||
}
|
||
|
||
func testEveryPricedStationInThePoolIsEstimatedNotJustAShortlist() {
|
||
// 40 stations, but only the first one has a measured road route — the
|
||
// old implementation capped the whole ranking to 12 shortlisted
|
||
// stations and returned "Outside route shortlist" for the rest.
|
||
let baseline = station("baseline", price: 145.9)
|
||
let pool = [baseline] + (0..<39).map { station("s\($0)", price: 135 + Double($0)) }
|
||
|
||
let ranked = FuelSavingsEstimator.ranked(
|
||
pool,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journeyMiles: { s in
|
||
s.id == "baseline"
|
||
? FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||
: self.straightLine(2.0 + (Double(s.id.dropFirst()) ?? 6))
|
||
}
|
||
)
|
||
|
||
XCTAssertEqual(ranked.count, pool.count)
|
||
|
||
for station in pool where station.id != "baseline" {
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: station,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: straightLine(10.0),
|
||
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||
)
|
||
XCTAssertNotNil(availability?.result,
|
||
"\(station.id) should carry an estimated saving, not an unavailable state")
|
||
}
|
||
}
|
||
|
||
func testMeasuredRoadRoutesAreReportedAsRoadRouteBasis() {
|
||
let baseline = station("baseline", price: 145.9)
|
||
let candidate = station("candidate", price: 138.9)
|
||
|
||
let result = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: FuelSavingsJourney(miles: 7.0, basis: .roadRoute),
|
||
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||
)?.result
|
||
|
||
XCTAssertEqual(result?.distanceBasis, .roadRoute)
|
||
XCTAssertFalse(FuelSavingsDistanceBasis.roadRoute.isEstimate)
|
||
}
|
||
|
||
func testMixedBasisIsReportedAsAnEstimate() {
|
||
let baseline = station("baseline", price: 145.9)
|
||
let candidate = station("candidate", price: 138.9)
|
||
|
||
// Baseline measured, candidate not: the comparison is only as good as
|
||
// its weakest leg, so it must be labelled an estimate.
|
||
let result = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: straightLine(7.0),
|
||
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||
)?.result
|
||
|
||
XCTAssertEqual(result?.distanceBasis, .straightLine)
|
||
}
|
||
|
||
func testBestValueBaselineSkipsClosestStalePriceButKeepsThatStationVisible() {
|
||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
|
||
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
|
||
}
|
||
let staleClosest = priced("stale-closest", 199.9, 27)
|
||
let eligibleClosest = priced("eligible-closest", 170.9, 1)
|
||
let eligibleFurther = priced("eligible-further", 150.9, 1)
|
||
let pool = [staleClosest, eligibleClosest, eligibleFurther]
|
||
let journeys: (FuelStation) -> FuelSavingsJourney? = { station in
|
||
self.straightLine(station.id == "stale-closest" ? 0.8 : station.id == "eligible-closest" ? 1.6 : 3.0)
|
||
}
|
||
let baseline = FuelSavingsEstimator.bestValueBaselineStation(pool, fuel: .e10,
|
||
journeyMiles: journeys, now: now)
|
||
XCTAssertEqual(baseline?.id, "eligible-closest")
|
||
XCTAssertEqual(FuelSavingsEstimator.baselineStation(pool, fuel: .e10, journeyMiles: journeys)?.id,
|
||
"stale-closest", "non-Best-Value closest behaviour must stay unchanged")
|
||
let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: baseline,
|
||
settings: briefSettings, amount: briefAmount, journeyMiles: journeys, now: now)
|
||
XCTAssertEqual(ranked.first?.id, "eligible-further")
|
||
XCTAssertTrue(ranked.contains(where: { $0.id == "stale-closest" }))
|
||
XCTAssertFalse(FuelSavingsEstimator.recommendationEligible(station: staleClosest, fuel: .e10,
|
||
baseline: baseline, now: now))
|
||
}
|
||
|
||
func testBestValueBaselineRequiresEligibleSelectedFuelAndUsableJourney() {
|
||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||
let recent = now.addingTimeInterval(-86_400).timeIntervalSince1970
|
||
let stale = now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970
|
||
let stations = [
|
||
FuelStation(id: "wrong-fuel", name: "Wrong", brand: "", address: "", postcode: "", lat: 0, lng: 0,
|
||
prices: [.diesel: 140], priceUpdated: recent),
|
||
FuelStation(id: "stale", name: "Stale", brand: "", address: "", postcode: "", lat: 0, lng: 0,
|
||
prices: [.e10: 140], priceUpdated: stale),
|
||
FuelStation(id: "bad-journey", name: "Bad journey", brand: "", address: "", postcode: "", lat: 0, lng: 0,
|
||
prices: [.e10: 141], priceUpdated: recent),
|
||
FuelStation(id: "eligible", name: "Eligible", brand: "", address: "", postcode: "", lat: 0, lng: 0,
|
||
prices: [.e10: 145], priceUpdated: recent)
|
||
]
|
||
let journeys: (FuelStation) -> FuelSavingsJourney? = { station in
|
||
self.straightLine(station.id == "wrong-fuel" ? 0.1 : station.id == "stale" ? 0.2 :
|
||
station.id == "bad-journey" ? .infinity : 2)
|
||
}
|
||
XCTAssertEqual(FuelSavingsEstimator.bestValueBaselineStation(stations, fuel: .e10,
|
||
journeyMiles: journeys, now: now)?.id, "eligible")
|
||
XCTAssertNil(FuelSavingsEstimator.bestValueBaselineStation(Array(stations.prefix(2)), fuel: .e10,
|
||
journeyMiles: journeys, now: now))
|
||
}
|
||
|
||
func testBaselineStationUsesTheSameJourneyValuesAsSavings() {
|
||
let geographicallyNearest = station("geographically-nearest", price: 145.9)
|
||
let roadNearest = station("road-nearest", price: 146.9)
|
||
let noPrice = station("no-price", price: nil)
|
||
|
||
let baseline = FuelSavingsEstimator.baselineStation(
|
||
[geographicallyNearest, roadNearest, noPrice],
|
||
fuel: .e10,
|
||
journeyMiles: { station in
|
||
switch station.id {
|
||
case "geographically-nearest": return FuelSavingsJourney(miles: 8.0, basis: .roadRoute)
|
||
case "road-nearest": return FuelSavingsJourney(miles: 4.0, basis: .roadRoute)
|
||
case "no-price": return FuelSavingsJourney(miles: 1.0, basis: .roadRoute)
|
||
default: return nil
|
||
}
|
||
}
|
||
)
|
||
|
||
XCTAssertEqual(baseline?.id, "road-nearest",
|
||
"the Best Value baseline must be the shortest usable journey, not a separate straight-line/display-distance choice")
|
||
}
|
||
|
||
func testBaselineStationFallsBackThenChangesWhenRoadRoutesArrive() {
|
||
let stationA = station("a", price: 145.9)
|
||
let stationB = station("b", price: 146.9)
|
||
|
||
let noRoadRoutes = FuelSavingsEstimator.baselineStation(
|
||
[stationA, stationB],
|
||
fuel: .e10,
|
||
journeyMiles: { station in
|
||
switch station.id {
|
||
case "a": return FuelSavingsJourney(miles: 2.0, basis: .straightLine)
|
||
case "b": return FuelSavingsJourney(miles: 3.0, basis: .straightLine)
|
||
default: return nil
|
||
}
|
||
}
|
||
)
|
||
XCTAssertEqual(noRoadRoutes?.id, "a")
|
||
|
||
let partialRoadRoutes = FuelSavingsEstimator.baselineStation(
|
||
[stationA, stationB],
|
||
fuel: .e10,
|
||
journeyMiles: { station in
|
||
switch station.id {
|
||
case "a": return FuelSavingsJourney(miles: 5.0, basis: .roadRoute)
|
||
case "b": return FuelSavingsJourney(miles: 3.0, basis: .straightLine)
|
||
default: return nil
|
||
}
|
||
}
|
||
)
|
||
XCTAssertEqual(partialRoadRoutes?.id, "b",
|
||
"baseline must be recalculated from the latest journey values when routes arrive")
|
||
}
|
||
|
||
func testRankingToleratesDuplicateStationIDs() {
|
||
let baseline = station("baseline", price: 145.9)
|
||
let duplicateA = station("duplicate", price: 138.9, lat: 51.55)
|
||
let duplicateB = station("duplicate", price: 139.9, lat: 51.56)
|
||
let pool = [baseline, duplicateA, duplicateB]
|
||
|
||
let ranked = FuelSavingsEstimator.ranked(
|
||
pool,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journeyMiles: { s in
|
||
s.id == "baseline" ? self.straightLine(2.0) : self.straightLine(7.0)
|
||
}
|
||
)
|
||
|
||
XCTAssertEqual(ranked.count, pool.count)
|
||
XCTAssertEqual(ranked.filter { $0.id == "duplicate" }.count, 2)
|
||
}
|
||
|
||
func testStationWithoutAPriceIsUnavailable() {
|
||
let baseline = station("baseline", price: 145.9)
|
||
let noPrice = station("no-price", price: nil)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: noPrice,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: straightLine(4.0),
|
||
baselineJourney: straightLine(2.0)
|
||
)
|
||
|
||
XCTAssertEqual(availability, .unavailable("No price"))
|
||
}
|
||
|
||
func testFeatureOffProducesNoSavingsRow() {
|
||
let baseline = station("baseline", price: 145.9)
|
||
let candidate = station("candidate", price: 138.9)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: .defaults,
|
||
amount: amount,
|
||
journey: straightLine(4.0),
|
||
baselineJourney: straightLine(2.0)
|
||
)
|
||
|
||
XCTAssertNil(availability)
|
||
}
|
||
|
||
// MARK: - Required wording and baseline labelling
|
||
|
||
private var briefSettings: FuelSavingsSettings {
|
||
FuelSavingsSettings(enabled: true,
|
||
mpg: 30,
|
||
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds))
|
||
}
|
||
|
||
private var briefAmount: FuelSavingsFillAmount {
|
||
FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||
}
|
||
|
||
/// The scenario from the brief: 30 MPG, £60 fill, baseline Rontec Shelf
|
||
/// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi).
|
||
///
|
||
/// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is
|
||
/// correct, not a calculation bug: £60 buys 35.74 L at 167.9p, so the
|
||
/// cheaper pump saves 35.74 × 4p = £1.43, while the extra 6.8 return miles
|
||
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.30.
|
||
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
|
||
let baseline = station("rontec-shelf", price: 171.9)
|
||
let candidate = station("shell-whetley-hill", price: 167.9)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: briefSettings,
|
||
amount: briefAmount,
|
||
journey: straightLine(7.6), // 3.8 mi out and back
|
||
baselineJourney: straightLine(0.8) // 0.4 mi out and back
|
||
)
|
||
|
||
guard let result = availability?.result else {
|
||
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||
}
|
||
XCTAssertEqual(result.fillLitres, 35.736, accuracy: 0.005,
|
||
"the £60 budget buys litres at the candidate pump price")
|
||
XCTAssertEqual(result.grossSavingGBP, 1.429, accuracy: 0.005)
|
||
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
|
||
"only the return-trip DELTA counts — the shared journey is never counted twice")
|
||
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
|
||
XCTAssertEqual(result.netSavingGBP, -0.301, accuracy: 0.005)
|
||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||
XCTAssertEqual(result.journeyConfidence, .estimated)
|
||
XCTAssertEqual(result.headline(compact: true), "Approx. Costs £0.30 more")
|
||
}
|
||
|
||
func testMeasuredRoadRoutesDropTheEstimateMarker() {
|
||
let baseline = station("baseline", price: 171.9)
|
||
let candidate = station("candidate", price: 167.9)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: briefSettings,
|
||
amount: briefAmount,
|
||
journey: FuelSavingsJourney(miles: 7.6, basis: .roadRoute),
|
||
baselineJourney: FuelSavingsJourney(miles: 0.8, basis: .roadRoute)
|
||
)
|
||
|
||
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
|
||
XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed)
|
||
XCTAssertEqual(availability?.result?.headline(compact: true), "Costs £0.30 more")
|
||
XCTAssertEqual(availability?.result?.headline(), "Costs £0.30 more")
|
||
}
|
||
|
||
func testBaselineRowIsLabelledClosestStation() {
|
||
let baseline = station("nearest", price: 171.9)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: baseline,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: briefSettings,
|
||
amount: briefAmount,
|
||
journey: straightLine(0.8),
|
||
baselineJourney: straightLine(0.8)
|
||
)
|
||
|
||
XCTAssertEqual(availability?.result?.status, .baseline)
|
||
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest eligible station")
|
||
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest eligible station")
|
||
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
|
||
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
|
||
}
|
||
|
||
func testPositiveResultHeadlineSaysSaveAfterTravel() {
|
||
// 4p cheaper AND almost the same distance, so the saving survives travel.
|
||
let baseline = station("baseline", price: 160.0)
|
||
let candidate = station("candidate", price: 150.0)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: briefSettings,
|
||
amount: briefAmount,
|
||
journey: straightLine(6.4),
|
||
baselineJourney: straightLine(6.0)
|
||
)
|
||
|
||
guard let result = availability?.result else {
|
||
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||
}
|
||
XCTAssertEqual(result.status, .worthTheDrive)
|
||
// £60 buys 40 L at 150p; 10p/litre cheaper saves £4.00; 0.4 extra
|
||
// return miles cost ~£0.09. Net ≈ £3.91.
|
||
XCTAssertEqual(result.netSavingGBP, 3.909, accuracy: 0.005)
|
||
XCTAssertEqual(result.headline(compact: true), "Approx. Save £3.91")
|
||
}
|
||
|
||
func testNeutralResultHeadlineIsBreakEven() {
|
||
// 0.1p cheaper over the same distance: inside the break-even tolerance.
|
||
let baseline = station("baseline", price: 150.0)
|
||
let candidate = station("candidate", price: 149.9)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: briefSettings,
|
||
amount: briefAmount,
|
||
journey: straightLine(6.0),
|
||
baselineJourney: straightLine(6.0)
|
||
)
|
||
|
||
XCTAssertEqual(availability?.result?.status, .breakEven)
|
||
XCTAssertEqual(availability?.result?.headline(compact: true), "Break even")
|
||
XCTAssertEqual(availability?.result?.headline(), "Break even")
|
||
}
|
||
|
||
/// The exact strings the brief asks for.
|
||
func testHeadlineWordingMatchesTheBrief() {
|
||
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest eligible station")
|
||
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
|
||
|
||
let saving = FuelSavingsResult(fillLitres: 34.9,
|
||
grossSavingGBP: 1.40,
|
||
extraDrivingCostGBP: 0.88,
|
||
extraDistanceMiles: 3.5,
|
||
netSavingGBP: 0.52,
|
||
status: .worthTheDrive,
|
||
distanceBasis: .roadRoute)
|
||
XCTAssertEqual(saving.headline(), "Save £0.52")
|
||
|
||
let loss = FuelSavingsResult(fillLitres: 34.9,
|
||
grossSavingGBP: 1.40,
|
||
extraDrivingCostGBP: 1.71,
|
||
extraDistanceMiles: 6.8,
|
||
netSavingGBP: -0.31,
|
||
status: .notWorthTheDrive,
|
||
distanceBasis: .roadRoute)
|
||
XCTAssertEqual(loss.headline(), "Costs £0.31 more")
|
||
}
|
||
|
||
/// The sort must order by NET saving, not pump price: a pricier station can
|
||
/// win when the cheaper one is far enough away.
|
||
func testBestValueAlertUsesClosestEligiblePriceAsBaseline() {
|
||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
|
||
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
|
||
}
|
||
let stations = [priced("stale-closest", 199.9, 27), priced("eligible-closest", 170.9, 1),
|
||
priced("eligible-cheaper", 150.9, 1)]
|
||
let settings = FuelSavingsSettings(enabled: true, mpg: 30,
|
||
preferredAmount: .init(value: 60, unit: .pounds))
|
||
let alert = FuelSavingsCalculator.bestValueAlertCandidate(
|
||
stations: stations, fuel: .e10, settings: settings, amount: settings.preferredAmount,
|
||
minimumNetSavingGBP: 1,
|
||
journeyMiles: { self.straightLine($0.id == "stale-closest" ? 0.8 : $0.id == "eligible-closest" ? 1.6 : 3) },
|
||
now: now)
|
||
XCTAssertEqual(alert?.station.id, "eligible-cheaper")
|
||
}
|
||
|
||
func testBestValueAlertDoesNotRecommendStaleCandidateOrStaleBaseline() {
|
||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
|
||
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
|
||
}
|
||
let base = priced("base", 199.9, 1)
|
||
let stale = priced("stale", 129.9, 40)
|
||
let fresh = priced("fresh", 159.9, 1)
|
||
let settings = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 60, unit: .pounds))
|
||
let choice = FuelSavingsCalculator.bestValueAlertCandidate(
|
||
stations: [base, stale, fresh], fuel: .e10, settings: settings,
|
||
amount: .init(value: 60, unit: .pounds), minimumNetSavingGBP: 1,
|
||
journeyMiles: { station in self.straightLine(station.id == "base" ? 0.8 : 2) }, now: now)
|
||
XCTAssertEqual(choice?.station.id, "fresh")
|
||
let noTrustedBaseline = FuelSavingsCalculator.bestValueAlertCandidate(
|
||
stations: [priced("base", 199.9, 40), fresh], fuel: .e10, settings: settings,
|
||
amount: .init(value: 60, unit: .pounds), minimumNetSavingGBP: 1,
|
||
journeyMiles: { station in self.straightLine(station.id == "base" ? 0.8 : 2) }, now: now)
|
||
XCTAssertNil(noTrustedBaseline)
|
||
}
|
||
|
||
func testStalePriceCannotLeadBestValueButRemainsVisible() {
|
||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||
let baseline = FuelStation(id: "base", name: "Baseline", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||
prices: [.e10: 199.9], priceUpdated: now.addingTimeInterval(-86_400).timeIntervalSince1970)
|
||
let stale = FuelStation(id: "stale-cheap", name: "Stale", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||
prices: [.e10: 129.9], priceUpdated: now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970)
|
||
let recent = FuelStation(id: "recent", name: "Recent", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||
prices: [.e10: 170.9], priceUpdated: now.addingTimeInterval(-86_400).timeIntervalSince1970)
|
||
let ranked = FuelSavingsEstimator.ranked([stale, recent], fuel: .e10, baseline: baseline,
|
||
settings: briefSettings, amount: briefAmount,
|
||
journeyMiles: { _ in self.straightLine(0.8) }, now: now)
|
||
XCTAssertEqual(ranked.map(\.id), ["recent", "stale-cheap"])
|
||
}
|
||
|
||
func testNoFreshPriceComparisonUsesExplicitCheapestPriceFallback() {
|
||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||
let staleTime = now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970
|
||
let baseline = FuelStation(id: "baseline", name: "Baseline", brand: "", address: "", postcode: "",
|
||
lat: 53, lng: -1, prices: [.diesel: 199.9], priceUpdated: staleTime)
|
||
let cheapest = FuelStation(id: "cheapest", name: "Cheapest", brand: "", address: "", postcode: "",
|
||
lat: 53, lng: -1, prices: [.diesel: 129.9], priceUpdated: staleTime)
|
||
let near = FuelStation(id: "near", name: "Near", brand: "", address: "", postcode: "",
|
||
lat: 53, lng: -1, prices: [.diesel: 170.9], priceUpdated: staleTime)
|
||
let ordered = FuelSavingsEstimator.ranked([near, baseline, cheapest], fuel: .diesel,
|
||
baseline: baseline, settings: briefSettings, amount: briefAmount,
|
||
journeyMiles: { station in self.straightLine(station.id == "cheapest" ? 250 : 1) }, now: now)
|
||
XCTAssertEqual(ordered.map(\.id), ["cheapest", "near", "baseline"])
|
||
XCTAssertFalse(FuelSavingsEstimator.recommendationEligible(station: ordered[0], fuel: .diesel,
|
||
baseline: baseline, now: now))
|
||
}
|
||
|
||
func testSortingRanksByNetSavingNotPumpPrice() {
|
||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||
func pricedStation(_ id: String, price: Double) -> FuelStation {
|
||
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
|
||
lat: 51.5, lng: -0.1, prices: [.e10: price],
|
||
priceUpdated: nil, priceUpdatedByFuel: [.e10: now.timeIntervalSince1970])
|
||
}
|
||
let baseline = pricedStation("baseline", price: 171.9)
|
||
let cheapButFar = pricedStation("cheap-but-far", price: 167.9)
|
||
let pricierButNear = pricedStation("pricier-but-near", price: 169.9)
|
||
|
||
let ranked = FuelSavingsEstimator.ranked(
|
||
[baseline, cheapButFar, pricierButNear],
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: briefSettings,
|
||
amount: briefAmount,
|
||
journeyMiles: { s in
|
||
switch s.id {
|
||
case "baseline": return self.straightLine(0.8)
|
||
case "cheap-but-far": return self.straightLine(7.6)
|
||
default: return self.straightLine(1.2)
|
||
}
|
||
}, now: now
|
||
)
|
||
|
||
XCTAssertEqual(ranked.first?.id, "pricier-but-near",
|
||
"net saving decides the order, not the pump price")
|
||
XCTAssertEqual(ranked.last?.id, "cheap-but-far")
|
||
}
|
||
|
||
// MARK: Row copy
|
||
|
||
func testCompactComparisonLabelUsesClosestEligibleStationWording() {
|
||
XCTAssertEqual(
|
||
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
|
||
"vs closest eligible station"
|
||
)
|
||
XCTAssertEqual(
|
||
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
|
||
"vs closest eligible station"
|
||
)
|
||
XCTAssertEqual(
|
||
FuelSavingsFormat.comparisonLabel(status: .breakEven),
|
||
"vs closest eligible station"
|
||
)
|
||
XCTAssertEqual(
|
||
FuelSavingsFormat.comparisonLabel(status: .baseline),
|
||
"your baseline"
|
||
)
|
||
}
|
||
|
||
/// The row's extra-driving caption must follow the DISPLAY unit: the stored
|
||
/// figure is always miles, and converting by the current unit instead would
|
||
/// leave a km user's figure unlabelled and 1.6× too large.
|
||
func testExtraTravelLabelFollowsTheDisplayUnit() {
|
||
XCTAssertEqual(
|
||
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 19.9, unit: .miles),
|
||
"19.9 mi extra return travel"
|
||
)
|
||
XCTAssertEqual(
|
||
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 19.9, unit: .kilometers),
|
||
"32.0 km extra return travel"
|
||
)
|
||
XCTAssertNil(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 0, unit: .miles),
|
||
"the baseline row has no extra travel to report")
|
||
// The same conversion backs the detail sheet's extra-distance figure,
|
||
// which used to be hardcoded miles ("%.1f miles") for km users too.
|
||
XCTAssertEqual(FuelSavingsFormat.distance(19.9, unit: .miles), "19.9 mi")
|
||
XCTAssertEqual(FuelSavingsFormat.distance(19.9, unit: .kilometers), "32.0 km")
|
||
}
|
||
|
||
/// The baseline capsule has to say what it IS: it previously repeated the
|
||
/// status word above it ("Closest eligible station" twice) and never explained that
|
||
/// the savings are measured against it.
|
||
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
|
||
let baseline = FuelSavingsResult(
|
||
fillLitres: 30,
|
||
grossSavingGBP: 0,
|
||
extraDrivingCostGBP: 0,
|
||
extraDistanceMiles: 0,
|
||
netSavingGBP: 0,
|
||
status: .baseline
|
||
)
|
||
XCTAssertEqual(baseline.capsuleHeadline(), "Closest eligible station")
|
||
|
||
let saving = FuelSavingsResult(
|
||
fillLitres: 30,
|
||
grossSavingGBP: 2.40,
|
||
extraDrivingCostGBP: 1.40,
|
||
extraDistanceMiles: 5.2,
|
||
netSavingGBP: 1.00,
|
||
status: .worthTheDrive,
|
||
distanceBasis: .straightLine
|
||
)
|
||
XCTAssertEqual(saving.capsuleHeadline(), "Approx. Save £1.00")
|
||
|
||
let worse = FuelSavingsResult(
|
||
fillLitres: 30,
|
||
grossSavingGBP: 1.40,
|
||
extraDrivingCostGBP: 1.70,
|
||
extraDistanceMiles: 6.7,
|
||
netSavingGBP: -0.31,
|
||
status: .notWorthTheDrive
|
||
)
|
||
XCTAssertEqual(worse.capsuleHeadline(), "Costs £0.31 more")
|
||
}
|
||
|
||
// MARK: - Estimate wording (Approx. labels)
|
||
|
||
/// The estimate marker is the WORD "Approx.", never a "~", and it is applied to
|
||
/// both directions of the money result.
|
||
func testEstimatedHeadlinesUseTheWordApproxForBothDirections() {
|
||
let saving = FuelSavingsResult(
|
||
fillLitres: 30,
|
||
grossSavingGBP: 4.00,
|
||
extraDrivingCostGBP: 2.75,
|
||
extraDistanceMiles: 6.4,
|
||
netSavingGBP: 1.25,
|
||
status: .worthTheDrive,
|
||
distanceBasis: .straightLine
|
||
)
|
||
XCTAssertEqual(saving.headline(), "Approx. Save £1.25")
|
||
|
||
let loss = FuelSavingsResult(
|
||
fillLitres: 30,
|
||
grossSavingGBP: 1.40,
|
||
extraDrivingCostGBP: 2.17,
|
||
extraDistanceMiles: 6.4,
|
||
netSavingGBP: -0.77,
|
||
status: .notWorthTheDrive,
|
||
distanceBasis: .straightLine
|
||
)
|
||
XCTAssertEqual(loss.headline(), "Approx. Costs £0.77 more")
|
||
}
|
||
|
||
/// A confirmed road-route comparison must never carry the marker — in either
|
||
/// direction, and not as a stray tilde either.
|
||
func testConfirmedHeadlinesCarryNoApproxMarker() {
|
||
let saving = FuelSavingsResult(
|
||
fillLitres: 30,
|
||
grossSavingGBP: 4.00,
|
||
extraDrivingCostGBP: 2.75,
|
||
extraDistanceMiles: 6.4,
|
||
netSavingGBP: 1.25,
|
||
status: .worthTheDrive,
|
||
distanceBasis: .roadRoute,
|
||
journeyConfidence: .confirmed
|
||
)
|
||
XCTAssertEqual(saving.headline(), "Save £1.25")
|
||
|
||
let loss = FuelSavingsResult(
|
||
fillLitres: 30,
|
||
grossSavingGBP: 1.40,
|
||
extraDrivingCostGBP: 2.17,
|
||
extraDistanceMiles: 6.4,
|
||
netSavingGBP: -0.77,
|
||
status: .notWorthTheDrive,
|
||
distanceBasis: .roadRoute,
|
||
journeyConfidence: .confirmed
|
||
)
|
||
XCTAssertEqual(loss.headline(), "Costs £0.77 more")
|
||
|
||
for headline in [saving.headline(), loss.headline(), saving.capsuleHeadline()] {
|
||
XCTAssertFalse(headline.contains("Approx"), "\"\(headline)\" must not claim to be an estimate")
|
||
XCTAssertFalse(headline.contains("~"), "\"\(headline)\" must use a word, not a symbol")
|
||
}
|
||
}
|
||
|
||
/// The extra-travel caption names the RETURN journey in both states. The
|
||
/// estimated caption repeats "Approx." deliberately: it describes a different
|
||
/// value from the verdict above it.
|
||
func testExtraTravelNamesTheReturnJourney() {
|
||
XCTAssertEqual(
|
||
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 6.4, unit: .miles),
|
||
"6.4 mi extra return travel"
|
||
)
|
||
XCTAssertEqual(
|
||
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
|
||
"Approx. 6.4 mi extra return travel"
|
||
)
|
||
}
|
||
|
||
/// VoiceOver gets words, not abbreviations: "Approx." → "Approximately" and
|
||
/// "mi"/"km" → "miles"/"kilometres".
|
||
func testSpokenLabelsSpellOutTheAbbreviations() {
|
||
let estimated = FuelSavingsResult(
|
||
fillLitres: 30,
|
||
grossSavingGBP: 1.40,
|
||
extraDrivingCostGBP: 2.17,
|
||
extraDistanceMiles: 6.4,
|
||
netSavingGBP: -0.77,
|
||
status: .notWorthTheDrive,
|
||
distanceBasis: .straightLine
|
||
)
|
||
XCTAssertEqual(estimated.spokenHeadline(), "Approximately Costs £0.77 more")
|
||
|
||
XCTAssertEqual(
|
||
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
|
||
"Approximately 6.4 miles extra return travel"
|
||
)
|
||
XCTAssertEqual(
|
||
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 19.9, unit: .kilometers),
|
||
"32.0 kilometres extra return travel"
|
||
)
|
||
|
||
let confirmed = FuelSavingsResult(
|
||
fillLitres: 30,
|
||
grossSavingGBP: 1.40,
|
||
extraDrivingCostGBP: 2.17,
|
||
extraDistanceMiles: 6.4,
|
||
netSavingGBP: -0.77,
|
||
status: .notWorthTheDrive,
|
||
distanceBasis: .roadRoute,
|
||
journeyConfidence: .confirmed
|
||
)
|
||
XCTAssertEqual(confirmed.spokenHeadline(), confirmed.headline(),
|
||
"a confirmed comparison has no abbreviation to spell out")
|
||
}
|
||
|
||
// MARK: - Slider bounds (Settings and the in-list control share these)
|
||
|
||
/// Both fill-up controls are slider-only now, so every position the slider
|
||
/// can reach must be a usable amount — otherwise the control could sit on a
|
||
/// figure the calculator rejects.
|
||
func testFillAmountSliderBoundsAreAlwaysValidAmounts() {
|
||
for unit in FuelSavingsFillUnit.allCases {
|
||
let range = FuelSavingsCalculator.fillAmountSliderRange(for: unit)
|
||
let step = FuelSavingsCalculator.fillAmountSliderStep(for: unit)
|
||
XCTAssertLessThan(range.lowerBound, range.upperBound, "\(unit) range must be usable")
|
||
XCTAssertGreaterThan(step, 0, "\(unit) step must advance")
|
||
XCTAssertGreaterThan(range.lowerBound, 0, "\(unit) must never offer a zero fill")
|
||
XCTAssertLessThanOrEqual(range.upperBound,
|
||
FuelSavingsCalculator.maximumFillAmount(for: unit),
|
||
"\(unit) slider must stay inside the valid ceiling")
|
||
for position in [range.lowerBound, range.upperBound, (range.lowerBound + range.upperBound) / 2] {
|
||
XCTAssertTrue(FuelSavingsFillAmount(value: position, unit: unit).isValid,
|
||
"\(position) \(unit) must be a valid amount")
|
||
}
|
||
}
|
||
}
|
||
|
||
func testMPGSliderBoundsAreInsideTheValidMPGRange() {
|
||
let range = FuelSavingsCalculator.mpgSliderRange
|
||
XCTAssertLessThan(range.lowerBound, range.upperBound)
|
||
XCTAssertGreaterThan(FuelSavingsCalculator.mpgSliderStep, 0)
|
||
XCTAssertTrue(FuelSavingsCalculator.isValidMPG(range.lowerBound))
|
||
XCTAssertTrue(FuelSavingsCalculator.isValidMPG(range.upperBound))
|
||
XCTAssertTrue(FuelSavingsCalculator.isValidMPG(45), "a typical UK figure must be reachable")
|
||
}
|
||
}
|