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
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
11 changed files with 174 additions and 265 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 —
+19 -1
View File
@@ -21,7 +21,7 @@ struct FuelBoardApp: App {
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() rootView
.onOpenURL(perform: handleOpenURL) .onOpenURL(perform: handleOpenURL)
.onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in .onReceive(NotificationCenter.default.publisher(for: .fuelBoardWatchRefreshRequested)) { _ in
WatchSyncManager.shared.pushSnapshot() 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 #if DEBUG
private func startQALiveActivity(fuel: FuelType) { private func startQALiveActivity(fuel: FuelType) {
let state = FuelBoardLiveActivityAttributes.ContentState( let state = FuelBoardLiveActivityAttributes.ContentState(
+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())")
+75 -47
View File
@@ -118,59 +118,42 @@ struct StationDetailView: View {
.accessibilityHint(String(localized: "Opens Apple Maps with driving directions", .accessibilityHint(String(localized: "Opens Apple Maps with driving directions",
comment: "Accessibility hint for the tappable map preview in the More info sheet")) comment: "Accessibility hint for the tappable map preview in the More info sheet"))
} }
.listRowInsets(EdgeInsets()) // No hairline between the map and the Address row: the map
// Both actions sit on ONE line directly under the map: the // already reads as its own block, so the line only added
// two things you can DO with a station belong with the // clutter. The Address/Distance and Distance/actions lines
// station, not stranded at the foot of the sheet. Equal // stay.
// halves, so neither reads as the primary action. .listRowSeparator(.hidden, edges: .bottom)
HStack(spacing: 12) { // The map, the address, the distance, any notices and the two
Button { // station actions all share ONE card (the list's own row
openDirections() // hairlines separate them), so no hand-rolled Divider here —
} label: { // adding one would double the line.
Label("Directions", systemImage: "arrow.triangle.turn.up.right.diamond.fill") //
.frame(maxWidth: .infinity) // The station name is NOT repeated: the sheet header already
} // shows it.
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.
// //
// The whole row is a copy control: it puts JUST the postcode // The whole row is a copy control: it puts JUST the postcode
// on the clipboard so it can be pasted into another // on the clipboard so it can be pasted into another
// navigation app. The copy glyph and its "Copied" // navigation app. The copy glyph sits ON the address line,
// confirmation share a SECOND line that is always there, so // and the "Copied" confirmation is a capsule that floats OVER
// the confirmation can never reflow the address above it. // 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 { Button {
copyPostcode() copyPostcode()
} label: { } label: {
VStack(alignment: .leading, spacing: 6) { HStack(alignment: .firstTextBaseline, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 8) { Text("Address")
Text("Address") Spacer(minLength: 8)
Spacer(minLength: 8) Text(addressLine)
Text(addressLine) .multilineTextAlignment(.trailing)
.multilineTextAlignment(.trailing) .foregroundStyle(.secondary)
.foregroundStyle(.secondary) // Stays the plain copy glyph — the capsule carries
} // the confirmation, so swapping this to a checkmark
HStack(spacing: 4) { // would say it twice.
Spacer(minLength: 0) Image(systemName: "doc.on.doc")
if showingCopied { .font(.footnote.weight(.semibold))
Text("Copied") .foregroundStyle(.secondary)
.foregroundStyle(.green)
.transition(.opacity)
}
Image(systemName: showingCopied ? "checkmark" : "doc.on.doc")
.foregroundStyle(showingCopied ? Color.green : Color.secondary)
}
.font(.footnote.weight(.semibold))
} }
} }
.buttonStyle(.plain) .buttonStyle(.plain)
@@ -181,6 +164,29 @@ struct StationDetailView: View {
? "" ? ""
: String(localized: "Copies the postcode", : String(localized: "Copies the postcode",
comment: "Accessibility hint for the address row in the More info sheet")) 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 { 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
@@ -196,6 +202,28 @@ struct StationDetailView: View {
.padding(.vertical, 6) .padding(.vertical, 6)
.background(Capsule().fill(Color.orange.opacity(0.14))) .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 // Only shown while Fuel Savings backs it. `fuelSavings` is nil
// when the feature is off — `savingsLookup` returns nil when it // when the feature is off — `savingsLookup` returns nil when it
+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)
+1 -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";
@@ -203,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,