import XCTest #if SWIFT_PACKAGE @testable import FuelBoardShared #endif /// Worth the Drive calculation audit. /// /// Written against the audit brief (30 MPG imperial, £60 fill-up, baseline /// Rontec Shelf at 171.9p, 0.4 miles). The brief's three observed results are /// reproduced here exactly, so any future change to the formula breaks a test /// instead of silently shifting the numbers on screen. /// /// Deterministic journey distances only — never live MKDirections responses. final class FuelSavingsCalculationAuditTests: XCTestCase { private func station(_ id: String, price: Double?) -> FuelStation { FuelStation( id: id, name: id, brand: "X", address: "", postcode: "", lat: 51.5, lng: -0.1, prices: price.map { [FuelType.e10: $0] } ?? [:], priceUpdated: nil ) } /// Journeys are OUT AND BACK (the app's convention): the brief's baseline is /// 0.4 miles away, so its journey is 0.8 miles, and "extra driving" of N /// miles means a candidate journey of 0.8 + N. private func journey(_ miles: Double, basis: FuelSavingsDistanceBasis = .roadRoute) -> FuelSavingsJourney { FuelSavingsJourney(miles: miles, basis: basis) } private let baselineJourneyMiles = 0.8 private func briefResult(candidatePrice: Double, extraMiles: Double) throws -> FuelSavingsResult { try FuelSavingsCalculator.calculate( mpg: 30, amount: FuelSavingsFillAmount(value: 60, unit: .pounds), baselinePricePencePerLitre: 171.9, candidatePricePencePerLitre: candidatePrice, baselineJourneyMiles: baselineJourneyMiles, candidateJourneyMiles: baselineJourneyMiles + extraMiles ) } // MARK: - 1. Unit conversion func testImperialGallonIsUsedNotTheUSGallon() { XCTAssertEqual(FuelSavingsCalculator.imperialGallonLitres, 4.54609) XCTAssertNotEqual(FuelSavingsCalculator.imperialGallonLitres, 3.785411784, "a US gallon must never be used: it would understate every travel cost") // 30 miles at 30 MPG burns exactly one imperial gallon. XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 30, mpg: 30), 4.54609, accuracy: 1e-12) // 100 miles at 50 MPG. XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 100, mpg: 50), 9.09218, accuracy: 1e-12) // Zero distance burns nothing, and the relationship is linear in distance. XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 0, mpg: 30), 0, accuracy: 1e-12) XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 60, mpg: 30), 2 * FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 30, mpg: 30), accuracy: 1e-12) } func testMileAndKilometreFactorsAreTheExactOnes() { XCTAssertEqual(FuelSavingsCalculator.kilometresPerMile, 1.609344) XCTAssertEqual(FuelSavingsCalculator.metresPerMile, 1609.344) XCTAssertEqual(FuelSavingsCalculator.metresPerMile, FuelSavingsCalculator.kilometresPerMile * 1000, accuracy: 1e-9, "the metre and kilometre factors must describe the same mile") } func testDistanceLabelUsesTheSameConversionAsTheModel() { // One mile, shown in kilometres, goes through the single factor. XCTAssertEqual( FuelSavingsFormat.distance(1, unit: .kilometers), DistanceUnit.kilometers.format(FuelSavingsCalculator.kilometresPerMile) ) XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344)) } // MARK: - 2/4. The three stations from the brief func testBriefCaseTescoBradfordIsMarginallyWorseOff() throws { let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0) XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6) XCTAssertEqual(result.grossSavingGBP, 0.349040, accuracy: 1e-6, "1p/litre cheaper on a 34.90 L fill") XCTAssertEqual(result.extraDistanceMiles, 2.0, accuracy: 1e-9) XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, -0.168911, accuracy: 1e-6) XCTAssertEqual(result.status, .notWorthTheDrive) XCTAssertEqual(result.headline(), "£0.17 worse off after travel") } /// The brief recorded "£0.18 worse off" for this row against a displayed /// "2.0 miles" of extra driving; the brief also warns that the displayed /// mileage is rounded. This pins that explanation instead of treating it as /// a calculation bug: at an extra distance the caption still shows as /// "2.0 mi" (2.00-2.05), the headline reads £0.17-£0.18, and the difference /// is entirely the rounding of the distance, not of the money. func testTheReportedEighteenPenceIsRoundedExtraDistanceNotARoundingBug() throws { XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.00).headline(), "£0.17 worse off after travel") XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(), "£0.18 worse off after travel") // 2.05 mi shows as "2.0 mi" at one decimal place — the same caption. XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles), "2.0 mi more driving than closest") XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.00, unit: .miles), "2.0 mi more driving than closest") } func testBriefCaseMFGHareAndHoundsIsWorseOff() throws { let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8) XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6) XCTAssertEqual(result.grossSavingGBP, -0.349040, accuracy: 1e-6, "1p/litre dearer costs 34.90 L × 1p") XCTAssertEqual(result.extraDistanceMiles, 2.8, accuracy: 1e-9) XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6) XCTAssertEqual(result.status, .notWorthTheDrive) XCTAssertEqual(result.headline(), "£1.08 worse off after travel") } func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws { let result = try briefResult(candidatePrice: 171.9, extraMiles: 2.8) XCTAssertEqual(result.grossSavingGBP, 0, accuracy: 1e-9, "same pump price saves nothing at the pump") XCTAssertEqual(result.extraDrivingCostGBP, 0.729375, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, -0.729375, accuracy: 1e-6, "with no price difference the whole result is the extra driving") XCTAssertEqual(result.status, .notWorthTheDrive) XCTAssertEqual(result.headline(), "£0.73 worse off after travel") } // MARK: - 5/6. Classification, tolerance and rounding func testSamePriceAndSameDistanceIsBreakEven() throws { let result = try briefResult(candidatePrice: 171.9, extraMiles: 0) XCTAssertEqual(result.grossSavingGBP, 0, accuracy: 1e-9) XCTAssertEqual(result.extraDrivingCostGBP, 0, accuracy: 1e-9) XCTAssertEqual(result.netSavingGBP, 0, accuracy: 1e-9) XCTAssertEqual(result.status, .breakEven) XCTAssertEqual(result.headline(), "Break even") } func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws { // 171.9p vs 170.9p over a 1.23 mi delta: net +£0.0305. let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23) XCTAssertEqual(result.netSavingGBP, 0.030500, accuracy: 1e-6) XCTAssertEqual(result.status, .breakEven, "±5p reads as break even rather than a meaningless £0.03") XCTAssertEqual(result.headline(), "Break even") } func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws { let net = -0.168911 // A tolerance wider than the loss → break even; narrower → worse off. let lenient = try FuelSavingsCalculator.calculate( mpg: 30, amount: FuelSavingsFillAmount(value: 60, unit: .pounds), baselinePricePencePerLitre: 171.9, candidatePricePencePerLitre: 170.9, baselineJourneyMiles: baselineJourneyMiles, candidateJourneyMiles: baselineJourneyMiles + 2.0, breakEvenToleranceGBP: 0.17 ) XCTAssertEqual(lenient.netSavingGBP, net, accuracy: 1e-6) XCTAssertEqual(lenient.status, .breakEven) let strict = try FuelSavingsCalculator.calculate( mpg: 30, amount: FuelSavingsFillAmount(value: 60, unit: .pounds), baselinePricePencePerLitre: 171.9, candidatePricePencePerLitre: 170.9, baselineJourneyMiles: baselineJourneyMiles, candidateJourneyMiles: baselineJourneyMiles + 2.0, breakEvenToleranceGBP: 0.16 ) XCTAssertEqual(strict.status, .notWorthTheDrive) } func testMoneyIsRoundedOnlyForDisplay() throws { // Full precision retained internally… let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8) XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6) XCTAssertNotEqual(result.netSavingGBP, -1.08, "the stored value must not be pre-rounded") // …and rounded to two places only when shown. XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08") XCTAssertEqual(FuelSavingsFormat.money(0.729375), "£0.73") XCTAssertEqual(FuelSavingsFormat.money(0.1749), "£0.17") XCTAssertEqual(FuelSavingsFormat.money(0.176), "£0.18") XCTAssertEqual(FuelSavingsFormat.money(0), "£0.00") } func testSortingUsesTheUnroundedNetSoRoundedTiesStillOrderCorrectly() { // Two rows that both DISPLAY "£0.18 worse off" but differ in the raw // figure: the sort must use the raw value, never the shown one. func ranked(_ id: String, net: Double) -> FuelSavingsRankedStation { FuelSavingsRankedStation( stationID: id, result: .available(FuelSavingsResult( fillLitres: 34.9, grossSavingGBP: 0, extraDrivingCostGBP: abs(net), extraDistanceMiles: 2.0, netSavingGBP: net, status: .notWorthTheDrive )), distanceMiles: 2.8, pricePencePerLitre: 171.9 ) } let worse = ranked("worse", net: -0.1751) let lessWorse = ranked("less-worse", net: -0.1749) XCTAssertEqual(FuelSavingsFormat.money(abs(worse.result.result!.netSavingGBP)), "£0.18") XCTAssertEqual(FuelSavingsFormat.money(abs(lessWorse.result.result!.netSavingGBP)), "£0.17") let sorted = FuelSavingsCalculator.sortedForWorthTheDrive([worse, lessWorse]) XCTAssertEqual(sorted.first?.stationID, "less-worse", "the smaller loss must rank first even when the money rounds to the same figure") } // MARK: - 3. Distances and travel cost func testCheaperFuelFurtherAwayCanStillBeWorthTheDrive() throws { // 145.9p nearby vs 138.9p five miles further out, £40 fill, 45 MPG. let result = try FuelSavingsCalculator.calculate( mpg: 45, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 145.9, candidatePricePencePerLitre: 138.9, baselineJourneyMiles: 2.0, candidateJourneyMiles: 7.0 ) XCTAssertEqual(result.fillLitres, 27.416038, accuracy: 1e-6) XCTAssertEqual(result.grossSavingGBP, 1.919123, accuracy: 1e-6) XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, 1.217509, accuracy: 1e-6) XCTAssertEqual(result.status, .worthTheDrive) XCTAssertEqual(result.headline(), "Save £1.22 after travel") } func testNearerCandidateIsNeverChargedExtraTravel() throws { // The baseline is the nearest priced station, so this should not happen // in the app — but the clamp must hold if it ever does. let result = try FuelSavingsCalculator.calculate( mpg: 30, amount: FuelSavingsFillAmount(value: 60, unit: .pounds), baselinePricePencePerLitre: 171.9, candidatePricePencePerLitre: 169.9, baselineJourneyMiles: 2.0, candidateJourneyMiles: 1.0 ) XCTAssertEqual(result.extraDistanceMiles, 0, "a shorter journey is never a negative extra distance") XCTAssertEqual(result.extraDrivingCostGBP, 0, accuracy: 1e-12) XCTAssertEqual(result.netSavingGBP, result.grossSavingGBP, accuracy: 1e-12) } func testDifferentMPGValuesScaleOnlyTheTravelCost() throws { let sixty = try FuelSavingsCalculator.calculate( mpg: 60, amount: FuelSavingsFillAmount(value: 60, unit: .pounds), baselinePricePencePerLitre: 171.9, candidatePricePencePerLitre: 172.9, baselineJourneyMiles: 0.8, candidateJourneyMiles: 3.6 ) XCTAssertEqual(sixty.grossSavingGBP, -0.349040, accuracy: 1e-6, "MPG must not touch the pump saving") XCTAssertEqual(sixty.extraDrivingCostGBP, 0.366809, accuracy: 1e-6) XCTAssertEqual(sixty.netSavingGBP, -0.715849, accuracy: 1e-6) let fortyFive = try FuelSavingsCalculator.calculate( mpg: 45, amount: FuelSavingsFillAmount(value: 60, unit: .pounds), baselinePricePencePerLitre: 171.9, candidatePricePencePerLitre: 172.9, baselineJourneyMiles: 0.8, candidateJourneyMiles: 3.6 ) XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6) XCTAssertEqual(fortyFive.netSavingGBP, -0.838119, accuracy: 1e-6) } func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws { let forty = try FuelSavingsCalculator.calculate( mpg: 30, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 171.9, candidatePricePencePerLitre: 172.9, baselineJourneyMiles: 0.8, candidateJourneyMiles: 3.6 ) XCTAssertEqual(forty.fillLitres, 23.269343, accuracy: 1e-6, "£40 buys 23.27 L at 171.9p") XCTAssertEqual(forty.grossSavingGBP, -0.232693, accuracy: 1e-6) XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent") XCTAssertEqual(forty.netSavingGBP, -0.966311, accuracy: 1e-6) let ninety = try FuelSavingsCalculator.calculate( mpg: 30, amount: FuelSavingsFillAmount(value: 90, unit: .pounds), baselinePricePencePerLitre: 171.9, candidatePricePencePerLitre: 172.9, baselineJourneyMiles: 0.8, candidateJourneyMiles: 3.6 ) XCTAssertEqual(ninety.fillLitres, 52.356021, accuracy: 1e-6) XCTAssertEqual(ninety.grossSavingGBP, -0.523560, accuracy: 1e-6) XCTAssertEqual(ninety.netSavingGBP, -1.257178, accuracy: 1e-6) } func testFillUpInLitresIsFixedRegardlessOfPrice() throws { // 40 L is 40 L: no baseline price is involved in the quantity. let result = try FuelSavingsCalculator.calculate( mpg: 30, amount: FuelSavingsFillAmount(value: 40, unit: .litres), baselinePricePencePerLitre: 171.9, candidatePricePencePerLitre: 165.9, baselineJourneyMiles: 0.8, candidateJourneyMiles: 3.6 ) XCTAssertEqual(result.fillLitres, 40, accuracy: 1e-9) XCTAssertEqual(result.grossSavingGBP, 40 * 0.06, accuracy: 1e-9) } // MARK: - 7. Failures never invent a saving func testUnavailableRouteProducesNoSavingAtAll() { let baseline = station("baseline", price: 171.9) let candidate = station("candidate", price: 170.9) let settings = FuelSavingsSettings( enabled: true, mpg: 30, preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds) ) let noRoute = FuelSavingsEstimator.availability( station: candidate, fuel: .e10, baseline: baseline, settings: settings, amount: FuelSavingsFillAmount(value: 60, unit: .pounds), journey: nil, baselineJourney: journey(0.8) ) XCTAssertEqual(noRoute, .unavailable("Distance unavailable")) let noBaselineRoute = FuelSavingsEstimator.availability( station: candidate, fuel: .e10, baseline: baseline, settings: settings, amount: FuelSavingsFillAmount(value: 60, unit: .pounds), journey: journey(2.8), baselineJourney: nil ) XCTAssertEqual(noBaselineRoute, .unavailable("No comparison station")) // With both legs present it produces the saving — no silent zero. let both = FuelSavingsEstimator.availability( station: candidate, fuel: .e10, baseline: baseline, settings: settings, amount: FuelSavingsFillAmount(value: 60, unit: .pounds), journey: journey(2.8), baselineJourney: journey(0.8) ) XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.168911, accuracy: 1e-6) } func testMixedRouteBasisIsReportedAsAnEstimate() { let baseline = station("baseline", price: 171.9) let candidate = station("candidate", price: 170.9) let settings = FuelSavingsSettings( enabled: true, mpg: 30, preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds) ) let mixed = FuelSavingsEstimator.availability( station: candidate, fuel: .e10, baseline: baseline, settings: settings, amount: FuelSavingsFillAmount(value: 60, unit: .pounds), journey: journey(2.8, basis: .straightLine), baselineJourney: journey(0.8, basis: .roadRoute) ) XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine, "one unmeasured leg makes the whole estimate an estimate") let headline = mixed?.result?.headline(compact: true) ?? "" XCTAssertTrue(headline.hasSuffix(" (est.)"), "an estimate must be marked as one, got \(headline)") } func testBaselineStationIsLabelledClosestStationNotBreakEven() { let baseline = station("baseline", price: 171.9) let settings = FuelSavingsSettings( enabled: true, mpg: 30, preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds) ) let result = FuelSavingsEstimator.availability( station: baseline, fuel: .e10, baseline: baseline, settings: settings, amount: FuelSavingsFillAmount(value: 60, unit: .pounds), journey: journey(0.8), baselineJourney: journey(0.8) )?.result XCTAssertEqual(result?.status, .baseline) XCTAssertEqual(result?.netSavingGBP, 0) XCTAssertEqual(result?.headline(), "Closest station") } }