Compare commits

..
Author SHA1 Message Date
FuelBoard Contributor 1b2bdd46ea More info sheet: give the two station actions their own card below the map
Sharing the map's section made the buttons read as part of the map card and
the map tap target. They now sit in their own section, rendering as a
separate card directly beneath the map.
2026-10-14 12:07:54 +01:00
FuelBoard Contributor 6983fa83cc More info sheet: put Directions and Report issue on one line under the map
The two station actions now sit together directly beneath the map card, each
taking half the width, instead of being two separate rows at the foot of the
sheet.
2026-10-14 11:52:41 +01:00
FuelBoard Contributor 68139e6690 More info sheet: overlay the map tap target, move the copy line below the address
The map tap target is now an OVERLAY button on top of the Map. A Map wrapped
in a Button inside a List row never received the touch, so the first attempt
left the card inert. The Map stays non-interactive and the overlay owns an
explicit rounded-rect content shape.

The copy glyph and its Copied confirmation move onto their own always-present
second line, so the confirmation can no longer reflow the address above it.
Verified: the address line's ink-pixel runs are identical with and without
the confirmation.
2026-10-14 11:26:21 +01:00
FuelBoard ContributorandFuelBoard Contributor d537667aa3 More info sheet: tappable map opens directions, address copies postcode 2026-10-14 11:26:08 +01:00
10 changed files with 222 additions and 234 deletions
+7 -37
View File
@@ -566,34 +566,10 @@ struct ContentView: View {
func source(_ basis: FuelSavingsDistanceBasis) -> String { func source(_ basis: FuelSavingsDistanceBasis) -> String {
basis == .roadRoute ? "road route" : "straight-line estimate" basis == .roadRoute ? "road route" : "straight-line estimate"
} }
func amount(_ value: FuelSavingsFillAmount) -> String { let price = station.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
switch value.unit { let baselinePrice = baseline?.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
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)] = [ let rows: [(String, String)] = [
("Station", "\(station.name) (\(station.id))"), ("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))"), ("Displayed distance", "\(distanceUnit.format(stationDistance.displayDistanceKM)) · \(source(stationDistance.displaySource))"),
("Outward road", meters(stationDistance.outwardMeters)), ("Outward road", meters(stationDistance.outwardMeters)),
("Return road", stationDistance.hasMeasuredReturn ? meters(stationDistance.returnMeters) : "inferred or unavailable"), ("Return road", stationDistance.hasMeasuredReturn ? meters(stationDistance.returnMeters) : "inferred or unavailable"),
@@ -605,11 +581,12 @@ struct ContentView: View {
("Station journey", miles(journey?.miles)), ("Station journey", miles(journey?.miles)),
("Baseline journey", miles(baselineJourney?.miles)), ("Baseline journey", miles(baselineJourney?.miles)),
("Additional journey", miles(result?.extraDistanceMiles)), ("Additional journey", miles(result?.extraDistanceMiles)),
("Additional fuel used", result.map { String(format: "%.6f L", $0.extraFuelLitres) } ?? "—"),
("Confidence", result?.journeyConfidence.rawValue ?? "estimated"), ("Confidence", result?.journeyConfidence.rawValue ?? "estimated"),
("Travel cost raw", rawMoney(result?.extraDrivingCostGBP)), ("Fuel price", price),
("Net saving raw", rawMoney(result?.netSavingGBP)), ("Baseline price", baselinePrice),
("Displayed net saving", result?.headline() ?? "—") ("Gross saving", result.map { FuelSavingsFormat.money($0.grossSavingGBP) } ?? "—"),
("Travel cost", result.map { FuelSavingsFormat.money($0.extraDrivingCostGBP) } ?? "—"),
("Net saving", result.map { FuelSavingsFormat.money($0.netSavingGBP) } ?? "—")
] ]
return FuelSavingsJourneyDiagnostics(rows: rows) return FuelSavingsJourneyDiagnostics(rows: rows)
#else #else
@@ -1127,13 +1104,6 @@ struct ContentView: View {
ensureBestValueAlertModeIsAvailable() ensureBestValueAlertModeIsAvailable()
updateAlertMonitor() 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 .onChange(of: scenePhase) { _, newPhase in
if newPhase == .active { if newPhase == .active {
// Never start location tracking while onboarding is on screen — // Never start location tracking while onboarding is on screen —
+2 -22
View File
@@ -119,9 +119,6 @@ struct FuelSavingsSummaryView: View {
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
capsule(for: result, compact: false) capsule(for: result, compact: false)
.accessibilityLabel(accessibilityHeadline(for: result)) .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( if let extraTravel = FuelSavingsFormat.extraTravelLabel(
extraDistanceMiles: result.extraDistanceMiles, extraDistanceMiles: result.extraDistanceMiles,
unit: distanceUnit, unit: distanceUnit,
@@ -137,26 +134,9 @@ struct FuelSavingsSummaryView: View {
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
} else { } else {
Text("Compared with \(baselineStationName ?? "your nearest station").") Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary)
.font(.caption) LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP))
.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)) 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 { if result.journeyConfidence.isEstimated {
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.") Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
.font(.caption2) .font(.caption2)
+2 -2
View File
@@ -618,7 +618,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
content.sound = .default content.sound = .default
content.title = "Best Value \(fuel.displayName) nearby: \(brand)" content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
content.body = "\(station.name): \(result.headline()). Tap for directions." content.body = "\(station.name) could leave you \(result.headline()). Tap for directions."
addAlertRequest(content: content, station: station) addAlertRequest(content: content, station: station)
logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())") logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())")
lastAlert = "Best Value · \(brand) · \(result.headline())" lastAlert = "Best Value · \(brand) · \(result.headline())"
@@ -923,7 +923,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.sound = .default content.sound = .default
let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand
content.title = "Best Value \(fuel.displayName) nearby: \(brand)" content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
content.body = "\(candidate.station.name): \(candidate.result.headline()). Tap for directions." content.body = "\(candidate.station.name) could leave you \(candidate.result.headline()). Tap for directions."
let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))" let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))"
lastTestResult = baseResult lastTestResult = baseResult
logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())") logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())")
+137 -17
View File
@@ -29,6 +29,10 @@ struct StationDetailView: View {
var onToggleFavourite: (() -> Void)? = nil var onToggleFavourite: (() -> Void)? = nil
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var showingReportIssueConfirmation = false @State private var showingReportIssueConfirmation = false
/// Transient "Copied" confirmation on the Address row. Cleared by a task so
/// the caption fades out on its own.
@State private var showingCopied = false
@State private var copyResetTask: Task<Void, Never>?
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")! private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
@@ -36,10 +40,61 @@ struct StationDetailView: View {
CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng) CLLocationCoordinate2D(latitude: station.lat, longitude: station.lng)
} }
/// The row's address text — the same shared line the listing row uses, with
/// any leading repeat of the station name stripped.
private var addressLine: String {
StationAddressLine.display(address: station.address,
postcode: station.postcode,
name: station.name)
}
/// The station's postcode, trimmed. Empty means there is nothing to copy and
/// the Address row stays inert.
private var postcodeForCopying: String {
station.postcode.trimmingCharacters(in: .whitespacesAndNewlines)
}
/// Opens Apple Maps driving directions — the same destination (and the same
/// URL) the Directions button below uses, so the map and the button can
/// never point at two different places.
private func openDirections() {
if let url = station.mapsDirectionsURL {
UIApplication.shared.open(url)
}
}
/// Copies ONLY the postcode, so it can be pasted into another navigation app
/// without dragging the station name and street along with it. Confirms with
/// a light haptic and a caption that fades after ~2s.
private func copyPostcode() {
let postcode = postcodeForCopying
guard !postcode.isEmpty else { return }
UIPasteboard.general.string = postcode
UINotificationFeedbackGenerator().notificationOccurred(.success)
withAnimation(.easeInOut(duration: 0.2)) { showingCopied = true }
copyResetTask?.cancel()
copyResetTask = Task {
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard !Task.isCancelled else { return }
withAnimation(.easeInOut(duration: 0.3)) { showingCopied = false }
}
}
var body: some View { var body: some View {
NavigationStack { NavigationStack {
List { List {
Section { Section {
// Tapping anywhere on the map opens the same Apple Maps
// directions the Directions button below opens.
//
// The tap target is an OVERLAY Button laid ON TOP of the
// Map, not a Button wrapping it. A Map runs its own UIKit
// view, and inside a List row the wrapped-Map form left the
// card untappable — the touch never reached the button. An
// overlay sits above the map, owns an explicit rounded-rect
// content shape, and cannot be intercepted by MapKit. The
// Map itself stays non-interactive: it is a preview, not
// something to pan.
Map(position: .constant(.region(MKCoordinateRegion( Map(position: .constant(.region(MKCoordinateRegion(
center: stationCoordinate, center: stationCoordinate,
span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02) span: MKCoordinateSpan(latitudeDelta: 0.02, longitudeDelta: 0.02)
@@ -50,16 +105,83 @@ struct StationDetailView: View {
.frame(height: 180) .frame(height: 180)
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
.allowsHitTesting(false) .allowsHitTesting(false)
.overlay {
Button {
openDirections()
} label: {
Color.clear
.contentShape(RoundedRectangle(cornerRadius: 12))
}
.buttonStyle(.plain)
.accessibilityLabel(String(localized: "Directions to \(station.name)",
comment: "Accessibility label for the tappable map preview in the More info sheet"))
.accessibilityHint(String(localized: "Opens Apple Maps with driving directions",
comment: "Accessibility hint for the tappable map preview in the More info sheet"))
}
.listRowInsets(EdgeInsets()) .listRowInsets(EdgeInsets())
} }
// The two station actions live in their OWN section, so they
// render as a separate card BELOW the map instead of sharing the
// map's card and looking attached to the map tap target.
Section {
HStack(spacing: 12) {
Button {
openDirections()
} label: {
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
.frame(maxWidth: .infinity)
}
Button(role: .destructive) {
showingReportIssueConfirmation = true
} label: {
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
.frame(maxWidth: .infinity)
}
}
}
Section { Section {
// The station name is NOT repeated here: the sheet header // The station name is NOT repeated here: the sheet header
// (navigationTitle, inline) already shows it, and the address // (navigationTitle, inline) already shows it, and the address
// line below has any leading repeat of the name stripped, so // line below has any leading repeat of the name stripped, so
// a "Name" row only said it twice. // a "Name" row only said it twice.
LabeledContent("Address", value: StationAddressLine.display(address: station.address, //
postcode: station.postcode, // The whole row is a copy control: it puts JUST the postcode
name: station.name)) // on the clipboard so it can be pasted into another
// navigation app. The copy glyph and its "Copied"
// confirmation share a SECOND line that is always there, so
// the confirmation can never reflow the address above it.
Button {
copyPostcode()
} label: {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("Address")
Spacer(minLength: 8)
Text(addressLine)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
}
HStack(spacing: 4) {
Spacer(minLength: 0)
if showingCopied {
Text("Copied")
.foregroundStyle(.green)
.transition(.opacity)
}
Image(systemName: showingCopied ? "checkmark" : "doc.on.doc")
.foregroundStyle(showingCopied ? Color.green : Color.secondary)
}
.font(.footnote.weight(.semibold))
}
}
.buttonStyle(.plain)
.disabled(postcodeForCopying.isEmpty)
.accessibilityLabel(String(localized: "Address: \(addressLine)",
comment: "Accessibility label for the address row in the More info sheet"))
.accessibilityHint(postcodeForCopying.isEmpty
? ""
: String(localized: "Copies the postcode",
comment: "Accessibility hint for the address row in the More info sheet"))
if let location { if let location {
let distance = FuelStore.stationDistanceDisplay( let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng station: station, userLat: location.lat, userLng: location.lng
@@ -105,20 +227,6 @@ struct StationDetailView: View {
} }
#endif #endif
Section {
Button {
if let url = station.mapsDirectionsURL {
UIApplication.shared.open(url)
}
} label: {
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill")
}
Button(role: .destructive) {
showingReportIssueConfirmation = true
} label: {
Label("Report issue", systemImage: "exclamationmark.bubble.fill")
}
}
} }
.navigationTitle(station.name) .navigationTitle(station.name)
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@@ -149,6 +257,18 @@ struct StationDetailView: View {
) )
} }
} }
// The "Copied" fade-out task must not outlive the sheet.
.onDisappear { copyResetTask?.cancel() }
#if DEBUG
// QA hook: `-qaCopyPostcode` runs the REAL copy on open, so the
// clipboard value (read back with `xcrun simctl pbpaste`) and the
// "Copied" confirmation can be verified without a tap — simctl
// cannot drive taps into the Simulator.
.task {
guard ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") else { return }
copyPostcode()
}
#endif
} }
} }
} }
+1 -12
View File
@@ -249,7 +249,7 @@ struct StationsView: View {
VStack(spacing: 10) { VStack(spacing: 10) {
keyCard( keyCard(
icon: "checkmark.circle.fill", color: .green, icon: "checkmark.circle.fill", color: .green,
title: "Good price", blurb: "Within 1.5p of the cheapest", title: "Best value", blurb: "Within 1.5p of the cheapest",
threshold: "≤1.5p") threshold: "≤1.5p")
keyCard( keyCard(
icon: "equal.circle.fill", color: .orange, icon: "equal.circle.fill", color: .orange,
@@ -298,17 +298,6 @@ struct StationsView: View {
.listRowBackground(Color.clear) .listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16)) .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") .navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
+6 -6
View File
@@ -23,7 +23,7 @@
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)"; "Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
"All %lld stations shown" = "All %lld stations shown"; "All %lld stations shown" = "All %lld stations shown";
"Key" = "Key"; "Key" = "Key";
"Good price" = "Good price"; "Best value" = "Best value";
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest"; "Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
"Okay" = "Okay"; "Okay" = "Okay";
"Within 4p of the cheapest" = "Within 4p of the cheapest"; "Within 4p of the cheapest" = "Within 4p of the cheapest";
@@ -35,10 +35,6 @@
"Add to favourites" = "Add to favourites"; "Add to favourites" = "Add to favourites";
"Remove from favourites" = "Remove from 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"; "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 */ /* Favourites tab */
"No favourites yet" = "No favourites yet"; "No favourites yet" = "No favourites yet";
@@ -71,6 +67,11 @@
"Name" = "Name"; "Name" = "Name";
"Address" = "Address"; "Address" = "Address";
"Distance" = "Distance"; "Distance" = "Distance";
"Copied" = "Copied";
"Copies the postcode" = "Copies the postcode";
"Address: %@" = "Address: %@";
"Directions to %@" = "Directions to %@";
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";
"Show introduction" = "Replay onboarding"; "Show introduction" = "Replay onboarding";
"Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts."; "Replay the welcome screen, including the location and notification permission prompts." = "Replay the welcome screen, including the location and notification permission prompts.";
"Test alert notification (real data)" = "Test alert notification (real data)"; "Test alert notification (real data)" = "Test alert notification (real data)";
@@ -198,7 +199,6 @@
"Cheapest station" = "Cheapest station"; "Cheapest station" = "Cheapest station";
"Alert type" = "Alert type"; "Alert type" = "Alert type";
"Best Value" = "Best Value"; "Best Value" = "Best Value";
"Total" = "Total";
"Minimum saving" = "Minimum saving"; "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 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."; "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.";
@@ -82,84 +82,18 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344)) XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344))
} }
// MARK: - 2. Candidate-priced fill-up // MARK: - 2/4. The three stations from the brief
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 { func testBriefCaseTescoBradfordIsMarginallyWorseOff() throws {
let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0) let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0)
XCTAssertEqual(result.fillLitres, 35.108250, accuracy: 1e-6) XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 0.351083, accuracy: 1e-6, "£60 buys 35.11 L at the candidate price") 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.extraDistanceMiles, 2.0, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6) XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -0.166869, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, -0.168911, accuracy: 1e-6)
XCTAssertEqual(result.status, .notWorthTheDrive) XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "Costs £0.17 more") XCTAssertEqual(result.headline(), "£0.17 worse off after travel")
} }
/// The brief recorded "£0.18 worse off" for this row against a displayed /// The brief recorded "£0.18 worse off" for this row against a displayed
@@ -170,9 +104,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
/// is entirely the rounding of the distance, not of the money. /// is entirely the rounding of the distance, not of the money.
func testTheReportedEighteenPenceIsRoundedExtraDistanceNotARoundingBug() throws { func testTheReportedEighteenPenceIsRoundedExtraDistanceNotARoundingBug() throws {
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.00).headline(), XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.00).headline(),
"Costs £0.17 more") "£0.17 worse off after travel")
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(), XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(),
"Costs £0.18 more") "£0.18 worse off after travel")
// 2.05 mi shows as "2.0 mi" at one decimal place — the same caption. // 2.05 mi shows as "2.0 mi" at one decimal place — the same caption.
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles), XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles),
@@ -184,13 +118,13 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
func testBriefCaseMFGHareAndHoundsIsWorseOff() throws { func testBriefCaseMFGHareAndHoundsIsWorseOff() throws {
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8) let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
XCTAssertEqual(result.fillLitres, 34.702140, accuracy: 1e-6) XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, -0.347021, accuracy: 1e-6, "£60 buys 34.70 L at the candidate price") 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.extraDistanceMiles, 2.8, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6) XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6)
XCTAssertEqual(result.status, .notWorthTheDrive) XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "Costs £1.08 more") XCTAssertEqual(result.headline(), "£1.08 worse off after travel")
} }
func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws { func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws {
@@ -201,7 +135,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
XCTAssertEqual(result.netSavingGBP, -0.729375, accuracy: 1e-6, XCTAssertEqual(result.netSavingGBP, -0.729375, accuracy: 1e-6,
"with no price difference the whole result is the extra driving") "with no price difference the whole result is the extra driving")
XCTAssertEqual(result.status, .notWorthTheDrive) XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "Costs £0.73 more") XCTAssertEqual(result.headline(), "£0.73 worse off after travel")
} }
// MARK: - 5/6. Classification, tolerance and rounding // MARK: - 5/6. Classification, tolerance and rounding
@@ -216,15 +150,15 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
} }
func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws { func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws {
// 171.9p vs 170.9p over a 1.23 mi delta: the small saving remains within tolerance. // 171.9p vs 170.9p over a 1.23 mi delta: net +£0.0305.
let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23) let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23)
XCTAssertEqual(result.netSavingGBP, 0.03254252417853143, accuracy: 1e-9) 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.status, .breakEven, "±5p reads as break even rather than a meaningless £0.03")
XCTAssertEqual(result.headline(), "Break even") XCTAssertEqual(result.headline(), "Break even")
} }
func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws { func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws {
let net = -0.166869 let net = -0.168911
// A tolerance wider than the loss → break even; narrower → worse off. // A tolerance wider than the loss → break even; narrower → worse off.
let lenient = try FuelSavingsCalculator.calculate( let lenient = try FuelSavingsCalculator.calculate(
mpg: 30, mpg: 30,
@@ -253,7 +187,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
func testMoneyIsRoundedOnlyForDisplay() throws { func testMoneyIsRoundedOnlyForDisplay() throws {
// Full precision retained internally… // Full precision retained internally…
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8) let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6)
XCTAssertNotEqual(result.netSavingGBP, -1.08, "the stored value must not be pre-rounded") XCTAssertNotEqual(result.netSavingGBP, -1.08, "the stored value must not be pre-rounded")
// …and rounded to two places only when shown. // …and rounded to two places only when shown.
XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08") XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08")
@@ -303,12 +237,12 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 2.0, baselineJourneyMiles: 2.0,
candidateJourneyMiles: 7.0 candidateJourneyMiles: 7.0
) )
XCTAssertEqual(result.fillLitres, 28.797696, accuracy: 1e-6) XCTAssertEqual(result.fillLitres, 27.416038, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 2.015839, accuracy: 1e-6) XCTAssertEqual(result.grossSavingGBP, 1.919123, accuracy: 1e-6)
XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6) XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, 1.314226, accuracy: 1e-6) XCTAssertEqual(result.netSavingGBP, 1.217509, accuracy: 1e-6)
XCTAssertEqual(result.status, .worthTheDrive) XCTAssertEqual(result.status, .worthTheDrive)
XCTAssertEqual(result.headline(), "Save £1.31") XCTAssertEqual(result.headline(), "£1.22 better off after travel")
} }
func testNearerCandidateIsNeverChargedExtraTravel() throws { func testNearerCandidateIsNeverChargedExtraTravel() throws {
@@ -336,9 +270,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 0.8, baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6 candidateJourneyMiles: 3.6
) )
XCTAssertEqual(sixty.grossSavingGBP, -0.347021, accuracy: 1e-6, "MPG must not touch the pump saving") 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.extraDrivingCostGBP, 0.366809, accuracy: 1e-6)
XCTAssertEqual(sixty.netSavingGBP, -0.713830, accuracy: 1e-6) XCTAssertEqual(sixty.netSavingGBP, -0.715849, accuracy: 1e-6)
let fortyFive = try FuelSavingsCalculator.calculate( let fortyFive = try FuelSavingsCalculator.calculate(
mpg: 45, mpg: 45,
@@ -349,7 +283,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
candidateJourneyMiles: 3.6 candidateJourneyMiles: 3.6
) )
XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6) XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6)
XCTAssertEqual(fortyFive.netSavingGBP, -0.836100, accuracy: 1e-6) XCTAssertEqual(fortyFive.netSavingGBP, -0.838119, accuracy: 1e-6)
} }
func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws { func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws {
@@ -361,10 +295,10 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 0.8, baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6 candidateJourneyMiles: 3.6
) )
XCTAssertEqual(forty.fillLitres, 23.134760, accuracy: 1e-6, "£40 buys 23.13 L at the candidate price") XCTAssertEqual(forty.fillLitres, 23.269343, accuracy: 1e-6, "£40 buys 23.27 L at 171.9p")
XCTAssertEqual(forty.grossSavingGBP, -0.231348, accuracy: 1e-6) XCTAssertEqual(forty.grossSavingGBP, -0.232693, accuracy: 1e-6)
XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent") XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent")
XCTAssertEqual(forty.netSavingGBP, -0.964965, accuracy: 1e-6) XCTAssertEqual(forty.netSavingGBP, -0.966311, accuracy: 1e-6)
let ninety = try FuelSavingsCalculator.calculate( let ninety = try FuelSavingsCalculator.calculate(
mpg: 30, mpg: 30,
@@ -374,9 +308,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 0.8, baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6 candidateJourneyMiles: 3.6
) )
XCTAssertEqual(ninety.fillLitres, 52.053210, accuracy: 1e-6) XCTAssertEqual(ninety.fillLitres, 52.356021, accuracy: 1e-6)
XCTAssertEqual(ninety.grossSavingGBP, -0.520532, accuracy: 1e-6) XCTAssertEqual(ninety.grossSavingGBP, -0.523560, accuracy: 1e-6)
XCTAssertEqual(ninety.netSavingGBP, -1.254150, accuracy: 1e-6) XCTAssertEqual(ninety.netSavingGBP, -1.257178, accuracy: 1e-6)
} }
func testFillUpInLitresIsFixedRegardlessOfPrice() throws { func testFillUpInLitresIsFixedRegardlessOfPrice() throws {
@@ -436,7 +370,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
journey: journey(2.8), journey: journey(2.8),
baselineJourney: journey(0.8) baselineJourney: journey(0.8)
) )
XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.166869, accuracy: 1e-6) XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.168911, accuracy: 1e-6)
} }
func testMixedRouteBasisIsReportedAsAnEstimate() { func testMixedRouteBasisIsReportedAsAnEstimate() {
@@ -459,7 +393,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine, XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine,
"one unmeasured leg makes the whole estimate an estimate") "one unmeasured leg makes the whole estimate an estimate")
XCTAssertEqual(mixed?.result?.journeyConfidence, .estimated) XCTAssertEqual(mixed?.result?.journeyConfidence, .estimated)
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. Costs £0.17 more") XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. £0.17 worse off after travel")
} }
func testBaselineStationIsLabelledClosestStationNotBreakEven() { func testBaselineStationIsLabelledClosestStationNotBreakEven() {
@@ -284,9 +284,9 @@ final class FuelSavingsEstimateTests: XCTestCase {
/// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi). /// (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 /// 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 /// correct, not a calculation bug: £60 buys 34.90 L at 171.9p, so the
/// cheaper pump saves 35.74 × 4p = £1.43, while the extra 6.8 return miles /// 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.30. /// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.33.
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() { func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
let baseline = station("rontec-shelf", price: 171.9) let baseline = station("rontec-shelf", price: 171.9)
let candidate = station("shell-whetley-hill", price: 167.9) let candidate = station("shell-whetley-hill", price: 167.9)
@@ -304,16 +304,16 @@ final class FuelSavingsEstimateTests: XCTestCase {
guard let result = availability?.result else { guard let result = availability?.result else {
return XCTFail("expected an estimate, got \(String(describing: availability))") return XCTFail("expected an estimate, got \(String(describing: availability))")
} }
XCTAssertEqual(result.fillLitres, 35.736, accuracy: 0.005, XCTAssertEqual(result.fillLitres, 34.904, accuracy: 0.005,
"the £60 budget buys litres at the candidate pump price") "the £60 budget buys litres at the BASELINE pump price")
XCTAssertEqual(result.grossSavingGBP, 1.429, accuracy: 0.005) XCTAssertEqual(result.grossSavingGBP, 1.396, accuracy: 0.005)
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001, XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
"only the return-trip DELTA counts — the shared journey is never counted twice") "only the return-trip DELTA counts — the shared journey is never counted twice")
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005) XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
XCTAssertEqual(result.netSavingGBP, -0.301, accuracy: 0.005) XCTAssertEqual(result.netSavingGBP, -0.334, accuracy: 0.005)
XCTAssertEqual(result.status, .notWorthTheDrive) XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.journeyConfidence, .estimated) XCTAssertEqual(result.journeyConfidence, .estimated)
XCTAssertEqual(result.headline(compact: true), "Approx. Costs £0.30 more") XCTAssertEqual(result.headline(compact: true), "Approx. £0.33 worse off after travel")
} }
func testMeasuredRoadRoutesDropTheEstimateMarker() { func testMeasuredRoadRoutesDropTheEstimateMarker() {
@@ -332,8 +332,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute) XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed) XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed)
XCTAssertEqual(availability?.result?.headline(compact: true), "Costs £0.30 more") XCTAssertEqual(availability?.result?.headline(compact: true), "£0.33 worse off after travel")
XCTAssertEqual(availability?.result?.headline(), "Costs £0.30 more") XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel")
} }
func testBaselineRowIsLabelledClosestStation() { func testBaselineRowIsLabelledClosestStation() {
@@ -375,10 +375,10 @@ final class FuelSavingsEstimateTests: XCTestCase {
return XCTFail("expected an estimate, got \(String(describing: availability))") return XCTFail("expected an estimate, got \(String(describing: availability))")
} }
XCTAssertEqual(result.status, .worthTheDrive) XCTAssertEqual(result.status, .worthTheDrive)
// £60 buys 40 L at 150p; 10p/litre cheaper saves £4.00; 0.4 extra // £60 buys 37.5 L at 160p; 10p/litre cheaper saves £3.75; 0.4 extra
// return miles cost ~£0.09. Net ≈ £3.91. // return miles cost ~£0.09. Net ≈ £3.66.
XCTAssertEqual(result.netSavingGBP, 3.909, accuracy: 0.005) XCTAssertEqual(result.netSavingGBP, 3.659, accuracy: 0.005)
XCTAssertEqual(result.headline(compact: true), "Approx. Save £3.91") XCTAssertEqual(result.headline(compact: true), "Approx. £3.66 better off after travel")
} }
func testNeutralResultHeadlineIsBreakEven() { func testNeutralResultHeadlineIsBreakEven() {
@@ -413,7 +413,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: 0.52, netSavingGBP: 0.52,
status: .worthTheDrive, status: .worthTheDrive,
distanceBasis: .roadRoute) distanceBasis: .roadRoute)
XCTAssertEqual(saving.headline(), "Save £0.52") XCTAssertEqual(saving.headline(), "£0.52 better off after travel")
let loss = FuelSavingsResult(fillLitres: 34.9, let loss = FuelSavingsResult(fillLitres: 34.9,
grossSavingGBP: 1.40, grossSavingGBP: 1.40,
@@ -422,7 +422,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: -0.31, netSavingGBP: -0.31,
status: .notWorthTheDrive, status: .notWorthTheDrive,
distanceBasis: .roadRoute) distanceBasis: .roadRoute)
XCTAssertEqual(loss.headline(), "Costs £0.31 more") XCTAssertEqual(loss.headline(), "£0.31 worse off after travel")
} }
/// The sort must order by NET saving, not pump price: a pricier station can /// The sort must order by NET saving, not pump price: a pricier station can
@@ -497,7 +497,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .worthTheDrive, status: .worthTheDrive,
distanceBasis: .straightLine distanceBasis: .straightLine
) )
XCTAssertEqual(saving.capsuleHeadline(), "Approx. Save £1.00") XCTAssertEqual(saving.capsuleHeadline(), "Approx. £1.00 better off after travel")
let worse = FuelSavingsResult( let worse = FuelSavingsResult(
fillLitres: 30, fillLitres: 30,
@@ -507,7 +507,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: -0.31, netSavingGBP: -0.31,
status: .notWorthTheDrive status: .notWorthTheDrive
) )
XCTAssertEqual(worse.capsuleHeadline(), "Costs £0.31 more") XCTAssertEqual(worse.capsuleHeadline(), "£0.31 worse off after travel")
} }
// MARK: - Estimate wording (Approx. labels) // MARK: - Estimate wording (Approx. labels)
@@ -524,7 +524,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .worthTheDrive, status: .worthTheDrive,
distanceBasis: .straightLine distanceBasis: .straightLine
) )
XCTAssertEqual(saving.headline(), "Approx. Save £1.25") XCTAssertEqual(saving.headline(), "Approx. £1.25 better off after travel")
let loss = FuelSavingsResult( let loss = FuelSavingsResult(
fillLitres: 30, fillLitres: 30,
@@ -535,7 +535,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .notWorthTheDrive, status: .notWorthTheDrive,
distanceBasis: .straightLine distanceBasis: .straightLine
) )
XCTAssertEqual(loss.headline(), "Approx. Costs £0.77 more") XCTAssertEqual(loss.headline(), "Approx. £0.77 worse off after travel")
} }
/// A confirmed road-route comparison must never carry the marker — in either /// A confirmed road-route comparison must never carry the marker — in either
@@ -551,7 +551,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
distanceBasis: .roadRoute, distanceBasis: .roadRoute,
journeyConfidence: .confirmed journeyConfidence: .confirmed
) )
XCTAssertEqual(saving.headline(), "Save £1.25") XCTAssertEqual(saving.headline(), "£1.25 better off after travel")
let loss = FuelSavingsResult( let loss = FuelSavingsResult(
fillLitres: 30, fillLitres: 30,
@@ -563,7 +563,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
distanceBasis: .roadRoute, distanceBasis: .roadRoute,
journeyConfidence: .confirmed journeyConfidence: .confirmed
) )
XCTAssertEqual(loss.headline(), "Costs £0.77 more") XCTAssertEqual(loss.headline(), "£0.77 worse off after travel")
for headline in [saving.headline(), loss.headline(), saving.capsuleHeadline()] { for headline in [saving.headline(), loss.headline(), saving.capsuleHeadline()] {
XCTAssertFalse(headline.contains("Approx"), "\"\(headline)\" must not claim to be an estimate") XCTAssertFalse(headline.contains("Approx"), "\"\(headline)\" must not claim to be an estimate")
@@ -597,7 +597,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .notWorthTheDrive, status: .notWorthTheDrive,
distanceBasis: .straightLine distanceBasis: .straightLine
) )
XCTAssertEqual(estimated.spokenHeadline(), "Approximately Costs £0.77 more") XCTAssertEqual(estimated.spokenHeadline(), "Approximately £0.77 worse off after travel")
XCTAssertEqual( XCTAssertEqual(
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated), FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
@@ -8,15 +8,15 @@ final class FuelSavingsCalculatorTests: XCTestCase {
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001) XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001)
} }
func testPoundsBudgetConvertsToLitresUsingGivenPrice() throws { func testPoundsBudgetConvertsToLitresUsingBaselinePrice() throws {
let amount = FuelSavingsFillAmount(value: 40, unit: .pounds) let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160) let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
XCTAssertEqual(litres, 25.0, accuracy: 0.0001) XCTAssertEqual(litres, 25.0, accuracy: 0.0001)
} }
func testLitresAmountPassesThrough() throws { func testLitresAmountPassesThrough() throws {
let amount = FuelSavingsFillAmount(value: 35, unit: .litres) let amount = FuelSavingsFillAmount(value: 35, unit: .litres)
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160) let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
XCTAssertEqual(litres, 35, accuracy: 0.0001) XCTAssertEqual(litres, 35, accuracy: 0.0001)
} }
@@ -29,11 +29,11 @@ final class FuelSavingsCalculatorTests: XCTestCase {
baselineJourneyMiles: 2, baselineJourneyMiles: 2,
candidateJourneyMiles: 8 candidateJourneyMiles: 8
) )
XCTAssertEqual(result.fillLitres, 26.6667, accuracy: 0.0001) XCTAssertEqual(result.fillLitres, 25, accuracy: 0.0001)
XCTAssertEqual(result.grossSavingGBP, 2.6667, accuracy: 0.0001) XCTAssertEqual(result.grossSavingGBP, 2.50, accuracy: 0.0001)
XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001) XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001)
XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001) XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001)
XCTAssertEqual(result.netSavingGBP, 1.7574, accuracy: 0.0001) XCTAssertEqual(result.netSavingGBP, 1.5908, accuracy: 0.0001)
XCTAssertEqual(result.status, .worthTheDrive) XCTAssertEqual(result.status, .worthTheDrive)
} }
@@ -61,7 +61,7 @@ final class FuelSavingsCalculatorTests: XCTestCase {
} }
func testInvalidInputsRejected() { func testInvalidInputsRejected() {
XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), pricePencePerLitre: 0)) XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 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: 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)) XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
} }
+6 -11
View File
@@ -82,7 +82,6 @@ enum JourneyConfidence: String, Codable, Equatable {
struct FuelSavingsResult: Codable, Equatable { struct FuelSavingsResult: Codable, Equatable {
let fillLitres: Double let fillLitres: Double
let grossSavingGBP: Double let grossSavingGBP: Double
let extraFuelLitres: Double
let extraDrivingCostGBP: Double let extraDrivingCostGBP: Double
let extraDistanceMiles: Double let extraDistanceMiles: Double
let netSavingGBP: Double let netSavingGBP: Double
@@ -92,7 +91,6 @@ struct FuelSavingsResult: Codable, Equatable {
init(fillLitres: Double, init(fillLitres: Double,
grossSavingGBP: Double, grossSavingGBP: Double,
extraFuelLitres: Double = 0,
extraDrivingCostGBP: Double, extraDrivingCostGBP: Double,
extraDistanceMiles: Double, extraDistanceMiles: Double,
netSavingGBP: Double, netSavingGBP: Double,
@@ -101,7 +99,6 @@ struct FuelSavingsResult: Codable, Equatable {
journeyConfidence: JourneyConfidence? = nil) { journeyConfidence: JourneyConfidence? = nil) {
self.fillLitres = fillLitres self.fillLitres = fillLitres
self.grossSavingGBP = grossSavingGBP self.grossSavingGBP = grossSavingGBP
self.extraFuelLitres = extraFuelLitres
self.extraDrivingCostGBP = extraDrivingCostGBP self.extraDrivingCostGBP = extraDrivingCostGBP
self.extraDistanceMiles = extraDistanceMiles self.extraDistanceMiles = extraDistanceMiles
self.netSavingGBP = netSavingGBP self.netSavingGBP = netSavingGBP
@@ -177,7 +174,6 @@ extension FuelSavingsResult {
FuelSavingsResult( FuelSavingsResult(
fillLitres: fillLitres, fillLitres: fillLitres,
grossSavingGBP: 0, grossSavingGBP: 0,
extraFuelLitres: 0,
extraDrivingCostGBP: 0, extraDrivingCostGBP: 0,
extraDistanceMiles: 0, extraDistanceMiles: 0,
netSavingGBP: 0, netSavingGBP: 0,
@@ -203,9 +199,9 @@ extension FuelSavingsResult {
case .breakEven: case .breakEven:
return Self.breakEvenHeadline return Self.breakEvenHeadline
case .worthTheDrive: case .worthTheDrive:
return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP))" return "\(prefix)\(FuelSavingsFormat.money(netSavingGBP)) better off after travel"
case .notWorthTheDrive: case .notWorthTheDrive:
return "\(prefix)Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more" return "\(prefix)\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel"
} }
} }
@@ -325,16 +321,16 @@ enum FuelSavingsCalculator {
distanceMiles / mpg * imperialGallonLitres distanceMiles / mpg * imperialGallonLitres
} }
static func fillLitres(amount: FuelSavingsFillAmount, pricePencePerLitre: Double) throws -> Double { static func fillLitres(amount: FuelSavingsFillAmount, baselinePricePencePerLitre: Double) throws -> Double {
guard amount.isValid else { throw FuelSavingsError.invalidAmount } guard amount.isValid else { throw FuelSavingsError.invalidAmount }
switch amount.unit { switch amount.unit {
case .litres: case .litres:
return amount.value return amount.value
case .pounds: case .pounds:
guard pricePencePerLitre.isFinite, pricePencePerLitre > 0 else { guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0 else {
throw FuelSavingsError.invalidPrice throw FuelSavingsError.invalidPrice
} }
return amount.value / (pricePencePerLitre / 100.0) return amount.value / (baselinePricePencePerLitre / 100.0)
} }
} }
@@ -380,7 +376,7 @@ enum FuelSavingsCalculator {
baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else { baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else {
throw FuelSavingsError.invalidDistance throw FuelSavingsError.invalidDistance
} }
let fillLitres = try fillLitres(amount: amount, pricePencePerLitre: candidatePricePencePerLitre) let fillLitres = try fillLitres(amount: amount, baselinePricePencePerLitre: baselinePricePencePerLitre)
let baselinePriceGBP = baselinePricePencePerLitre / 100.0 let baselinePriceGBP = baselinePricePencePerLitre / 100.0
let candidatePriceGBP = candidatePricePencePerLitre / 100.0 let candidatePriceGBP = candidatePricePencePerLitre / 100.0
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP) let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
@@ -399,7 +395,6 @@ enum FuelSavingsCalculator {
return FuelSavingsResult( return FuelSavingsResult(
fillLitres: fillLitres, fillLitres: fillLitres,
grossSavingGBP: grossSaving, grossSavingGBP: grossSaving,
extraFuelLitres: extraFuelLitres,
extraDrivingCostGBP: extraFuelCost, extraDrivingCostGBP: extraFuelCost,
extraDistanceMiles: extraDistance, extraDistanceMiles: extraDistance,
netSavingGBP: netSaving, netSavingGBP: netSaving,