Compare commits

..
Author SHA1 Message Date
FuelBoard Contributor 7dbb3d1a15 More info sheet: float the "Copied" capsule over the address row, not the map
The confirmation belongs to the address row, so hovering it over the map read as
belonging to the map. It now overlays the address row itself, horizontally centred
on the address line, as .overlay(alignment: .center) on the row with
allowsHitTesting(false) still keeping the row tappable through the capsule.

Verified: with the capsule up, the OCR box sits at y 1189-1231 inside the address
band (map bottom is 1075), the tint blob is centred at x 602.5 against a screen
centre of 603, the row's ink runs outside the capsule footprint are byte-identical
to the idle frame, and a real tap on the capsule's own footprint still copies.
2026-10-14 13:57:19 +01:00
FuelBoard Contributor 6e534ed5b7 More info sheet: float the "Copied" capsule and put the copy glyph on the address line
The address row is back to a single line: the copy glyph now sits on the address
line itself (trailing, plain glyph — the confirmation says it elsewhere), and the
always-reserved second line is gone.

The "Copied" confirmation is now a green-tinted capsule that floats over the
bottom of the map, directly above the address and horizontally centred. It is an
overlay with allowsHitTesting(false), so nothing reflows while it is up (verified:
the address row's ink, column for column, does not move) and the map's tap target
stays live underneath (verified: tapping where the capsule floats still opens
Apple Maps). A material base keeps it legible against map tiles, where a 14% wash
alone — the notice-capsule recipe — disappears. Timing is unchanged: 2s, then
fades, with the success haptic on copy.
2026-10-14 13:45:05 +01:00
FuelBoard Contributor 73db893f4f More info sheet: drop the hairline between the map and the address
The map already reads as its own block, so the row separator below it only
added clutter. Removed with .listRowSeparator(.hidden, edges: .bottom) on the map
row, leaving the Address/Distance and Distance/actions hairlines in place.
2026-10-14 13:16:19 +01:00
FuelBoard Contributor 7da09928ca More info sheet: one card for the map, address, distance and the two actions
The map, the address, the distance, any notices and the Directions/Report issue
row now share a single section, in that order, with the buttons as the last row.
The list's own row hairlines separate them (inset like Address/Distance), so no
hand-rolled Divider is added — that would double the line.

The map loses .listRowInsets(EdgeInsets()) so it sits inset inside the card with
its rounded corners, aligned with the hairlines and text rather than butting the
card edges.

Directions/Report issue keep .buttonStyle(.borderless); verified with real
simulated taps in the merged card that each fires alone.
2026-10-14 12:48:33 +01:00
FuelBoard Contributor 666d59c483 More info sheet: give each action button its own tap target
Directions and Report issue both fired on any tap inside their row: with the
default button style a List row has a single row-wide tap target, so tapping
one button triggered the other's action too (tapping Report issue opened Apple
Maps, and tapping Directions showed the report alert).

