755 lines
32 KiB
Swift
755 lines
32 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)
|
||
|
||
func testListingCapsuleUsesSixShortFinancialVerdictsWithoutAmounts() {
|
||
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
|
||
)
|
||
}
|
||
|
||
XCTAssertEqual(result(.worthTheDrive, .confirmed).listingCapsuleHeadline(), "Saves")
|
||
XCTAssertEqual(result(.worthTheDrive, .estimated).listingCapsuleHeadline(), "Saves (approx.)")
|
||
XCTAssertEqual(result(.notWorthTheDrive, .confirmed).listingCapsuleHeadline(), "Costs more")
|
||
XCTAssertEqual(result(.notWorthTheDrive, .estimated).listingCapsuleHeadline(), "Costs more (approx.)")
|
||
XCTAssertEqual(result(.breakEven, .confirmed).listingCapsuleHeadline(), "Break even")
|
||
XCTAssertEqual(result(.breakEven, .estimated).listingCapsuleHeadline(), "Break even (approx.)")
|
||
}
|
||
|
||
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")
|
||
}
|
||
}
|