The two Fuel Savings figures and the in-list fill-up control were text
fields. A typed figure could sit outside the slider's own bounds, so the
control and the number it claimed to show could disagree, and the decimal
pad needed a Done button to escape.
- Settings: "Average fuel economy" and "Typical fill-up" are now sliders
with a read-out ("45 MPG", "£40" / "40 L") in place of the field. The
warning copy reads "Set a realistic..." rather than "Enter...".
- FuelSavingsAdjustmentView (shared by the Stations Worth-the-Drive header
and the Favourites tab) drops its text field and gains the same read-out;
the typed-value machinery and the -focusFillAmount QA hook go with it.
- Slider bounds live in FuelSavingsCalculator (fillAmountSliderRange/Step,
mpgSliderRange/Step) so the Settings control and the in-list control can
never offer different ranges, and the fill-up slider range stays inside
the calculator's validity ceiling (asserted by test).
- Two tests pin those invariants: every slider position is a valid amount,
and both MPG bounds are valid MPG values. 155 tests, 0 failures.
- Comments that described the removed fields were updated; the keyboard
Done-bar plumbing is kept (no numeric field remains in the app, so it can
no longer fire) rather than deleted, so any field added later still has an
escape route.
Verified on the simulator: Settings shows "Average fuel economy / 45 MPG"
with no field, and the Worth-the-Drive fill-up control shows the unit picker
with a "£40" read-out and a slider. The Settings "Typical fill-up" row sits
below the fold on every simulator available here (and synthetic input cannot
scroll it), so those pixels are unverified.
467 lines
19 KiB
Swift
467 lines
19 KiB
Swift
import XCTest
|
||
#if SWIFT_PACKAGE
|
||
@testable import FuelBoardShared
|
||
#endif
|
||
|
||
/// Ranking rules for the Worth the Drive sort.
|
||
///
|
||
/// Regression: the sort used to rank only a shortlist of the nearest stations
|
||
/// whose ROAD ROUTES had been measured, and everything else was listed as
|
||
/// unavailable. Cheaper stations a few miles further out — the exact stations
|
||
/// the sort exists to surface — were dropped from the top of the list, and
|
||
/// stations on later pages showed no savings at all.
|
||
final class FuelSavingsEstimateTests: XCTestCase {
|
||
private let settings = FuelSavingsSettings(
|
||
enabled: true,
|
||
mpg: 45,
|
||
preferredAmount: FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||
)
|
||
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||
|
||
private func station(_ id: String,
|
||
price: Double?,
|
||
lat: Double = 51.5,
|
||
lng: Double = -0.1) -> FuelStation {
|
||
FuelStation(
|
||
id: id,
|
||
name: id,
|
||
brand: "X",
|
||
address: "",
|
||
postcode: "",
|
||
lat: lat,
|
||
lng: lng,
|
||
prices: price.map { [FuelType.e10: $0] } ?? [:],
|
||
priceUpdated: nil
|
||
)
|
||
}
|
||
|
||
/// Straight-line out-and-back estimate, as the app builds it when no road
|
||
/// route has been measured yet.
|
||
private func straightLine(_ miles: Double) -> FuelSavingsJourney {
|
||
FuelSavingsJourney(miles: miles, basis: .straightLine)
|
||
}
|
||
|
||
func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() {
|
||
// Nearest station: 1 mile away at 145.9p. The station the user can see
|
||
// in the Cheapest tab: 3.5 miles away at 138.9p.
|
||
let baseline = station("baseline", price: 145.9)
|
||
let candidate = station("cheaper-3.5", price: 138.9, lat: 51.55)
|
||
let filler = (0..<14).map { station("filler-\($0)", price: 150 + Double($0)) }
|
||
let pool = [baseline, candidate] + filler
|
||
|
||
let ranked = FuelSavingsEstimator.ranked(
|
||
pool,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journeyMiles: { s in
|
||
switch s.id {
|
||
case "baseline": return self.straightLine(2.0)
|
||
case "cheaper-3.5": return self.straightLine(7.0)
|
||
default: return self.straightLine(20.0)
|
||
}
|
||
}
|
||
)
|
||
|
||
XCTAssertEqual(ranked.count, pool.count)
|
||
XCTAssertEqual(ranked.first?.id, "cheaper-3.5",
|
||
"a cheaper station 3.5 miles away must rank above the nearest station")
|
||
|
||
let result = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: straightLine(7.0),
|
||
baselineJourney: straightLine(2.0)
|
||
)?.result
|
||
|
||
XCTAssertNotNil(result, "the station must show a saving, not an unavailable state")
|
||
XCTAssertGreaterThan(result?.netSavingGBP ?? -1, 0)
|
||
XCTAssertEqual(result?.status, .worthTheDrive)
|
||
XCTAssertEqual(result?.distanceBasis, .straightLine,
|
||
"unmeasured routes are reported as straight-line estimates")
|
||
}
|
||
|
||
func testEveryPricedStationInThePoolIsEstimatedNotJustAShortlist() {
|
||
// 40 stations, but only the first one has a measured road route — the
|
||
// old implementation capped the whole ranking to 12 shortlisted
|
||
// stations and returned "Outside route shortlist" for the rest.
|
||
let baseline = station("baseline", price: 145.9)
|
||
let pool = [baseline] + (0..<39).map { station("s\($0)", price: 135 + Double($0)) }
|
||
|
||
let ranked = FuelSavingsEstimator.ranked(
|
||
pool,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journeyMiles: { s in
|
||
s.id == "baseline"
|
||
? FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||
: self.straightLine(2.0 + (Double(s.id.dropFirst()) ?? 6))
|
||
}
|
||
)
|
||
|
||
XCTAssertEqual(ranked.count, pool.count)
|
||
|
||
for station in pool where station.id != "baseline" {
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: station,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: straightLine(10.0),
|
||
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||
)
|
||
XCTAssertNotNil(availability?.result,
|
||
"\(station.id) should carry an estimated saving, not an unavailable state")
|
||
}
|
||
}
|
||
|
||
func testMeasuredRoadRoutesAreReportedAsRoadRouteBasis() {
|
||
let baseline = station("baseline", price: 145.9)
|
||
let candidate = station("candidate", price: 138.9)
|
||
|
||
let result = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: FuelSavingsJourney(miles: 7.0, basis: .roadRoute),
|
||
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||
)?.result
|
||
|
||
XCTAssertEqual(result?.distanceBasis, .roadRoute)
|
||
XCTAssertFalse(FuelSavingsDistanceBasis.roadRoute.isEstimate)
|
||
}
|
||
|
||
func testMixedBasisIsReportedAsAnEstimate() {
|
||
let baseline = station("baseline", price: 145.9)
|
||
let candidate = station("candidate", price: 138.9)
|
||
|
||
// Baseline measured, candidate not: the comparison is only as good as
|
||
// its weakest leg, so it must be labelled an estimate.
|
||
let result = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: straightLine(7.0),
|
||
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||
)?.result
|
||
|
||
XCTAssertEqual(result?.distanceBasis, .straightLine)
|
||
}
|
||
|
||
func testStationWithoutAPriceIsUnavailable() {
|
||
let baseline = station("baseline", price: 145.9)
|
||
let noPrice = station("no-price", price: nil)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: noPrice,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: settings,
|
||
amount: amount,
|
||
journey: straightLine(4.0),
|
||
baselineJourney: straightLine(2.0)
|
||
)
|
||
|
||
XCTAssertEqual(availability, .unavailable("No price"))
|
||
}
|
||
|
||
func testFeatureOffProducesNoSavingsRow() {
|
||
let baseline = station("baseline", price: 145.9)
|
||
let candidate = station("candidate", price: 138.9)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: .defaults,
|
||
amount: amount,
|
||
journey: straightLine(4.0),
|
||
baselineJourney: straightLine(2.0)
|
||
)
|
||
|
||
XCTAssertNil(availability)
|
||
}
|
||
|
||
// MARK: - Required wording and baseline labelling
|
||
|
||
private var briefSettings: FuelSavingsSettings {
|
||
FuelSavingsSettings(enabled: true,
|
||
mpg: 30,
|
||
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds))
|
||
}
|
||
|
||
private var briefAmount: FuelSavingsFillAmount {
|
||
FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||
}
|
||
|
||
/// The scenario from the brief: 30 MPG, £60 fill, baseline Rontec Shelf
|
||
/// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi).
|
||
///
|
||
/// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is
|
||
/// correct, not a calculation bug: £60 buys 34.90 L at 171.9p, so the
|
||
/// cheaper pump saves 34.90 × 4p = £1.40, while the extra 6.8 return miles
|
||
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.33.
|
||
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
|
||
let baseline = station("rontec-shelf", price: 171.9)
|
||
let candidate = station("shell-whetley-hill", price: 167.9)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: briefSettings,
|
||
amount: briefAmount,
|
||
journey: straightLine(7.6), // 3.8 mi out and back
|
||
baselineJourney: straightLine(0.8) // 0.4 mi out and back
|
||
)
|
||
|
||
guard let result = availability?.result else {
|
||
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||
}
|
||
XCTAssertEqual(result.fillLitres, 34.904, accuracy: 0.005,
|
||
"the £60 budget buys litres at the BASELINE pump price")
|
||
XCTAssertEqual(result.grossSavingGBP, 1.396, accuracy: 0.005)
|
||
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
|
||
"only the return-trip DELTA counts — the shared journey is never counted twice")
|
||
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
|
||
XCTAssertEqual(result.netSavingGBP, -0.334, accuracy: 0.005)
|
||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||
XCTAssertEqual(result.headline(compact: true), "£0.33 worse off after travel (est.)")
|
||
}
|
||
|
||
func testMeasuredRoadRoutesDropTheEstimateMarker() {
|
||
let baseline = station("baseline", price: 171.9)
|
||
let candidate = station("candidate", price: 167.9)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: briefSettings,
|
||
amount: briefAmount,
|
||
journey: FuelSavingsJourney(miles: 7.6, basis: .roadRoute),
|
||
baselineJourney: FuelSavingsJourney(miles: 0.8, basis: .roadRoute)
|
||
)
|
||
|
||
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
|
||
XCTAssertEqual(availability?.result?.headline(compact: true), "£0.33 worse off after travel")
|
||
XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel")
|
||
}
|
||
|
||
func testBaselineRowIsLabelledClosestStation() {
|
||
let baseline = station("nearest", price: 171.9)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: baseline,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: briefSettings,
|
||
amount: briefAmount,
|
||
journey: straightLine(0.8),
|
||
baselineJourney: straightLine(0.8)
|
||
)
|
||
|
||
XCTAssertEqual(availability?.result?.status, .baseline)
|
||
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station")
|
||
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station")
|
||
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
|
||
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
|
||
}
|
||
|
||
func testPositiveResultHeadlineSaysSaveAfterTravel() {
|
||
// 4p cheaper AND almost the same distance, so the saving survives travel.
|
||
let baseline = station("baseline", price: 160.0)
|
||
let candidate = station("candidate", price: 150.0)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: briefSettings,
|
||
amount: briefAmount,
|
||
journey: straightLine(6.4),
|
||
baselineJourney: straightLine(6.0)
|
||
)
|
||
|
||
guard let result = availability?.result else {
|
||
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||
}
|
||
XCTAssertEqual(result.status, .worthTheDrive)
|
||
// £60 buys 37.5 L at 160p; 10p/litre cheaper saves £3.75; 0.4 extra
|
||
// return miles cost ~£0.09. Net ≈ £3.66.
|
||
XCTAssertEqual(result.netSavingGBP, 3.659, accuracy: 0.005)
|
||
XCTAssertEqual(result.headline(compact: true), "Save £3.66 after travel (est.)")
|
||
}
|
||
|
||
func testNeutralResultHeadlineIsBreakEven() {
|
||
// 0.1p cheaper over the same distance: inside the break-even tolerance.
|
||
let baseline = station("baseline", price: 150.0)
|
||
let candidate = station("candidate", price: 149.9)
|
||
|
||
let availability = FuelSavingsEstimator.availability(
|
||
station: candidate,
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: briefSettings,
|
||
amount: briefAmount,
|
||
journey: straightLine(6.0),
|
||
baselineJourney: straightLine(6.0)
|
||
)
|
||
|
||
XCTAssertEqual(availability?.result?.status, .breakEven)
|
||
XCTAssertEqual(availability?.result?.headline(compact: true), "Break even")
|
||
XCTAssertEqual(availability?.result?.headline(), "Break even")
|
||
}
|
||
|
||
/// The exact strings the brief asks for.
|
||
func testHeadlineWordingMatchesTheBrief() {
|
||
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station")
|
||
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
|
||
|
||
let saving = FuelSavingsResult(fillLitres: 34.9,
|
||
grossSavingGBP: 1.40,
|
||
extraDrivingCostGBP: 0.88,
|
||
extraDistanceMiles: 3.5,
|
||
netSavingGBP: 0.52,
|
||
status: .worthTheDrive,
|
||
distanceBasis: .roadRoute)
|
||
XCTAssertEqual(saving.headline(), "Save £0.52 after travel")
|
||
|
||
let loss = FuelSavingsResult(fillLitres: 34.9,
|
||
grossSavingGBP: 1.40,
|
||
extraDrivingCostGBP: 1.71,
|
||
extraDistanceMiles: 6.8,
|
||
netSavingGBP: -0.31,
|
||
status: .notWorthTheDrive,
|
||
distanceBasis: .roadRoute)
|
||
XCTAssertEqual(loss.headline(), "£0.31 worse off after travel")
|
||
}
|
||
|
||
/// The sort must order by NET saving, not pump price: a pricier station can
|
||
/// win when the cheaper one is far enough away.
|
||
func testSortingRanksByNetSavingNotPumpPrice() {
|
||
let baseline = station("baseline", price: 171.9)
|
||
let cheapButFar = station("cheap-but-far", price: 167.9)
|
||
let pricierButNear = station("pricier-but-near", price: 169.9)
|
||
|
||
let ranked = FuelSavingsEstimator.ranked(
|
||
[baseline, cheapButFar, pricierButNear],
|
||
fuel: .e10,
|
||
baseline: baseline,
|
||
settings: briefSettings,
|
||
amount: briefAmount,
|
||
journeyMiles: { s in
|
||
switch s.id {
|
||
case "baseline": return self.straightLine(0.8)
|
||
case "cheap-but-far": return self.straightLine(7.6)
|
||
default: return self.straightLine(1.2)
|
||
}
|
||
}
|
||
)
|
||
|
||
XCTAssertEqual(ranked.first?.id, "pricier-but-near",
|
||
"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")
|
||
}
|
||
|
||
// MARK: - Slider bounds (Settings and the in-list control share these)
|
||
|
||
/// Both fill-up controls are slider-only now, so every position the slider
|
||
/// can reach must be a usable amount — otherwise the control could sit on a
|
||
/// figure the calculator rejects.
|
||
func testFillAmountSliderBoundsAreAlwaysValidAmounts() {
|
||
for unit in FuelSavingsFillUnit.allCases {
|
||
let range = FuelSavingsCalculator.fillAmountSliderRange(for: unit)
|
||
let step = FuelSavingsCalculator.fillAmountSliderStep(for: unit)
|
||
XCTAssertLessThan(range.lowerBound, range.upperBound, "\(unit) range must be usable")
|
||
XCTAssertGreaterThan(step, 0, "\(unit) step must advance")
|
||
XCTAssertGreaterThan(range.lowerBound, 0, "\(unit) must never offer a zero fill")
|
||
XCTAssertLessThanOrEqual(range.upperBound,
|
||
FuelSavingsCalculator.maximumFillAmount(for: unit),
|
||
"\(unit) slider must stay inside the valid ceiling")
|
||
for position in [range.lowerBound, range.upperBound, (range.lowerBound + range.upperBound) / 2] {
|
||
XCTAssertTrue(FuelSavingsFillAmount(value: position, unit: unit).isValid,
|
||
"\(position) \(unit) must be a valid amount")
|
||
}
|
||
}
|
||
}
|
||
|
||
func testMPGSliderBoundsAreInsideTheValidMPGRange() {
|
||
let range = FuelSavingsCalculator.mpgSliderRange
|
||
XCTAssertLessThan(range.lowerBound, range.upperBound)
|
||
XCTAssertGreaterThan(FuelSavingsCalculator.mpgSliderStep, 0)
|
||
XCTAssertTrue(FuelSavingsCalculator.isValidMPG(range.lowerBound))
|
||
XCTAssertTrue(FuelSavingsCalculator.isValidMPG(range.upperBound))
|
||
XCTAssertTrue(FuelSavingsCalculator.isValidMPG(45), "a typical UK figure must be reachable")
|
||
}
|
||
}
|