`.buttonStyle(.borderless)` gives each button its own hit area over its
half-row frame. Verified with real simulated taps on both buttons.
2026-10-14 12:25:16 +01:00
FuelBoard Contributor 311118f0fa Add a DEBUG screen-marker hook so Simulator taps can be verified
`-qaScreenMarker` paints the screen flat magenta, which lets a macOS capture
locate the device-screen rectangle exactly and map simulator-screenshot pixels
to click coordinates. `simctl` cannot drive taps and the project has no UI-test
target, so this is the only way to test real touch behaviour on the sheet.
Guarded by #if DEBUG, exactly like the existing -qaCopyPostcode hook.
2026-10-14 12:25:16 +01:00
11 changed files with 174 additions and 266 deletions
+7 -37
View File
@@ -566,34 +566,10 @@ struct ContentView: View {
func source(_ basis: FuelSavingsDistanceBasis) -> String {
basis == .roadRoute ? "road route" : "straight-line estimate"
}
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 price = station.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
let baselinePrice = baseline?.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
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"),
@@ -605,11 +581,12 @@ 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"),
("Travel cost raw", rawMoney(result?.extraDrivingCostGBP)),
("Net saving raw", rawMoney(result?.netSavingGBP)),
("Displayed net saving", result?.headline() ?? "—")
("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) } ?? "—")
]
return FuelSavingsJourneyDiagnostics(rows: rows)
#else
@@ -1127,13 +1104,6 @@ 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 —
+19 -1
View File
@@ -21,7 +21,7 @@ struct FuelBoardApp: App {
var body: some Scene {
WindowGroup {
ContentView()
rootView
.onOpenURL(perform: handleOpenURL)
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
WatchSyncManager.shared.pushSnapshot()
@@ -32,6 +32,24 @@ struct FuelBoardApp: App {
}
}
/// QA hook (Debug builds only): `-qaScreenMarker` paints the whole screen a
/// flat magenta. A macOS capture of the Simulator window can then locate the
/// device-screen rectangle exactly, which yields a pixel-accurate mapping
/// from simulator screenshots to click coordinates. That is the only way to
/// drive REAL taps into the Simulator: `simctl` cannot, and this project has
/// no UI-test target. Everything else renders ContentView as normal.
@ViewBuilder private var rootView: some View {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("-qaScreenMarker") {
Color(red: 1, green: 0, blue: 1).ignoresSafeArea()
} else {
ContentView()
}
#else
ContentView()
#endif
}
#if DEBUG
private func startQALiveActivity(fuel: FuelType) {
let state = FuelBoardLiveActivityAttributes.ContentState(
+2 -22
View File
@@ -119,9 +119,6 @@ struct FuelSavingsSummaryView: View {
VStack(alignment: .leading, spacing: 10) {
capsule(for: result, compact: false)
.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,
@@ -137,26 +134,9 @@ struct FuelSavingsSummaryView: View {
.font(.caption)
.foregroundStyle(.secondary)
} else {
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"
)
Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary)
LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP))
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)
+2 -2
View File
@@ -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): \(result.headline()). Tap for directions."
content.body = "\(station.name) could leave you \(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): \(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))"
lastTestResult = baseResult
logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())")
+75 -48
View File
@@ -118,60 +118,42 @@ struct StationDetailView: View {
.accessibilityHint(String(localized: "Opens Apple Maps with driving directions",
comment: "Accessibility hint for the tappable map preview in the More info sheet"))
}
.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 {
// The station name is NOT repeated here: the sheet header
// (navigationTitle, inline) already shows it, and the address
// line below has any leading repeat of the name stripped, so
// a "Name" row only said it twice.
// No hairline between the map and the Address row: the map
// already reads as its own block, so the line only added
// clutter. The Address/Distance and Distance/actions lines
// stay.
.listRowSeparator(.hidden, edges: .bottom)
// The map, the address, the distance, any notices and the two
// station actions all share ONE card (the list's own row
// hairlines separate them), so no hand-rolled Divider here —
// adding one would double the line.
//
// The station name is NOT repeated: the sheet header already
// shows it.
//
// The whole row is a copy control: it puts JUST the postcode
// 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.
// navigation app. The copy glyph sits ON the address line,
// and the "Copied" confirmation is a capsule that floats OVER
// THIS ROW — it confirms the address, so it must not hover
// over the map. It is an overlay, so nothing moves while it is
// up, and `allowsHitTesting(false)` keeps the row tappable
// through the capsule.
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))
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("Address")
Spacer(minLength: 8)
Text(addressLine)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
// Stays the plain copy glyph — the capsule carries
// the confirmation, so swapping this to a checkmark
// would say it twice.
Image(systemName: "doc.on.doc")
.font(.footnote.weight(.semibold))
.foregroundStyle(.secondary)
}
}
.buttonStyle(.plain)
@@ -182,6 +164,29 @@ struct StationDetailView: View {
? ""
: String(localized: "Copies the postcode",
comment: "Accessibility hint for the address row in the More info sheet"))
// Horizontally centred over the address line itself.
.overlay(alignment: .center) {
if showingCopied {
HStack(spacing: 4) {
Image(systemName: "checkmark")
Text("Copied")
}
.font(.footnote.weight(.semibold))
.foregroundStyle(.green)
.padding(.horizontal, 10)
.padding(.vertical, 6)
// A material base keeps it legible where the tint
// alone (the notice-capsule recipe) washes out.
.background {
Capsule()
.fill(.regularMaterial)
.overlay(Capsule().fill(Color.green.opacity(0.22)))
}
.allowsHitTesting(false)
.accessibilityHidden(true)
.transition(.opacity)
}
}
if let location {
let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
@@ -197,6 +202,28 @@ struct StationDetailView: View {
.padding(.vertical, 6)
.background(Capsule().fill(Color.orange.opacity(0.14)))
}
// The two station actions are the LAST row of the card: half
// the width each, on one line, below Address and Distance.
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)
}
}
// REQUIRED: with the default button style a `List` row has ONE
// row-wide tap target, so two buttons in the same row BOTH fire
// on any tap inside the row (tapping Directions also showed the
// report-issue alert and vice versa). `.borderless` gives each
// button its own hit area — the half-row frame above.
.buttonStyle(.borderless)
}
// Only shown while Fuel Savings backs it. `fuelSavings` is nil
// when the feature is off — `savingsLookup` returns nil when it
+1 -12
View File
@@ -249,7 +249,7 @@ struct StationsView: View {
VStack(spacing: 10) {
keyCard(
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")
keyCard(
icon: "equal.circle.fill", color: .orange,
@@ -298,17 +298,6 @@ 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)
+1 -6
View File
@@ -23,7 +23,7 @@
"Show %lld more (%lld remaining)" = "Show %lld more (%lld remaining)";
"All %lld stations shown" = "All %lld stations shown";
"Key" = "Key";
"Good price" = "Good price";
"Best value" = "Best value";
"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,10 +35,6 @@
"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";
@@ -203,7 +199,6 @@
"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.";
@@ -82,84 +82,18 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344))
}
// 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
// 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, 35.108250, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 0.351083, accuracy: 1e-6, "£60 buys 35.11 L at the candidate price")
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.166869, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -0.168911, accuracy: 1e-6)
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
@@ -170,9 +104,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(),
"Costs £0.17 more")
"£0.17 worse off after travel")
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.
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles),
@@ -184,13 +118,13 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
func testBriefCaseMFGHareAndHoundsIsWorseOff() throws {
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
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.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.080639, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6)
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "Costs £1.08 more")
XCTAssertEqual(result.headline(), "£1.08 worse off after travel")
}
func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws {
@@ -201,7 +135,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(), "Costs £0.73 more")
XCTAssertEqual(result.headline(), "£0.73 worse off after travel")
}
// MARK: - 5/6. Classification, tolerance and rounding
@@ -216,15 +150,15 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
}
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)
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.headline(), "Break even")
}
func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws {
let net = -0.166869
let net = -0.168911
// A tolerance wider than the loss → break even; narrower → worse off.
let lenient = try FuelSavingsCalculator.calculate(
mpg: 30,
@@ -253,7 +187,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.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")
// …and rounded to two places only when shown.
XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08")
@@ -303,12 +237,12 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 2.0,
candidateJourneyMiles: 7.0
)
XCTAssertEqual(result.fillLitres, 28.797696, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 2.015839, accuracy: 1e-6)
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.314226, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, 1.217509, accuracy: 1e-6)
XCTAssertEqual(result.status, .worthTheDrive)
XCTAssertEqual(result.headline(), "Save £1.31")
XCTAssertEqual(result.headline(), "£1.22 better off after travel")
}
func testNearerCandidateIsNeverChargedExtraTravel() throws {
@@ -336,9 +270,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 0.8,
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.netSavingGBP, -0.713830, accuracy: 1e-6)
XCTAssertEqual(sixty.netSavingGBP, -0.715849, accuracy: 1e-6)
let fortyFive = try FuelSavingsCalculator.calculate(
mpg: 45,
@@ -349,7 +283,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
candidateJourneyMiles: 3.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 {
@@ -361,10 +295,10 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.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.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.964965, accuracy: 1e-6)
XCTAssertEqual(forty.netSavingGBP, -0.966311, accuracy: 1e-6)
let ninety = try FuelSavingsCalculator.calculate(
mpg: 30,
@@ -374,9 +308,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.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)
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 {
@@ -436,7 +370,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
journey: journey(2.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() {
@@ -459,7 +393,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. Costs £0.17 more")
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. £0.17 worse off after travel")
}
func testBaselineStationIsLabelledClosestStationNotBreakEven() {
@@ -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 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.
/// correct, not a calculation bug: £60 buys 34.90 L at 171.9p, so the
/// cheaper pump saves 34.90 × 4p = £1.40, while the extra 6.8 return miles
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.33.
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
let baseline = station("rontec-shelf", price: 171.9)
let candidate = station("shell-whetley-hill", price: 167.9)
@@ -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, 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.fillLitres, 34.904, accuracy: 0.005,
"the £60 budget buys litres at the BASELINE pump price")
XCTAssertEqual(result.grossSavingGBP, 1.396, accuracy: 0.005)
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
"only the return-trip DELTA counts — the shared journey is never counted twice")
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
XCTAssertEqual(result.netSavingGBP, -0.301, accuracy: 0.005)
XCTAssertEqual(result.netSavingGBP, -0.334, accuracy: 0.005)
XCTAssertEqual(result.status, .notWorthTheDrive)
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() {
@@ -332,8 +332,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed)
XCTAssertEqual(availability?.result?.headline(compact: true), "Costs £0.30 more")
XCTAssertEqual(availability?.result?.headline(), "Costs £0.30 more")
XCTAssertEqual(availability?.result?.headline(compact: true), "£0.33 worse off after travel")
XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel")
}
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 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")
// £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")
}
func testNeutralResultHeadlineIsBreakEven() {
@@ -413,7 +413,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: 0.52,
status: .worthTheDrive,
distanceBasis: .roadRoute)
XCTAssertEqual(saving.headline(), "Save £0.52")
XCTAssertEqual(saving.headline(), "£0.52 better off after travel")
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(), "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
@@ -497,7 +497,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .worthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(saving.capsuleHeadline(), "Approx. Save £1.00")
XCTAssertEqual(saving.capsuleHeadline(), "Approx. £1.00 better off after travel")
let worse = FuelSavingsResult(
fillLitres: 30,
@@ -507,7 +507,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: -0.31,
status: .notWorthTheDrive
)
XCTAssertEqual(worse.capsuleHeadline(), "Costs £0.31 more")
XCTAssertEqual(worse.capsuleHeadline(), "£0.31 worse off after travel")
}
// MARK: - Estimate wording (Approx. labels)
@@ -524,7 +524,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .worthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(saving.headline(), "Approx. Save £1.25")
XCTAssertEqual(saving.headline(), "Approx. £1.25 better off after travel")
let loss = FuelSavingsResult(
fillLitres: 30,
@@ -535,7 +535,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .notWorthTheDrive,
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
@@ -551,7 +551,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
distanceBasis: .roadRoute,
journeyConfidence: .confirmed
)
XCTAssertEqual(saving.headline(), "Save £1.25")
XCTAssertEqual(saving.headline(), "£1.25 better off after travel")
let loss = FuelSavingsResult(
fillLitres: 30,
@@ -563,7 +563,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
distanceBasis: .roadRoute,
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()] {
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 Costs £0.77 more")
XCTAssertEqual(estimated.spokenHeadline(), "Approximately £0.77 worse off after travel")
XCTAssertEqual(
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)
}
func testPoundsBudgetConvertsToLitresUsingGivenPrice() throws {
func testPoundsBudgetConvertsToLitresUsingBaselinePrice() throws {
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)
}
func testLitresAmountPassesThrough() throws {
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)
}
@@ -29,11 +29,11 @@ final class FuelSavingsCalculatorTests: XCTestCase {
baselineJourneyMiles: 2,
candidateJourneyMiles: 8
)
XCTAssertEqual(result.fillLitres, 26.6667, accuracy: 0.0001)
XCTAssertEqual(result.grossSavingGBP, 2.6667, accuracy: 0.0001)
XCTAssertEqual(result.fillLitres, 25, accuracy: 0.0001)
XCTAssertEqual(result.grossSavingGBP, 2.50, accuracy: 0.0001)
XCTAssertEqual(result.extraDistanceMiles, 6, 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)
}
@@ -61,7 +61,7 @@ final class FuelSavingsCalculatorTests: XCTestCase {
}
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: 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 {
let fillLitres: Double
let grossSavingGBP: Double
let extraFuelLitres: Double
let extraDrivingCostGBP: Double
let extraDistanceMiles: Double
let netSavingGBP: Double
@@ -92,7 +91,6 @@ struct FuelSavingsResult: Codable, Equatable {
init(fillLitres: Double,
grossSavingGBP: Double,
extraFuelLitres: Double = 0,
extraDrivingCostGBP: Double,
extraDistanceMiles: Double,
netSavingGBP: Double,
@@ -101,7 +99,6 @@ 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
@@ -177,7 +174,6 @@ extension FuelSavingsResult {
FuelSavingsResult(
fillLitres: fillLitres,
grossSavingGBP: 0,
extraFuelLitres: 0,
extraDrivingCostGBP: 0,
extraDistanceMiles: 0,
netSavingGBP: 0,
@@ -203,9 +199,9 @@ extension FuelSavingsResult {
case .breakEven:
return Self.breakEvenHeadline
case .worthTheDrive:
return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP))"
return "\(prefix)\(FuelSavingsFormat.money(netSavingGBP)) better off after travel"
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
}
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 }
switch amount.unit {
case .litres:
return amount.value
case .pounds:
guard pricePencePerLitre.isFinite, pricePencePerLitre > 0 else {
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0 else {
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 {
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 candidatePriceGBP = candidatePricePencePerLitre / 100.0
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
@@ -399,7 +395,6 @@ enum FuelSavingsCalculator {
return FuelSavingsResult(
fillLitres: fillLitres,
grossSavingGBP: grossSaving,
extraFuelLitres: extraFuelLitres,
extraDrivingCostGBP: extraFuelCost,
extraDistanceMiles: extraDistance,
netSavingGBP: netSaving,