The sheet leads with the station name and address inside the card, above the map, then a two-column facts row (fuel price on the left, distance and return on the right, split by a hairline) and the two station actions divided the same way. The Best Value block becomes a tinted card: verdict badge, the two figures the estimate is made of, the working and the total. "How Best Value works" moves to its own row below it, using the shared HowBestValueRow the Key sheet already shows. Presentation only: every figure still comes from the shared sources (FuelStore.priceText, stationDistanceDisplay, FuelSavingsResult), so the sheet cannot disagree with the listing row, the widget or the Live Activity, and no calculation, ranking or freshness rule changed. StationDistanceDisplay.returnLabel(unit:) and FuelSavingsFormat.extraTravelDistanceLabel(...) are split out of their caption forms so a surface printing the return or extra-travel figure on its own line takes it from the same place, confidence marker included; their existing output is unchanged, pinned by tests. FuelSavingsSummaryView is deleted - the sheet's card supersedes it and nothing referenced it. DEBUG-only QA hooks: -qaConfigureSavings configures a Fuel Savings plan (the Best Value card and the Worth the Drive sort are gated on it), -qaDetailBottom scrolls to that card (defaultScrollAnchor is a no-op on a List) and -qaCopyPostcode holds the copied confirmation long enough to screenshot.
734 lines
30 KiB
Swift
734 lines
30 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)
|
||
}
|
||
|
||
/// 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 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 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 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")
|
||
|
||
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 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")
|
||
|
||
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")
|
||
}
|
||
}
|