Compare commits

..
Author SHA1 Message Date
FuelBoard Contributor 93df94ddc9 How Best Value works: its own sheet, opened from the Key sheet and More info sheet
The explanation used to be a section inside the Key sheet. It is a standalone
HowBestValueSheet now, opened from a tappable row with a chevron: at the bottom of
the Key sheet, and at the bottom of the More info sheet below the Best Value card.

Gating: the More info row sits INSIDE the same `if let fuelSavings` as the card it
follows, so it disappears with the feature. Verified with Best Value toggled off in
Settings: the More info sheet then shows neither the card nor the row. The Key
sheet row is deliberately ungated — that sheet is the legend and is reachable
whatever the feature is doing.

Copy is unchanged: the same Localizable.strings strings are reused, and the old
section header became the sheet's title. A DEBUG-only `-showBestValueInfo` hook
opens the sheet without a tap.
2026-10-14 14:37:18 +01:00
FuelBoard Contributor 78b43ce6d7 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 13:57:21 +01:00
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 13967c3782 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 13:45:05 +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 4718e9b76a Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 13:16:20 +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 6dfe732199 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 12:48:33 +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 f0c13a79d6 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 12:25:17 +01:00
FuelBoard Contributor 6b41e8ac51 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 12:07:54 +01:00
FuelBoard Contributor e58dbb7833 Merge branch 'feature/more-info-map-and-postcode-copy' into integration/more-info-and-best-value 2026-10-14 11:52:42 +01:00
FuelBoard Contributor 7ee1f06ce6 Merge branch 'feature/best-value-candidate-fill' into integration/more-info-and-best-value 2026-10-14 11:34:33 +01:00
FuelBoard Contributor 6ea675499e Best Value detail sheet: keep the verdict capsule left-aligned
The capsule was right-aligned on its own line; the user asked for it back at
the left margin, leaving the Total row as it is.
2026-10-14 11:31:59 +01:00
FuelBoard Contributor 3c424ab397 Best Value: candidate-priced fills, key copy, detail total 2026-10-14 10:43:12 +01:00
11 changed files with 400 additions and 145 deletions
+37 -7
View File
@@ -566,10 +566,34 @@ struct ContentView: View {
func source(_ basis: FuelSavingsDistanceBasis) -> String {
basis == .roadRoute ? "road route" : "straight-line estimate"
}
let price = station.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
let baselinePrice = baseline?.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
func amount(_ value: FuelSavingsFillAmount) -> String {
switch value.unit {
case .pounds: return String(format: "£%.2f", value.value)
case .litres: return String(format: "%.2f L", value.value)
}
}
func rawMoney(_ value: Double?) -> String {
value.map { String(format: "£%.6f", $0) } ?? "—"
}
let candidatePriceValue = station.prices[selectedFuel]
let baselinePriceValue = baseline?.prices[selectedFuel]
let price = candidatePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—"
let baselinePrice = baselinePriceValue.map { String(format: "%.1fp/L", $0) } ?? "—"
let priceDifference = candidatePriceValue.flatMap { candidate in
baselinePriceValue.map { String(format: "%+.1fp/L", $0 - candidate) }
} ?? "—"
let litresBasis = sessionFuelSavingsAmount.unit == .pounds ? "candidate price" : "fixed litres"
let rows: [(String, String)] = [
("Station", "\(station.name) (\(station.id))"),
("Settings default fill-up", amount(fuelSavingsSettings.preferredAmount)),
("Effective/session fill-up", amount(sessionFuelSavingsAmount)),
("Fill-up unit", sessionFuelSavingsAmount.unit.displayName),
("MPG", String(format: "%.1f UK MPG", fuelSavingsSettings.mpg)),
("Candidate price", price),
("Baseline price", baselinePrice),
("Price difference", priceDifference),
("Candidate litres", result.map { String(format: "%.6f L · %@", $0.fillLitres, litresBasis) } ?? "—"),
("Gross saving raw", rawMoney(result?.grossSavingGBP)),
("Displayed distance", "\(distanceUnit.format(stationDistance.displayDistanceKM)) · \(source(stationDistance.displaySource))"),
("Outward road", meters(stationDistance.outwardMeters)),
("Return road", stationDistance.hasMeasuredReturn ? meters(stationDistance.returnMeters) : "inferred or unavailable"),
@@ -581,12 +605,11 @@ struct ContentView: View {
("Station journey", miles(journey?.miles)),
("Baseline journey", miles(baselineJourney?.miles)),
("Additional journey", miles(result?.extraDistanceMiles)),
("Additional fuel used", result.map { String(format: "%.6f L", $0.extraFuelLitres) } ?? "—"),
("Confidence", result?.journeyConfidence.rawValue ?? "estimated"),
("Fuel price", price),
("Baseline price", baselinePrice),
("Gross saving", result.map { FuelSavingsFormat.money($0.grossSavingGBP) } ?? "—"),
("Travel cost", result.map { FuelSavingsFormat.money($0.extraDrivingCostGBP) } ?? "—"),
("Net saving", result.map { FuelSavingsFormat.money($0.netSavingGBP) } ?? "—")
("Travel cost raw", rawMoney(result?.extraDrivingCostGBP)),
("Net saving raw", rawMoney(result?.netSavingGBP)),
("Displayed net saving", result?.headline() ?? "—")
]
return FuelSavingsJourneyDiagnostics(rows: rows)
#else
@@ -1104,6 +1127,13 @@ struct ContentView: View {
ensureBestValueAlertModeIsAvailable()
updateAlertMonitor()
}
.onChange(of: sessionFuelSavingsAmount) { _, _ in
// This remains a session-only override: do not write it to FuelStore.
// Any active Best Value alert/Live Activity must nevertheless use
// the same effective amount as rows, details, favourites and sorting.
updateAlertMonitor()
updateLiveActivity()
}
.onChange(of: scenePhase) { _, newPhase in
if newPhase == .active {
// Never start location tracking while onboarding is on screen —
+22 -2
View File
@@ -119,6 +119,9 @@ 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,
@@ -134,9 +137,26 @@ struct FuelSavingsSummaryView: View {
.font(.caption)
.foregroundStyle(.secondary)
} else {
Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary)
LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP))
Text("Compared with \(baselineStationName ?? "your nearest station").")
.font(.caption)
.foregroundStyle(.secondary)
LabeledContent(
result.grossSavingGBP >= 0 ? "Fuel-price saving" : "Fuel-price difference",
value: result.grossSavingGBP >= 0
? FuelSavingsFormat.money(result.grossSavingGBP)
: "Costs \(FuelSavingsFormat.money(abs(result.grossSavingGBP))) more"
)
LabeledContent("Extra driving cost", value: FuelSavingsFormat.money(result.extraDrivingCostGBP))
Divider()
LabeledContent {
Text(result.headline())
.fontWeight(.semibold)
.foregroundStyle(capsuleColor(for: result.status))
.monospacedDigit()
} label: {
Text("Total")
.fontWeight(.semibold)
}
if result.journeyConfidence.isEstimated {
Text("Estimated from straight-line or incomplete route data — refined once both routes are measured.")
.font(.caption2)
+69
View File
@@ -0,0 +1,69 @@
import SwiftUI
/// The "How Best Value works" explanation as its own sheet.
///
/// It used to sit inline in the Key sheet as a `Section`; it is one shared view
/// now so the Key sheet and the More info sheet can both open it, and the copy is
/// unchanged (the same `Localizable.strings` keys are reused — the old section
/// header string is the sheet's title).
struct HowBestValueSheet: View {
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section {
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("How Best Value works")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .confirmationAction) {
Button("Done") { dismiss() }
}
}
}
.presentationDetents([.fraction(0.42)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
}
}
/// The row that opens `HowBestValueSheet`. A tappable row with a chevron, like
/// the other rows in both sheets. `.buttonStyle(.plain)` keeps it from becoming a
/// row-wide tap target that could swallow a neighbouring control (see the
/// two-buttons-in-one-row rule).
struct HowBestValueRow: View {
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Text("How Best Value works")
Spacer(minLength: 8)
Image(systemName: "chevron.forward")
.font(.footnote.weight(.semibold))
.foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
}
}
#if DEBUG
/// QA hook support: `-showBestValueInfo` opens this sheet without a tap, so it can
/// be screenshotted (simctl has no tap command) — the same pattern as
/// `-showKeySheet` / `-showDetail`.
enum BestValueInfoQA {
static var isRequested: Bool {
ProcessInfo.processInfo.arguments.contains("-showBestValueInfo")
}
}
#endif
+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) could leave you \(result.headline()). Tap for directions."
content.body = "\(station.name): \(result.headline()). Tap for directions."
addAlertRequest(content: content, station: station)
logAlert(.fired, "Best Value alert scheduled — \(brand) · \(result.headline())")
lastAlert = "Best Value · \(brand) · \(result.headline())"
@@ -923,7 +923,7 @@ final class ProximityMonitor: NSObject, ObservableObject, @preconcurrency CLLoca
content.sound = .default
let brand = candidate.station.brand.isEmpty ? candidate.station.name : candidate.station.brand
content.title = "Best Value \(fuel.displayName) nearby: \(brand)"
content.body = "\(candidate.station.name) could leave you \(candidate.result.headline()). Tap for directions."
content.body = "\(candidate.station.name): \(candidate.result.headline()). Tap for directions."
let baseResult = "Best Value · \(brand) · \(candidate.result.headline()) · radius \(unit.format(radiusKM))"
lastTestResult = baseResult
logAlert(.fired, "Best Value test alert scheduled — \(brand) · \(candidate.result.headline())")
+98 -65
View File
@@ -33,6 +33,9 @@ struct StationDetailView: View {
/// the caption fades out on its own.
@State private var showingCopied = false
@State private var copyResetTask: Task<Void, Never>?
/// The "How Best Value works" sheet, opened from the row at the bottom of the
/// Best Value card (the Key sheet opens the same shared view).
@State private var showBestValueInfo = false
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
@@ -118,12 +121,92 @@ 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 {
// 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 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: {
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)
.disabled(postcodeForCopying.isEmpty)
.accessibilityLabel(String(localized: "Address: \(addressLine)",
comment: "Accessibility label for the address row in the More info sheet"))
.accessibilityHint(postcodeForCopying.isEmpty
? ""
: String(localized: "Copies the postcode",
comment: "Accessibility hint for the address row in the More info sheet"))
// 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
)
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
.monospacedDigit()
}
ForEach(StationNotice.notices(for: station)) { notice in
Label(notice.title, systemImage: notice.symbolName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 10)
.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()
@@ -145,65 +228,6 @@ struct StationDetailView: View {
// button its own hit area — the half-row frame above.
.buttonStyle(.borderless)
}
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
// on the clipboard so it can be pasted into another
// navigation app. The copy glyph and its "Copied"
// confirmation share a SECOND line that is always there, so
// the confirmation can never reflow the address above it.
Button {
copyPostcode()
} label: {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("Address")
Spacer(minLength: 8)
Text(addressLine)
.multilineTextAlignment(.trailing)
.foregroundStyle(.secondary)
}
HStack(spacing: 4) {
Spacer(minLength: 0)
if showingCopied {
Text("Copied")
.foregroundStyle(.green)
.transition(.opacity)
}
Image(systemName: showingCopied ? "checkmark" : "doc.on.doc")
.foregroundStyle(showingCopied ? Color.green : Color.secondary)
}
.font(.footnote.weight(.semibold))
}
}
.buttonStyle(.plain)
.disabled(postcodeForCopying.isEmpty)
.accessibilityLabel(String(localized: "Address: \(addressLine)",
comment: "Accessibility label for the address row in the More info sheet"))
.accessibilityHint(postcodeForCopying.isEmpty
? ""
: String(localized: "Copies the postcode",
comment: "Accessibility hint for the address row in the More info sheet"))
if let location {
let distance = FuelStore.stationDistanceDisplay(
station: station, userLat: location.lat, userLng: location.lng
)
LabeledContent("Distance", value: distance.labelWithReturn(unit: distanceUnit))
.monospacedDigit()
}
ForEach(StationNotice.notices(for: station)) { notice in
Label(notice.title, systemImage: notice.symbolName)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.orange)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(Capsule().fill(Color.orange.opacity(0.14)))
}
}
// Only shown while Fuel Savings backs it. `fuelSavings` is nil
// when the feature is off — `savingsLookup` returns nil when it
// is not configured — so the heading cannot be left dangling
@@ -219,6 +243,13 @@ struct StationDetailView: View {
} header: {
Text("Best Value")
}
// The explanation lives in its own sheet now, opened from this
// row. The row sits INSIDE the `if let` so it carries the same
// gate as the card above: no card, no row.
Section {
HowBestValueRow { showBestValueInfo = true }
}
}
#if DEBUG
@@ -244,6 +275,8 @@ struct StationDetailView: View {
} message: {
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
}
// Nested sheet: the More info sheet stays put behind the explanation.
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Done") { dismiss() }
+28 -1
View File
@@ -35,6 +35,11 @@ struct StationsView: View {
/// info button in the header — keeps the list focused on stations.
@State private var showKey = false
/// The "How Best Value works" sheet. The explanation used to be a section in
/// the Key sheet; it is its own sheet now, opened from a row at the bottom of
/// the Key sheet and from the More info sheet.
@State private var showBestValueInfo = false
/// Station picked for the More-info sheet (tap action = showDetails).
@State private var detailStation: FuelStation?
@@ -218,6 +223,18 @@ struct StationsView: View {
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
showKey = true
}
// QA hook: `-showBestValueInfo` opens the Key sheet and then the
// explanation on top of it, so the copy can be screenshotted
// without a tap. It has to chain: the explanation's `.sheet` lives
// inside the Key sheet's content, so it cannot present while the
// Key sheet is shut.
if BestValueInfoQA.isRequested {
showKey = true
Task { @MainActor in
try? await Task.sleep(nanoseconds: 1_200_000_000)
showBestValueInfo = true
}
}
#endif
}
.task(id: stations.count) {
@@ -249,7 +266,7 @@ struct StationsView: View {
VStack(spacing: 10) {
keyCard(
icon: "checkmark.circle.fill", color: .green,
title: "Best value", blurb: "Within 1.5p of the cheapest",
title: "Good price", blurb: "Within 1.5p of the cheapest",
threshold: "≤1.5p")
keyCard(
icon: "equal.circle.fill", color: .orange,
@@ -298,6 +315,14 @@ struct StationsView: View {
.listRowBackground(Color.clear)
.listRowInsets(EdgeInsets(top: 4, leading: 16, bottom: 4, trailing: 16))
}
// The explanation is its own sheet now, opened from this
// row. Deliberately NOT gated by Best Value: the Key sheet
// is the legend and is reachable whatever the feature is
// doing.
Section {
HowBestValueRow { showBestValueInfo = true }
}
}
.navigationTitle("Key")
.navigationBarTitleDisplayMode(.inline)
@@ -306,6 +331,8 @@ struct StationsView: View {
Button("Done") { showKey = false }
}
}
// Nested sheet: the Key sheet stays behind the explanation.
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
}
.presentationDetents([.fraction(0.6)])
.presentationBackground(Color(UIColor.systemGroupedBackground))
+6 -1
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";
"Best value" = "Best value";
"Good price" = "Good price";
"Within 1.5p of the cheapest" = "Within 1.5p of the cheapest";
"Okay" = "Okay";
"Within 4p of the cheapest" = "Within 4p of the cheapest";
@@ -35,6 +35,10 @@
"Add to favourites" = "Add to favourites";
"Remove from favourites" = "Remove from favourites";
"Colours match each station's rating on the list" = "Colours match each station's rating on the list";
"How Best Value works" = "How Best Value works";
"Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey." = "Best Value compares each station with the closest one. It works out what you could save at the pump, then takes off the fuel used for the extra return journey.";
"Your fill-up amount and MPG are used to show whether you could save overall." = "Your fill-up amount and MPG are used to show whether you could save overall.";
"Results are marked “Approx.” until road distances are available." = "Results are marked “Approx.” until road distances are available.";
/* Favourites tab */
"No favourites yet" = "No favourites yet";
@@ -199,6 +203,7 @@
"Cheapest station" = "Cheapest station";
"Alert type" = "Alert type";
"Best Value" = "Best Value";
"Total" = "Total";
"Minimum saving" = "Minimum saving";
"Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost." = "Set up Best Value first so FuelBoard can account for your fill size, MPG and travel cost.";
"Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost." = "Set up Best Value first so Best Value alerts can account for your fill size, MPG and travel cost.";
@@ -82,18 +82,84 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344))
}
// MARK: - 2/4. The three stations from the brief
// MARK: - 2. Candidate-priced fill-up
func testPoundsFillUsesCandidatePriceForLitresAndGrossSaving() throws {
let result = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0.879,
candidateJourneyMiles: 8.306
)
XCTAssertEqual(result.fillLitres, 60 / 1.699, accuracy: 1e-9)
XCTAssertEqual(result.grossSavingGBP, (60 / 1.699) * 0.02, accuracy: 1e-9)
XCTAssertEqual(FuelSavingsFormat.money(result.grossSavingGBP), "£0.71")
XCTAssertEqual(result.extraFuelLitres, 1.1254603476666667, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 1.9121571306856667, accuracy: 1e-9)
XCTAssertEqual(result.netSavingGBP, -1.2058593084372853, accuracy: 1e-9)
XCTAssertEqual(result.headline(), "Costs £1.21 more")
}
func testPoundsExamplesScaleFromThirtyToOneHundredPounds() throws {
for (budget, expectedLitres, expectedGross) in [
(30.0, 17.657445556209535, 0.3531489111241907),
(60.0, 35.31489111241907, 0.7062978222483814),
(100.0, 58.85815185403179, 1.1771630370806357)
] {
let result = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: budget, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0,
candidateJourneyMiles: 0
)
XCTAssertEqual(result.fillLitres, expectedLitres, accuracy: 1e-8)
XCTAssertEqual(result.grossSavingGBP, expectedGross, accuracy: 1e-8)
}
}
func testCandidatePriceControlsLitresWhileBaselinePriceOnlyChangesSaving() throws {
let amount = FuelSavingsFillAmount(value: 60, unit: .pounds)
let first = try FuelSavingsCalculator.calculate(
mpg: 30, amount: amount,
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0, candidateJourneyMiles: 0
)
let differentBaseline = try FuelSavingsCalculator.calculate(
mpg: 30, amount: amount,
baselinePricePencePerLitre: 179.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0, candidateJourneyMiles: 0
)
let differentCandidate = try FuelSavingsCalculator.calculate(
mpg: 30, amount: amount,
baselinePricePencePerLitre: 179.9,
candidatePricePencePerLitre: 159.9,
baselineJourneyMiles: 0, candidateJourneyMiles: 0
)
XCTAssertEqual(first.fillLitres, differentBaseline.fillLitres, accuracy: 1e-12)
XCTAssertNotEqual(first.grossSavingGBP, differentBaseline.grossSavingGBP)
XCTAssertNotEqual(differentBaseline.fillLitres, differentCandidate.fillLitres)
}
// MARK: - 3/5. The three stations from the brief
func testBriefCaseTescoBradfordIsMarginallyWorseOff() throws {
let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0)
XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 0.349040, accuracy: 1e-6, "1p/litre cheaper on a 34.90 L fill")
XCTAssertEqual(result.fillLitres, 35.108250, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 0.351083, accuracy: 1e-6, "£60 buys 35.11 L at the candidate price")
XCTAssertEqual(result.extraDistanceMiles, 2.0, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -0.168911, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -0.166869, accuracy: 1e-6)
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "£0.17 worse off after travel")
XCTAssertEqual(result.headline(), "Costs £0.17 more")
}
/// The brief recorded "£0.18 worse off" for this row against a displayed
@@ -104,9 +170,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
/// is entirely the rounding of the distance, not of the money.
func testTheReportedEighteenPenceIsRoundedExtraDistanceNotARoundingBug() throws {
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.00).headline(),
"£0.17 worse off after travel")
"Costs £0.17 more")
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(),
"£0.18 worse off after travel")
"Costs £0.18 more")
// 2.05 mi shows as "2.0 mi" at one decimal place — the same caption.
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles),
@@ -118,13 +184,13 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
func testBriefCaseMFGHareAndHoundsIsWorseOff() throws {
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, -0.349040, accuracy: 1e-6, "1p/litre dearer costs 34.90 L × 1p")
XCTAssertEqual(result.fillLitres, 34.702140, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, -0.347021, accuracy: 1e-6, "£60 buys 34.70 L at the candidate price")
XCTAssertEqual(result.extraDistanceMiles, 2.8, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "£1.08 worse off after travel")
XCTAssertEqual(result.headline(), "Costs £1.08 more")
}
func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws {
@@ -135,7 +201,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
XCTAssertEqual(result.netSavingGBP, -0.729375, accuracy: 1e-6,
"with no price difference the whole result is the extra driving")
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "£0.73 worse off after travel")
XCTAssertEqual(result.headline(), "Costs £0.73 more")
}
// MARK: - 5/6. Classification, tolerance and rounding
@@ -150,15 +216,15 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
}
func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws {
// 171.9p vs 170.9p over a 1.23 mi delta: net +£0.0305.
// 171.9p vs 170.9p over a 1.23 mi delta: the small saving remains within tolerance.
let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23)
XCTAssertEqual(result.netSavingGBP, 0.030500, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, 0.03254252417853143, accuracy: 1e-9)
XCTAssertEqual(result.status, .breakEven, "±5p reads as break even rather than a meaningless £0.03")
XCTAssertEqual(result.headline(), "Break even")
}
func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws {
let net = -0.168911
let net = -0.166869
// A tolerance wider than the loss → break even; narrower → worse off.
let lenient = try FuelSavingsCalculator.calculate(
mpg: 30,
@@ -187,7 +253,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
func testMoneyIsRoundedOnlyForDisplay() throws {
// Full precision retained internally…
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
XCTAssertNotEqual(result.netSavingGBP, -1.08, "the stored value must not be pre-rounded")
// …and rounded to two places only when shown.
XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08")
@@ -237,12 +303,12 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 2.0,
candidateJourneyMiles: 7.0
)
XCTAssertEqual(result.fillLitres, 27.416038, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 1.919123, accuracy: 1e-6)
XCTAssertEqual(result.fillLitres, 28.797696, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 2.015839, accuracy: 1e-6)
XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, 1.217509, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, 1.314226, accuracy: 1e-6)
XCTAssertEqual(result.status, .worthTheDrive)
XCTAssertEqual(result.headline(), "£1.22 better off after travel")
XCTAssertEqual(result.headline(), "Save £1.31")
}
func testNearerCandidateIsNeverChargedExtraTravel() throws {
@@ -270,9 +336,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
XCTAssertEqual(sixty.grossSavingGBP, -0.349040, accuracy: 1e-6, "MPG must not touch the pump saving")
XCTAssertEqual(sixty.grossSavingGBP, -0.347021, accuracy: 1e-6, "MPG must not touch the pump saving")
XCTAssertEqual(sixty.extraDrivingCostGBP, 0.366809, accuracy: 1e-6)
XCTAssertEqual(sixty.netSavingGBP, -0.715849, accuracy: 1e-6)
XCTAssertEqual(sixty.netSavingGBP, -0.713830, accuracy: 1e-6)
let fortyFive = try FuelSavingsCalculator.calculate(
mpg: 45,
@@ -283,7 +349,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
candidateJourneyMiles: 3.6
)
XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6)
XCTAssertEqual(fortyFive.netSavingGBP, -0.838119, accuracy: 1e-6)
XCTAssertEqual(fortyFive.netSavingGBP, -0.836100, accuracy: 1e-6)
}
func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws {
@@ -295,10 +361,10 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
XCTAssertEqual(forty.fillLitres, 23.269343, accuracy: 1e-6, "£40 buys 23.27 L at 171.9p")
XCTAssertEqual(forty.grossSavingGBP, -0.232693, accuracy: 1e-6)
XCTAssertEqual(forty.fillLitres, 23.134760, accuracy: 1e-6, "£40 buys 23.13 L at the candidate price")
XCTAssertEqual(forty.grossSavingGBP, -0.231348, accuracy: 1e-6)
XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent")
XCTAssertEqual(forty.netSavingGBP, -0.966311, accuracy: 1e-6)
XCTAssertEqual(forty.netSavingGBP, -0.964965, accuracy: 1e-6)
let ninety = try FuelSavingsCalculator.calculate(
mpg: 30,
@@ -308,9 +374,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
XCTAssertEqual(ninety.fillLitres, 52.356021, accuracy: 1e-6)
XCTAssertEqual(ninety.grossSavingGBP, -0.523560, accuracy: 1e-6)
XCTAssertEqual(ninety.netSavingGBP, -1.257178, accuracy: 1e-6)
XCTAssertEqual(ninety.fillLitres, 52.053210, accuracy: 1e-6)
XCTAssertEqual(ninety.grossSavingGBP, -0.520532, accuracy: 1e-6)
XCTAssertEqual(ninety.netSavingGBP, -1.254150, accuracy: 1e-6)
}
func testFillUpInLitresIsFixedRegardlessOfPrice() throws {
@@ -370,7 +436,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
journey: journey(2.8),
baselineJourney: journey(0.8)
)
XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.168911, accuracy: 1e-6)
XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.166869, accuracy: 1e-6)
}
func testMixedRouteBasisIsReportedAsAnEstimate() {
@@ -393,7 +459,7 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine,
"one unmeasured leg makes the whole estimate an estimate")
XCTAssertEqual(mixed?.result?.journeyConfidence, .estimated)
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. £0.17 worse off after travel")
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. Costs £0.17 more")
}
func testBaselineStationIsLabelledClosestStationNotBreakEven() {
@@ -284,9 +284,9 @@ final class FuelSavingsEstimateTests: XCTestCase {
/// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi).
///
/// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is
/// correct, not a calculation bug: £60 buys 34.90 L at 171.9p, so the
/// cheaper pump saves 34.90 × 4p = £1.40, while the extra 6.8 return miles
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.33.
/// correct, not a calculation bug: £60 buys 35.74 L at 167.9p, so the
/// cheaper pump saves 35.74 × 4p = £1.43, while the extra 6.8 return miles
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.30.
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
let baseline = station("rontec-shelf", price: 171.9)
let candidate = station("shell-whetley-hill", price: 167.9)
@@ -304,16 +304,16 @@ final class FuelSavingsEstimateTests: XCTestCase {
guard let result = availability?.result else {
return XCTFail("expected an estimate, got \(String(describing: availability))")
}
XCTAssertEqual(result.fillLitres, 34.904, accuracy: 0.005,
"the £60 budget buys litres at the BASELINE pump price")
XCTAssertEqual(result.grossSavingGBP, 1.396, accuracy: 0.005)
XCTAssertEqual(result.fillLitres, 35.736, accuracy: 0.005,
"the £60 budget buys litres at the candidate pump price")
XCTAssertEqual(result.grossSavingGBP, 1.429, accuracy: 0.005)
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
"only the return-trip DELTA counts — the shared journey is never counted twice")
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
XCTAssertEqual(result.netSavingGBP, -0.334, accuracy: 0.005)
XCTAssertEqual(result.netSavingGBP, -0.301, accuracy: 0.005)
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.journeyConfidence, .estimated)
XCTAssertEqual(result.headline(compact: true), "Approx. £0.33 worse off after travel")
XCTAssertEqual(result.headline(compact: true), "Approx. Costs £0.30 more")
}
func testMeasuredRoadRoutesDropTheEstimateMarker() {
@@ -332,8 +332,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
XCTAssertEqual(availability?.result?.journeyConfidence, .confirmed)
XCTAssertEqual(availability?.result?.headline(compact: true), "£0.33 worse off after travel")
XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel")
XCTAssertEqual(availability?.result?.headline(compact: true), "Costs £0.30 more")
XCTAssertEqual(availability?.result?.headline(), "Costs £0.30 more")
}
func testBaselineRowIsLabelledClosestStation() {
@@ -375,10 +375,10 @@ final class FuelSavingsEstimateTests: XCTestCase {
return XCTFail("expected an estimate, got \(String(describing: availability))")
}
XCTAssertEqual(result.status, .worthTheDrive)
// £60 buys 37.5 L at 160p; 10p/litre cheaper saves £3.75; 0.4 extra
// return miles cost ~£0.09. Net ≈ £3.66.
XCTAssertEqual(result.netSavingGBP, 3.659, accuracy: 0.005)
XCTAssertEqual(result.headline(compact: true), "Approx. £3.66 better off after travel")
// £60 buys 40 L at 150p; 10p/litre cheaper saves £4.00; 0.4 extra
// return miles cost ~£0.09. Net ≈ £3.91.
XCTAssertEqual(result.netSavingGBP, 3.909, accuracy: 0.005)
XCTAssertEqual(result.headline(compact: true), "Approx. Save £3.91")
}
func testNeutralResultHeadlineIsBreakEven() {
@@ -413,7 +413,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: 0.52,
status: .worthTheDrive,
distanceBasis: .roadRoute)
XCTAssertEqual(saving.headline(), "£0.52 better off after travel")
XCTAssertEqual(saving.headline(), "Save £0.52")
let loss = FuelSavingsResult(fillLitres: 34.9,
grossSavingGBP: 1.40,
@@ -422,7 +422,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: -0.31,
status: .notWorthTheDrive,
distanceBasis: .roadRoute)
XCTAssertEqual(loss.headline(), "£0.31 worse off after travel")
XCTAssertEqual(loss.headline(), "Costs £0.31 more")
}
/// The sort must order by NET saving, not pump price: a pricier station can
@@ -497,7 +497,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .worthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(saving.capsuleHeadline(), "Approx. £1.00 better off after travel")
XCTAssertEqual(saving.capsuleHeadline(), "Approx. Save £1.00")
let worse = FuelSavingsResult(
fillLitres: 30,
@@ -507,7 +507,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
netSavingGBP: -0.31,
status: .notWorthTheDrive
)
XCTAssertEqual(worse.capsuleHeadline(), "£0.31 worse off after travel")
XCTAssertEqual(worse.capsuleHeadline(), "Costs £0.31 more")
}
// MARK: - Estimate wording (Approx. labels)
@@ -524,7 +524,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .worthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(saving.headline(), "Approx. £1.25 better off after travel")
XCTAssertEqual(saving.headline(), "Approx. Save £1.25")
let loss = FuelSavingsResult(
fillLitres: 30,
@@ -535,7 +535,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .notWorthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(loss.headline(), "Approx. £0.77 worse off after travel")
XCTAssertEqual(loss.headline(), "Approx. Costs £0.77 more")
}
/// A confirmed road-route comparison must never carry the marker — in either
@@ -551,7 +551,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
distanceBasis: .roadRoute,
journeyConfidence: .confirmed
)
XCTAssertEqual(saving.headline(), "£1.25 better off after travel")
XCTAssertEqual(saving.headline(), "Save £1.25")
let loss = FuelSavingsResult(
fillLitres: 30,
@@ -563,7 +563,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
distanceBasis: .roadRoute,
journeyConfidence: .confirmed
)
XCTAssertEqual(loss.headline(), "£0.77 worse off after travel")
XCTAssertEqual(loss.headline(), "Costs £0.77 more")
for headline in [saving.headline(), loss.headline(), saving.capsuleHeadline()] {
XCTAssertFalse(headline.contains("Approx"), "\"\(headline)\" must not claim to be an estimate")
@@ -597,7 +597,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
status: .notWorthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(estimated.spokenHeadline(), "Approximately £0.77 worse off after travel")
XCTAssertEqual(estimated.spokenHeadline(), "Approximately Costs £0.77 more")
XCTAssertEqual(
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
@@ -8,15 +8,15 @@ final class FuelSavingsCalculatorTests: XCTestCase {
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001)
}
func testPoundsBudgetConvertsToLitresUsingBaselinePrice() throws {
func testPoundsBudgetConvertsToLitresUsingGivenPrice() throws {
let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160)
XCTAssertEqual(litres, 25.0, accuracy: 0.0001)
}
func testLitresAmountPassesThrough() throws {
let amount = FuelSavingsFillAmount(value: 35, unit: .litres)
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 160)
XCTAssertEqual(litres, 35, accuracy: 0.0001)
}
@@ -29,11 +29,11 @@ final class FuelSavingsCalculatorTests: XCTestCase {
baselineJourneyMiles: 2,
candidateJourneyMiles: 8
)
XCTAssertEqual(result.fillLitres, 25, accuracy: 0.0001)
XCTAssertEqual(result.grossSavingGBP, 2.50, accuracy: 0.0001)
XCTAssertEqual(result.fillLitres, 26.6667, accuracy: 0.0001)
XCTAssertEqual(result.grossSavingGBP, 2.6667, accuracy: 0.0001)
XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001)
XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001)
XCTAssertEqual(result.netSavingGBP, 1.5908, accuracy: 0.0001)
XCTAssertEqual(result.netSavingGBP, 1.7574, accuracy: 0.0001)
XCTAssertEqual(result.status, .worthTheDrive)
}
@@ -61,7 +61,7 @@ final class FuelSavingsCalculatorTests: XCTestCase {
}
func testInvalidInputsRejected() {
XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 0))
XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), pricePencePerLitre: 0))
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
}
+11 -6
View File
@@ -82,6 +82,7 @@ enum JourneyConfidence: String, Codable, Equatable {
struct FuelSavingsResult: Codable, Equatable {
let fillLitres: Double
let grossSavingGBP: Double
let extraFuelLitres: Double
let extraDrivingCostGBP: Double
let extraDistanceMiles: Double
let netSavingGBP: Double
@@ -91,6 +92,7 @@ struct FuelSavingsResult: Codable, Equatable {
init(fillLitres: Double,
grossSavingGBP: Double,
extraFuelLitres: Double = 0,
extraDrivingCostGBP: Double,
extraDistanceMiles: Double,
netSavingGBP: Double,
@@ -99,6 +101,7 @@ struct FuelSavingsResult: Codable, Equatable {
journeyConfidence: JourneyConfidence? = nil) {
self.fillLitres = fillLitres
self.grossSavingGBP = grossSavingGBP
self.extraFuelLitres = extraFuelLitres
self.extraDrivingCostGBP = extraDrivingCostGBP
self.extraDistanceMiles = extraDistanceMiles
self.netSavingGBP = netSavingGBP
@@ -174,6 +177,7 @@ extension FuelSavingsResult {
FuelSavingsResult(
fillLitres: fillLitres,
grossSavingGBP: 0,
extraFuelLitres: 0,
extraDrivingCostGBP: 0,
extraDistanceMiles: 0,
netSavingGBP: 0,
@@ -199,9 +203,9 @@ extension FuelSavingsResult {
case .breakEven:
return Self.breakEvenHeadline
case .worthTheDrive:
return "\(prefix)\(FuelSavingsFormat.money(netSavingGBP)) better off after travel"
return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP))"
case .notWorthTheDrive:
return "\(prefix)\(FuelSavingsFormat.money(abs(netSavingGBP))) worse off after travel"
return "\(prefix)Costs \(FuelSavingsFormat.money(abs(netSavingGBP))) more"
}
}
@@ -321,16 +325,16 @@ enum FuelSavingsCalculator {
distanceMiles / mpg * imperialGallonLitres
}
static func fillLitres(amount: FuelSavingsFillAmount, baselinePricePencePerLitre: Double) throws -> Double {
static func fillLitres(amount: FuelSavingsFillAmount, pricePencePerLitre: Double) throws -> Double {
guard amount.isValid else { throw FuelSavingsError.invalidAmount }
switch amount.unit {
case .litres:
return amount.value
case .pounds:
guard baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0 else {
guard pricePencePerLitre.isFinite, pricePencePerLitre > 0 else {
throw FuelSavingsError.invalidPrice
}
return amount.value / (baselinePricePencePerLitre / 100.0)
return amount.value / (pricePencePerLitre / 100.0)
}
}
@@ -376,7 +380,7 @@ enum FuelSavingsCalculator {
baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else {
throw FuelSavingsError.invalidDistance
}
let fillLitres = try fillLitres(amount: amount, baselinePricePencePerLitre: baselinePricePencePerLitre)
let fillLitres = try fillLitres(amount: amount, pricePencePerLitre: candidatePricePencePerLitre)
let baselinePriceGBP = baselinePricePencePerLitre / 100.0
let candidatePriceGBP = candidatePricePencePerLitre / 100.0
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
@@ -395,6 +399,7 @@ enum FuelSavingsCalculator {
return FuelSavingsResult(
fillLitres: fillLitres,
grossSavingGBP: grossSaving,
extraFuelLitres: extraFuelLitres,
extraDrivingCostGBP: extraFuelCost,
extraDistanceMiles: extraDistance,
netSavingGBP: netSaving,