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:
FuelBoard Contributor
2026-10-16 09:48:01 +01:00
parent 5900a81473
commit e960ef85c6
19 changed files with 530 additions and 732 deletions
@@ -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 {