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 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 34.90 L at 171.9p, so the /// cheaper pump saves 34.90 × 4p = £1.40, while the extra 6.8 return miles /// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.33. 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, 34.904, accuracy: 0.005, "the £60 budget buys litres at the BASELINE pump price") XCTAssertEqual(result.grossSavingGBP, 1.396, 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.334, accuracy: 0.005) XCTAssertEqual(result.status, .notWorthTheDrive) XCTAssertEqual(result.headline(compact: true), "£0.33 worse off after travel (est.)") } 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?.headline(compact: true), "£0.33 worse off after travel") XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel") } 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 37.5 L at 160p; 10p/litre cheaper saves £3.75; 0.4 extra // return miles cost ~£0.09. Net ≈ £3.66. XCTAssertEqual(result.netSavingGBP, 3.659, accuracy: 0.005) XCTAssertEqual(result.headline(compact: true), "Save £3.66 after travel (est.)") } 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 after travel") 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(), "£0.31 worse off after travel") } /// 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 more driving than closest" ) XCTAssertEqual( FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 19.9, unit: .kilometers), "32.0 km more driving than closest" ) 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(), "Save £1.00 after travel (est.)") let worse = FuelSavingsResult( fillLitres: 30, grossSavingGBP: 1.40, extraDrivingCostGBP: 1.70, extraDistanceMiles: 6.7, netSavingGBP: -0.31, status: .notWorthTheDrive ) XCTAssertEqual(worse.capsuleHeadline(), "£0.31 worse off after travel") } // 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") } }