Files
fuelboard/FuelBoardTests/Tests/FuelBoardSharedTests/FuelSavingsCalculationAuditTests.swift
T
FuelBoard Contributor 8153a36168 Make Closest a station label and retire Top
Top and Cheapest were two words for one state: both meant "the lowest price
among the stations on screen", differing only in the candidate set (radius vs
Favourites). Which word a surface used had drifted by device — the phone's
rows said Cheapest while the watch said Top for the identical state. Top is
gone from the enum, the Key legend, the badge and the watch's hand-rolled
label; Cheapest survives because it names the measure and is already the word
the favourites copy uses.

Closest is now a real station label rather than a verdict. StationRecommendationBadge
gains .closest with a total precedence — Best value, then Cheapest, then Closest
— because a row has room for one label and proximity is the weakest claim: a
short drive says nothing about the price, and unlike the other two it reports no
saving at all. The badge sits above the price, where the other two sit. The
closest row's verdict capsule is suppressed: its only word for that state was
"Closest", so printing it below as well would state the same fact twice, three
lines apart. Other rows keep their Saves / Break even / Costs capsules unchanged.

"Closest station" becomes "Closest", read from the single
FuelSavingsResult.baselineHeadline constant so the badge, the verdict and the Key
cannot word it differently. The Key legend renders all three station labels with
the shared StationLabelBadge; the old KeyStationLabelIndicator, which
special-cased the closest station to a wider verdict pill so it read as a
different class of thing from the two above it, is deleted.

One closest icon everywhere. The listing and detail verdict symbols used
location.circle and location.circle.fill for the state the Key taught as
location.north.fill; both now read StationLabelKind.closest.symbol, so the three
surfaces resolve one glyph from one place.

The detail sheet gains the Closest capsule below the map, ordered to match the
Key. Favourites gains the nearest favourite as its Closest, measured on the same
one-way distance its rows print and restricted to favourites with a price for the
active fuel — matching the radius tab, where the closest station is by definition
the savings baseline.

closestStationID flows from the one scaffold baseline the savings lookup already
compares against, so a row's badge and the reference station cannot disagree.
No savings calculation, threshold, ranking, eligibility or station-selection
logic changed.

The Key sheet's detent is now bound rather than fixed, so dragging it up to read
the Station labels section sticks instead of snapping back; a QA launch argument
opens it at full height, since the 0.6 detent puts that section below the fold on
every device. That argument is read unconditionally, unlike the other harness
hooks which are #if DEBUG-gated.

Tests: 278 pass, 9 new — the label precedence ladder across every combination,
capsule order in the sheet, Closest alone, and the retired label pinned absent.
2026-10-19 14:59:47 +01:00

539 lines
26 KiB
Swift

