Files
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

798 lines
34 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
func testListingCapsulePutsTheFigureOnItsOwnLine() {
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: status == .notWorthTheDrive ? 0 : 12.50,
extraDrivingCostGBP: status == .notWorthTheDrive ? 123.45 : 0,
extraDistanceMiles: 2,
netSavingGBP: status == .notWorthTheDrive ? -123.45 : 12.50,
status: status,
journeyConfidence: confidence
)
}
// The figure takes its own line, so the capsule's shape is decided by the
// data rather than by how much width the price column hands it.
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingVerdict().lines,
["Saves", "£12.50"])
XCTAssertEqual(result(.worthTheDrive, .estimated).listingVerdict().lines,
["Saves", "£12.50", "(approx.)"])
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingVerdict().lines,
["Costs", "£123.45 more"])
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingVerdict().lines,
["Costs", "£123.45 more", "(approx.)"])
// Break even carries no figure — ±£0.05 is noise — so it stays one line
// when confirmed and gains a second only for the estimate qualifier.
XCTAssertEqual(result(.breakEven, .confirmed).listingVerdict().lines, ["Break even"])
XCTAssertEqual(result(.breakEven, .estimated).listingVerdict().lines,
["Break even", "(approx.)"])
XCTAssertEqual(result(.baseline, .confirmed).listingVerdict().lines, ["Closest"])
// The Key sheet's legend demonstrates the wording, never a figure.
XCTAssertEqual(
result(.notWorthTheDrive, .confirmed).listingVerdict(showsAmount: false).lines,
["Costs"]
)
// VoiceOver keeps a single line: a drawn break is a spoken pause.
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(),
"Saves £12.50 (approx.)")
}
func testDetailCapsulePlacesApproximationAfterTheFinancialResult() {
func result(_ status: FuelSavingsStatus, _ confidence: JourneyConfidence) -> FuelSavingsResult {
FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: status == .notWorthTheDrive ? 0 : 2.50,
extraDrivingCostGBP: status == .notWorthTheDrive ? 1.20 : 0,
extraDistanceMiles: 2,
netSavingGBP: status == .notWorthTheDrive ? -1.20 : 2.50,
status: status,
journeyConfidence: confidence
)
}
XCTAssertEqual(result(.worthTheDrive, .estimated).detailCapsuleHeadline(), "Save £2.50 (approx.)")
XCTAssertEqual(result(.notWorthTheDrive, .estimated).detailCapsuleHeadline(), "Costs £1.20 more (approx.)")
XCTAssertEqual(result(.breakEven, .estimated).detailCapsuleHeadline(), "Break even (approx.)")
XCTAssertEqual(result(.worthTheDrive, .confirmed).detailCapsuleHeadline(), "Save £2.50")
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).detailCapsuleHeadline(), "Costs £1.20 more")
XCTAssertEqual(result(.breakEven, .confirmed).detailCapsuleHeadline(), "Break even")
XCTAssertEqual(result(.worthTheDrive, .estimated).detailCapsuleSpokenHeadline(), "Save £2.50 (approximately)")
}
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)
}
/// Fuel types are never mixed: a station that does not sell the selected
/// fuel cannot be its baseline, and cannot be compared with it.
func testDifferentFuelNeverUsesAnotherGradeAsItsBaseline() {
let petrolOnly = FuelStation(id: "petrol", name: "Petrol", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.e10: 199.9], priceUpdated: nil)
let dieselOnly = FuelStation(id: "diesel", name: "Diesel", brand: "", address: "", postcode: "", lat: 53, lng: -1,
prices: [.diesel: 129.9], priceUpdated: nil)
let nearest = FuelSavingsEstimator.baselineStation([petrolOnly, dieselOnly], fuel: .diesel) { station in
self.straightLine(station.id == "petrol" ? 0.5 : 1)
}
XCTAssertEqual(nearest?.id, "diesel")
let unavailable = FuelSavingsEstimator.availability(station: dieselOnly, fuel: .diesel, baseline: petrolOnly,
settings: settings, amount: amount, journey: straightLine(1), baselineJourney: straightLine(0.5))
XCTAssertEqual(unavailable, .unavailable("No comparison station"))
}
/// Ranking must still work when the list being ordered is filtered tighter
/// than the pool the baseline was chosen from.
func testBaselineOutsideFilteredPoolStillRanksByNetSaving() {
let baseline = station("nearest-outside-radius", price: 199.9)
let cheap = station("z-cheap", price: 129.9)
let expensive = station("a-expensive", price: 198.9)
let ranked = FuelSavingsEstimator.ranked(
[expensive, cheap], fuel: .e10, baseline: baseline,
settings: settings, amount: amount,
journeyMiles: { s in self.straightLine(s.id == baseline.id ? 0.8 : 4.0) }
)
XCTAssertEqual(ranked.map(\.id), ["z-cheap", "a-expensive"])
let expected = FuelSavingsEstimator.availability(
station: cheap, fuel: .e10, baseline: baseline,
settings: settings, amount: amount,
journey: straightLine(4.0), baselineJourney: straightLine(0.8)
)?.result
XCTAssertGreaterThan(expected?.netSavingGBP ?? -1, 0)
}
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 testBaselineStationUsesTheSameJourneyValuesAsSavings() {
let geographicallyNearest = station("geographically-nearest", price: 145.9)
let roadNearest = station("road-nearest", price: 146.9)
let noPrice = station("no-price", price: nil)
let baseline = FuelSavingsEstimator.baselineStation(
[geographicallyNearest, roadNearest, noPrice],
fuel: .e10,
journeyMiles: { station in
switch station.id {
case "geographically-nearest": return FuelSavingsJourney(miles: 8.0, basis: .roadRoute)
case "road-nearest": return FuelSavingsJourney(miles: 4.0, basis: .roadRoute)
case "no-price": return FuelSavingsJourney(miles: 1.0, basis: .roadRoute)
default: return nil
}
}
)
XCTAssertEqual(baseline?.id, "road-nearest",
"the Best Value baseline must be the shortest usable journey, not a separate straight-line/display-distance choice")
}
func testBaselineStationFallsBackThenChangesWhenRoadRoutesArrive() {
let stationA = station("a", price: 145.9)
let stationB = station("b", price: 146.9)
let noRoadRoutes = FuelSavingsEstimator.baselineStation(
[stationA, stationB],
fuel: .e10,
journeyMiles: { station in
switch station.id {
case "a": return FuelSavingsJourney(miles: 2.0, basis: .straightLine)
case "b": return FuelSavingsJourney(miles: 3.0, basis: .straightLine)
default: return nil
}
}
)
XCTAssertEqual(noRoadRoutes?.id, "a")
let partialRoadRoutes = FuelSavingsEstimator.baselineStation(
[stationA, stationB],
fuel: .e10,
journeyMiles: { station in
switch station.id {
case "a": return FuelSavingsJourney(miles: 5.0, basis: .roadRoute)
case "b": return FuelSavingsJourney(miles: 3.0, basis: .straightLine)
default: return nil
}
}
)
XCTAssertEqual(partialRoadRoutes?.id, "b",
"baseline must be recalculated from the latest journey values when routes arrive")
}
func testRankingToleratesDuplicateStationIDs() {
let baseline = station("baseline", price: 145.9)
let duplicateA = station("duplicate", price: 138.9, lat: 51.55)
let duplicateB = station("duplicate", price: 139.9, lat: 51.56)
let pool = [baseline, duplicateA, duplicateB]
let ranked = FuelSavingsEstimator.ranked(
pool,
fuel: .e10,
baseline: baseline,
settings: settings,
amount: amount,
journeyMiles: { s in
s.id == "baseline" ? self.straightLine(2.0) : self.straightLine(7.0)
}
)
XCTAssertEqual(ranked.count, pool.count)
XCTAssertEqual(ranked.filter { $0.id == "duplicate" }.count, 2)
}
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 35.74 L at 167.9p, so the
/// cheaper pump saves 35.74 × 4p = £1.43, while the extra 6.8 return miles
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.30.
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
let baseline = station("rontec-shelf", price: 171.9)
let candidate = station("shell-whetley-hill", price: 167.9)
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, 35.736, accuracy: 0.005,
"the £60 budget buys litres at the candidate pump price")
XCTAssertEqual(result.grossSavingGBP, 1.429, accuracy: 0.005)
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
"only the return-trip DELTA counts — the shared journey is never counted twice")
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
XCTAssertEqual(result.netSavingGBP, -0.301, accuracy: 0.005)
XCTAssertEqual(result.status, .notWorthTheDrive)
XCTAssertEqual(result.journeyConfidence, .estimated)
XCTAssertEqual(result.headline(compact: true), "Approx. Costs £0.30 more")
}
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?.journeyConfidence, .confirmed)
XCTAssertEqual(availability?.result?.headline(compact: true), "Costs £0.30 more")
XCTAssertEqual(availability?.result?.headline(), "Costs £0.30 more")
}
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")
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest")
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 40 L at 150p; 10p/litre cheaper saves £4.00; 0.4 extra
// return miles cost ~£0.09. Net ≈ £3.91.
XCTAssertEqual(result.netSavingGBP, 3.909, accuracy: 0.005)
XCTAssertEqual(result.headline(compact: true), "Approx. Save £3.91")
}
func testNeutralResultHeadlineIsBreakEven() {
// 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")
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")
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(), "Costs £0.31 more")
}
/// 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
func testCompactComparisonLabelUsesGenericNearestStationWording() {
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
"vs closest station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
"vs closest station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .breakEven),
"vs closest station"
)
XCTAssertEqual(
FuelSavingsFormat.comparisonLabel(status: .baseline),
"your baseline"
)
}
/// 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 extra return travel"
)
XCTAssertEqual(
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 19.9, unit: .kilometers),
"32.0 km extra return travel"
)
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" 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")
let saving = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 2.40,
extraDrivingCostGBP: 1.40,
extraDistanceMiles: 5.2,
netSavingGBP: 1.00,
status: .worthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(saving.capsuleHeadline(), "Approx. Save £1.00")
let worse = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 1.40,
extraDrivingCostGBP: 1.70,
extraDistanceMiles: 6.7,
netSavingGBP: -0.31,
status: .notWorthTheDrive
)
XCTAssertEqual(worse.capsuleHeadline(), "Costs £0.31 more")
}
// MARK: - Estimate wording (Approx. labels)
/// The estimate marker is the WORD "Approx.", never a "~", and it is applied to
/// both directions of the money result.
func testEstimatedHeadlinesUseTheWordApproxForBothDirections() {
let saving = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 4.00,
extraDrivingCostGBP: 2.75,
extraDistanceMiles: 6.4,
netSavingGBP: 1.25,
status: .worthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(saving.headline(), "Approx. Save £1.25")
let loss = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 1.40,
extraDrivingCostGBP: 2.17,
extraDistanceMiles: 6.4,
netSavingGBP: -0.77,
status: .notWorthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(loss.headline(), "Approx. Costs £0.77 more")
}
/// A confirmed road-route comparison must never carry the marker — in either
/// direction, and not as a stray tilde either.
func testConfirmedHeadlinesCarryNoApproxMarker() {
let saving = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 4.00,
extraDrivingCostGBP: 2.75,
extraDistanceMiles: 6.4,
netSavingGBP: 1.25,
status: .worthTheDrive,
distanceBasis: .roadRoute,
journeyConfidence: .confirmed
)
XCTAssertEqual(saving.headline(), "Save £1.25")
let loss = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 1.40,
extraDrivingCostGBP: 2.17,
extraDistanceMiles: 6.4,
netSavingGBP: -0.77,
status: .notWorthTheDrive,
distanceBasis: .roadRoute,
journeyConfidence: .confirmed
)
XCTAssertEqual(loss.headline(), "Costs £0.77 more")
for headline in [saving.headline(), loss.headline(), saving.capsuleHeadline()] {
XCTAssertFalse(headline.contains("Approx"), "\"\(headline)\" must not claim to be an estimate")
XCTAssertFalse(headline.contains("~"), "\"\(headline)\" must use a word, not a symbol")
}
}
/// The extra-travel caption names the RETURN journey in both states. The
/// estimated caption repeats "Approx." deliberately: it describes a different
/// value from the verdict above it.
func testExtraTravelNamesTheReturnJourney() {
XCTAssertEqual(
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 6.4, unit: .miles),
"6.4 mi extra return travel"
)
XCTAssertEqual(
FuelSavingsFormat.extraTravelLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
"Approx. 6.4 mi extra return travel"
)
}
/// The same figure on its own, for the sheet's row form ("Extra return
/// travel | 6.4 mi"): identical value and identical marker rule, so the row
/// and the caption can never disagree about being an estimate. Below the
/// 0.05-mile floor both forms return nil together.
func testExtraTravelDistanceLabelMatchesTheCaption() {
XCTAssertEqual(
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 6.4, unit: .miles),
"6.4 mi"
)
XCTAssertEqual(
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
"Approx. 6.4 mi"
)
XCTAssertEqual(
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 6.4, unit: .kilometers),
"10.3 km"
)
XCTAssertNil(
FuelSavingsFormat.extraTravelDistanceLabel(extraDistanceMiles: 0.04, unit: .miles),
"below the floor there is no extra travel to name"
)
}
/// VoiceOver gets words, not abbreviations: "Approx." → "Approximately" and
/// "mi"/"km" → "miles"/"kilometres".
func testSpokenLabelsSpellOutTheAbbreviations() {
let estimated = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 1.40,
extraDrivingCostGBP: 2.17,
extraDistanceMiles: 6.4,
netSavingGBP: -0.77,
status: .notWorthTheDrive,
distanceBasis: .straightLine
)
XCTAssertEqual(estimated.spokenHeadline(), "Approximately Costs £0.77 more")
XCTAssertEqual(
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 6.4, unit: .miles, confidence: .estimated),
"Approximately 6.4 miles extra return travel"
)
XCTAssertEqual(
FuelSavingsFormat.extraTravelSpokenLabel(extraDistanceMiles: 19.9, unit: .kilometers),
"32.0 kilometres extra return travel"
)
let confirmed = FuelSavingsResult(
fillLitres: 30,
grossSavingGBP: 1.40,
extraDrivingCostGBP: 2.17,
extraDistanceMiles: 6.4,
netSavingGBP: -0.77,
status: .notWorthTheDrive,
distanceBasis: .roadRoute,
journeyConfidence: .confirmed
)
XCTAssertEqual(confirmed.spokenHeadline(), confirmed.headline(),
"a confirmed comparison has no abbreviation to spell out")
}
// 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")
}
}