Add Worth the Drive fuel savings with road-routed estimates
Optional Fuel Savings feature (off by default): vehicle MPG in UK imperial gallons, fill amount in pounds or litres, persisted through the existing keychain/app-group store. Ranks the WHOLE station pool by estimated net saving (fuel-price saving at each pump minus the extra return-trip driving cost) using MapKit road distances with a straight-line fallback marked (est.). The closest station with a valid price for the selected fuel is the comparison baseline and is labelled "Closest station"; other rows read "Save £X.XX after travel" / "£X.XX worse off after travel" / "Break even". Labels live in the shared model so they are unit-tested. The TOP badge still marks the cheapest pump price and reads "CHEAPEST" when the list is ranked by net saving. Road routing now prioritises the Worth-the-Drive candidates plus the baseline and merges cache entries instead of replacing them, so far-out stations get real routes rather than permanent straight-line estimates. Numeric fields gain a Done button above the keypad (decimalPad has no Return key); the fill-up control is hidden unless the feature is configured; Favourites shares one session fill amount with the station list. 131 tests.
This commit is contained in:
@@ -0,0 +1,377 @@
|
||||
import XCTest
|
||||
#if SWIFT_PACKAGE
|
||||
@testable import FuelBoardShared
|
||||
#endif
|
||||
|
||||
/// Ranking rules for the Worth the Drive sort.
|
||||
///
|
||||
/// Regression: the sort used to rank only a shortlist of the nearest stations
|
||||
/// whose ROAD ROUTES had been measured, and everything else was listed as
|
||||
/// unavailable. Cheaper stations a few miles further out — the exact stations
|
||||
/// the sort exists to surface — were dropped from the top of the list, and
|
||||
/// stations on later pages showed no savings at all.
|
||||
final class FuelSavingsEstimateTests: XCTestCase {
|
||||
private let settings = FuelSavingsSettings(
|
||||
enabled: true,
|
||||
mpg: 45,
|
||||
preferredAmount: FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||
)
|
||||
private let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||
|
||||
private func station(_ id: String,
|
||||
price: Double?,
|
||||
lat: Double = 51.5,
|
||||
lng: Double = -0.1) -> FuelStation {
|
||||
FuelStation(
|
||||
id: id,
|
||||
name: id,
|
||||
brand: "X",
|
||||
address: "",
|
||||
postcode: "",
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
prices: price.map { [FuelType.e10: $0] } ?? [:],
|
||||
priceUpdated: nil
|
||||
)
|
||||
}
|
||||
|
||||
/// Straight-line out-and-back estimate, as the app builds it when no road
|
||||
/// route has been measured yet.
|
||||
private func straightLine(_ miles: Double) -> FuelSavingsJourney {
|
||||
FuelSavingsJourney(miles: miles, basis: .straightLine)
|
||||
}
|
||||
|
||||
func testCheaperStationThreePointFiveMilesAwayIsRankedFirstWithASaving() {
|
||||
// Nearest station: 1 mile away at 145.9p. The station the user can see
|
||||
// in the Cheapest tab: 3.5 miles away at 138.9p.
|
||||
let baseline = station("baseline", price: 145.9)
|
||||
let candidate = station("cheaper-3.5", price: 138.9, lat: 51.55)
|
||||
let filler = (0..<14).map { station("filler-\($0)", price: 150 + Double($0)) }
|
||||
let pool = [baseline, candidate] + filler
|
||||
|
||||
let ranked = FuelSavingsEstimator.ranked(
|
||||
pool,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: settings,
|
||||
amount: amount,
|
||||
journeyMiles: { s in
|
||||
switch s.id {
|
||||
case "baseline": return self.straightLine(2.0)
|
||||
case "cheaper-3.5": return self.straightLine(7.0)
|
||||
default: return self.straightLine(20.0)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(ranked.count, pool.count)
|
||||
XCTAssertEqual(ranked.first?.id, "cheaper-3.5",
|
||||
"a cheaper station 3.5 miles away must rank above the nearest station")
|
||||
|
||||
let result = FuelSavingsEstimator.availability(
|
||||
station: candidate,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: settings,
|
||||
amount: amount,
|
||||
journey: straightLine(7.0),
|
||||
baselineJourney: straightLine(2.0)
|
||||
)?.result
|
||||
|
||||
XCTAssertNotNil(result, "the station must show a saving, not an unavailable state")
|
||||
XCTAssertGreaterThan(result?.netSavingGBP ?? -1, 0)
|
||||
XCTAssertEqual(result?.status, .worthTheDrive)
|
||||
XCTAssertEqual(result?.distanceBasis, .straightLine,
|
||||
"unmeasured routes are reported as straight-line estimates")
|
||||
}
|
||||
|
||||
func testEveryPricedStationInThePoolIsEstimatedNotJustAShortlist() {
|
||||
// 40 stations, but only the first one has a measured road route — the
|
||||
// old implementation capped the whole ranking to 12 shortlisted
|
||||
// stations and returned "Outside route shortlist" for the rest.
|
||||
let baseline = station("baseline", price: 145.9)
|
||||
let pool = [baseline] + (0..<39).map { station("s\($0)", price: 135 + Double($0)) }
|
||||
|
||||
let ranked = FuelSavingsEstimator.ranked(
|
||||
pool,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: settings,
|
||||
amount: amount,
|
||||
journeyMiles: { s in
|
||||
s.id == "baseline"
|
||||
? FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||||
: self.straightLine(2.0 + (Double(s.id.dropFirst()) ?? 6))
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(ranked.count, pool.count)
|
||||
|
||||
for station in pool where station.id != "baseline" {
|
||||
let availability = FuelSavingsEstimator.availability(
|
||||
station: station,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: settings,
|
||||
amount: amount,
|
||||
journey: straightLine(10.0),
|
||||
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||||
)
|
||||
XCTAssertNotNil(availability?.result,
|
||||
"\(station.id) should carry an estimated saving, not an unavailable state")
|
||||
}
|
||||
}
|
||||
|
||||
func testMeasuredRoadRoutesAreReportedAsRoadRouteBasis() {
|
||||
let baseline = station("baseline", price: 145.9)
|
||||
let candidate = station("candidate", price: 138.9)
|
||||
|
||||
let result = FuelSavingsEstimator.availability(
|
||||
station: candidate,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: settings,
|
||||
amount: amount,
|
||||
journey: FuelSavingsJourney(miles: 7.0, basis: .roadRoute),
|
||||
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||||
)?.result
|
||||
|
||||
XCTAssertEqual(result?.distanceBasis, .roadRoute)
|
||||
XCTAssertFalse(FuelSavingsDistanceBasis.roadRoute.isEstimate)
|
||||
}
|
||||
|
||||
func testMixedBasisIsReportedAsAnEstimate() {
|
||||
let baseline = station("baseline", price: 145.9)
|
||||
let candidate = station("candidate", price: 138.9)
|
||||
|
||||
// Baseline measured, candidate not: the comparison is only as good as
|
||||
// its weakest leg, so it must be labelled an estimate.
|
||||
let result = FuelSavingsEstimator.availability(
|
||||
station: candidate,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: settings,
|
||||
amount: amount,
|
||||
journey: straightLine(7.0),
|
||||
baselineJourney: FuelSavingsJourney(miles: 2.0, basis: .roadRoute)
|
||||
)?.result
|
||||
|
||||
XCTAssertEqual(result?.distanceBasis, .straightLine)
|
||||
}
|
||||
|
||||
func testStationWithoutAPriceIsUnavailable() {
|
||||
let baseline = station("baseline", price: 145.9)
|
||||
let noPrice = station("no-price", price: nil)
|
||||
|
||||
let availability = FuelSavingsEstimator.availability(
|
||||
station: noPrice,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: settings,
|
||||
amount: amount,
|
||||
journey: straightLine(4.0),
|
||||
baselineJourney: straightLine(2.0)
|
||||
)
|
||||
|
||||
XCTAssertEqual(availability, .unavailable("No price"))
|
||||
}
|
||||
|
||||
func testFeatureOffProducesNoSavingsRow() {
|
||||
let baseline = station("baseline", price: 145.9)
|
||||
let candidate = station("candidate", price: 138.9)
|
||||
|
||||
let availability = FuelSavingsEstimator.availability(
|
||||
station: candidate,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: .defaults,
|
||||
amount: amount,
|
||||
journey: straightLine(4.0),
|
||||
baselineJourney: straightLine(2.0)
|
||||
)
|
||||
|
||||
XCTAssertNil(availability)
|
||||
}
|
||||
|
||||
// MARK: - Required wording and baseline labelling
|
||||
|
||||
private var briefSettings: FuelSavingsSettings {
|
||||
FuelSavingsSettings(enabled: true,
|
||||
mpg: 30,
|
||||
preferredAmount: FuelSavingsFillAmount(value: 60, unit: .pounds))
|
||||
}
|
||||
|
||||
private var briefAmount: FuelSavingsFillAmount {
|
||||
FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||
}
|
||||
|
||||
/// The scenario from the brief: 30 MPG, £60 fill, baseline Rontec Shelf
|
||||
/// (171.9p, 0.4 mi), candidate Shell Whetley Hill (167.9p, 3.8 mi).
|
||||
///
|
||||
/// Whetley Hill is 4p/litre cheaper, yet it is WORSE OFF — and that is
|
||||
/// correct, not a calculation bug: £60 buys 34.90 L at 171.9p, so the
|
||||
/// cheaper pump saves 34.90 × 4p = £1.40, while the extra 6.8 return miles
|
||||
/// burn 6.8/30 × 4.54609 = 1.03 L at 167.9p = £1.73. Net ≈ −£0.33.
|
||||
func testCheaperStationFurtherAwayIsWorseOffAfterTravel() {
|
||||
let baseline = station("rontec-shelf", price: 171.9)
|
||||
let candidate = station("shell-whetley-hill", price: 167.9)
|
||||
|
||||
let availability = FuelSavingsEstimator.availability(
|
||||
station: candidate,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: briefSettings,
|
||||
amount: briefAmount,
|
||||
journey: straightLine(7.6), // 3.8 mi out and back
|
||||
baselineJourney: straightLine(0.8) // 0.4 mi out and back
|
||||
)
|
||||
|
||||
guard let result = availability?.result else {
|
||||
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||||
}
|
||||
XCTAssertEqual(result.fillLitres, 34.904, accuracy: 0.005,
|
||||
"the £60 budget buys litres at the BASELINE pump price")
|
||||
XCTAssertEqual(result.grossSavingGBP, 1.396, accuracy: 0.005)
|
||||
XCTAssertEqual(result.extraDistanceMiles, 6.8, accuracy: 0.001,
|
||||
"only the return-trip DELTA counts — the shared journey is never counted twice")
|
||||
XCTAssertEqual(result.extraDrivingCostGBP, 1.730, accuracy: 0.005)
|
||||
XCTAssertEqual(result.netSavingGBP, -0.334, accuracy: 0.005)
|
||||
XCTAssertEqual(result.status, .notWorthTheDrive)
|
||||
XCTAssertEqual(result.headline(compact: true), "£0.33 worse off after travel (est.)")
|
||||
}
|
||||
|
||||
func testMeasuredRoadRoutesDropTheEstimateMarker() {
|
||||
let baseline = station("baseline", price: 171.9)
|
||||
let candidate = station("candidate", price: 167.9)
|
||||
|
||||
let availability = FuelSavingsEstimator.availability(
|
||||
station: candidate,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: briefSettings,
|
||||
amount: briefAmount,
|
||||
journey: FuelSavingsJourney(miles: 7.6, basis: .roadRoute),
|
||||
baselineJourney: FuelSavingsJourney(miles: 0.8, basis: .roadRoute)
|
||||
)
|
||||
|
||||
XCTAssertEqual(availability?.result?.distanceBasis, .roadRoute)
|
||||
XCTAssertEqual(availability?.result?.headline(compact: true), "£0.33 worse off after travel")
|
||||
XCTAssertEqual(availability?.result?.headline(), "£0.33 worse off after travel")
|
||||
}
|
||||
|
||||
func testBaselineRowIsLabelledClosestStation() {
|
||||
let baseline = station("nearest", price: 171.9)
|
||||
|
||||
let availability = FuelSavingsEstimator.availability(
|
||||
station: baseline,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: briefSettings,
|
||||
amount: briefAmount,
|
||||
journey: straightLine(0.8),
|
||||
baselineJourney: straightLine(0.8)
|
||||
)
|
||||
|
||||
XCTAssertEqual(availability?.result?.status, .baseline)
|
||||
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station")
|
||||
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station")
|
||||
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
|
||||
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
|
||||
}
|
||||
|
||||
func testPositiveResultHeadlineSaysSaveAfterTravel() {
|
||||
// 4p cheaper AND almost the same distance, so the saving survives travel.
|
||||
let baseline = station("baseline", price: 160.0)
|
||||
let candidate = station("candidate", price: 150.0)
|
||||
|
||||
let availability = FuelSavingsEstimator.availability(
|
||||
station: candidate,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: briefSettings,
|
||||
amount: briefAmount,
|
||||
journey: straightLine(6.4),
|
||||
baselineJourney: straightLine(6.0)
|
||||
)
|
||||
|
||||
guard let result = availability?.result else {
|
||||
return XCTFail("expected an estimate, got \(String(describing: availability))")
|
||||
}
|
||||
XCTAssertEqual(result.status, .worthTheDrive)
|
||||
// £60 buys 37.5 L at 160p; 10p/litre cheaper saves £3.75; 0.4 extra
|
||||
// return miles cost ~£0.09. Net ≈ £3.66.
|
||||
XCTAssertEqual(result.netSavingGBP, 3.659, accuracy: 0.005)
|
||||
XCTAssertEqual(result.headline(compact: true), "Save £3.66 after travel (est.)")
|
||||
}
|
||||
|
||||
func testNeutralResultHeadlineIsBreakEven() {
|
||||
// 0.1p cheaper over the same distance: inside the break-even tolerance.
|
||||
let baseline = station("baseline", price: 150.0)
|
||||
let candidate = station("candidate", price: 149.9)
|
||||
|
||||
let availability = FuelSavingsEstimator.availability(
|
||||
station: candidate,
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: briefSettings,
|
||||
amount: briefAmount,
|
||||
journey: straightLine(6.0),
|
||||
baselineJourney: straightLine(6.0)
|
||||
)
|
||||
|
||||
XCTAssertEqual(availability?.result?.status, .breakEven)
|
||||
XCTAssertEqual(availability?.result?.headline(compact: true), "Break even")
|
||||
XCTAssertEqual(availability?.result?.headline(), "Break even")
|
||||
}
|
||||
|
||||
/// The exact strings the brief asks for.
|
||||
func testHeadlineWordingMatchesTheBrief() {
|
||||
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station")
|
||||
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
|
||||
|
||||
let saving = FuelSavingsResult(fillLitres: 34.9,
|
||||
grossSavingGBP: 1.40,
|
||||
extraDrivingCostGBP: 0.88,
|
||||
extraDistanceMiles: 3.5,
|
||||
netSavingGBP: 0.52,
|
||||
status: .worthTheDrive,
|
||||
distanceBasis: .roadRoute)
|
||||
XCTAssertEqual(saving.headline(), "Save £0.52 after travel")
|
||||
|
||||
let loss = FuelSavingsResult(fillLitres: 34.9,
|
||||
grossSavingGBP: 1.40,
|
||||
extraDrivingCostGBP: 1.71,
|
||||
extraDistanceMiles: 6.8,
|
||||
netSavingGBP: -0.31,
|
||||
status: .notWorthTheDrive,
|
||||
distanceBasis: .roadRoute)
|
||||
XCTAssertEqual(loss.headline(), "£0.31 worse off after travel")
|
||||
}
|
||||
|
||||
/// The sort must order by NET saving, not pump price: a pricier station can
|
||||
/// win when the cheaper one is far enough away.
|
||||
func testSortingRanksByNetSavingNotPumpPrice() {
|
||||
let baseline = station("baseline", price: 171.9)
|
||||
let cheapButFar = station("cheap-but-far", price: 167.9)
|
||||
let pricierButNear = station("pricier-but-near", price: 169.9)
|
||||
|
||||
let ranked = FuelSavingsEstimator.ranked(
|
||||
[baseline, cheapButFar, pricierButNear],
|
||||
fuel: .e10,
|
||||
baseline: baseline,
|
||||
settings: briefSettings,
|
||||
amount: briefAmount,
|
||||
journeyMiles: { s in
|
||||
switch s.id {
|
||||
case "baseline": return self.straightLine(0.8)
|
||||
case "cheap-but-far": return self.straightLine(7.6)
|
||||
default: return self.straightLine(1.2)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
XCTAssertEqual(ranked.first?.id, "pricier-but-near",
|
||||
"net saving decides the order, not the pump price")
|
||||
XCTAssertEqual(ranked.last?.id, "cheap-but-far")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import XCTest
|
||||
#if SWIFT_PACKAGE
|
||||
@testable import FuelBoardShared
|
||||
#endif
|
||||
|
||||
final class FuelSavingsRoutingCacheTests: XCTestCase {
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
FuelStore.saveRoadDistances(sourceLat: 0, sourceLng: 0, entries: [:])
|
||||
}
|
||||
|
||||
private func station(_ id: String, _ lat: Double, _ lng: Double) -> FuelStation {
|
||||
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "", lat: lat, lng: lng, prices: [.e10: 140], priceUpdated: nil)
|
||||
}
|
||||
|
||||
func testJourneyDistanceUsesSeparateOutAndBackRoadRoutes() {
|
||||
let s = station("a", 51.5074, -0.1278)
|
||||
FuelStore.saveRoadDistances(
|
||||
sourceLat: 51.6,
|
||||
sourceLng: -0.1,
|
||||
entries: ["a": CachedRoadDistance(meters: 3_200, lat: 51.5074, lng: -0.1278, returnMeters: 3_700)]
|
||||
)
|
||||
|
||||
XCTAssertEqual(FuelStore.roadJourneyMeters(for: s, userLat: 51.6, userLng: -0.1), 6_900)
|
||||
}
|
||||
|
||||
func testLegacyOneWayCacheDoublesForJourneyDistance() {
|
||||
let s = station("a", 51.5074, -0.1278)
|
||||
FuelStore.saveRoadDistances(
|
||||
sourceLat: 51.6,
|
||||
sourceLng: -0.1,
|
||||
entries: ["a": CachedRoadDistance(meters: 3_200, lat: 51.5074, lng: -0.1278)]
|
||||
)
|
||||
|
||||
XCTAssertEqual(FuelStore.roadJourneyMeters(for: s, userLat: 51.6, userLng: -0.1), 6_400)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import XCTest
|
||||
#if SWIFT_PACKAGE
|
||||
@testable import FuelBoardShared
|
||||
#endif
|
||||
|
||||
final class FuelSavingsCalculatorTests: XCTestCase {
|
||||
func testUKMPGConversionUsesImperialGallons() {
|
||||
XCTAssertEqual(FuelSavingsCalculator.fuelUsedLitres(distanceMiles: 10, mpg: 45), 1.0102, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testPoundsBudgetConvertsToLitresUsingBaselinePrice() throws {
|
||||
let amount = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
|
||||
XCTAssertEqual(litres, 25.0, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testLitresAmountPassesThrough() throws {
|
||||
let amount = FuelSavingsFillAmount(value: 35, unit: .litres)
|
||||
let litres = try FuelSavingsCalculator.fillLitres(amount: amount, baselinePricePencePerLitre: 160)
|
||||
XCTAssertEqual(litres, 35, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testNetSavingSubtractsAdditionalDrivingFuelCost() throws {
|
||||
let result = try FuelSavingsCalculator.calculate(
|
||||
mpg: 45,
|
||||
amount: FuelSavingsFillAmount(value: 40, unit: .pounds),
|
||||
baselinePricePencePerLitre: 160,
|
||||
candidatePricePencePerLitre: 150,
|
||||
baselineJourneyMiles: 2,
|
||||
candidateJourneyMiles: 8
|
||||
)
|
||||
XCTAssertEqual(result.fillLitres, 25, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.grossSavingGBP, 2.50, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.extraDistanceMiles, 6, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.extraDrivingCostGBP, 0.9092, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.netSavingGBP, 1.5908, accuracy: 0.0001)
|
||||
XCTAssertEqual(result.status, .worthTheDrive)
|
||||
}
|
||||
|
||||
func testNegativeAndBreakEvenOutcomes() throws {
|
||||
let negative = try FuelSavingsCalculator.calculate(
|
||||
mpg: 45,
|
||||
amount: FuelSavingsFillAmount(value: 10, unit: .litres),
|
||||
baselinePricePencePerLitre: 150,
|
||||
candidatePricePencePerLitre: 149,
|
||||
baselineJourneyMiles: 1,
|
||||
candidateJourneyMiles: 20
|
||||
)
|
||||
XCTAssertEqual(negative.status, .notWorthTheDrive)
|
||||
XCTAssertLessThan(negative.netSavingGBP, 0)
|
||||
|
||||
let even = try FuelSavingsCalculator.calculate(
|
||||
mpg: 45,
|
||||
amount: FuelSavingsFillAmount(value: 10, unit: .litres),
|
||||
baselinePricePencePerLitre: 150,
|
||||
candidatePricePencePerLitre: 150,
|
||||
baselineJourneyMiles: 2,
|
||||
candidateJourneyMiles: 2
|
||||
)
|
||||
XCTAssertEqual(even.status, .breakEven)
|
||||
}
|
||||
|
||||
func testInvalidInputsRejected() {
|
||||
XCTAssertThrowsError(try FuelSavingsCalculator.fillLitres(amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 0))
|
||||
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 0, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
|
||||
XCTAssertThrowsError(try FuelSavingsCalculator.calculate(mpg: 500, amount: FuelSavingsFillAmount(value: 40, unit: .pounds), baselinePricePencePerLitre: 160, candidatePricePencePerLitre: 150, baselineJourneyMiles: 1, candidateJourneyMiles: 2))
|
||||
}
|
||||
|
||||
func testSwitchingPoundsToLitresPreservesPhysicalQuantity() throws {
|
||||
let pounds = FuelSavingsFillAmount(value: 40, unit: .pounds)
|
||||
let litres = try FuelSavingsCalculator.convert(amount: pounds, to: .litres, referencePricePencePerLitre: 160)
|
||||
XCTAssertEqual(litres.unit, .litres)
|
||||
XCTAssertEqual(litres.value, 25, accuracy: 0.0001)
|
||||
let back = try FuelSavingsCalculator.convert(amount: litres, to: .pounds, referencePricePencePerLitre: 160)
|
||||
XCTAssertEqual(back.value, 40, accuracy: 0.0001)
|
||||
}
|
||||
|
||||
func testSortingPlacesUnavailableAfterSavingsAndBreaksTies() {
|
||||
let a = FuelSavingsRankedStation(stationID: "a", result: .available(.init(fillLitres: 25, grossSavingGBP: 2, extraDrivingCostGBP: 1, extraDistanceMiles: 3, netSavingGBP: 1, status: .worthTheDrive)), distanceMiles: 5, pricePencePerLitre: 150)
|
||||
let b = FuelSavingsRankedStation(stationID: "b", result: .available(.init(fillLitres: 25, grossSavingGBP: 3, extraDrivingCostGBP: 1, extraDistanceMiles: 2, netSavingGBP: 2, status: .worthTheDrive)), distanceMiles: 8, pricePencePerLitre: 149)
|
||||
let c = FuelSavingsRankedStation(stationID: "c", result: .unavailable("No route"), distanceMiles: 1, pricePencePerLitre: 140)
|
||||
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([a, c, b]).map(\.stationID), ["b", "a", "c"])
|
||||
}
|
||||
}
|
||||
|
||||
final class FuelSavingsSettingsTests: XCTestCase {
|
||||
func testSettingsValidationRequiresEnabledMPGAndFillAmount() {
|
||||
XCTAssertFalse(FuelSavingsSettings(enabled: false, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||
XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 0, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||
XCTAssertFalse(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 0, unit: .pounds)).isConfigured)
|
||||
XCTAssertTrue(FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds)).isConfigured)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import XCTest
|
||||
@testable import FuelBoardShared
|
||||
|
||||
/// Shared payload storage must survive an install whose provisioning profile
|
||||
/// has NO `com.apple.security.application-groups` entitlement (free/personal
|
||||
/// team and SideStore installs) — the exact situation this test runner is in.
|
||||
///
|
||||
/// Regression guard: `appGroupFileURL` used to return nil without a group
|
||||
/// container, so every save silently failed and every load returned empty. That
|
||||
/// left `loadStations()` permanently empty (letting the freshness gate skip the
|
||||
/// fetch for ever, i.e. a blank station list) and re-downloaded the ~2.8 MB
|
||||
/// dump on every refresh. Storage now falls back to the app's own container.
|
||||
final class FuelStoreFileCacheTests: XCTestCase {
|
||||
|
||||
func testSharedFileURLExistsWithoutAppGroupContainer() {
|
||||
XCTAssertNotNil(
|
||||
FuelStore.appGroupFileURL("fuelboard.test.probe.json"),
|
||||
"storage must resolve to a writable location even when the App Group container is unprovisioned"
|
||||
)
|
||||
}
|
||||
|
||||
func testSharedFileRoundTripsWithoutAppGroupContainer() throws {
|
||||
let name = "fuelboard.test.roundtrip.json"
|
||||
let payload = Data("hello-cache".utf8)
|
||||
|
||||
XCTAssertTrue(
|
||||
FuelStore.saveAppGroupFile(payload, name: name),
|
||||
"save must succeed without an App Group container"
|
||||
)
|
||||
XCTAssertEqual(FuelStore.loadAppGroupFile(name), payload)
|
||||
|
||||
if let url = FuelStore.appGroupFileURL(name) {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
/// The dump cache is what stops a ~2.8 MB re-download on every refresh, so
|
||||
/// it must round-trip too — not just the station list.
|
||||
func testDumpCacheRoundTripsWithoutAppGroupContainer() {
|
||||
let day = "2026-01-01"
|
||||
let data = Data(repeating: 7, count: 4096)
|
||||
|
||||
MirrorFuelProvider.saveDumpCache(day: day, data: data)
|
||||
let cache = MirrorFuelProvider.loadDumpCache()
|
||||
|
||||
XCTAssertEqual(cache?.day, day, "cached dump day must survive a round-trip")
|
||||
XCTAssertEqual(cache?.data, data, "cached dump payload must survive a round-trip")
|
||||
|
||||
if let url = FuelStore.appGroupFileURL(MirrorFuelProvider.liveDumpCacheFileName) {
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
}
|
||||
|
||||
/// The road-distance cache is memoized in memory because re-reading it per
|
||||
/// station (it lives in the keychain) stalled the main thread past the iOS
|
||||
/// launch watchdog. A save must therefore invalidate the memo — a stale
|
||||
/// in-memory cache would show wrong distances after a route refresh.
|
||||
func testRoadDistanceMemoRefreshesAfterSave() {
|
||||
FuelStore.saveRoadDistances(sourceLat: 51.0, sourceLng: -1.0, entries: [:])
|
||||
XCTAssertEqual(FuelStore.loadRoadDistances()?.sourceLat, 51.0)
|
||||
|
||||
FuelStore.saveRoadDistances(sourceLat: 52.0, sourceLng: -2.0, entries: [:])
|
||||
|
||||
XCTAssertEqual(
|
||||
FuelStore.loadRoadDistances()?.sourceLat, 52.0,
|
||||
"load must reflect the newest save, not a stale memoized cache"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user