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 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 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 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 station") XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest 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 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 testSortingRanksByNetSavingNotPumpPrice() { let baseline = station("baseline", price: 171.9) let cheapButFar = station("cheap-but-far", price: 167.9) let pricierButNear = station("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) } } ) 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 /// 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 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 station · your baseline") 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") } }