Best Value: baseline on the closest eligible price and gate on freshness
- Add FuelPriceFreshness: selected-fuel-aware eligibility plus a shared comparison confidence, so a saving is presented as verified only when both the candidate and the baseline pass the gate for the fuel on screen. - Rank against the closest station with an eligible selected-fuel price instead of the physically closest station, so a single stale neighbour can no longer invalidate every comparison in the list. Ordinary Closest sorting still includes every priced station. - Keep ineligible prices visible, fall back to pump-price order, and label Best Value unavailable when no comparison qualifies. - Widget: same baseline and confidence path as the app. - Tests: stale-closest baseline, alert baseline, eligibility policy, boundary and missing-timestamp regressions.
This commit is contained in:
@@ -0,0 +1 @@
|
||||
../../../Shared/FuelPriceFreshness.swift
|
||||
@@ -0,0 +1,243 @@
|
||||
import XCTest
|
||||
#if SWIFT_PACKAGE
|
||||
@testable import FuelBoardShared
|
||||
#endif
|
||||
|
||||
final class FuelPriceFreshnessTests: XCTestCase {
|
||||
private let now = Date(timeIntervalSince1970: 1_792_022_400)
|
||||
|
||||
private func station(
|
||||
priceUpdated: TimeInterval? = nil,
|
||||
priceUpdatedByFuel: [FuelType: TimeInterval]? = nil
|
||||
) -> FuelStation {
|
||||
FuelStation(
|
||||
id: "freshness-station",
|
||||
name: "Freshness Station",
|
||||
brand: "Test",
|
||||
address: "1 High Street",
|
||||
postcode: "BD1 1AA",
|
||||
lat: 53.79,
|
||||
lng: -1.75,
|
||||
prices: [.diesel: 129.9, .e10: 169.9],
|
||||
priceUpdated: priceUpdated,
|
||||
priceUpdatedByFuel: priceUpdatedByFuel
|
||||
)
|
||||
}
|
||||
|
||||
func testPerGradeTimestampIsDecodedAndEligibleWhenFresh() throws {
|
||||
let json = """
|
||||
{
|
||||
"stations": [{
|
||||
"id": "s1",
|
||||
"name": "LISTER ARMS",
|
||||
"brand": "Shell",
|
||||
"address": "MANCHESTER ROAD, BRADFORD",
|
||||
"postcode": "BD5 7JX",
|
||||
"lat": 53.78,
|
||||
"lng": -1.75,
|
||||
"prices": {"E10": 169.9, "DIESEL": 129.9},
|
||||
"price_updated": "2026-09-25T07:45:52Z",
|
||||
"price_updated_by_grade": {
|
||||
"E10": "2026-10-14T07:45:52Z",
|
||||
"DIESEL": "2026-10-14T07:45:52Z"
|
||||
}
|
||||
}]
|
||||
}
|
||||
"""
|
||||
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
|
||||
let decoded = try XCTUnwrap(stations.first)
|
||||
|
||||
XCTAssertEqual(decoded.priceUpdatedByFuel?[.diesel],
|
||||
ISO8601DateFormatter.fuelBoardDate("2026-10-14T07:45:52Z")?.timeIntervalSince1970)
|
||||
let assessment = FuelPriceFreshness.assessment(for: decoded, fuel: .diesel, now: now)
|
||||
XCTAssertEqual(assessment.source, .gradeSpecific)
|
||||
XCTAssertEqual(assessment.status, .fresh)
|
||||
XCTAssertTrue(assessment.isEligibleForBestValue)
|
||||
}
|
||||
|
||||
func testStationWideTimestampIsNotPresentedAsGradeSpecificOrEligible() {
|
||||
let updated = now.addingTimeInterval(-FuelPriceFreshness.warningAge / 2).timeIntervalSince1970
|
||||
let assessment = FuelPriceFreshness.assessment(
|
||||
for: station(priceUpdated: updated), fuel: .diesel, now: now
|
||||
)
|
||||
|
||||
XCTAssertEqual(assessment.source, .stationWide)
|
||||
XCTAssertEqual(assessment.status, .fresh)
|
||||
XCTAssertEqual(assessment.confidence, .provisional)
|
||||
XCTAssertTrue(assessment.recommendationEligibility == .eligible)
|
||||
XCTAssertTrue(assessment.isEligibleForBestValue)
|
||||
XCTAssertTrue(assessment.warning)
|
||||
XCTAssertTrue(assessment.displayLabel.contains("station-wide"))
|
||||
}
|
||||
|
||||
func testRecentStationReportExplainsSelectedFuelTimeIsMissing() {
|
||||
let updated = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
|
||||
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .e10, now: now)
|
||||
XCTAssertEqual(assessment.stationReportAgeLabel, "28h ago")
|
||||
XCTAssertEqual(assessment.selectedFuelUpdateLabel, "Not provided")
|
||||
XCTAssertEqual(assessment.source, .stationWide)
|
||||
XCTAssertEqual(assessment.status, .fresh)
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
|
||||
station: station(priceUpdated: updated), fuel: .e10,
|
||||
baseline: station(priceUpdated: updated), now: now),
|
||||
"Estimated saving · Unleaded price not individually dated")
|
||||
}
|
||||
|
||||
func testOlderFuelSpecificTimestampDoesNotClaimUndatedPrice() {
|
||||
let updated = now.addingTimeInterval(-10 * 86_400).timeIntervalSince1970
|
||||
let priced = station(priceUpdatedByFuel: [.diesel: updated])
|
||||
let assessment = FuelPriceFreshness.assessment(for: priced, fuel: .diesel, now: now)
|
||||
XCTAssertNil(assessment.stationReportAgeLabel)
|
||||
XCTAssertEqual(assessment.selectedFuelUpdateLabel, "10d ago")
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
|
||||
station: priced, fuel: .diesel, baseline: priced, now: now),
|
||||
"Estimated saving · Diesel price over 7 days old")
|
||||
}
|
||||
|
||||
func testProvisionalWarningNamesBaselineWhenCandidateHasFuelSpecificTimestamp() {
|
||||
let recent = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
|
||||
let candidate = station(priceUpdatedByFuel: [.e10: recent])
|
||||
let baseline = station(priceUpdated: recent)
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
|
||||
station: candidate, fuel: .e10, baseline: baseline, now: now),
|
||||
"Estimated saving · closest eligible station's Unleaded price not individually dated")
|
||||
}
|
||||
|
||||
func testStationWideTimestampAtWarningBoundaryChangesRecommendationEligibility() {
|
||||
let updated = now.addingTimeInterval(-FuelPriceFreshness.warningAge).timeIntervalSince1970
|
||||
let onBoundary = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .diesel, now: now)
|
||||
XCTAssertEqual(onBoundary.status, .fresh)
|
||||
XCTAssertTrue(onBoundary.isEligibleForBestValue)
|
||||
let pastBoundary = FuelPriceFreshness.assessment(
|
||||
for: station(priceUpdated: updated - 1), fuel: .diesel, now: now)
|
||||
XCTAssertEqual(pastBoundary.status, .warning)
|
||||
XCTAssertFalse(pastBoundary.isEligibleForBestValue)
|
||||
}
|
||||
|
||||
func testStationWideTimestampOlderThanWarningThresholdCannotRecommendSuspiciousDiesel() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let shell = station(priceUpdated: now.addingTimeInterval(-20 * 86_400).timeIntervalSince1970)
|
||||
let age = FuelPriceFreshness.assessment(for: shell, fuel: .diesel, now: now)
|
||||
XCTAssertEqual(age.status, .warning)
|
||||
XCTAssertFalse(age.isEligibleForBestValue)
|
||||
XCTAssertEqual(shell.prices[.diesel], 129.9)
|
||||
}
|
||||
|
||||
func testComparisonConfidenceRejectsMissingSelectedFuelPriceEvenWithFreshTimestamp() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let updated = now.addingTimeInterval(-86_400).timeIntervalSince1970
|
||||
let dieselOnly = FuelStation(id: "diesel", name: "Diesel", brand: "", address: "", postcode: "",
|
||||
lat: 53, lng: -1, prices: [.diesel: 129.9], priceUpdated: nil,
|
||||
priceUpdatedByFuel: [.e10: updated])
|
||||
let e10 = FuelStation(id: "petrol", name: "Petrol", brand: "", address: "", postcode: "",
|
||||
lat: 53, lng: -1, prices: [.e10: 169.9], priceUpdated: nil,
|
||||
priceUpdatedByFuel: [.e10: updated])
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: dieselOnly, fuel: .e10,
|
||||
baseline: e10, now: now), .ineligible)
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: e10, fuel: .e10,
|
||||
baseline: dieselOnly, now: now), .ineligible)
|
||||
}
|
||||
|
||||
func testComparisonConfidenceRequiresBothSelectedGradeTimestamps() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let recent = now.addingTimeInterval(-86_400).timeIntervalSince1970
|
||||
let old = now.addingTimeInterval(-20 * 86_400).timeIntervalSince1970
|
||||
let verified = station(priceUpdatedByFuel: [.diesel: recent])
|
||||
let provisional = station(priceUpdated: recent)
|
||||
let oldStationWide = station(priceUpdated: old)
|
||||
let oldGrade = station(priceUpdatedByFuel: [.diesel: old])
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .diesel,
|
||||
baseline: verified, now: now), .verified)
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .diesel,
|
||||
baseline: provisional, now: now), .provisional)
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: oldGrade, fuel: .diesel,
|
||||
baseline: verified, now: now), .provisional)
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .diesel,
|
||||
baseline: oldStationWide, now: now), .ineligible)
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: provisional, fuel: .diesel,
|
||||
baseline: verified, now: now), .provisional)
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(station: verified, fuel: .e10,
|
||||
baseline: verified, now: now), .ineligible)
|
||||
}
|
||||
|
||||
func testNonFiniteAndExtremeEpochsCannotBecomeTrusted() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
for value in [Double.nan, .infinity, -.infinity, Double.greatestFiniteMagnitude, -Double.greatestFiniteMagnitude] {
|
||||
let assessment = FuelPriceFreshness.assessment(
|
||||
priceUpdated: value, priceUpdatedByFuel: nil, fuel: .diesel, now: now)
|
||||
XCTAssertFalse(assessment.isEligibleForBestValue)
|
||||
XCTAssertTrue(assessment.warning)
|
||||
}
|
||||
}
|
||||
|
||||
func testMissingAndMalformedTimestampsAreUnknownAndIneligible() {
|
||||
let missing = FuelPriceFreshness.assessment(for: station(), fuel: .diesel, now: now)
|
||||
XCTAssertEqual(missing.status, .unknown)
|
||||
XCTAssertEqual(missing.source, .unknown)
|
||||
XCTAssertFalse(missing.isEligibleForBestValue)
|
||||
|
||||
let malformed = FuelPriceFreshness.assessment(
|
||||
priceUpdated: "not-a-date", priceUpdatedByGrade: nil, fuel: .diesel, now: now
|
||||
)
|
||||
XCTAssertEqual(malformed.status, .invalid)
|
||||
XCTAssertFalse(malformed.isEligibleForBestValue)
|
||||
}
|
||||
|
||||
func testStaleDisplayLabelDoesNotCallIneligiblePriceProvisional() {
|
||||
let stale = station(priceUpdatedByFuel: [
|
||||
.diesel: now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970
|
||||
])
|
||||
let assessment = FuelPriceFreshness.assessment(for: stale, fuel: .diesel, now: now)
|
||||
XCTAssertFalse(assessment.isEligibleForBestValue)
|
||||
XCTAssertFalse(assessment.displayLabel.contains("provisional"))
|
||||
XCTAssertTrue(assessment.displayLabel.contains("outdated"))
|
||||
}
|
||||
|
||||
func testStaleAndFutureGradeTimestampsAreRejected() {
|
||||
let stale = station(priceUpdatedByFuel: [
|
||||
.diesel: now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970
|
||||
])
|
||||
let staleAssessment = FuelPriceFreshness.assessment(for: stale, fuel: .diesel, now: now)
|
||||
XCTAssertEqual(staleAssessment.status, .stale)
|
||||
XCTAssertFalse(staleAssessment.isEligibleForBestValue)
|
||||
XCTAssertTrue(staleAssessment.warning)
|
||||
|
||||
let future = station(priceUpdatedByFuel: [
|
||||
.diesel: now.addingTimeInterval(FuelPriceFreshness.futureTolerance + 1).timeIntervalSince1970
|
||||
])
|
||||
let futureAssessment = FuelPriceFreshness.assessment(for: future, fuel: .diesel, now: now)
|
||||
XCTAssertEqual(futureAssessment.status, .invalid)
|
||||
XCTAssertFalse(futureAssessment.isEligibleForBestValue)
|
||||
}
|
||||
|
||||
func testMalformedAndFutureSourceValuesDoNotCrashOrProduceNegativeAge() {
|
||||
let malformed = FuelPriceFreshness.assessment(
|
||||
priceUpdated: "2026-99-99T99:99:99Z",
|
||||
priceUpdatedByGrade: ["DIESEL": "2026-99-99T99:99:99Z"],
|
||||
fuel: .diesel,
|
||||
now: now
|
||||
)
|
||||
XCTAssertNil(malformed.age)
|
||||
XCTAssertFalse(malformed.isEligibleForBestValue)
|
||||
|
||||
let future = FuelPriceFreshness.assessment(
|
||||
priceUpdated: now.addingTimeInterval(86_400).timeIntervalSince1970,
|
||||
priceUpdatedByFuel: nil,
|
||||
fuel: .diesel,
|
||||
now: now
|
||||
)
|
||||
XCTAssertNil(future.age)
|
||||
XCTAssertFalse(future.isEligibleForBestValue)
|
||||
}
|
||||
}
|
||||
|
||||
private extension ISO8601DateFormatter {
|
||||
static func fuelBoardDate(_ string: String) -> Date? {
|
||||
let formatter = ISO8601DateFormatter()
|
||||
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
return formatter.date(from: string) ?? {
|
||||
formatter.formatOptions = [.withInternetDateTime]
|
||||
return formatter.date(from: string)
|
||||
}()
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,42 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
|
||||
// MARK: - 1. Unit conversion
|
||||
|
||||
func testBudgetExamplesAcrossAllFuelTypes() throws {
|
||||
for fuel in FuelType.allCases {
|
||||
let base = FuelStation(id: "base", name: "Base", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [fuel: 199.9], priceUpdated: nil)
|
||||
let other = FuelStation(id: "other", name: "Other", brand: "", address: "", postcode: "", lat: 53, lng: -1, prices: [fuel: 129.9], priceUpdated: nil)
|
||||
let settings = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 40, unit: .pounds))
|
||||
for budget in [20.0, 40, 60, 73, 100] {
|
||||
let result = FuelSavingsEstimator.availability(station: other, fuel: fuel, baseline: base,
|
||||
settings: settings, amount: .init(value: budget, unit: .pounds),
|
||||
journey: .init(miles: 8.3, basis: .roadRoute),
|
||||
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
|
||||
let volume = budget / 1.299
|
||||
XCTAssertEqual(result?.fillLitres ?? 0, volume, accuracy: 1e-9)
|
||||
XCTAssertEqual(result?.grossSavingGBP ?? 0, volume * 0.7, accuracy: 1e-9)
|
||||
XCTAssertEqual(result?.netSavingGBP ?? 0, volume * 0.7 - 7.4 / 30 * 4.54609 * 1.299, accuracy: 1e-9)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testCandidateBudgetAndEqualQuantitySavingsForEveryFuel() throws {
|
||||
for fuel in FuelType.allCases {
|
||||
let candidate = FuelStation(id: "candidate-\(fuel.rawValue)", name: "Candidate", brand: "", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [fuel: 129.9], priceUpdated: nil)
|
||||
let baseline = FuelStation(id: "baseline-\(fuel.rawValue)", name: "Nearest", brand: "", address: "", postcode: "", lat: 53.7, lng: -1.8, prices: [fuel: 199.9], priceUpdated: nil)
|
||||
let configured = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 60, unit: .pounds))
|
||||
let money = FuelSavingsEstimator.availability(station: candidate, fuel: fuel, baseline: baseline, settings: configured,
|
||||
amount: .init(value: 60, unit: .pounds), journey: .init(miles: 8.3, basis: .roadRoute),
|
||||
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
|
||||
let litres = FuelSavingsEstimator.availability(station: candidate, fuel: fuel, baseline: baseline, settings: configured,
|
||||
amount: .init(value: 60 / 1.299, unit: .litres), journey: .init(miles: 8.3, basis: .roadRoute),
|
||||
baselineJourney: .init(miles: 0.9, basis: .roadRoute))?.result
|
||||
XCTAssertEqual(money?.fillLitres ?? 0, 60 / 1.299, accuracy: 1e-8, fuel.rawValue)
|
||||
XCTAssertEqual(money?.grossSavingGBP ?? 0, litres?.grossSavingGBP ?? -1, accuracy: 1e-8, fuel.rawValue)
|
||||
XCTAssertEqual(money?.netSavingGBP ?? 0, litres?.netSavingGBP ?? -1, accuracy: 1e-8, fuel.rawValue)
|
||||
XCTAssertEqual(money?.netSavingGBP ?? 0, (money?.grossSavingGBP ?? 0) - (money?.extraDrivingCostGBP ?? 0), accuracy: 1e-8, fuel.rawValue)
|
||||
}
|
||||
}
|
||||
|
||||
func testImperialGallonIsUsedNotTheUSGallon() {
|
||||
XCTAssertEqual(FuelSavingsCalculator.imperialGallonLitres, 4.54609)
|
||||
XCTAssertNotEqual(FuelSavingsCalculator.imperialGallonLitres, 3.785411784,
|
||||
@@ -497,6 +533,6 @@ final class FuelSavingsCalculationAuditTests: XCTestCase {
|
||||
)?.result
|
||||
XCTAssertEqual(result?.status, .baseline)
|
||||
XCTAssertEqual(result?.netSavingGBP, 0)
|
||||
XCTAssertEqual(result?.headline(), "Closest station")
|
||||
XCTAssertEqual(result?.headline(), "Closest eligible station")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,20 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
FuelSavingsJourney(miles: miles, basis: .straightLine)
|
||||
}
|
||||
|
||||
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"))
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -85,6 +99,24 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
"unmeasured routes are reported as straight-line estimates")
|
||||
}
|
||||
|
||||
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 testEveryPricedStationInThePoolIsEstimatedNotJustAShortlist() {
|
||||
// 40 stations, but only the first one has a measured road route — the
|
||||
// old implementation capped the whole ranking to 12 shortlisted
|
||||
@@ -159,6 +191,56 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
XCTAssertEqual(result?.distanceBasis, .straightLine)
|
||||
}
|
||||
|
||||
func testBestValueBaselineSkipsClosestStalePriceButKeepsThatStationVisible() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
|
||||
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
|
||||
}
|
||||
let staleClosest = priced("stale-closest", 199.9, 27)
|
||||
let eligibleClosest = priced("eligible-closest", 170.9, 1)
|
||||
let eligibleFurther = priced("eligible-further", 150.9, 1)
|
||||
let pool = [staleClosest, eligibleClosest, eligibleFurther]
|
||||
let journeys: (FuelStation) -> FuelSavingsJourney? = { station in
|
||||
self.straightLine(station.id == "stale-closest" ? 0.8 : station.id == "eligible-closest" ? 1.6 : 3.0)
|
||||
}
|
||||
let baseline = FuelSavingsEstimator.bestValueBaselineStation(pool, fuel: .e10,
|
||||
journeyMiles: journeys, now: now)
|
||||
XCTAssertEqual(baseline?.id, "eligible-closest")
|
||||
XCTAssertEqual(FuelSavingsEstimator.baselineStation(pool, fuel: .e10, journeyMiles: journeys)?.id,
|
||||
"stale-closest", "non-Best-Value closest behaviour must stay unchanged")
|
||||
let ranked = FuelSavingsEstimator.ranked(pool, fuel: .e10, baseline: baseline,
|
||||
settings: briefSettings, amount: briefAmount, journeyMiles: journeys, now: now)
|
||||
XCTAssertEqual(ranked.first?.id, "eligible-further")
|
||||
XCTAssertTrue(ranked.contains(where: { $0.id == "stale-closest" }))
|
||||
XCTAssertFalse(FuelSavingsEstimator.recommendationEligible(station: staleClosest, fuel: .e10,
|
||||
baseline: baseline, now: now))
|
||||
}
|
||||
|
||||
func testBestValueBaselineRequiresEligibleSelectedFuelAndUsableJourney() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let recent = now.addingTimeInterval(-86_400).timeIntervalSince1970
|
||||
let stale = now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970
|
||||
let stations = [
|
||||
FuelStation(id: "wrong-fuel", name: "Wrong", brand: "", address: "", postcode: "", lat: 0, lng: 0,
|
||||
prices: [.diesel: 140], priceUpdated: recent),
|
||||
FuelStation(id: "stale", name: "Stale", brand: "", address: "", postcode: "", lat: 0, lng: 0,
|
||||
prices: [.e10: 140], priceUpdated: stale),
|
||||
FuelStation(id: "bad-journey", name: "Bad journey", brand: "", address: "", postcode: "", lat: 0, lng: 0,
|
||||
prices: [.e10: 141], priceUpdated: recent),
|
||||
FuelStation(id: "eligible", name: "Eligible", brand: "", address: "", postcode: "", lat: 0, lng: 0,
|
||||
prices: [.e10: 145], priceUpdated: recent)
|
||||
]
|
||||
let journeys: (FuelStation) -> FuelSavingsJourney? = { station in
|
||||
self.straightLine(station.id == "wrong-fuel" ? 0.1 : station.id == "stale" ? 0.2 :
|
||||
station.id == "bad-journey" ? .infinity : 2)
|
||||
}
|
||||
XCTAssertEqual(FuelSavingsEstimator.bestValueBaselineStation(stations, fuel: .e10,
|
||||
journeyMiles: journeys, now: now)?.id, "eligible")
|
||||
XCTAssertNil(FuelSavingsEstimator.bestValueBaselineStation(Array(stations.prefix(2)), fuel: .e10,
|
||||
journeyMiles: journeys, now: now))
|
||||
}
|
||||
|
||||
func testBaselineStationUsesTheSameJourneyValuesAsSavings() {
|
||||
let geographicallyNearest = station("geographically-nearest", price: 145.9)
|
||||
let roadNearest = station("road-nearest", price: 146.9)
|
||||
@@ -350,8 +432,8 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
)
|
||||
|
||||
XCTAssertEqual(availability?.result?.status, .baseline)
|
||||
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest station")
|
||||
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest station")
|
||||
XCTAssertEqual(availability?.result?.headline(compact: true), "Closest eligible station")
|
||||
XCTAssertEqual(FuelSavingsStatus.baseline.displayName, "Closest eligible station")
|
||||
XCTAssertEqual(availability?.result?.netSavingGBP, 0)
|
||||
XCTAssertEqual(availability?.result?.extraDistanceMiles, 0)
|
||||
}
|
||||
@@ -403,7 +485,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
|
||||
/// The exact strings the brief asks for.
|
||||
func testHeadlineWordingMatchesTheBrief() {
|
||||
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest station")
|
||||
XCTAssertEqual(FuelSavingsResult.baselineHeadline, "Closest eligible station")
|
||||
XCTAssertEqual(FuelSavingsResult.breakEvenHeadline, "Break even")
|
||||
|
||||
let saving = FuelSavingsResult(fillLitres: 34.9,
|
||||
@@ -427,10 +509,87 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
|
||||
/// The sort must order by NET saving, not pump price: a pricier station can
|
||||
/// win when the cheaper one is far enough away.
|
||||
func testBestValueAlertUsesClosestEligiblePriceAsBaseline() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
|
||||
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
|
||||
}
|
||||
let stations = [priced("stale-closest", 199.9, 27), priced("eligible-closest", 170.9, 1),
|
||||
priced("eligible-cheaper", 150.9, 1)]
|
||||
let settings = FuelSavingsSettings(enabled: true, mpg: 30,
|
||||
preferredAmount: .init(value: 60, unit: .pounds))
|
||||
let alert = FuelSavingsCalculator.bestValueAlertCandidate(
|
||||
stations: stations, fuel: .e10, settings: settings, amount: settings.preferredAmount,
|
||||
minimumNetSavingGBP: 1,
|
||||
journeyMiles: { self.straightLine($0.id == "stale-closest" ? 0.8 : $0.id == "eligible-closest" ? 1.6 : 3) },
|
||||
now: now)
|
||||
XCTAssertEqual(alert?.station.id, "eligible-cheaper")
|
||||
}
|
||||
|
||||
func testBestValueAlertDoesNotRecommendStaleCandidateOrStaleBaseline() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
func priced(_ id: String, _ price: Double, _ daysOld: Double) -> FuelStation {
|
||||
FuelStation(id: id, name: id, brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||
prices: [.e10: price], priceUpdated: now.addingTimeInterval(-daysOld * 86_400).timeIntervalSince1970)
|
||||
}
|
||||
let base = priced("base", 199.9, 1)
|
||||
let stale = priced("stale", 129.9, 40)
|
||||
let fresh = priced("fresh", 159.9, 1)
|
||||
let settings = FuelSavingsSettings(enabled: true, mpg: 30, preferredAmount: .init(value: 60, unit: .pounds))
|
||||
let choice = FuelSavingsCalculator.bestValueAlertCandidate(
|
||||
stations: [base, stale, fresh], fuel: .e10, settings: settings,
|
||||
amount: .init(value: 60, unit: .pounds), minimumNetSavingGBP: 1,
|
||||
journeyMiles: { station in self.straightLine(station.id == "base" ? 0.8 : 2) }, now: now)
|
||||
XCTAssertEqual(choice?.station.id, "fresh")
|
||||
let noTrustedBaseline = FuelSavingsCalculator.bestValueAlertCandidate(
|
||||
stations: [priced("base", 199.9, 40), fresh], fuel: .e10, settings: settings,
|
||||
amount: .init(value: 60, unit: .pounds), minimumNetSavingGBP: 1,
|
||||
journeyMiles: { station in self.straightLine(station.id == "base" ? 0.8 : 2) }, now: now)
|
||||
XCTAssertNil(noTrustedBaseline)
|
||||
}
|
||||
|
||||
func testStalePriceCannotLeadBestValueButRemainsVisible() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let baseline = FuelStation(id: "base", name: "Baseline", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||
prices: [.e10: 199.9], priceUpdated: now.addingTimeInterval(-86_400).timeIntervalSince1970)
|
||||
let stale = FuelStation(id: "stale-cheap", name: "Stale", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||
prices: [.e10: 129.9], priceUpdated: now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970)
|
||||
let recent = FuelStation(id: "recent", name: "Recent", brand: "", address: "", postcode: "", lat: 53, lng: -1,
|
||||
prices: [.e10: 170.9], priceUpdated: now.addingTimeInterval(-86_400).timeIntervalSince1970)
|
||||
let ranked = FuelSavingsEstimator.ranked([stale, recent], fuel: .e10, baseline: baseline,
|
||||
settings: briefSettings, amount: briefAmount,
|
||||
journeyMiles: { _ in self.straightLine(0.8) }, now: now)
|
||||
XCTAssertEqual(ranked.map(\.id), ["recent", "stale-cheap"])
|
||||
}
|
||||
|
||||
func testNoFreshPriceComparisonUsesExplicitCheapestPriceFallback() {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let staleTime = now.addingTimeInterval(-40 * 86_400).timeIntervalSince1970
|
||||
let baseline = FuelStation(id: "baseline", name: "Baseline", brand: "", address: "", postcode: "",
|
||||
lat: 53, lng: -1, prices: [.diesel: 199.9], priceUpdated: staleTime)
|
||||
let cheapest = FuelStation(id: "cheapest", name: "Cheapest", brand: "", address: "", postcode: "",
|
||||
lat: 53, lng: -1, prices: [.diesel: 129.9], priceUpdated: staleTime)
|
||||
let near = FuelStation(id: "near", name: "Near", brand: "", address: "", postcode: "",
|
||||
lat: 53, lng: -1, prices: [.diesel: 170.9], priceUpdated: staleTime)
|
||||
let ordered = FuelSavingsEstimator.ranked([near, baseline, cheapest], fuel: .diesel,
|
||||
baseline: baseline, settings: briefSettings, amount: briefAmount,
|
||||
journeyMiles: { station in self.straightLine(station.id == "cheapest" ? 250 : 1) }, now: now)
|
||||
XCTAssertEqual(ordered.map(\.id), ["cheapest", "near", "baseline"])
|
||||
XCTAssertFalse(FuelSavingsEstimator.recommendationEligible(station: ordered[0], fuel: .diesel,
|
||||
baseline: baseline, now: now))
|
||||
}
|
||||
|
||||
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 now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
func pricedStation(_ id: String, price: Double) -> FuelStation {
|
||||
FuelStation(id: id, name: id, brand: "X", address: "", postcode: "",
|
||||
lat: 51.5, lng: -0.1, prices: [.e10: price],
|
||||
priceUpdated: nil, priceUpdatedByFuel: [.e10: now.timeIntervalSince1970])
|
||||
}
|
||||
let baseline = pricedStation("baseline", price: 171.9)
|
||||
let cheapButFar = pricedStation("cheap-but-far", price: 167.9)
|
||||
let pricierButNear = pricedStation("pricier-but-near", price: 169.9)
|
||||
|
||||
let ranked = FuelSavingsEstimator.ranked(
|
||||
[baseline, cheapButFar, pricierButNear],
|
||||
@@ -444,7 +603,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
case "cheap-but-far": return self.straightLine(7.6)
|
||||
default: return self.straightLine(1.2)
|
||||
}
|
||||
}
|
||||
}, now: now
|
||||
)
|
||||
|
||||
XCTAssertEqual(ranked.first?.id, "pricier-but-near",
|
||||
@@ -454,18 +613,18 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
|
||||
// MARK: Row copy
|
||||
|
||||
func testCompactComparisonLabelUsesGenericNearestStationWording() {
|
||||
func testCompactComparisonLabelUsesClosestEligibleStationWording() {
|
||||
XCTAssertEqual(
|
||||
FuelSavingsFormat.comparisonLabel(status: .worthTheDrive),
|
||||
"vs closest station"
|
||||
"vs closest eligible station"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
FuelSavingsFormat.comparisonLabel(status: .notWorthTheDrive),
|
||||
"vs closest station"
|
||||
"vs closest eligible station"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
FuelSavingsFormat.comparisonLabel(status: .breakEven),
|
||||
"vs closest station"
|
||||
"vs closest eligible station"
|
||||
)
|
||||
XCTAssertEqual(
|
||||
FuelSavingsFormat.comparisonLabel(status: .baseline),
|
||||
@@ -494,7 +653,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
}
|
||||
|
||||
/// The baseline capsule has to say what it IS: it previously repeated the
|
||||
/// status word above it ("Closest station" twice) and never explained that
|
||||
/// status word above it ("Closest eligible station" twice) and never explained that
|
||||
/// the savings are measured against it.
|
||||
func testCapsuleHeadlineMarksTheBaselineAndKeepsTheEstimateMarker() {
|
||||
let baseline = FuelSavingsResult(
|
||||
@@ -505,7 +664,7 @@ final class FuelSavingsEstimateTests: XCTestCase {
|
||||
netSavingGBP: 0,
|
||||
status: .baseline
|
||||
)
|
||||
XCTAssertEqual(baseline.capsuleHeadline(), "Closest station")
|
||||
XCTAssertEqual(baseline.capsuleHeadline(), "Closest eligible station")
|
||||
|
||||
let saving = FuelSavingsResult(
|
||||
fillLitres: 30,
|
||||
|
||||
@@ -110,10 +110,11 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
||||
}
|
||||
|
||||
func testBestValueAlertCandidatePicksHighestNetSavingAboveThreshold() throws {
|
||||
let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
let stations = [
|
||||
station("baseline", lat: 0, lng: 0, price: 160),
|
||||
station("small", lat: 0, lng: 0, price: 154),
|
||||
station("best", lat: 0, lng: 0, price: 148),
|
||||
station("baseline", lat: 0, lng: 0, price: 160, priceUpdated: now.timeIntervalSince1970),
|
||||
station("small", lat: 0, lng: 0, price: 154, priceUpdated: now.timeIntervalSince1970),
|
||||
station("best", lat: 0, lng: 0, price: 148, priceUpdated: now.timeIntervalSince1970),
|
||||
]
|
||||
let journeys = ["baseline": 2.0, "small": 4.0, "best": 6.0]
|
||||
let settings = FuelSavingsSettings(enabled: true, mpg: 45, preferredAmount: .init(value: 40, unit: .pounds))
|
||||
@@ -123,7 +124,8 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
||||
settings: settings,
|
||||
amount: settings.preferredAmount,
|
||||
minimumNetSavingGBP: 1.0,
|
||||
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } }
|
||||
journeyMiles: { journeys[$0.id].map { FuelSavingsJourney(miles: $0, basis: .roadRoute) } },
|
||||
now: now
|
||||
)
|
||||
XCTAssertEqual(candidate?.station.id, "best")
|
||||
XCTAssertEqual(candidate?.result.status, .worthTheDrive)
|
||||
@@ -137,12 +139,23 @@ final class FuelSavingsCalculatorTests: XCTestCase {
|
||||
XCTAssertEqual(FuelSavingsCalculator.sortedForWorthTheDrive([farther, nearer]).first?.stationID, "nearer")
|
||||
}
|
||||
|
||||
private func station(_ id: String, lat: Double, lng: Double, price: Double) -> FuelStation {
|
||||
FuelStation(id: id, name: id, brand: id, address: "", postcode: "", lat: lat, lng: lng, prices: [.e10: price], priceUpdated: nil)
|
||||
private func station(_ id: String, lat: Double, lng: Double, price: Double,
|
||||
priceUpdated: TimeInterval? = nil) -> FuelStation {
|
||||
FuelStation(id: id, name: id, brand: id, address: "", postcode: "", lat: lat, lng: lng,
|
||||
prices: [.e10: price], priceUpdated: priceUpdated)
|
||||
}
|
||||
}
|
||||
|
||||
final class FuelSavingsSettingsTests: XCTestCase {
|
||||
func testSwitchingFillUnitStartsAnExplicitNewComparison() {
|
||||
let budget = FuelSavingsFillAmount(value: 60, unit: .pounds)
|
||||
let litres = FuelSavingsCalculator.newComparisonAmount(afterSwitching: budget, to: .litres)
|
||||
XCTAssertEqual(litres, FuelSavingsFillAmount(value: 30, unit: .litres))
|
||||
XCTAssertEqual(FuelSavingsCalculator.newComparisonAmount(afterSwitching: litres, to: .pounds),
|
||||
FuelSavingsFillAmount(value: 40, unit: .pounds))
|
||||
XCTAssertEqual(FuelSavingsCalculator.newComparisonAmount(afterSwitching: budget, to: .pounds), budget)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user