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.
180 lines
8.0 KiB
Swift
180 lines
8.0 KiB
Swift
import XCTest
|
|
#if SWIFT_PACKAGE
|
|
@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_800_000_000)
|
|
|
|
private func at(_ offset: TimeInterval) -> TimeInterval {
|
|
now.addingTimeInterval(offset).timeIntervalSince1970
|
|
}
|
|
|
|
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": [{
|
|
"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-10-14T07:45:52Z",
|
|
"price_updated_by_grade": {
|
|
"E10": "2026-01-01T00:00:00Z",
|
|
"DIESEL": "2026-01-01T00:00:00Z"
|
|
}
|
|
}]
|
|
}
|
|
"""
|
|
let decoded = try XCTUnwrap(try FuelPriceProvider.decodeStations(from: Data(json.utf8)).first)
|
|
|
|
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)
|
|
XCTAssertFalse(assessment.isOutdated)
|
|
XCTAssertNil(assessment.outdatedNotice)
|
|
XCTAssertEqual(assessment.age, 28 * 60 * 60)
|
|
}
|
|
|
|
// MARK: - The thirty-day display threshold
|
|
|
|
func testInformationExactlyThirtyDaysOldIsNotYetFlagged() {
|
|
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(-FuelPriceFreshness.outdatedAge), now: now)
|
|
XCTAssertEqual(assessment.status, .fresh)
|
|
XCTAssertNil(assessment.outdatedNotice)
|
|
}
|
|
|
|
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 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")
|
|
}
|
|
|
|
// 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 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 testSmallClockSkewReadsAsJustNowNotAsAFutureDate() {
|
|
let assessment = FuelPriceFreshness.assessment(priceUpdated: at(60), now: now)
|
|
XCTAssertEqual(assessment.status, .fresh)
|
|
XCTAssertEqual(assessment.age, 0)
|
|
XCTAssertEqual(assessment.updateAgeLabel, "0m ago")
|
|
}
|
|
|
|
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 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)")
|
|
}
|
|
}
|
|
}
|
|
|
|
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)
|
|
}()
|
|
}
|
|
}
|