Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
558f956d8d | ||
|
|
f04a0fe0f5 | ||
|
|
f35f605b58 | ||
|
|
7da277547b | ||
|
|
93df94ddc9 | ||
|
|
78b43ce6d7 | ||
|
|
13967c3782 | ||
|
|
4718e9b76a | ||
|
|
6dfe732199 | ||
|
|
f0c13a79d6 | ||
|
|
6b41e8ac51 | ||
|
|
e58dbb7833 | ||
|
|
7ee1f06ce6 | ||
|
|
6ea675499e | ||
|
|
3c424ab397 |
@@ -566,10 +566,34 @@ 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"
|
||||||
}
|
}
|
||||||
let price = station.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
|
func amount(_ value: FuelSavingsFillAmount) -> String {
|
||||||
let baselinePrice = baseline?.prices[selectedFuel].map { String(format: "%.1fp/L", $0) } ?? "—"
|
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)] = [
|
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"),
|
||||||
@@ -581,12 +605,11 @@ 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"),
|
||||||
("Fuel price", price),
|
("Travel cost raw", rawMoney(result?.extraDrivingCostGBP)),
|
||||||
("Baseline price", baselinePrice),
|
("Net saving raw", rawMoney(result?.netSavingGBP)),
|
||||||
("Gross saving", result.map { FuelSavingsFormat.money($0.grossSavingGBP) } ?? "—"),
|
("Displayed net saving", result?.headline() ?? "—")
|
||||||
("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
|
||||||
@@ -1104,6 +1127,13 @@ 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 —
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ struct FuelSavingsSummaryView: View {
|
|||||||
/// Sizes the "extra return travel" caption in the user's own unit (the
|
/// Sizes the "extra return travel" caption in the user's own unit (the
|
||||||
/// stored figure is miles) — used by both the row banner and the detail sheet.
|
/// stored figure is miles) — used by both the row banner and the detail sheet.
|
||||||
var distanceUnit: DistanceUnit = .miles
|
var distanceUnit: DistanceUnit = .miles
|
||||||
|
var showInfoAction: (() -> Void)? = nil
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
switch availability {
|
switch availability {
|
||||||
@@ -119,6 +120,9 @@ 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,
|
||||||
@@ -134,9 +138,26 @@ struct FuelSavingsSummaryView: View {
|
|||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
} else {
|
} else {
|
||||||
Text("Compared with \(baselineStationName ?? "your nearest station") for \(amountText).").font(.caption).foregroundStyle(.secondary)
|
Text("Compared with \(baselineStationName ?? "your nearest station").")
|
||||||
LabeledContent("Fuel-price saving", value: FuelSavingsFormat.money(result.grossSavingGBP))
|
.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))
|
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)
|
||||||
@@ -146,6 +167,22 @@ struct FuelSavingsSummaryView: View {
|
|||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
|
if let showInfoAction {
|
||||||
|
Rectangle()
|
||||||
|
.fill(Color(.separator))
|
||||||
|
.frame(height: 0.5)
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
Button(action: showInfoAction) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Text("How Best Value works")
|
||||||
|
Spacer(minLength: 8)
|
||||||
|
Image(systemName: "chevron.forward")
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
case .unavailable(let reason):
|
case .unavailable(let reason):
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -98,7 +98,7 @@ struct OnboardingView: View {
|
|||||||
private var welcomePage: some View {
|
private var welcomePage: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
AnimatedOnboardingIcon(tint: .accentColor) {
|
AnimatedOnboardingIcon(tint: .accentColor, showsHalo: false) {
|
||||||
// The app icon itself, with the standard iOS rounded-corner
|
// The app icon itself, with the standard iOS rounded-corner
|
||||||
// treatment (~22% of the size), so onboarding matches the
|
// treatment (~22% of the size), so onboarding matches the
|
||||||
// Home Screen.
|
// Home Screen.
|
||||||
@@ -108,10 +108,10 @@ struct OnboardingView: View {
|
|||||||
.frame(width: 96, height: 96)
|
.frame(width: 96, height: 96)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
|
.clipShape(RoundedRectangle(cornerRadius: 21, style: .continuous))
|
||||||
}
|
}
|
||||||
.padding(.bottom, 28)
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
Text(String(localized: "Welcome to FuelBoard", comment: "Onboarding welcome title"))
|
Text(String(localized: "Welcome to\nFuelBoard", comment: "Onboarding welcome title"))
|
||||||
.font(.largeTitle.bold())
|
.font(.title.bold())
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
|
|
||||||
Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle"))
|
Text(String(localized: "The cheapest petrol, diesel and premium fuel near you — from the official UK Fuel Finder data.", comment: "Onboarding welcome subtitle"))
|
||||||
@@ -121,12 +121,13 @@ struct OnboardingView: View {
|
|||||||
.padding(.horizontal, 32)
|
.padding(.horizontal, 32)
|
||||||
.padding(.top, 12)
|
.padding(.top, 12)
|
||||||
|
|
||||||
featureRow(icon: "globe.europe.africa.fill", text: String(localized: "UK-wide prices — 8,000+ stations, updated twice a day", comment: "Onboarding feature row"))
|
featureRow(icon: "map.fill", title: String(localized: "Compare fuel prices near you", comment: "Onboarding feature row title"), subtitle: String(localized: "Over 8,000 stations across Great Britain, with prices updated daily.", comment: "Onboarding feature row subtitle"))
|
||||||
featureRow(icon: "scope", text: String(localized: "Cheapest within your chosen radius, or closest station first", comment: "Onboarding feature row"))
|
|
||||||
featureRow(icon: "star.fill", text: String(localized: "Favourites with instant price comparison", comment: "Onboarding feature row"))
|
featureRow(icon: "chart.line.uptrend.xyaxis", title: String(localized: "See how much you could save", comment: "Onboarding feature row title"), subtitle: String(localized: "Compare fill-up costs and potential savings at different stations.", comment: "Onboarding feature row subtitle"))
|
||||||
featureRow(icon: "chart.bar.fill", text: String(localized: "Green/amber/red rating vs the best nearby price", comment: "Onboarding feature row"))
|
|
||||||
featureRow(icon: "square.grid.2x2.fill", text: String(localized: "Home-screen widget showing the cheapest nearby", comment: "Onboarding feature row"))
|
featureRow(icon: "car.fill", title: String(localized: "Know if it's worth the drive", comment: "Onboarding feature row title"), subtitle: String(localized: "Factor in your journey and fuel costs to find the best value.", comment: "Onboarding feature row subtitle"))
|
||||||
featureRow(icon: "bell.fill", text: String(localized: "Alerts when you approach the cheapest station", comment: "Onboarding feature row"))
|
|
||||||
|
featureRow(icon: "star.fill", title: String(localized: "Keep your favourites close", comment: "Onboarding feature row title"), subtitle: String(localized: "Save stations and quickly compare their latest prices.", comment: "Onboarding feature row subtitle"))
|
||||||
|
|
||||||
Spacer()
|
Spacer()
|
||||||
}
|
}
|
||||||
@@ -298,18 +299,23 @@ struct OnboardingView: View {
|
|||||||
OnboardingPrimaryButton(title: title, action: action)
|
OnboardingPrimaryButton(title: title, action: action)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func featureRow(icon: String, text: String) -> some View {
|
private func featureRow(icon: String, title: String, subtitle: String) -> some View {
|
||||||
HStack(spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Image(systemName: icon)
|
Image(systemName: icon)
|
||||||
.font(.system(size: 20, weight: .semibold))
|
.font(.system(size: 20, weight: .semibold))
|
||||||
.foregroundStyle(Color.accentColor)
|
.foregroundStyle(Color.accentColor)
|
||||||
.frame(width: 32)
|
.frame(width: 32)
|
||||||
Text(text)
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
.font(.subheadline)
|
Text(title)
|
||||||
|
.font(.subheadline.bold())
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 36)
|
.padding(.horizontal, 36)
|
||||||
.padding(.vertical, 10)
|
.padding(.vertical, 8)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View {
|
private func permissionStatusLabel(granted: Bool, denied: Bool, deniedText: String) -> some View {
|
||||||
@@ -393,6 +399,9 @@ struct AnimatedOnboardingIcon<Content: View>: View {
|
|||||||
|
|
||||||
let tint: Color
|
let tint: Color
|
||||||
var motion: Motion = .gentle
|
var motion: Motion = .gentle
|
||||||
|
/// The soft circle behind the glyph. The welcome page turns it off: it shows
|
||||||
|
/// the app icon itself and reads better with no container around it.
|
||||||
|
var showsHalo: Bool = true
|
||||||
@ViewBuilder var content: Content
|
@ViewBuilder var content: Content
|
||||||
|
|
||||||
@State private var settled = false
|
@State private var settled = false
|
||||||
@@ -402,16 +411,18 @@ struct AnimatedOnboardingIcon<Content: View>: View {
|
|||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
if showsHalo {
|
||||||
.fill(tint.opacity(0.10))
|
Circle()
|
||||||
.frame(width: 108, height: 108)
|
.fill(tint.opacity(0.10))
|
||||||
.scaleEffect(settled ? 1.0 : 0.94)
|
.frame(width: 108, height: 108)
|
||||||
|
.scaleEffect(settled ? 1.0 : 0.94)
|
||||||
|
|
||||||
Circle()
|
Circle()
|
||||||
.stroke(tint.opacity(0.18), lineWidth: 1)
|
.stroke(tint.opacity(0.18), lineWidth: 1)
|
||||||
.frame(width: 118, height: 118)
|
.frame(width: 118, height: 118)
|
||||||
.scaleEffect(settled ? 1.03 : 0.97)
|
.scaleEffect(settled ? 1.03 : 0.97)
|
||||||
.opacity(settled ? 1.0 : 0.55)
|
.opacity(settled ? 1.0 : 0.55)
|
||||||
|
}
|
||||||
|
|
||||||
content
|
content
|
||||||
.scaleEffect(iconScale)
|
.scaleEffect(iconScale)
|
||||||
|
|||||||
@@ -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) could leave you \(result.headline()). Tap for directions."
|
content.body = "\(station.name): \(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) 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))"
|
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())")
|
||||||
|
|||||||
@@ -29,10 +29,13 @@ struct StationDetailView: View {
|
|||||||
var onToggleFavourite: (() -> Void)? = nil
|
var onToggleFavourite: (() -> Void)? = nil
|
||||||
@Environment(\.dismiss) private var dismiss
|
@Environment(\.dismiss) private var dismiss
|
||||||
@State private var showingReportIssueConfirmation = false
|
@State private var showingReportIssueConfirmation = false
|
||||||
/// Transient "Copied" confirmation on the Address row. Cleared by a task so
|
/// Transient "Postcode copied" confirmation on the Address row. Cleared by a task so
|
||||||
/// the caption fades out on its own.
|
/// the caption fades out on its own.
|
||||||
@State private var showingCopied = false
|
@State private var showingCopied = false
|
||||||
@State private var copyResetTask: Task<Void, Never>?
|
@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")!
|
private let reportIssueURL = URL(string: "https://www.gov.uk/guidance/report-an-error-in-fuel-prices-or-forecourt-details")!
|
||||||
|
|
||||||
@@ -134,7 +137,7 @@ struct StationDetailView: View {
|
|||||||
// 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 sits ON the address line,
|
// navigation app. The copy glyph sits ON the address line,
|
||||||
// and the "Copied" confirmation is a capsule that floats OVER
|
// and the "Postcode copied" confirmation is a capsule that floats OVER
|
||||||
// THIS ROW — it confirms the address, so it must not hover
|
// THIS ROW — it confirms the address, so it must not hover
|
||||||
// over the map. It is an overlay, so nothing moves while it is
|
// over the map. It is an overlay, so nothing moves while it is
|
||||||
// up, and `allowsHitTesting(false)` keeps the row tappable
|
// up, and `allowsHitTesting(false)` keeps the row tappable
|
||||||
@@ -169,7 +172,7 @@ struct StationDetailView: View {
|
|||||||
if showingCopied {
|
if showingCopied {
|
||||||
HStack(spacing: 4) {
|
HStack(spacing: 4) {
|
||||||
Image(systemName: "checkmark")
|
Image(systemName: "checkmark")
|
||||||
Text("Copied")
|
Text("Postcode copied")
|
||||||
}
|
}
|
||||||
.font(.footnote.weight(.semibold))
|
.font(.footnote.weight(.semibold))
|
||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
@@ -235,7 +238,8 @@ struct StationDetailView: View {
|
|||||||
availability: fuelSavings,
|
availability: fuelSavings,
|
||||||
amount: fuelSavingsAmount,
|
amount: fuelSavingsAmount,
|
||||||
baselineStationName: savingsBaselineStationName,
|
baselineStationName: savingsBaselineStationName,
|
||||||
distanceUnit: distanceUnit
|
distanceUnit: distanceUnit,
|
||||||
|
showInfoAction: { showBestValueInfo = true }
|
||||||
)
|
)
|
||||||
} header: {
|
} header: {
|
||||||
Text("Best Value")
|
Text("Best Value")
|
||||||
@@ -265,6 +269,8 @@ struct StationDetailView: View {
|
|||||||
} message: {
|
} message: {
|
||||||
Text("Are you sure you want to report an error in fuel prices or forecourt details? This will open in your chosen browser.")
|
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 {
|
.toolbar {
|
||||||
ToolbarItem(placement: .cancellationAction) {
|
ToolbarItem(placement: .cancellationAction) {
|
||||||
Button("Done") { dismiss() }
|
Button("Done") { dismiss() }
|
||||||
@@ -284,12 +290,12 @@ struct StationDetailView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The "Copied" fade-out task must not outlive the sheet.
|
// The "Postcode copied" fade-out task must not outlive the sheet.
|
||||||
.onDisappear { copyResetTask?.cancel() }
|
.onDisappear { copyResetTask?.cancel() }
|
||||||
#if DEBUG
|
#if DEBUG
|
||||||
// QA hook: `-qaCopyPostcode` runs the REAL copy on open, so the
|
// QA hook: `-qaCopyPostcode` runs the REAL copy on open, so the
|
||||||
// clipboard value (read back with `xcrun simctl pbpaste`) and the
|
// clipboard value (read back with `xcrun simctl pbpaste`) and the
|
||||||
// "Copied" confirmation can be verified without a tap — simctl
|
// "Postcode copied" confirmation can be verified without a tap — simctl
|
||||||
// cannot drive taps into the Simulator.
|
// cannot drive taps into the Simulator.
|
||||||
.task {
|
.task {
|
||||||
guard ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") else { return }
|
guard ProcessInfo.processInfo.arguments.contains("-qaCopyPostcode") else { return }
|
||||||
|
|||||||
@@ -35,6 +35,11 @@ struct StationsView: View {
|
|||||||
/// info button in the header — keeps the list focused on stations.
|
/// info button in the header — keeps the list focused on stations.
|
||||||
@State private var showKey = false
|
@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).
|
/// Station picked for the More-info sheet (tap action = showDetails).
|
||||||
@State private var detailStation: FuelStation?
|
@State private var detailStation: FuelStation?
|
||||||
|
|
||||||
@@ -218,6 +223,18 @@ struct StationsView: View {
|
|||||||
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
if ProcessInfo.processInfo.arguments.contains("-showKeySheet") {
|
||||||
showKey = true
|
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
|
#endif
|
||||||
}
|
}
|
||||||
.task(id: stations.count) {
|
.task(id: stations.count) {
|
||||||
@@ -249,15 +266,15 @@ struct StationsView: View {
|
|||||||
VStack(spacing: 10) {
|
VStack(spacing: 10) {
|
||||||
keyCard(
|
keyCard(
|
||||||
icon: "checkmark.circle.fill", color: .green,
|
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")
|
threshold: "0–1.5p")
|
||||||
keyCard(
|
keyCard(
|
||||||
icon: "equal.circle.fill", color: .orange,
|
icon: "equal.circle.fill", color: .orange,
|
||||||
title: "Okay", blurb: "Within 4p of the cheapest",
|
title: "Okay", blurb: "More than 1.5p, up to 4p above the cheapest",
|
||||||
threshold: "≤4p")
|
threshold: ">1.5–4p")
|
||||||
keyCard(
|
keyCard(
|
||||||
icon: "exclamationmark.circle.fill", color: .red,
|
icon: "exclamationmark.circle.fill", color: .red,
|
||||||
title: "Pricey", blurb: "More than 4p over the cheapest",
|
title: "Pricey", blurb: "More than 4p above the cheapest",
|
||||||
threshold: ">4p")
|
threshold: ">4p")
|
||||||
Rectangle()
|
Rectangle()
|
||||||
.fill(Color(.separator))
|
.fill(Color(.separator))
|
||||||
@@ -289,15 +306,27 @@ struct StationsView: View {
|
|||||||
.padding(.horizontal, 14)
|
.padding(.horizontal, 14)
|
||||||
.padding(.vertical, 12)
|
.padding(.vertical, 12)
|
||||||
.keyCardFill(color: .yellow)
|
.keyCardFill(color: .yellow)
|
||||||
Text("Colours match each station's rating on the list")
|
Text("Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list.")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.frame(maxWidth: .infinity)
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
.padding(.top, 4)
|
.padding(.top, 4)
|
||||||
}
|
}
|
||||||
.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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The explanation is its own sheet now, opened from this
|
||||||
|
// row. Gated exactly like the More info row and the Best
|
||||||
|
// Value card itself: `isConfigured` is the same condition
|
||||||
|
// `savingsForStation` uses, so the row appears and
|
||||||
|
// disappears with the feature.
|
||||||
|
if fuelSavingsSettings.isConfigured {
|
||||||
|
Section {
|
||||||
|
HowBestValueRow { showBestValueInfo = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Key")
|
.navigationTitle("Key")
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
@@ -306,6 +335,8 @@ struct StationsView: View {
|
|||||||
Button("Done") { showKey = false }
|
Button("Done") { showKey = false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Nested sheet: the Key sheet stays behind the explanation.
|
||||||
|
.sheet(isPresented: $showBestValueInfo) { HowBestValueSheet() }
|
||||||
}
|
}
|
||||||
.presentationDetents([.fraction(0.6)])
|
.presentationDetents([.fraction(0.6)])
|
||||||
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
.presentationBackground(Color(UIColor.systemGroupedBackground))
|
||||||
@@ -347,6 +378,7 @@ struct StationsView: View {
|
|||||||
Text(LocalizedStringKey(blurb))
|
Text(LocalizedStringKey(blurb))
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
}
|
}
|
||||||
Spacer(minLength: 8)
|
Spacer(minLength: 8)
|
||||||
HStack(spacing: 6) {
|
HStack(spacing: 6) {
|
||||||
|
|||||||
@@ -23,18 +23,22 @@
|
|||||||
"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";
|
||||||
"Best value" = "Best value";
|
"Good price" = "Good price";
|
||||||
"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";
|
"More than 1.5p, up to 4p above the cheapest" = "More than 1.5p, up to 4p above the cheapest";
|
||||||
"Pricey" = "Pricey";
|
"Pricey" = "Pricey";
|
||||||
"More than 4p over the cheapest" = "More than 4p over the cheapest";
|
"More than 4p above the cheapest" = "More than 4p above the cheapest";
|
||||||
"Top result for the current sort" = "Top result for the current sort";
|
"Top result for the current sort" = "Top result for the current sort";
|
||||||
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
|
"Star a station to add it to Favourites" = "Star a station to add it to Favourites";
|
||||||
/* Star button in the More info sheet heading (accessibility labels) */
|
/* Star button in the More info sheet heading (accessibility labels) */
|
||||||
"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";
|
"Price differences are shown in pence per litre (p/litre). Colours match each station's rating on the list." = "Price differences are shown in pence per litre (p/litre). 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";
|
||||||
@@ -67,8 +71,8 @@
|
|||||||
"Name" = "Name";
|
"Name" = "Name";
|
||||||
"Address" = "Address";
|
"Address" = "Address";
|
||||||
"Distance" = "Distance";
|
"Distance" = "Distance";
|
||||||
"Copied" = "Copied";
|
|
||||||
"Copies the postcode" = "Copies the postcode";
|
"Copies the postcode" = "Copies the postcode";
|
||||||
|
"Postcode copied" = "Postcode copied";
|
||||||
"Address: %@" = "Address: %@";
|
"Address: %@" = "Address: %@";
|
||||||
"Directions to %@" = "Directions to %@";
|
"Directions to %@" = "Directions to %@";
|
||||||
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";
|
"Opens Apple Maps with driving directions" = "Opens Apple Maps with driving directions";
|
||||||
@@ -199,6 +203,7 @@
|
|||||||
"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,18 +82,84 @@ 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/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 {
|
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, 34.904014, accuracy: 1e-6)
|
XCTAssertEqual(result.fillLitres, 35.108250, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.grossSavingGBP, 0.349040, accuracy: 1e-6, "1p/litre cheaper on a 34.90 L fill")
|
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.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.168911, accuracy: 1e-6)
|
XCTAssertEqual(result.netSavingGBP, -0.166869, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
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
|
/// 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.
|
/// 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(),
|
||||||
"£0.17 worse off after travel")
|
"Costs £0.17 more")
|
||||||
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(),
|
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.
|
// 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),
|
||||||
@@ -118,13 +184,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.904014, accuracy: 1e-6)
|
XCTAssertEqual(result.fillLitres, 34.702140, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.grossSavingGBP, -0.349040, accuracy: 1e-6, "1p/litre dearer costs 34.90 L × 1p")
|
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.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.082658, accuracy: 1e-6)
|
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||||
XCTAssertEqual(result.headline(), "£1.08 worse off after travel")
|
XCTAssertEqual(result.headline(), "Costs £1.08 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws {
|
func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws {
|
||||||
@@ -135,7 +201,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(), "£0.73 worse off after travel")
|
XCTAssertEqual(result.headline(), "Costs £0.73 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - 5/6. Classification, tolerance and rounding
|
// MARK: - 5/6. Classification, tolerance and rounding
|
||||||
@@ -150,15 +216,15 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws {
|
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)
|
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.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.168911
|
let net = -0.166869
|
||||||
// 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,
|
||||||
@@ -187,7 +253,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.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")
|
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")
|
||||||
@@ -237,12 +303,12 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
baselineJourneyMiles: 2.0,
|
baselineJourneyMiles: 2.0,
|
||||||
candidateJourneyMiles: 7.0
|
candidateJourneyMiles: 7.0
|
||||||
)
|
)
|
||||||
XCTAssertEqual(result.fillLitres, 27.416038, accuracy: 1e-6)
|
XCTAssertEqual(result.fillLitres, 28.797696, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.grossSavingGBP, 1.919123, accuracy: 1e-6)
|
XCTAssertEqual(result.grossSavingGBP, 2.015839, accuracy: 1e-6)
|
||||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, 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.status, .worthTheDrive)
|
||||||
XCTAssertEqual(result.headline(), "£1.22 better off after travel")
|
XCTAssertEqual(result.headline(), "Save £1.31")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testNearerCandidateIsNeverChargedExtraTravel() throws {
|
func testNearerCandidateIsNeverChargedExtraTravel() throws {
|
||||||
@@ -270,9 +336,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
baselineJourneyMiles: 0.8,
|
baselineJourneyMiles: 0.8,
|
||||||
candidateJourneyMiles: 3.6
|
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.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(
|
let fortyFive = try FuelSavingsCalculator.calculate(
|
||||||
mpg: 45,
|
mpg: 45,
|
||||||
@@ -283,7 +349,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.838119, accuracy: 1e-6)
|
XCTAssertEqual(fortyFive.netSavingGBP, -0.836100, accuracy: 1e-6)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws {
|
func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws {
|
||||||
@@ -295,10 +361,10 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
baselineJourneyMiles: 0.8,
|
baselineJourneyMiles: 0.8,
|
||||||
candidateJourneyMiles: 3.6
|
candidateJourneyMiles: 3.6
|
||||||
)
|
)
|
||||||
XCTAssertEqual(forty.fillLitres, 23.269343, accuracy: 1e-6, "£40 buys 23.27 L at 171.9p")
|
XCTAssertEqual(forty.fillLitres, 23.134760, accuracy: 1e-6, "£40 buys 23.13 L at the candidate price")
|
||||||
XCTAssertEqual(forty.grossSavingGBP, -0.232693, accuracy: 1e-6)
|
XCTAssertEqual(forty.grossSavingGBP, -0.231348, 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.966311, accuracy: 1e-6)
|
XCTAssertEqual(forty.netSavingGBP, -0.964965, accuracy: 1e-6)
|
||||||
|
|
||||||
let ninety = try FuelSavingsCalculator.calculate(
|
let ninety = try FuelSavingsCalculator.calculate(
|
||||||
mpg: 30,
|
mpg: 30,
|
||||||
@@ -308,9 +374,9 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
|||||||
baselineJourneyMiles: 0.8,
|
baselineJourneyMiles: 0.8,
|
||||||
candidateJourneyMiles: 3.6
|
candidateJourneyMiles: 3.6
|
||||||
)
|
)
|
||||||
XCTAssertEqual(ninety.fillLitres, 52.356021, accuracy: 1e-6)
|
XCTAssertEqual(ninety.fillLitres, 52.053210, accuracy: 1e-6)
|
||||||
XCTAssertEqual(ninety.grossSavingGBP, -0.523560, accuracy: 1e-6)
|
XCTAssertEqual(ninety.grossSavingGBP, -0.520532, accuracy: 1e-6)
|
||||||
XCTAssertEqual(ninety.netSavingGBP, -1.257178, accuracy: 1e-6)
|
XCTAssertEqual(ninety.netSavingGBP, -1.254150, accuracy: 1e-6)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testFillUpInLitresIsFixedRegardlessOfPrice() throws {
|
func testFillUpInLitresIsFixedRegardlessOfPrice() throws {
|
||||||
@@ -370,7 +436,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.168911, accuracy: 1e-6)
|
XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.166869, accuracy: 1e-6)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testMixedRouteBasisIsReportedAsAnEstimate() {
|
func testMixedRouteBasisIsReportedAsAnEstimate() {
|
||||||
@@ -393,7 +459,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. £0.17 worse off after travel")
|
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. Costs £0.17 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
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 34.90 L at 171.9p, so the
|
/// correct, not a calculation bug: £60 buys 35.74 L at 167.9p, so the
|
||||||
/// cheaper pump saves 34.90 × 4p = £1.40, while the extra 6.8 return miles
|
/// 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.33.
|
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.30.
|
||||||
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, 34.904, accuracy: 0.005,
|
XCTAssertEqual(result.fillLitres, 35.736, accuracy: 0.005,
|
||||||
"the £60 budget buys litres at the BASELINE pump price")
|
"the £60 budget buys litres at the candidate pump price")
|
||||||
XCTAssertEqual(result.grossSavingGBP, 1.396, accuracy: 0.005)
|
XCTAssertEqual(result.grossSavingGBP, 1.429, 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.334, accuracy: 0.005)
|
XCTAssertEqual(result.netSavingGBP, -0.301, 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. £0.33 worse off after travel")
|
XCTAssertEqual(result.headline(compact: true), "Approx. Costs £0.30 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
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), "£0.33 worse off after travel")
|
XCTAssertEqual(availability?.result?.headline(compact: true), "Costs £0.30 more")
|
||||||
XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel")
|
XCTAssertEqual(availability?.result?.headline(), "Costs £0.30 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
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 37.5 L at 160p; 10p/litre cheaper saves £3.75; 0.4 extra
|
// £60 buys 40 L at 150p; 10p/litre cheaper saves £4.00; 0.4 extra
|
||||||
// return miles cost ~£0.09. Net ≈ £3.66.
|
// return miles cost ~£0.09. Net ≈ £3.91.
|
||||||
XCTAssertEqual(result.netSavingGBP, 3.659, accuracy: 0.005)
|
XCTAssertEqual(result.netSavingGBP, 3.909, accuracy: 0.005)
|
||||||
XCTAssertEqual(result.headline(compact: true), "Approx. £3.66 better off after travel")
|
XCTAssertEqual(result.headline(compact: true), "Approx. Save £3.91")
|
||||||
}
|
}
|
||||||
|
|
||||||
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(), "£0.52 better off after travel")
|
XCTAssertEqual(saving.headline(), "Save £0.52")
|
||||||
|
|
||||||
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(), "£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
|
/// 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. £1.00 better off after travel")
|
XCTAssertEqual(saving.capsuleHeadline(), "Approx. Save £1.00")
|
||||||
|
|
||||||
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(), "£0.31 worse off after travel")
|
XCTAssertEqual(worse.capsuleHeadline(), "Costs £0.31 more")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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. £1.25 better off after travel")
|
XCTAssertEqual(saving.headline(), "Approx. Save £1.25")
|
||||||
|
|
||||||
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. £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
|
/// 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(), "£1.25 better off after travel")
|
XCTAssertEqual(saving.headline(), "Save £1.25")
|
||||||
|
|
||||||
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(), "£0.77 worse off after travel")
|
XCTAssertEqual(loss.headline(), "Costs £0.77 more")
|
||||||
|
|
||||||
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 £0.77 worse off after travel")
|
XCTAssertEqual(estimated.spokenHeadline(), "Approximately Costs £0.77 more")
|
||||||
|
|
||||||
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 testPoundsBudgetConvertsToLitresUsingBaselinePrice() throws {
|
func testPoundsBudgetConvertsToLitresUsingGivenPrice() throws {
|
||||||
let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
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)
|
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, baselinePricePencePerLitre: 160)
|
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, pricePencePerLitre: 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, 25, accuracy: 0.0001)
|
XCTAssertEqual(result.fillLitres, 26.6667, accuracy: 0.0001)
|
||||||
XCTAssertEqual(result.grossSavingGBP, 2.50, accuracy: 0.0001)
|
XCTAssertEqual(result.grossSavingGBP, 2.6667, 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.5908, accuracy: 0.0001)
|
XCTAssertEqual(result.netSavingGBP, 1.7574, 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), 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: 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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ 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
|
||||||
@@ -91,6 +92,7 @@ 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,
|
||||||
@@ -99,6 +101,7 @@ 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
|
||||||
@@ -174,6 +177,7 @@ 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,
|
||||||
@@ -199,9 +203,9 @@ extension FuelSavingsResult {
|
|||||||
case .breakEven:
|
case .breakEven:
|
||||||
return Self.breakEvenHeadline
|
return Self.breakEvenHeadline
|
||||||
case .worthTheDrive:
|
case .worthTheDrive:
|
||||||
return "\(prefix)\(FuelSavingsFormat.money(netSavingGBP)) better off after travel"
|
return "\(prefix)Save \(FuelSavingsFormat.money(netSavingGBP))"
|
||||||
case .notWorthTheDrive:
|
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
|
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 }
|
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 baselinePricePencePerLitre.isFinite, baselinePricePencePerLitre > 0 else {
|
guard pricePencePerLitre.isFinite, pricePencePerLitre > 0 else {
|
||||||
throw FuelSavingsError.invalidPrice
|
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 {
|
baselineJourneyMiles >= 0, candidateJourneyMiles >= 0 else {
|
||||||
throw FuelSavingsError.invalidDistance
|
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 baselinePriceGBP = baselinePricePencePerLitre / 100.0
|
||||||
let candidatePriceGBP = candidatePricePencePerLitre / 100.0
|
let candidatePriceGBP = candidatePricePencePerLitre / 100.0
|
||||||
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
|
let grossSaving = fillLitres * (baselinePriceGBP - candidatePriceGBP)
|
||||||
@@ -395,6 +399,7 @@ 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user