Worth the Drive: calculation audit + listing/detail polish
Calculation audit (brief config: 30 MPG imperial, £60 fill-up, baseline
Rontec Shelf 171.9p at 0.4 mi):
- One mile<->kilometre constant pair (kilometresPerMile 1.609344,
metresPerMile 1609.344) replaces the mixed 1609.34 / 0.621371 / 1.60934
used in the savings path. Effect ~2 ppm (invisible), but the conversions
are now auditable in one place.
- New FuelSavingsCalculationAuditTests (20 deterministic tests, no live
MKDirections) pins the brief's three stations - Tesco 170.9p/2.0 mi,
MFG Hare & Hounds 172.9p/2.8 mi, PGG Napoleon 171.9p/2.8 mi - plus
imperial-vs-US gallon, MPG and budget scaling, the break-even tolerance
boundary, the nearer-candidate clamp, route-unavailable, mixed-basis
estimates and display-rounding boundaries.
- Formula unchanged, and verified correct: fixed quantity (budget / baseline
price), pump saving, round-trip extra distance clamped at zero, travel fuel
priced at the candidate station, +/-5p break even, baseline labelled
"Closest station", sorting on the unrounded net saving. The brief's "£0.18"
for Tesco is the rounded 1 dp extra-distance caption (2.02-2.05 mi reads as
"2.0 mi"), not a rounding bug in the money.
UI (uncommitted since d7a0ecb):
- Done bar drawn in the bottom safe area from keyboard notifications:
.toolbar(placement: .keyboard) is device-dependent (present on the 15 Pro,
absent on the SE on the same iOS build).
- Favourite star in the More-info heading, wired to the same fuel-scoped
toggle as the listing rows at all three call sites.
- CHEAPEST/TOP badge moved above the price so it reads as part of it.
- Listing rows carry the real session fill-up amount instead of a hard zero
(the compact banner does not print it today; a future row layout would).
153 tests, 0 failures. Simulator and device builds succeed.
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
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 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/4. The three stations from the brief
|
||||
|
||||
func testBriefCaseTescoBradfordIsMarginallyWorseOff() throws {
|
||||
let result = try briefResult(candidatePrice: 170.9, extraMiles: 2.0)
|
||||
|
||||
XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.grossSavingGBP, 0.349040, accuracy: 1e-6, "1p/litre cheaper on a 34.90 L fill")
|
||||
XCTAssertEqual(result.extraDistanceMiles, 2.0, accuracy: 1e-9)
|
||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.517951, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.netSavingGBP, -0.168911, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||
XCTAssertEqual(result.headline(), "£0.17 worse off after travel")
|
||||
}
|
||||
|
||||
/// 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(),
|
||||
"£0.17 worse off after travel")
|
||||
XCTAssertEqual(try briefResult(candidatePrice: 170.9, extraMiles: 2.05).headline(),
|
||||
"£0.18 worse off after travel")
|
||||
|
||||
// 2.05 mi shows as "2.0 mi" at one decimal place — the same caption.
|
||||
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.05, unit: .miles),
|
||||
"2.0 mi more driving than closest")
|
||||
XCTAssertEqual(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 2.00, unit: .miles),
|
||||
"2.0 mi more driving than closest")
|
||||
}
|
||||
|
||||
func testBriefCaseMFGHareAndHoundsIsWorseOff() throws {
|
||||
let result = try briefResult(candidatePrice: 172.9, extraMiles: 2.8)
|
||||
|
||||
XCTAssertEqual(result.fillLitres, 34.904014, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.grossSavingGBP, -0.349040, accuracy: 1e-6, "1p/litre dearer costs 34.90 L × 1p")
|
||||
XCTAssertEqual(result.extraDistanceMiles, 2.8, accuracy: 1e-9)
|
||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.733618, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.netSavingGBP, -1.082658, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||
XCTAssertEqual(result.headline(), "£1.08 worse off after travel")
|
||||
}
|
||||
|
||||
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(), "£0.73 worse off after travel")
|
||||
}
|
||||
|
||||
// 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: net +£0.0305.
|
||||
let result = try briefResult(candidatePrice: 170.9, extraMiles: 1.23)
|
||||
XCTAssertEqual(result.netSavingGBP, 0.030500, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.status, .breakEven, "±5p reads as break even rather than a meaningless £0.03")
|
||||
XCTAssertEqual(result.headline(), "Break even")
|
||||
}
|
||||
|
||||
func testBreakEvenToleranceDecidesTheClassificationAtTheBoundary() throws {
|
||||
let net = -0.168911
|
||||
// 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.082658, accuracy: 1e-6)
|
||||
XCTAssertNotEqual(result.netSavingGBP, -1.08, "the stored value must not be pre-rounded")
|
||||
// …and rounded to two places only when shown.
|
||||
XCTAssertEqual(FuelSavingsFormat.money(1.082658), "£1.08")
|
||||
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 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, 27.416038, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.grossSavingGBP, 1.919123, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.701613, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.netSavingGBP, 1.217509, accuracy: 1e-6)
|
||||
XCTAssertEqual(result.status, .worthTheDrive)
|
||||
XCTAssertEqual(result.headline(), "Save £1.22 after travel")
|
||||
}
|
||||
|
||||
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.349040, accuracy: 1e-6, "MPG must not touch the pump saving")
|
||||
XCTAssertEqual(sixty.extraDrivingCostGBP, 0.366809, accuracy: 1e-6)
|
||||
XCTAssertEqual(sixty.netSavingGBP, -0.715849, accuracy: 1e-6)
|
||||
|
||||
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.838119, 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.269343, accuracy: 1e-6, "£40 buys 23.27 L at 171.9p")
|
||||
XCTAssertEqual(forty.grossSavingGBP, -0.232693, accuracy: 1e-6)
|
||||
XCTAssertEqual(forty.extraDrivingCostGBP, 0.733618, accuracy: 1e-6, "the journey cost is budget-independent")
|
||||
XCTAssertEqual(forty.netSavingGBP, -0.966311, 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.356021, accuracy: 1e-6)
|
||||
XCTAssertEqual(ninety.grossSavingGBP, -0.523560, accuracy: 1e-6)
|
||||
XCTAssertEqual(ninety.netSavingGBP, -1.257178, 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.168911, 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")
|
||||
let headline = mixed?.result?.headline(compact: true) ?? ""
|
||||
XCTAssertTrue(headline.hasSuffix(" (est.)"), "an estimate must be marked as one, got \(headline)")
|
||||
}
|
||||
|
||||
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 station")
|
||||
}
|
||||
}
|
||||
@@ -374,4 +374,62 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
"net saving decides the order, not the pump price")
|
||||
XCTAssertEqual(ranked.last?.id, "cheap-but-far")
|
||||
}
|
||||
|
||||
// MARK: Row copy
|
||||
|
||||
/// The row's extra-driving caption must follow the DISPLAY unit: the stored
|
||||
/// figure is always miles, and converting by the current unit instead would
|
||||
/// leave a km user's figure unlabelled and 1.6× too large.
|
||||
func testExtraTravelLabelFollowsTheDisplayUnit() {
|
||||
XCTAssertEqual(
|
||||
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 19.9, unit: .miles),
|
||||
"19.9 mi more driving than closest"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 19.9, unit: .kilometers),
|
||||
"32.0 km more driving than closest"
|
||||
)
|
||||
XCTAssertNil(FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 0, unit: .miles),
|
||||
"the baseline row has no extra travel to report")
|
||||
// The same conversion backs the detail sheet's extra-distance figure,
|
||||
// which used to be hardcoded miles ("%.1f miles") for km users too.
|
||||
XCTAssertEqual(FuelSavingsFormat.distance(19.9, unit: .miles), "19.9 mi")
|
||||
XCTAssertEqual(FuelSavingsFormat.distance(19.9, unit: .kilometers), "32.0 km")
|
||||
}
|
||||
|
||||
/// The baseline capsule has to say what it IS: it previously repeated the
|
||||
/// status word above it ("Closest station" twice) and never explained that
|
||||
/// the savings are measured against it.
|
||||
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
|
||||
let baseline = FuelSavingsResult(
|
||||
fillLitres: 30,
|
||||
grossSavingGBP: 0,
|
||||
extraDrivingCostGBP: 0,
|
||||
extraDistanceMiles: 0,
|
||||
netSavingGBP: 0,
|
||||
status: .baseline
|
||||
)
|
||||
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station · your baseline")
|
||||
|
||||
let saving = FuelSavingsResult(
|
||||
fillLitres: 30,
|
||||
grossSavingGBP: 2.40,
|
||||
extraDrivingCostGBP: 1.40,
|
||||
extraDistanceMiles: 5.2,
|
||||
netSavingGBP: 1.00,
|
||||
status: .worthTheDrive,
|
||||
distanceBasis: .straightLine
|
||||
)
|
||||
XCTAssertEqual(saving.capsuleHeadline(), "Save £1.00 after travel (est.)")
|
||||
|
||||
let worse = FuelSavingsResult(
|
||||
fillLitres: 30,
|
||||
grossSavingGBP: 1.40,
|
||||
extraDrivingCostGBP: 1.70,
|
||||
extraDistanceMiles: 6.7,
|
||||
netSavingGBP: -0.31,
|
||||
status: .notWorthTheDrive
|
||||
)
|
||||
XCTAssertEqual(worse.capsuleHeadline(), "£0.31 worse off after travel")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user