From 3c424ab3977ff395ff235fcf28242480c390da1b Mon Sep 17 00:00:00 2001 From: FuelBoard Contributor Date: Wed, 14 Oct 2026 10:43:12 +0100 Subject: [PATCH] Best Value: candidate-priced fills, key copy, detail total --- FuelBoard/ContentView.swift | 44 +++++- FuelBoard/FuelSavingsViews.swift | 25 +++- FuelBoard/ProximityMonitor.swift | 4 +- FuelBoard/StationsView.swift | 13 +- FuelBoard/en.lproj/Localizable.strings | 7 +- .../FuelSavingsCalculationAuditTests.swift | 128 +++++++++++++----- .../FuelSavingsEstimateTests.swift | 46 +++---- .../FuelSavingsTests.swift | 14 +- Shared/FuelSavings.swift | 17 ++- 9 files changed, 218 insertions(+), 80 deletions(-) diff --git a/FuelBoard/ContentView.swift b/FuelBoard/ContentView.swift index eed0426..b9b536f 100644 --- a/FuelBoard/ContentView.swift +++ b/FuelBoard/ContentView.swift @@ -566,10 +566,34 @@ struct ContentView: View { func source(_ basis: FuelSavingsDistanceBasis) -> String { basis == .roadRoute ? "road route" : "straight-line estimate" } - let price = station.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—" - let baselinePrice = baseline?.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—" + func amount(_ value: FuelSavingsFillAmount) -> String { + switch value.unit { + case .pounds: return String(format: "£%.2f", value.value) + case .litres: return String(format: "%.2f L", value.value) + } + } + func rawMoney(_ value: Double?) -> String { + value.map { String(format: "£%.6f", $0) } ?? "—" + } + let candidatePriceValue = station.prices[selectedFuel] + let baselinePriceValue = baseline?.prices[selectedFuel] + let price = candidatePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—" + let baselinePrice = baselinePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—" + let priceDifference = candidatePriceValue.flatMap { candidate in + baselinePriceValue.map { String(format: "%+.1fp/L", $0 - candidate) } + } ?? "—" + let litresBasis = sessionFuelSavingsAmount.unit == .pounds ? "candidate price" : "fixed litres" let rows: [(String, String)] = [ ("Station", "\(station.name) (\(station.id))"), + ("Settings default fill-up", amount(fuelSavingsSettings.preferredAmount)), + ("Effective/session fill-up", amount(sessionFuelSavingsAmount)), + ("Fill-up unit", sessionFuelSavingsAmount.unit.displayName), + ("MPG", String(format: "%.1f UK MPG", fuelSavingsSettings.mpg)), + ("Candidate price", price), + ("Baseline price", baselinePrice), + ("Price difference", priceDifference), + ("Candidate litres", result.map { String(format: "%.6f L · %@", $0.fillLitres, litresBasis) } ?? "—"), + ("Gross saving raw", rawMoney(result?.grossSavingGBP)), ("Displayed distance", "\(distanceUnit.format(stationDistance.displayDistanceKM)) · \(source(stationDistance.displaySource))"), ("Outward road", meters(stationDistance.outwardMeters)), ("Return road", stationDistance.hasMeasuredReturn ? meters(stationDistance.returnMeters) : "inferred or unavailable"), @@ -581,12 +605,11 @@ struct ContentView: View { ("Station journey", miles(journey?.miles)), ("Baseline journey", miles(baselineJourney?.miles)), ("Additional journey", miles(result?.extraDistanceMiles)), + ("Additional fuel used", result.map { String(format: "%.6f L", $0.extraFuelLitres) } ?? "—"), ("Confidence", result?.journeyConfidence.rawValue ?? "estimated"), - ("Fuel price", price), - ("Baseline price", baselinePrice), - ("Gross saving", result.map { FuelSavingsFormat.money($0.grossSavingGBP) } ?? "—"), - ("Travel cost", result.map { FuelSavingsFormat.money($0.extraDrivingCostGBP) } ?? "—"), - ("Net saving", result.map { FuelSavingsFormat.money($0.netSavingGBP) } ?? "—") + ("Travel cost raw", rawMoney(result?.extraDrivingCostGBP)), + ("Net saving raw", rawMoney(result?.netSavingGBP)), + ("Displayed net saving", result?.headline() ?? "—") ] return FuelSavingsJourneyDiagnostics(rows: rows) #else @@ -1104,6 +1127,13 @@ struct ContentView: View { ensureBestValueAlertModeIsAvailable() updateAlertMonitor() } + .onChange(of: sessionFuelSavingsAmount) { _, _ in + // This remains a session-only override: do not write it to FuelStore. + // Any active Best Value alert/Live Activity must nevertheless use + // the same effective amount as rows, details, favourites and sorting. + updateAlertMonitor() + updateLiveActivity() + } .onChange(of: scenePhase) { _, newPhase in if newPhase == .active { // Never start location tracking while onboarding is on screen — diff --git a/FuelBoard/FuelSavingsViews.swift b/FuelBoard/FuelSavingsViews.swift index 2449dd2..2d73165 100644 --- a/FuelBoard/FuelSavingsViews.swift +++ b/FuelBoard/FuelSavingsViews.swift @@ -118,7 +118,11 @@ struct FuelSavingsSummaryView: View { // matched neither the listing nor each other. VStack(alignment: .leading, spacing: 10) { capsule(for: result, compact: false) + .frame(maxWidth: .infinity, alignment: .trailing) .accessibilityLabel(accessibilityHeadline(for: result)) + LabeledContent("Your fill-up", value: amountText) + LabeledContent("Fuel you'll receive", value: String(format: "%.2f litres", result.fillLitres)) + .monospacedDigit() if let extraTravel = FuelSavingsFormat.extraTravelLabel( extraDistanceMiles: result.extraDistanceMiles, unit: distanceUnit, @@ -134,9 +138,26 @@ struct FuelSavingsSummaryView: View { .font(.caption) .foregroundStyle(.secondary) } else { - Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary) - LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP)) + Text("Compared with \(baselineStationName ?? "your nearest station").") + .font(.caption) + .foregroundStyle(.secondary) + LabeledContent( + result.grossSavingGBP >= 0 ? "Fuel-price saving" : "Fuel-price difference", + value: result.grossSavingGBP >= 0 + ? FuelSavingsFormat.money(result.grossSavingGBP) + : "Costs \(FuelSavingsFormat.money(abs(result.grossSavingGBP))) more" + ) LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP)) + Divider() + LabeledContent { + Text(result.headline()) + .fontWeight(.semibold) + .foregroundStyle(capsuleColor(for: result.status)) + .monospacedDigit() + } label: { + Text("Total") + .fontWeight(.semibold) + } if result.journeyConfidence.isEstimated { Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.") .font(.caption2) diff --git a/FuelBoard/ProximityMonitor.swift b/FuelBoard/ProximityMonitor.swift index bec1f43..28dcd2a 100644 --- a/FuelBoard/ProximityMonitor.swift +++ b/FuelBoard/ProximityMonitor.swift @@ -618,7 +618,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca let content = UNMutableNotificationContent() content.sound = .default content.title = "Best Value \(fuel.displayName) nearby: \(brand)" - content.body = "\(station.name) could leave you \(result.headline()). Tap for directions." + content.body = "\(station.name): \(result.headline()). Tap for directions." addAlertRequest(content: content, station: station) logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())") lastAlert = "Best Value · \(brand) · \(result.headline())" @@ -923,7 +923,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca content.sound = .default let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand content.title = "Best Value \(fuel.displayName) nearby: \(brand)" - content.body = "\(candidate.station.name) could leave you \(candidate.result.headline()). Tap for directions." + content.body = "\(candidate.station.name): \(candidate.result.headline()). Tap for directions." let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))" lastTestResult = baseResult logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())") diff --git a/FuelBoard/StationsView.swift b/FuelBoard/StationsView.swift index 807f63a..34465bf 100644 --- a/FuelBoard/StationsView.swift +++ b/FuelBoard/StationsView.swift @@ -249,7 +249,7 @@ struct StationsView: View { VStack(spacing: 10) { keyCard( icon: "checkmark.circle.fill", color: .green, - title: "Best value", blurb: "Within 1.5p of the cheapest", + title: "Good price", blurb: "Within 1.5p of the cheapest", threshold: "≤1.5p") keyCard( icon: "equal.circle.fill", color: .orange, @@ -298,6 +298,17 @@ struct StationsView: View { .listRowBackground(Color.clear) .listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) } + + Section("How Best Value works") { + VStack(alignment: .leading, spacing: 10) { + Text("Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.") + Text("Your fill-up amount and MPG are used to show whether you could save overall.") + Text("Results are marked “Approx.” until road distances are available.") + .foregroundStyle(.secondary) + } + .font(.subheadline) + .padding(.vertical, 4) + } } .navigationTitle("Key") .navigationBarTitleDisplayMode(.inline) diff --git a/FuelBoard/en.lproj/Localizable.strings b/FuelBoard/en.lproj/Localizable.strings index c0234e1..29b2248 100644 --- a/FuelBoard/en.lproj/Localizable.strings +++ b/FuelBoard/en.lproj/Localizable.strings @@ -23,7 +23,7 @@ "Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)"; "All %lld stations shown" = "All %lld stations shown"; "Key" = "Key"; -"Best value" = "Best value"; +"Good price" = "Good price"; "Within 1.5p of the cheapest" = "Within 1.5p of the cheapest"; "Okay" = "Okay"; "Within 4p of the cheapest" = "Within 4p of the cheapest"; @@ -35,6 +35,10 @@ "Add to favourites" = "Add to favourites"; "Remove from favourites" = "Remove from favourites"; "Colours match each station's rating on the list" = "Colours match each station's rating on the list"; +"How Best Value works" = "How Best Value works"; +"Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey."; +"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall."; +"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available."; /* Favourites tab */ "No favourites yet" = "No favourites yet"; @@ -194,6 +198,7 @@ "Cheapest station" = "Cheapest station"; "Alert type" = "Alert type"; "Best Value" = "Best Value"; +"Total" = "Total"; "Minimum saving" = "Minimum saving"; "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost."; "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost."; diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift index 8f06460..ac3ab95 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift @@ -82,18 +82,84 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344)) } - // MARK: - 2/4. The three stations from the brief + // MARK: - 2. Candidate-priced fill-up + + func testPoundsFillUsesCandidatePriceForLitresAndGrossSaving() throws { + let result = try FuelSavingsCalculator.calculate( + mpg: 30, + amount: FuelSavingsFillAmount(value: 60, unit: .pounds), + baselinePricePencePerLitre: 171.9, + candidatePricePencePerLitre: 169.9, + baselineJourneyMiles: 0.879, + candidateJourneyMiles: 8.306 + ) + + XCTAssertEqual(result.fillLitres, 60 / 1.699, accuracy: 1e-9) + XCTAssertEqual(result.grossSavingGBP, (60 / 1.699) * 0.02, accuracy: 1e-9) + XCTAssertEqual(FuelSavingsFormat.money(result.grossSavingGBP), "£0.71") + XCTAssertEqual(result.extraFuelLitres, 1.1254603476666667, accuracy: 1e-9) + XCTAssertEqual(result.extraDrivingCostGBP, 1.9121571306856667, accuracy: 1e-9) + XCTAssertEqual(result.netSavingGBP, -1.2058593084372853, accuracy: 1e-9) + XCTAssertEqual(result.headline(), "Costs £1.21 more") + } + + func testPoundsExamplesScaleFromThirtyToOneHundredPounds() throws { + for (budget, expectedLitres, expectedGross) in [ + (30.0, 17.657445556209535, 0.3531489111241907), + (60.0, 35.31489111241907, 0.7062978222483814), + (100.0, 58.85815185403179, 1.1771630370806357) + ] { + let result = try FuelSavingsCalculator.calculate( + mpg: 30, + amount: FuelSavingsFillAmount(value: budget, unit: .pounds), + baselinePricePencePerLitre: 171.9, + candidatePricePencePerLitre: 169.9, + baselineJourneyMiles: 0, + candidateJourneyMiles: 0 + ) + XCTAssertEqual(result.fillLitres, expectedLitres, accuracy: 1e-8) + XCTAssertEqual(result.grossSavingGBP, expectedGross, accuracy: 1e-8) + } + } + + func testCandidatePriceControlsLitresWhileBaselinePriceOnlyChangesSaving() throws { + let amount = FuelSavingsFillAmount(value: 60, unit: .pounds) + let first = try FuelSavingsCalculator.calculate( + mpg: 30, amount: amount, + baselinePricePencePerLitre: 171.9, + candidatePricePencePerLitre: 169.9, + baselineJourneyMiles: 0, candidateJourneyMiles: 0 + ) + let differentBaseline = try FuelSavingsCalculator.calculate( + mpg: 30, amount: amount, + baselinePricePencePerLitre: 179.9, + candidatePricePencePerLitre: 169.9, + baselineJourneyMiles: 0, candidateJourneyMiles: 0 + ) + let differentCandidate = try FuelSavingsCalculator.calculate( + mpg: 30, amount: amount, + baselinePricePencePerLitre: 179.9, + candidatePricePencePerLitre: 159.9, + baselineJourneyMiles: 0, candidateJourneyMiles: 0 + ) + + XCTAssertEqual(first.fillLitres, differentBaseline.fillLitres, accuracy: 1e-12) + XCTAssertNotEqual(first.grossSavingGBP, differentBaseline.grossSavingGBP) + XCTAssertNotEqual(differentBaseline.fillLitres, differentCandidate.fillLitres) + } + + // MARK: - 3/5. 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.fillLitres, 35.108250, accuracy: 1e-6) + XCTAssertEqual(result.grossSavingGBP, 0.351083, accuracy: 1e-6, "£60 buys 35.11 L at the candidate price") 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.netSavingGBP, -0.166869, accuracy: 1e-6) XCTAssertEqual(result.status, .notWorthTheDrive) - XCTAssertEqual(result.headline(), "£0.17 worse off after travel") + XCTAssertEqual(result.headline(), "Costs £0.17 more") } /// The brief recorded "£0.18 worse off" for this row against a displayed @@ -104,9 +170,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { /// 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") + "Costs £0.17 more") XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(), - "£0.18 worse off after travel") + "Costs £0.18 more") // 2.05 mi shows as "2.0 mi" at one decimal place — the same caption. XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles), @@ -118,13 +184,13 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { 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.fillLitres, 34.702140, accuracy: 1e-6) + XCTAssertEqual(result.grossSavingGBP, -0.347021, accuracy: 1e-6, "£60 buys 34.70 L at the candidate price") 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.netSavingGBP, -1.080639, accuracy: 1e-6) XCTAssertEqual(result.status, .notWorthTheDrive) - XCTAssertEqual(result.headline(), "£1.08 worse off after travel") + XCTAssertEqual(result.headline(), "Costs £1.08 more") } func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws { @@ -135,7 +201,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { 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") + XCTAssertEqual(result.headline(), "Costs £0.73 more") } // MARK: - 5/6. Classification, tolerance and rounding @@ -150,15 +216,15 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { } func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws { - // 171.9p vs 170.9p over a 1.23 mi delta: net +£0.0305. + // 171.9p vs 170.9p over a 1.23 mi delta: the small saving remains within tolerance. let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23) - XCTAssertEqual(result.netSavingGBP, 0.030500, accuracy: 1e-6) + XCTAssertEqual(result.netSavingGBP, 0.03254252417853143, accuracy: 1e-9) 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 + let net = -0.166869 // A tolerance wider than the loss → break even; narrower → worse off. let lenient = try FuelSavingsCalculator.calculate( mpg: 30, @@ -187,7 +253,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { 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) + XCTAssertEqual(result.netSavingGBP, -1.080639, 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") @@ -237,12 +303,12 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { baselineJourneyMiles: 2.0, candidateJourneyMiles: 7.0 ) - XCTAssertEqual(result.fillLitres, 27.416038, accuracy: 1e-6) - XCTAssertEqual(result.grossSavingGBP, 1.919123, accuracy: 1e-6) + XCTAssertEqual(result.fillLitres, 28.797696, accuracy: 1e-6) + XCTAssertEqual(result.grossSavingGBP, 2.015839, accuracy: 1e-6) XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6) - XCTAssertEqual(result.netSavingGBP, 1.217509, accuracy: 1e-6) + XCTAssertEqual(result.netSavingGBP, 1.314226, accuracy: 1e-6) XCTAssertEqual(result.status, .worthTheDrive) - XCTAssertEqual(result.headline(), "£1.22 better off after travel") + XCTAssertEqual(result.headline(), "Save £1.31") } func testNearerCandidateIsNeverChargedExtraTravel() throws { @@ -270,9 +336,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { baselineJourneyMiles: 0.8, candidateJourneyMiles: 3.6 ) - XCTAssertEqual(sixty.grossSavingGBP, -0.349040, accuracy: 1e-6, "MPG must not touch the pump saving") + XCTAssertEqual(sixty.grossSavingGBP, -0.347021, 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) + XCTAssertEqual(sixty.netSavingGBP, -0.713830, accuracy: 1e-6) let fortyFive = try FuelSavingsCalculator.calculate( mpg: 45, @@ -283,7 +349,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { candidateJourneyMiles: 3.6 ) XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6) - XCTAssertEqual(fortyFive.netSavingGBP, -0.838119, accuracy: 1e-6) + XCTAssertEqual(fortyFive.netSavingGBP, -0.836100, accuracy: 1e-6) } func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws { @@ -295,10 +361,10 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { 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.fillLitres, 23.134760, accuracy: 1e-6, "£40 buys 23.13 L at the candidate price") + XCTAssertEqual(forty.grossSavingGBP, -0.231348, 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) + XCTAssertEqual(forty.netSavingGBP, -0.964965, accuracy: 1e-6) let ninety = try FuelSavingsCalculator.calculate( mpg: 30, @@ -308,9 +374,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { 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) + XCTAssertEqual(ninety.fillLitres, 52.053210, accuracy: 1e-6) + XCTAssertEqual(ninety.grossSavingGBP, -0.520532, accuracy: 1e-6) + XCTAssertEqual(ninety.netSavingGBP, -1.254150, accuracy: 1e-6) } func testFillUpInLitresIsFixedRegardlessOfPrice() throws { @@ -370,7 +436,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { journey: journey(2.8), baselineJourney: journey(0.8) ) - XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.168911, accuracy: 1e-6) + XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.166869, accuracy: 1e-6) } func testMixedRouteBasisIsReportedAsAnEstimate() { @@ -393,7 +459,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase { XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine, "one unmeasured leg makes the whole estimate an estimate") XCTAssertEqual(mixed?.result?.journeyConfidence, .estimated) - XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. £0.17 worse off after travel") + XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. Costs £0.17 more") } func testBaselineStationIsLabelledClosestStationNotBreakEven() { diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift index 091ea4f..0fa0d45 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsEstimateTests.swift @@ -284,9 +284,9 @@ final class FuelSavingsEstimateTests: XCTestCase { /// (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. + /// 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) @@ -304,16 +304,16 @@ final class FuelSavingsEstimateTests: XCTestCase { 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.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.334, 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. £0.33 worse off after travel") + XCTAssertEqual(result.headline(compact: true), "Approx. Costs £0.30 more") } func testMeasuredRoadRoutesDropTheEstimateMarker() { @@ -332,8 +332,8 @@ final class FuelSavingsEstimateTests: XCTestCase { XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute) XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed) - XCTAssertEqual(availability?.result?.headline(compact: true), "£0.33 worse off after travel") - XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel") + XCTAssertEqual(availability?.result?.headline(compact: true), "Costs £0.30 more") + XCTAssertEqual(availability?.result?.headline(), "Costs £0.30 more") } func testBaselineRowIsLabelledClosestStation() { @@ -375,10 +375,10 @@ final class FuelSavingsEstimateTests: XCTestCase { 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), "Approx. £3.66 better off after travel") + // £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() { @@ -413,7 +413,7 @@ final class FuelSavingsEstimateTests: XCTestCase { netSavingGBP: 0.52, status: .worthTheDrive, distanceBasis: .roadRoute) - XCTAssertEqual(saving.headline(), "£0.52 better off after travel") + XCTAssertEqual(saving.headline(), "Save £0.52") let loss = FuelSavingsResult(fillLitres: 34.9, grossSavingGBP: 1.40, @@ -422,7 +422,7 @@ final class FuelSavingsEstimateTests: XCTestCase { netSavingGBP: -0.31, status: .notWorthTheDrive, distanceBasis: .roadRoute) - XCTAssertEqual(loss.headline(), "£0.31 worse off after travel") + XCTAssertEqual(loss.headline(), "Costs £0.31 more") } /// The sort must order by NET saving, not pump price: a pricier station can @@ -497,7 +497,7 @@ final class FuelSavingsEstimateTests: XCTestCase { status: .worthTheDrive, distanceBasis: .straightLine ) - XCTAssertEqual(saving.capsuleHeadline(), "Approx. £1.00 better off after travel") + XCTAssertEqual(saving.capsuleHeadline(), "Approx. Save £1.00") let worse = FuelSavingsResult( fillLitres: 30, @@ -507,7 +507,7 @@ final class FuelSavingsEstimateTests: XCTestCase { netSavingGBP: -0.31, status: .notWorthTheDrive ) - XCTAssertEqual(worse.capsuleHeadline(), "£0.31 worse off after travel") + XCTAssertEqual(worse.capsuleHeadline(), "Costs £0.31 more") } // MARK: - Estimate wording (Approx. labels) @@ -524,7 +524,7 @@ final class FuelSavingsEstimateTests: XCTestCase { status: .worthTheDrive, distanceBasis: .straightLine ) - XCTAssertEqual(saving.headline(), "Approx. £1.25 better off after travel") + XCTAssertEqual(saving.headline(), "Approx. Save £1.25") let loss = FuelSavingsResult( fillLitres: 30, @@ -535,7 +535,7 @@ final class FuelSavingsEstimateTests: XCTestCase { status: .notWorthTheDrive, distanceBasis: .straightLine ) - XCTAssertEqual(loss.headline(), "Approx. £0.77 worse off after travel") + XCTAssertEqual(loss.headline(), "Approx. Costs £0.77 more") } /// A confirmed road-route comparison must never carry the marker — in either @@ -551,7 +551,7 @@ final class FuelSavingsEstimateTests: XCTestCase { distanceBasis: .roadRoute, journeyConfidence: .confirmed ) - XCTAssertEqual(saving.headline(), "£1.25 better off after travel") + XCTAssertEqual(saving.headline(), "Save £1.25") let loss = FuelSavingsResult( fillLitres: 30, @@ -563,7 +563,7 @@ final class FuelSavingsEstimateTests: XCTestCase { distanceBasis: .roadRoute, journeyConfidence: .confirmed ) - XCTAssertEqual(loss.headline(), "£0.77 worse off after travel") + 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") @@ -597,7 +597,7 @@ final class FuelSavingsEstimateTests: XCTestCase { status: .notWorthTheDrive, distanceBasis: .straightLine ) - XCTAssertEqual(estimated.spokenHeadline(), "Approximately £0.77 worse off after travel") + XCTAssertEqual(estimated.spokenHeadline(), "Approximately Costs £0.77 more") XCTAssertEqual( FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated), diff --git a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift index 9356e34..c7cd784 100644 --- a/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift +++ b/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsTests.swift @@ -8,15 +8,15 @@ final class FuelSavingsCalculatorTests: XCTestCase { XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001) } - func testPoundsBudgetConvertsToLitresUsingBaselinePrice() throws { + func testPoundsBudgetConvertsToLitresUsingGivenPrice() throws { let amount = FuelSavingsFillAmount(value: 40, unit: .pounds) - let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160) + let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160) XCTAssertEqual(litres, 25.0, accuracy: 0.0001) } func testLitresAmountPassesThrough() throws { let amount = FuelSavingsFillAmount(value: 35, unit: .litres) - let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160) + let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160) XCTAssertEqual(litres, 35, accuracy: 0.0001) } @@ -29,11 +29,11 @@ final class FuelSavingsCalculatorTests: XCTestCase { baselineJourneyMiles: 2, candidateJourneyMiles: 8 ) - XCTAssertEqual(result.fillLitres, 25, accuracy: 0.0001) - XCTAssertEqual(result.grossSavingGBP, 2.50, accuracy: 0.0001) + XCTAssertEqual(result.fillLitres, 26.6667, accuracy: 0.0001) + XCTAssertEqual(result.grossSavingGBP, 2.6667, accuracy: 0.0001) XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001) XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001) - XCTAssertEqual(result.netSavingGBP, 1.5908, accuracy: 0.0001) + XCTAssertEqual(result.netSavingGBP, 1.7574, accuracy: 0.0001) XCTAssertEqual(result.status, .worthTheDrive) } @@ -61,7 +61,7 @@ final class FuelSavingsCalculatorTests: XCTestCase { } func testInvalidInputsRejected() { - XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 0)) + XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), pricePencePerLitre: 0)) XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2)) XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2)) } diff --git a/Shared/FuelSavings.swift b/Shared/FuelSavings.swift index 76fd9a1..90d1cbf 100644 --- a/Shared/FuelSavings.swift +++ b/Shared/FuelSavings.swift @@ -82,6 +82,7 @@ enum JourneyConfidence: String, Codable, Equatable { struct FuelSavingsResult: Codable, Equatable { let fillLitres: Double let grossSavingGBP: Double + let extraFuelLitres: Double let extraDrivingCostGBP: Double let extraDistanceMiles: Double let netSavingGBP: Double @@ -91,6 +92,7 @@ struct FuelSavingsResult: Codable, Equatable { init(fillLitres: Double, grossSavingGBP: Double, + extraFuelLitres: Double = 0, extraDrivingCostGBP: Double, extraDistanceMiles: Double, netSavingGBP: Double, @@ -99,6 +101,7 @@ struct FuelSavingsResult: Codable, Equatable { journeyConfidence: JourneyConfidence? = nil) { self.fillLitres = fillLitres self.grossSavingGBP = grossSavingGBP + self.extraFuelLitres = extraFuelLitres self.extraDrivingCostGBP = extraDrivingCostGBP self.extraDistanceMiles = extraDistanceMiles self.netSavingGBP = netSavingGBP @@ -174,6 +177,7 @@ extension FuelSavingsResult { FuelSavingsResult( fillLitres: fillLitres, grossSavingGBP: 0, + extraFuelLitres: 0, extraDrivingCostGBP: 0, extraDistanceMiles: 0, netSavingGBP: 0, @@ -199,9 +203,9 @@ extension FuelSavingsResult { case .breakEven: return Self.breakEvenHeadline case .worthTheDrive: - return "\(prefix)\(FuelSavingsFormat.money(netSavingGBP)) better off after travel" + return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP))" case .notWorthTheDrive: - return "\(prefix)\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel" + return "\(prefix)Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more" } } @@ -321,16 +325,16 @@ enum FuelSavingsCalculator { distanceMiles / mpg * imperialGallonLitres } - static func fillLitres(amount: FuelSavingsFillAmount, baselinePricePencePerLitre: Double) throws -> Double { + static func fillLitres(amount: FuelSavingsFillAmount, pricePencePerLitre: Double) throws -> Double { guard amount.isValid else { throw FuelSavingsError.invalidAmount } switch amount.unit { case .litres: return amount.value case .pounds: - guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0 else { + guard pricePencePerLitre.isFinite, pricePencePerLitre > 0 else { throw FuelSavingsError.invalidPrice } - return amount.value / (baselinePricePencePerLitre / 100.0) + return amount.value / (pricePencePerLitre / 100.0) } } @@ -376,7 +380,7 @@ enum FuelSavingsCalculator { baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else { throw FuelSavingsError.invalidDistance } - let fillLitres = try fillLitres(amount: amount, baselinePricePencePerLitre: baselinePricePencePerLitre) + let fillLitres = try fillLitres(amount: amount, pricePencePerLitre: candidatePricePencePerLitre) let baselinePriceGBP = baselinePricePencePerLitre / 100.0 let candidatePriceGBP = candidatePricePencePerLitre / 100.0 let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP) @@ -395,6 +399,7 @@ enum FuelSavingsCalculator { return FuelSavingsResult( fillLitres: fillLitres, grossSavingGBP: grossSaving, + extraFuelLitres: extraFuelLitres, extraDrivingCostGBP: extraFuelCost, extraDistanceMiles: extraDistance, netSavingGBP: netSaving,