import XCTest
#if SWIFT_PACKAGE
@testable import FuelBoardShared
#endif
/// Worth the Drive calculation audit.
///
/// Written against the audit brief (30 MPG imperial, £60 fill-up, baseline
/// Rontec Shelf at 171.9p, 0.4 miles). The brief's three observed results are
/// reproduced here exactly, so any future change to the formula breaks a test
/// instead of silently shifting the numbers on screen.
///
/// Deterministic journey distances only — never live MKDirections responses.
final class FuelSavingsCalculationAuditTests: XCTestCase {
private func station(_ id: String, price: Double?) -> FuelStation {
FuelStation(
id: id,
name: id,
brand: "X",
address: "",
postcode: "",
lat: 51.5,
lng: -0.1,
prices: price.map { [FuelType.e10: $0] } ?? [:],
priceUpdated: nil
)
}
/// Journeys are OUT AND BACK (the app's convention): the brief's baseline is
/// 0.4 miles away, so its journey is 0.8 miles, and "extra driving" of N
/// miles means a candidate journey of 0.8 + N.
private func journey(_ miles: Double, basis: FuelSavingsDistanceBasis = .roadRoute) -> FuelSavingsJourney {
FuelSavingsJourney(miles: miles, basis: basis)
}
private let baselineJourneyMiles = 0.8
private func briefResult(candidatePrice: Double, extraMiles: Double) throws -> FuelSavingsResult {
try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: candidatePrice,
baselineJourneyMiles: baselineJourneyMiles,
candidateJourneyMiles: baselineJourneyMiles + extraMiles
)
}
// MARK: - 1. Unit conversion
func testBudgetExamplesAcrossAllFuelTypes() throws {
for fuel in FuelType.allCases {
let base = FuelStation(id: "base", name: "Base", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [fuel: 199.9], priceUpdated: nil)
let other = FuelStation(id: "other", name: "Other", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [fuel: 129.9], priceUpdated: nil)
let settings = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 40, unit: .pounds))
for budget in [20.0, 40, 60, 73, 100] {
let result = FuelSavingsEstimator.availability(station: other, fuel: fuel, baseline: base,
settings: settings, amount: .init(value: budget, unit: .pounds),
journey: .init(miles: 8.3, basis: .roadRoute),
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
let volume = budget / 1.299
XCTAssertEqual(result?.fillLitres ?? 0, volume, accuracy: 1e-9)
XCTAssertEqual(result?.grossSavingGBP ?? 0, volume * 0.7, accuracy: 1e-9)
XCTAssertEqual(result?.netSavingGBP ?? 0, volume * 0.7 - 7.4 / 30 * 4.54609 * 1.299, accuracy: 1e-9)
}
}
}
func testCandidateBudgetAndEqualQuantitySavingsForEveryFuel() throws {
for fuel in FuelType.allCases {
let candidate = FuelStation(id: "candidate-\(fuel.rawValue)", name: "Candidate", brand: "", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [fuel: 129.9], priceUpdated: nil)
let baseline = FuelStation(id: "baseline-\(fuel.rawValue)", name: "Nearest", brand: "", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [fuel: 199.9], priceUpdated: nil)
let configured = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 60, unit: .pounds))
let money = FuelSavingsEstimator.availability(station: candidate, fuel: fuel, baseline: baseline, settings: configured,
amount: .init(value: 60, unit: .pounds), journey: .init(miles: 8.3, basis: .roadRoute),
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
let litres = FuelSavingsEstimator.availability(station: candidate, fuel: fuel, baseline: baseline, settings: configured,
amount: .init(value: 60 / 1.299, unit: .litres), journey: .init(miles: 8.3, basis: .roadRoute),
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
XCTAssertEqual(money?.fillLitres ?? 0, 60 / 1.299, accuracy: 1e-8, fuel.rawValue)
XCTAssertEqual(money?.grossSavingGBP ?? 0, litres?.grossSavingGBP ?? -1, accuracy: 1e-8, fuel.rawValue)
XCTAssertEqual(money?.netSavingGBP ?? 0, litres?.netSavingGBP ?? -1, accuracy: 1e-8, fuel.rawValue)
XCTAssertEqual(money?.netSavingGBP ?? 0, (money?.grossSavingGBP ?? 0) - (money?.extraDrivingCostGBP ?? 0), accuracy: 1e-8, fuel.rawValue)
}
}
func testImperialGallonIsUsedNotTheUSGallon() {
XCTAssertEqual(FuelSavingsCalculator.imperialGallonLitres, 4.54609)
XCTAssertNotEqual(FuelSavingsCalculator.imperialGallonLitres, 3.785411784,
"a US gallon must never be used: it would understate every travel cost")
// 30 miles at 30 MPG burns exactly one imperial gallon.
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 30, mpg: 30), 4.54609, accuracy: 1e-12)
// 100 miles at 50 MPG.
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 100, mpg: 50), 9.09218, accuracy: 1e-12)
// Zero distance burns nothing, and the relationship is linear in distance.
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 0, mpg: 30), 0, accuracy: 1e-12)
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 60, mpg: 30),
2 * FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 30, mpg: 30),
accuracy: 1e-12)
}
func testMileAndKilometreFactorsAreTheExactOnes() {
XCTAssertEqual(FuelSavingsCalculator.kilometresPerMile, 1.609344)
XCTAssertEqual(FuelSavingsCalculator.metresPerMile, 1609.344)
XCTAssertEqual(FuelSavingsCalculator.metresPerMile,
FuelSavingsCalculator.kilometresPerMile * 1000,
accuracy: 1e-9,
"the metre and kilometre factors must describe the same mile")
}
func testDistanceLabelUsesTheSameConversionAsTheModel() {
// One mile, shown in kilometres, goes through the single factor.
XCTAssertEqual(
FuelSavingsFormat.distance(1, unit: .kilometers),
DistanceUnit.kilometers.format(FuelSavingsCalculator.kilometresPerMile)
)
XCTAssertEqual(FuelSavingsFormat.distance(1, unit: .miles), DistanceUnit.miles.format(1.609344))
}
// MARK: - 2. Candidate-priced fill-up
func testPoundsFillUsesCandidatePriceForLitresAndGrossSaving() throws {
let result = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0.879,
candidateJourneyMiles: 8.306
)
XCTAssertEqual(result.fillLitres, 60 / 1.699, accuracy: 1e-9)
XCTAssertEqual(result.grossSavingGBP, (60 / 1.699) * 0.02, accuracy: 1e-9)
XCTAssertEqual(FuelSavingsFormat.money(result.grossSavingGBP), "£0.71")
XCTAssertEqual(result.extraFuelLitres, 1.1254603476666667, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 1.9121571306856667, accuracy: 1e-9)
XCTAssertEqual(result.netSavingGBP, -1.2058593084372853, accuracy: 1e-9)
XCTAssertEqual(result.headline(), "Costs £1.21 more")
}
func testPoundsExamplesScaleFromThirtyToOneHundredPounds() throws {
for (budget, expectedLitres, expectedGross) in [
(30.0, 17.657445556209535, 0.3531489111241907),
(60.0, 35.31489111241907, 0.7062978222483814),
(100.0, 58.85815185403179, 1.1771630370806357)
] {
let result = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: budget, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0,
candidateJourneyMiles: 0
)
XCTAssertEqual(result.fillLitres, expectedLitres, accuracy: 1e-8)
XCTAssertEqual(result.grossSavingGBP, expectedGross, accuracy: 1e-8)
}
}
func testCandidatePriceControlsLitresWhileBaselinePriceOnlyChangesSaving() throws {
let amount = FuelSavingsFillAmount(value: 60, unit: .pounds)
let first = try FuelSavingsCalculator.calculate(
mpg: 30, amount: amount,
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0, candidateJourneyMiles: 0
)
let differentBaseline = try FuelSavingsCalculator.calculate(
mpg: 30, amount: amount,
baselinePricePencePerLitre: 179.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 0, candidateJourneyMiles: 0
)
let differentCandidate = try FuelSavingsCalculator.calculate(
mpg: 30, amount: amount,
baselinePricePencePerLitre: 179.9,
candidatePricePencePerLitre: 159.9,
baselineJourneyMiles: 0, candidateJourneyMiles: 0
)
XCTAssertEqual(first.fillLitres, differentBaseline.fillLitres, accuracy: 1e-12)
XCTAssertNotEqual(first.grossSavingGBP, differentBaseline.grossSavingGBP)
XCTAssertNotEqual(differentBaseline.fillLitres, differentCandidate.fillLitres)
}
// MARK: - 3/5. The three stations from the brief
func testBriefCaseTescoBradfordIsMarginallyWorseOff() throws {
let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0)
XCTAssertEqual(result.fillLitres, 35.108250, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, 0.351083, accuracy: 1e-6, "£60 buys 35.11 L at the candidate price")
XCTAssertEqual(result.extraDistanceMiles, 2.0, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -0.166869, accuracy: 1e-6)
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "Costs £0.17 more")
}
/// The brief recorded "£0.18 worse off" for this row against a displayed
/// "2.0 miles" of extra driving; the brief also warns that the displayed
/// mileage is rounded. This pins that explanation instead of treating it as
/// a calculation bug: at an extra distance the caption still shows as
/// "2.0 mi" (2.00-2.05), the headline reads £0.17-£0.18, and the difference
/// is entirely the rounding of the distance, not of the money.
func testTheReportedEighteenPenceIsRoundedExtraDistanceNotARoundingBug() throws {
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.00).headline(),
"Costs £0.17 more")
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(),
"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),
"2.0 mi extra return travel")
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.00, unit: .miles),
"2.0 mi extra return travel")
}
func testBriefCaseMFGHareAndHoundsIsWorseOff() throws {
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
XCTAssertEqual(result.fillLitres, 34.702140, accuracy: 1e-6)
XCTAssertEqual(result.grossSavingGBP, -0.347021, accuracy: 1e-6, "£60 buys 34.70 L at the candidate price")
XCTAssertEqual(result.extraDistanceMiles, 2.8, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "Costs £1.08 more")
}
func testBriefCasePGGNapoleonSamePumpPriceIsTravelCostOnly() throws {
let result = try briefResult(candidatePrice: 171.9, extraMiles: 2.8)
XCTAssertEqual(result.grossSavingGBP, 0, accuracy: 1e-9, "same pump price saves nothing at the pump")
XCTAssertEqual(result.extraDrivingCostGBP, 0.729375, accuracy: 1e-6)
XCTAssertEqual(result.netSavingGBP, -0.729375, accuracy: 1e-6,
"with no price difference the whole result is the extra driving")
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.headline(), "Costs £0.73 more")
}
// MARK: - 5/6. Classification, tolerance and rounding
func testSamePriceAndSameDistanceIsBreakEven() throws {
let result = try briefResult(candidatePrice: 171.9, extraMiles: 0)
XCTAssertEqual(result.grossSavingGBP, 0, accuracy: 1e-9)
XCTAssertEqual(result.extraDrivingCostGBP, 0, accuracy: 1e-9)
XCTAssertEqual(result.netSavingGBP, 0, accuracy: 1e-9)
XCTAssertEqual(result.status, .breakEven)
XCTAssertEqual(result.headline(), "Break even")
}
func testSmallSavingInsideTheToleranceIsBreakEvenNotPennies() throws {
// 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.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.166869
// A tolerance wider than the loss → break even; narrower → worse off.
let lenient = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 170.9,
baselineJourneyMiles: baselineJourneyMiles,
candidateJourneyMiles: baselineJourneyMiles + 2.0,
breakEvenToleranceGBP: 0.17
)
XCTAssertEqual(lenient.netSavingGBP, net, accuracy: 1e-6)
XCTAssertEqual(lenient.status, .breakEven)
let strict = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 170.9,
baselineJourneyMiles: baselineJourneyMiles,
candidateJourneyMiles: baselineJourneyMiles + 2.0,
breakEvenToleranceGBP: 0.16
)
XCTAssertEqual(strict.status, .notWorthTheDrive)
}
func testMoneyIsRoundedOnlyForDisplay() throws {
// Full precision retained internally…
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
XCTAssertEqual(result.netSavingGBP, -1.080639, accuracy: 1e-6)
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")
XCTAssertEqual(FuelSavingsFormat.money(0.729375), "£0.73")
XCTAssertEqual(FuelSavingsFormat.money(0.1749), "£0.17")
XCTAssertEqual(FuelSavingsFormat.money(0.176), "£0.18")
XCTAssertEqual(FuelSavingsFormat.money(0), "£0.00")
}
func testIndependentlyRoundedFinancialComponentsMayDifferFromRoundedTotalByOnePenny() {
let gross = 1.234
let drivingCost = 0.005
let net = gross - drivingCost
XCTAssertEqual(FuelSavingsFormat.money(gross), "£1.23")
XCTAssertEqual(FuelSavingsFormat.money(drivingCost), "£0.01")
XCTAssertEqual(FuelSavingsFormat.money(net), "£1.23")
let independentlyRoundedSubtraction = 1.23 - 0.01
XCTAssertEqual(independentlyRoundedSubtraction, 1.22, accuracy: 1e-12)
XCTAssertNotEqual(
FuelSavingsFormat.money(independentlyRoundedSubtraction),
FuelSavingsFormat.money(net),
"the raw total remains authoritative; independently rounded components need not add back exactly"
)
}
func testSortingUsesTheUnroundedNetSoRoundedTiesStillOrderCorrectly() {
// Two rows that both DISPLAY "£0.18 worse off" but differ in the raw
// figure: the sort must use the raw value, never the shown one.
func ranked(_ id: String, net: Double) -> FuelSavingsRankedStation {
FuelSavingsRankedStation(
stationID: id,
result: .available(FuelSavingsResult(
fillLitres: 34.9,
grossSavingGBP: 0,
extraDrivingCostGBP: abs(net),
extraDistanceMiles: 2.0,
netSavingGBP: net,
status: .notWorthTheDrive
)),
distanceMiles: 2.8,
pricePencePerLitre: 171.9
)
}
let worse = ranked("worse", net: -0.1751)
let lessWorse = ranked("less-worse", net: -0.1749)
XCTAssertEqual(FuelSavingsFormat.money(abs(worse.result.result!.netSavingGBP)), "£0.18")
XCTAssertEqual(FuelSavingsFormat.money(abs(lessWorse.result.result!.netSavingGBP)), "£0.17")
let sorted = FuelSavingsCalculator.sortedForWorthTheDrive([worse, lessWorse])
XCTAssertEqual(sorted.first?.stationID, "less-worse",
"the smaller loss must rank first even when the money rounds to the same figure")
}
// MARK: - 3. Distances and travel cost
func testCheaperFuelFurtherAwayCanStillBeWorthTheDrive() throws {
// 145.9p nearby vs 138.9p five miles further out, £40 fill, 45 MPG.
let result = try FuelSavingsCalculator.calculate(
mpg: 45,
amount: FuelSavingsFillAmount(value: 40, unit: .pounds),
baselinePricePencePerLitre: 145.9,
candidatePricePencePerLitre: 138.9,
baselineJourneyMiles: 2.0,
candidateJourneyMiles: 7.0
)
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.314226, accuracy: 1e-6)
XCTAssertEqual(result.status, .worthTheDrive)
XCTAssertEqual(result.headline(), "Save £1.31")
}
func testNearerCandidateIsNeverChargedExtraTravel() throws {
// The baseline is the nearest priced station, so this should not happen
// in the app — but the clamp must hold if it ever does.
let result = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 169.9,
baselineJourneyMiles: 2.0,
candidateJourneyMiles: 1.0
)
XCTAssertEqual(result.extraDistanceMiles, 0, "a shorter journey is never a negative extra distance")
XCTAssertEqual(result.extraDrivingCostGBP, 0, accuracy: 1e-12)
XCTAssertEqual(result.netSavingGBP, result.grossSavingGBP, accuracy: 1e-12)
}
func testDifferentMPGValuesScaleOnlyTheTravelCost() throws {
let sixty = try FuelSavingsCalculator.calculate(
mpg: 60,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 172.9,
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
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.713830, accuracy: 1e-6)
let fortyFive = try FuelSavingsCalculator.calculate(
mpg: 45,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 172.9,
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
XCTAssertEqual(fortyFive.extraDrivingCostGBP, 0.489078, accuracy: 1e-6)
XCTAssertEqual(fortyFive.netSavingGBP, -0.836100, accuracy: 1e-6)
}
func testDifferentFillBudgetsScaleOnlyThePumpSaving() throws {
let forty = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 40, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 172.9,
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
XCTAssertEqual(forty.fillLitres, 23.134760, accuracy: 1e-6, "£40 buys 23.13 L at the candidate price")
XCTAssertEqual(forty.grossSavingGBP, -0.231348, accuracy: 1e-6)
XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent")
XCTAssertEqual(forty.netSavingGBP, -0.964965, accuracy: 1e-6)
let ninety = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 90, unit: .pounds),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 172.9,
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
XCTAssertEqual(ninety.fillLitres, 52.053210, accuracy: 1e-6)
XCTAssertEqual(ninety.grossSavingGBP, -0.520532, accuracy: 1e-6)
XCTAssertEqual(ninety.netSavingGBP, -1.254150, accuracy: 1e-6)
}
func testFillUpInLitresIsFixedRegardlessOfPrice() throws {
// 40 L is 40 L: no baseline price is involved in the quantity.
let result = try FuelSavingsCalculator.calculate(
mpg: 30,
amount: FuelSavingsFillAmount(value: 40, unit: .litres),
baselinePricePencePerLitre: 171.9,
candidatePricePencePerLitre: 165.9,
baselineJourneyMiles: 0.8,
candidateJourneyMiles: 3.6
)
XCTAssertEqual(result.fillLitres, 40, accuracy: 1e-9)
XCTAssertEqual(result.grossSavingGBP, 40 * 0.06, accuracy: 1e-9)
}
// MARK: - 7. Failures never invent a saving
func testUnavailableRouteProducesNoSavingAtAll() {
let baseline = station("baseline", price: 171.9)
let candidate = station("candidate", price: 170.9)
let settings = FuelSavingsSettings(
enabled: true,
mpg: 30,
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
)
let noRoute = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
journey: nil,
baselineJourney: journey(0.8)
)
XCTAssertEqual(noRoute, .unavailable("Distance unavailable"))
let noBaselineRoute = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
journey: journey(2.8),
baselineJourney: nil
)
XCTAssertEqual(noBaselineRoute, .unavailable("No comparison station"))
// With both legs present it produces the saving — no silent zero.
let both = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
journey: journey(2.8),
baselineJourney: journey(0.8)
)
XCTAssertEqual(both?.result?.netSavingGBP ?? 0, -0.166869, accuracy: 1e-6)
}
func testMixedRouteBasisIsReportedAsAnEstimate() {
let baseline = station("baseline", price: 171.9)
let candidate = station("candidate", price: 170.9)
let settings = FuelSavingsSettings(
enabled: true,
mpg: 30,
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
)
let mixed = FuelSavingsEstimator.availability(
station: candidate,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
journey: journey(2.8, basis: .straightLine),
baselineJourney: journey(0.8, basis: .roadRoute)
)
XCTAssertEqual(mixed?.result?.distanceBasis, .straightLine,
"one unmeasured leg makes the whole estimate an estimate")
XCTAssertEqual(mixed?.result?.journeyConfidence, .estimated)
XCTAssertEqual(mixed?.result?.headline(compact: true), "Approx. Costs £0.17 more")
}
func testBaselineStationIsLabelledClosestStationNotBreakEven() {
let baseline = station("baseline", price: 171.9)
let settings = FuelSavingsSettings(
enabled: true,
mpg: 30,
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds)
)
let result = FuelSavingsEstimator.availability(
station: baseline,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: FuelSavingsFillAmount(value: 60, unit: .pounds),
journey: journey(0.8),
baselineJourney: journey(0.8)
)?.result
XCTAssertEqual(result?.status, .baseline)
XCTAssertEqual(result?.netSavingGBP, 0)
XCTAssertEqual(result?.headline(), "Closest")
}
}