The listing's verdict capsule now carries white text on muted semantic fills instead of the neutral secondary tint: Saves #45825E, Costs #A9635B, Break even #707070, Closest #58789D. Fixed tones, not translucent tints, because white text on a 15% tint over a white row measures ~1.8:1. Each tone sits at a relative luminance of ~0.18 — the band where white clears 4.5:1 AND the pill still separates ~3:1 from both a white row and the dark card fill — so one value each works in light and dark without a dynamic colour. The saturated palette colours cannot carry white text at all: systemGreen measures 2.2:1, systemRed 3.6:1. "Save" becomes "Saves", matching the detail sheet and reading as a statement of the outcome; amounts, symbols, padding, radius and the shape-follows-line-count rule are unchanged. Saves and Costs share an 84pt minimum width so their trailing edges line up down the list. The price and its capsule are now one vertically grouped element (5pt), with the badge keeping the column's full 12pt above, so the row no longer reads as three equal lines. Station names cap at three lines with a tail ellipsis: unbounded wrap let one very long forecourt name set the height of the whole row. 59 of the 8,073 names in the dump exceed three lines and now ellipsise; the full name is always in Station Details. Addresses still wrap freely. The baseline pill moves onto ListingFuelSavingsCapsule so the listing shows the same soft blue and white text as its siblings, leaving FuelSavingsCapsule to the detail sheet, which it now alone uses. Badges keep their tinted styling: they annotate a price rather than report an outcome. Verified: 261/261 swift test; Debug simulator and Release device builds; all four fills and their white text measured from simulator pixels in both appearances, and the detail sheet's .blue absent from the listing entirely.
798 lines
34 KiB
Swift
798 lines
34 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 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 station"])
|
||
|
||
// 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 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")
|
||
}
|
||
}
|