Make station price age informational only
The relay's price_updated is the maximum price_last_updated across the grades it maps, so it cannot vouch for the selected grade: stop using it to score, gate or warn about Best Value. - FuelPriceFreshness is now station-level and display-only: one age per station, fresh/outdated/unknown/invalid, "May be outdated" past 30 days, with future-tolerance and non-finite guards. - Best Value restores the closest-station-selling-the-selected-fuel baseline and its savings ranking; eligibility gating, eligible-first sorting and the unavailable banner are gone. - Price age appears in the More info sheet only - no listing-row warnings, no extra banners. - Copy reverted from "closest eligible station" to "closest station"; stale price-confidence strings removed, "May be outdated" added. - Dormant per-grade timestamp plumbing removed, and regression tests added for every fuel type, missing/invalid/future stamps and the bundled offline dump.
This commit is contained in:
@@ -3,28 +3,41 @@ import XCTest
|
||||
@testable import FuelBoardShared
|
||||
#endif
|
||||
|
||||
/// Station-level price age — one timestamp per station, reported in More info
|
||||
/// and nowhere else.
|
||||
///
|
||||
/// The relay's `price_updated` is the newest `price_last_updated` across every
|
||||
/// grade a station maps, so it is not evidence about the selected grade. These
|
||||
/// tests pin what the app *reports*; `StationPriceAgePolicyTests` pins what the
|
||||
/// app does about it (nothing).
|
||||
final class FuelPriceFreshnessTests: XCTestCase {
|
||||
private let now = Date(timeIntervalSince1970: 1_792_022_400)
|
||||
private let now = Date(timeIntervalSince1970: 1_800_000_000)
|
||||
|
||||
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
|
||||
)
|
||||
private func at(_ offset: TimeInterval) -> TimeInterval {
|
||||
now.addingTimeInterval(offset).timeIntervalSince1970
|
||||
}
|
||||
|
||||
func testPerGradeTimestampIsDecodedAndEligibleWhenFresh() throws {
|
||||
private func station(priceUpdated: TimeInterval?) -> FuelStation {
|
||||
FuelStation(id: "freshness-station", name: "Freshness Station", brand: "Test",
|
||||
address: "1 High Street", postcode: "BD1 1AA", lat: 53.79, lng: -1.75,
|
||||
prices: [.e10: 169.9], priceUpdated: priceUpdated)
|
||||
}
|
||||
|
||||
// MARK: - One figure per station, whatever fuel is on screen
|
||||
|
||||
func testOneAgeIsReportedForEveryFuelAStationSells() {
|
||||
let sold = FuelStation(id: "multi", name: "Multi", brand: "", address: "", postcode: "",
|
||||
lat: 53, lng: -1, prices: [.e5: 165.9, .e10: 169.9, .diesel: 129.9],
|
||||
priceUpdated: at(-28 * 60 * 60))
|
||||
|
||||
for fuel in FuelType.allCases {
|
||||
XCTAssertEqual(FuelPriceFreshness.assessment(for: sold, now: now).updateAgeLabel,
|
||||
"28h ago",
|
||||
"\(fuel.rawValue): every grade reads the same station timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func testStationTimestampIsDecodedAndNoPerGradeTimestampIsInvented() throws {
|
||||
let json = """
|
||||
{
|
||||
"stations": [{
|
||||
@@ -36,207 +49,122 @@ final class FuelPriceFreshnessTests: XCTestCase {
|
||||
"lat": 53.78,
|
||||
"lng": -1.75,
|
||||
"prices": {"E10": 169.9, "DIESEL": 129.9},
|
||||
"price_updated": "2026-09-25T07:45:52Z",
|
||||
"price_updated": "2026-10-14T07:45:52Z",
|
||||
"price_updated_by_grade": {
|
||||
"E10": "2026-10-14T07:45:52Z",
|
||||
"DIESEL": "2026-10-14T07:45:52Z"
|
||||
"E10": "2026-01-01T00:00:00Z",
|
||||
"DIESEL": "2026-01-01T00:00:00Z"
|
||||
}
|
||||
}]
|
||||
}
|
||||
"""
|
||||
let stations = try FuelPriceProvider.decodeStations(from: Data(json.utf8))
|
||||
let decoded = try XCTUnwrap(stations.first)
|
||||
let decoded = try XCTUnwrap(try FuelPriceProvider.decodeStations(from: Data(json.utf8)).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(decoded.priceUpdated,
|
||||
ISO8601DateFormatter.fuelBoardDate("2026-10-14T07:45:52Z")?.timeIntervalSince1970,
|
||||
"the station-level field is what the app records")
|
||||
// A per-grade map in the payload is ignored: the app has one age, and it
|
||||
// is the station's.
|
||||
let assessment = FuelPriceFreshness.assessment(for: decoded, now: now)
|
||||
XCTAssertEqual(assessment.status, .outdated,
|
||||
"the station field is used, not the 2026-01-01 grade entries")
|
||||
}
|
||||
|
||||
// MARK: - Reading the age
|
||||
|
||||
func testFreshAgeReadsInMinutesThenHours() {
|
||||
XCTAssertEqual(FuelPriceFreshness.assessment(priceUpdated: at(-59 * 60), now: now).updateAgeLabel,
|
||||
"59m ago")
|
||||
XCTAssertEqual(FuelPriceFreshness.assessment(priceUpdated: at(-60 * 60), now: now).updateAgeLabel,
|
||||
"1h ago")
|
||||
XCTAssertEqual(FuelPriceFreshness.assessment(priceUpdated: at(-47 * 60 * 60), now: now).updateAgeLabel,
|
||||
"47h ago")
|
||||
XCTAssertEqual(FuelPriceFreshness.assessment(priceUpdated: at(-48 * 60 * 60), now: now).updateAgeLabel,
|
||||
"2d ago")
|
||||
}
|
||||
|
||||
func testFreshInformationIsNeverFlagged() {
|
||||
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: at(-28 * 60 * 60)), now: now)
|
||||
XCTAssertEqual(assessment.status, .fresh)
|
||||
XCTAssertTrue(assessment.isEligibleForBestValue)
|
||||
XCTAssertFalse(assessment.isOutdated)
|
||||
XCTAssertNil(assessment.outdatedNotice)
|
||||
XCTAssertEqual(assessment.age, 28 * 60 * 60)
|
||||
}
|
||||
|
||||
func testStationWideTimestampIsNotPresentedAsGradeSpecificOrEligible() {
|
||||
let updated = now.addingTimeInterval(-FuelPriceFreshness.warningAge / 2).timeIntervalSince1970
|
||||
let assessment = FuelPriceFreshness.assessment(
|
||||
for: station(priceUpdated: updated), fuel: .diesel, now: now
|
||||
)
|
||||
// MARK: - The thirty-day display threshold
|
||||
|
||||
XCTAssertEqual(assessment.source, .stationWide)
|
||||
func testInformationExactlyThirtyDaysOldIsNotYetFlagged() {
|
||||
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(-FuelPriceFreshness.outdatedAge), now: now)
|
||||
XCTAssertEqual(assessment.status, .fresh)
|
||||
XCTAssertEqual(assessment.confidence, .provisional)
|
||||
XCTAssertTrue(assessment.recommendationEligibility == .eligible)
|
||||
XCTAssertTrue(assessment.isEligibleForBestValue)
|
||||
XCTAssertTrue(assessment.warning)
|
||||
XCTAssertTrue(assessment.displayLabel.contains("station-wide"))
|
||||
XCTAssertNil(assessment.outdatedNotice)
|
||||
}
|
||||
|
||||
func testMoreInfoReportsOneStationPriceAge() {
|
||||
let updated = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
|
||||
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: updated), fuel: .e10, now: now)
|
||||
XCTAssertEqual(assessment.updateAgeLabel, "28h ago")
|
||||
XCTAssertEqual(assessment.source, .stationWide)
|
||||
XCTAssertEqual(assessment.status, .fresh)
|
||||
func testInformationJustPastThirtyDaysIsFlaggedButStillReportsItsAge() {
|
||||
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(-FuelPriceFreshness.outdatedAge - 1), now: now)
|
||||
XCTAssertEqual(assessment.status, .outdated)
|
||||
XCTAssertTrue(assessment.isOutdated)
|
||||
XCTAssertEqual(assessment.outdatedNotice, "May be outdated")
|
||||
XCTAssertEqual(assessment.updateAgeLabel, "30d ago")
|
||||
}
|
||||
|
||||
func testOlderFuelSpecificTimestampStillReportsItsOwnAgeOnce() {
|
||||
let updated = now.addingTimeInterval(-10 * 86_400).timeIntervalSince1970
|
||||
let priced = station(priceUpdatedByFuel: [.diesel: updated])
|
||||
let assessment = FuelPriceFreshness.assessment(for: priced, fuel: .diesel, now: now)
|
||||
XCTAssertEqual(assessment.updateAgeLabel, "10d ago")
|
||||
XCTAssertEqual(assessment.source, .gradeSpecific)
|
||||
func testLongOldInformationIsFlaggedWithItsDayCount() {
|
||||
// The oldest figure the mirror carries for a real station is ~250 days.
|
||||
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(-250 * 86_400), now: now)
|
||||
XCTAssertEqual(assessment.status, .outdated)
|
||||
XCTAssertEqual(assessment.updateAgeLabel, "250d ago")
|
||||
XCTAssertEqual(assessment.outdatedNotice, "May be outdated")
|
||||
}
|
||||
|
||||
func testMissingUpdateTimeReadsNotProvided() {
|
||||
let assessment = FuelPriceFreshness.assessment(for: station(), fuel: .e10, now: now)
|
||||
// MARK: - Missing, malformed, future and absurd timestamps
|
||||
|
||||
func testMissingTimestampReadsNotProvidedAndIsNeverFlagged() {
|
||||
let assessment = FuelPriceFreshness.assessment(for: station(priceUpdated: nil), now: now)
|
||||
XCTAssertEqual(assessment.status, .unknown)
|
||||
XCTAssertNil(assessment.age)
|
||||
XCTAssertEqual(assessment.updateAgeLabel, "Not provided")
|
||||
XCTAssertFalse(assessment.isOutdated)
|
||||
XCTAssertNil(assessment.outdatedNotice)
|
||||
}
|
||||
|
||||
func testProvisionalSavingAddsNoSecondWarningLine() {
|
||||
let recent = now.addingTimeInterval(-28 * 60 * 60).timeIntervalSince1970
|
||||
let stationWide = station(priceUpdated: recent)
|
||||
let gradeSpecific = station(priceUpdatedByFuel: [.e10: recent])
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonConfidence(
|
||||
station: gradeSpecific, fuel: .e10, baseline: stationWide, now: now), .provisional)
|
||||
XCTAssertNil(FuelPriceFreshness.comparisonWarning(
|
||||
station: stationWide, fuel: .e10, baseline: stationWide, now: now))
|
||||
XCTAssertNil(FuelPriceFreshness.comparisonWarning(
|
||||
station: gradeSpecific, fuel: .e10, baseline: stationWide, now: now))
|
||||
func testUnparseableSourceTextIsDroppedRatherThanGuessed() throws {
|
||||
XCTAssertNil(FuelPriceTimestamp.parse("not-a-date"))
|
||||
XCTAssertNil(FuelPriceTimestamp.parse(""))
|
||||
XCTAssertNil(FuelPriceTimestamp.parse(" "))
|
||||
|
||||
let json = """
|
||||
{"stations": [{"id": "bad", "name": "Bad", "brand": "", "address": "", "postcode": "",
|
||||
"lat": 53, "lng": -1, "prices": {"E10": 169.9}, "price_updated": "2026-99-99T99:99:99Z"}]}
|
||||
"""
|
||||
let decoded = try XCTUnwrap(try FuelPriceProvider.decodeStations(from: Data(json.utf8)).first)
|
||||
XCTAssertNil(decoded.priceUpdated)
|
||||
XCTAssertEqual(FuelPriceFreshness.assessment(for: decoded, now: now).updateAgeLabel, "Not provided")
|
||||
}
|
||||
|
||||
func testIneligibleComparisonStillExplainsItself() {
|
||||
let old = now.addingTimeInterval(-FuelPriceFreshness.staleAge - 1).timeIntervalSince1970
|
||||
let stale = station(priceUpdatedByFuel: [.diesel: old])
|
||||
XCTAssertEqual(FuelPriceFreshness.comparisonWarning(
|
||||
station: stale, fuel: .diesel, baseline: stale, now: now),
|
||||
"Price age unavailable or too old — not a Best Value recommendation.")
|
||||
func testSmallClockSkewReadsAsJustNowNotAsAFutureDate() {
|
||||
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(60), now: now)
|
||||
XCTAssertEqual(assessment.status, .fresh)
|
||||
XCTAssertEqual(assessment.age, 0)
|
||||
XCTAssertEqual(assessment.updateAgeLabel, "0m ago")
|
||||
}
|
||||
|
||||
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 testTimestampBeyondTheSkewToleranceIsUnusableNotOptimistic() {
|
||||
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(86_400), now: now)
|
||||
XCTAssertEqual(assessment.status, .invalid)
|
||||
XCTAssertNil(assessment.age)
|
||||
XCTAssertEqual(assessment.updateAgeLabel, "Not provided")
|
||||
XCTAssertNil(assessment.outdatedNotice)
|
||||
}
|
||||
|
||||
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 testNonFiniteAndAbsurdEpochsAreUnusableAndCannotTrap() {
|
||||
let values: [TimeInterval] = [.nan, .infinity, -.infinity,
|
||||
.greatestFiniteMagnitude, -.greatestFiniteMagnitude]
|
||||
for value in values {
|
||||
let assessment = FuelPriceFreshness.assessment(priceUpdated: value, now: now)
|
||||
XCTAssertEqual(assessment.status, .invalid, "\(value)")
|
||||
XCTAssertNil(assessment.age, "\(value)")
|
||||
XCTAssertEqual(assessment.updateAgeLabel, "Not provided", "\(value)")
|
||||
XCTAssertFalse(assessment.isOutdated, "\(value)")
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user