Legend rows now show each station's average over the days shown in brackets (pence; signed +X.Xp in vs-cheapest mode, matching the list-row deltas). Footer under the key explains the figure per mode. averagePence helper (nil for empty series) + 3 tests; -showTrends becomes a permanent QA hook like -showKeySheet.
206 lines
8.4 KiB
Swift
206 lines
8.4 KiB
Swift
import XCTest
|
|
@testable import FuelBoardShared
|
|
|
|
// FuelHistory — price-history store for the Favourites Trends graph.
|
|
// Covers day math, slim day parsing, series building, delta rebasing and
|
|
// cache pruning. The network layer is exercised separately (pure helpers are
|
|
// what the chart logic depends on).
|
|
|
|
final class FuelHistoryTests: XCTestCase {
|
|
// MARK: Day math
|
|
|
|
func testDayStringFormat() {
|
|
XCTAssertEqual(FuelHistoryStore.dayString(), FuelHistoryStore.dayString())
|
|
let d = FuelHistoryStore.date(fromDay: "2026-08-15")
|
|
XCTAssertNotNil(d)
|
|
XCTAssertEqual(FuelHistoryStore.dayString(d!), "2026-08-15")
|
|
}
|
|
|
|
func testNeededDaysCountAndOrder() {
|
|
let days = FuelHistoryStore.neededDays(back: 7, from: FuelHistoryStore.date(fromDay: "2026-08-15")!)
|
|
XCTAssertEqual(days.count, 7)
|
|
XCTAssertEqual(days.first, "2026-08-09")
|
|
XCTAssertEqual(days.last, "2026-08-15")
|
|
}
|
|
|
|
func testNeededDaysZero() {
|
|
XCTAssertTrue(FuelHistoryStore.neededDays(back: 0).isEmpty)
|
|
}
|
|
|
|
// MARK: Day parsing (slim decode + shared band guard)
|
|
|
|
private func dayJSON(stations: [[String: Any]]) -> Data {
|
|
let dict: [String: Any] = ["stations": stations]
|
|
return try! JSONSerialization.data(withJSONObject: dict)
|
|
}
|
|
|
|
func testParseDayStationsExtractsRequestedFuels() throws {
|
|
let data = dayJSON(stations: [
|
|
["id": "s1", "prices": ["E10": 129.9, "E5": 139.9, "B7": 134.9]],
|
|
["id": "s2", "prices": ["E10": 131.9]],
|
|
])
|
|
let parsed = try FuelHistoryStore.parseDayStations(data, stationIDs: ["s1", "s2"])
|
|
XCTAssertEqual(parsed["s1"]?[.e10], 129.9)
|
|
XCTAssertEqual(parsed["s1"]?[.e5], 139.9)
|
|
XCTAssertEqual(parsed["s1"]?[.diesel], 134.9) // B7 → diesel
|
|
XCTAssertEqual(parsed["s2"]?[.e10], 131.9)
|
|
XCTAssertNil(parsed["s2"]?[.e5])
|
|
}
|
|
|
|
func testParseDayStationsIgnoresUnrequestedStations() throws {
|
|
let data = dayJSON(stations: [
|
|
["id": "wanted", "prices": ["E10": 129.9]],
|
|
["id": "other", "prices": ["E10": 99.9]],
|
|
])
|
|
let parsed = try FuelHistoryStore.parseDayStations(data, stationIDs: ["wanted"])
|
|
XCTAssertNotNil(parsed["wanted"])
|
|
XCTAssertNil(parsed["other"])
|
|
}
|
|
|
|
func testParseDayStationsBandGuardDropsGarbage() throws {
|
|
let data = dayJSON(stations: [
|
|
["id": "s1", "prices": ["E10": 129.9, "E5": 1.3, "B7": 1589.0]],
|
|
])
|
|
let parsed = try FuelHistoryStore.parseDayStations(data, stationIDs: ["s1"])
|
|
XCTAssertEqual(parsed["s1"]?[.e10], 129.9)
|
|
XCTAssertNil(parsed["s1"]?[.e5])
|
|
XCTAssertNil(parsed["s1"]?[.diesel])
|
|
}
|
|
|
|
func testParseDayStationsMissingPricesIsEmpty() throws {
|
|
let data = dayJSON(stations: [["id": "s1"]])
|
|
let parsed = try FuelHistoryStore.parseDayStations(data, stationIDs: ["s1"])
|
|
XCTAssertTrue(parsed.isEmpty)
|
|
}
|
|
|
|
// MARK: Series building
|
|
|
|
func testSeriesFromCacheBuildsOrderedPoints() {
|
|
let cache: [String: [String: [String: Double]]] = [
|
|
"2026-08-13": ["s1": ["e10": 130.0]],
|
|
"2026-08-14": ["s1": ["e10": 129.5]],
|
|
"2026-08-15": ["s1": ["e10": 128.9]],
|
|
]
|
|
let days = ["2026-08-13", "2026-08-14", "2026-08-15"]
|
|
let series = FuelHistoryStore.series(
|
|
fromCache: cache,
|
|
stations: [("s1", "Shell Test")],
|
|
fuel: .e10,
|
|
days: days
|
|
)
|
|
XCTAssertEqual(series.count, 1)
|
|
XCTAssertEqual(series[0].name, "Shell Test")
|
|
XCTAssertEqual(series[0].points.count, 3)
|
|
XCTAssertEqual(series[0].points.map(\.pence), [130.0, 129.5, 128.9])
|
|
XCTAssertEqual(series[0].points.map(\.date), days.compactMap { FuelHistoryStore.date(fromDay: $0) })
|
|
}
|
|
|
|
func testSeriesSkipsMissingDays() {
|
|
let cache: [String: [String: [String: Double]]] = [
|
|
"2026-08-13": ["s1": ["e10": 130.0]],
|
|
"2026-08-15": ["s1": ["e10": 128.9]], // gap on the 14th
|
|
]
|
|
let days = ["2026-08-13", "2026-08-14", "2026-08-15"]
|
|
let series = FuelHistoryStore.series(
|
|
fromCache: cache,
|
|
stations: [("s1", "Shell Test")],
|
|
fuel: .e10,
|
|
days: days
|
|
)
|
|
XCTAssertEqual(series[0].points.count, 2) // gap skipped, never fabricated
|
|
XCTAssertEqual(series[0].points.map(\.pence), [130.0, 128.9])
|
|
}
|
|
|
|
func testSeriesFiltersFuel() {
|
|
let cache: [String: [String: [String: Double]]] = [
|
|
"2026-08-15": ["s1": ["e10": 128.9, "diesel": 134.9]],
|
|
]
|
|
let days = ["2026-08-15"]
|
|
let diesel = FuelHistoryStore.series(fromCache: cache, stations: [("s1", "Shell")], fuel: .diesel, days: days)
|
|
XCTAssertEqual(diesel[0].points.map(\.pence), [134.9])
|
|
let e10 = FuelHistoryStore.series(fromCache: cache, stations: [("s1", "Shell")], fuel: .e10, days: days)
|
|
XCTAssertEqual(e10[0].points.map(\.pence), [128.9])
|
|
}
|
|
|
|
// MARK: Delta rebasing
|
|
|
|
func testDeltaSeriesRebasesToCheapestPerDay() {
|
|
let days = ["2026-08-13", "2026-08-14", "2026-08-15"]
|
|
let cache: [String: [String: [String: Double]]] = [
|
|
"2026-08-13": ["a": ["e10": 130.0], "b": ["e10": 132.0]],
|
|
"2026-08-14": ["a": ["e10": 131.0], "b": ["e10": 131.0]],
|
|
"2026-08-15": ["a": ["e10": 129.0], "b": ["e10": 130.5]],
|
|
]
|
|
let stations = [("a", "Asda A"), ("b", "Bp B")]
|
|
let raw = FuelHistoryStore.series(fromCache: cache, stations: stations, fuel: .e10, days: days)
|
|
let delta = FuelHistoryStore.deltaSeries(raw)
|
|
|
|
// Day 1: a=0, b=+2. Day 2: both 0. Day 3: a=0, b=+1.5
|
|
XCTAssertEqual(delta[0].points.map(\.pence), [0, 0, 0])
|
|
XCTAssertEqual(delta[1].points.map(\.pence), [2.0, 0, 1.5])
|
|
}
|
|
|
|
func testDeltaSeriesGapDayKeepsOnlyPresentStations() {
|
|
let cache: [String: [String: [String: Double]]] = [
|
|
"2026-08-15": ["a": ["e10": 129.0]], // b missing this day
|
|
]
|
|
let raw = FuelHistoryStore.series(fromCache: cache, stations: [("a", "A"), ("b", "B")], fuel: .e10, days: ["2026-08-15"])
|
|
let delta = FuelHistoryStore.deltaSeries(raw)
|
|
XCTAssertEqual(delta[0].points.map(\.pence), [0])
|
|
XCTAssertTrue(delta[1].points.isEmpty) // gap stays a gap
|
|
}
|
|
|
|
// MARK: Cache pruning
|
|
|
|
func testPruneKeepsRecentDaysOnly() {
|
|
let days = FuelHistoryStore.neededDays(back: FuelHistoryStore.maxCachedDays)
|
|
var cache: [String: [String: [String: Double]]] = [:]
|
|
for (i, d) in days.enumerated() {
|
|
cache[d] = ["s1": ["e10": Double(100 + i)]]
|
|
}
|
|
cache["2020-01-01"] = ["s1": ["e10": 1.0]] // stale — must go
|
|
let pruned = FuelHistoryStore.prunedCache(cache)
|
|
XCTAssertNil(pruned["2020-01-01"])
|
|
XCTAssertEqual(pruned.count, days.count)
|
|
}
|
|
|
|
// MARK: Chart-key average
|
|
|
|
func testAveragePenceEmptyIsNil() {
|
|
XCTAssertNil(FuelHistoryStore.averagePence([]))
|
|
}
|
|
|
|
func testAveragePenceSinglePoint() {
|
|
let d = FuelHistoryStore.date(fromDay: "2026-08-15")!
|
|
XCTAssertEqual(FuelHistoryStore.averagePence([PricePoint(date: d, pence: 156.7)]), 156.7)
|
|
}
|
|
|
|
func testAveragePenceMultiplePoints() {
|
|
let d1 = FuelHistoryStore.date(fromDay: "2026-08-15")!
|
|
let d2 = FuelHistoryStore.date(fromDay: "2026-08-16")!
|
|
// (153.9 + 156.7) / 2 = 155.3 — exact in binary? 153.9+156.7=310.6, /2=155.3
|
|
let avg = FuelHistoryStore.averagePence([
|
|
PricePoint(date: d1, pence: 153.9),
|
|
PricePoint(date: d2, pence: 156.7),
|
|
])
|
|
XCTAssertNotNil(avg)
|
|
XCTAssertEqual(avg!, 155.3, accuracy: 0.0001)
|
|
}
|
|
|
|
// MARK: Mirror URLs
|
|
|
|
func testHistoryFileURLKeepsBaseLastSegment() {
|
|
// Regression: URL(string:relativeTo:) drops the base's last segment
|
|
// ("main") without a trailing slash — every fetch 404'd (2026-08-15).
|
|
let base = URL(string: "https://raw.githubusercontent.com/aptonline/fuelboard-data/main")!
|
|
XCTAssertEqual(
|
|
FuelHistoryStore.historyFileURL(day: "2026-08-15", base: base).absoluteString,
|
|
"https://raw.githubusercontent.com/aptonline/fuelboard-data/main/history/2026-08-15.json"
|
|
)
|
|
XCTAssertEqual(
|
|
FuelHistoryStore.latestFileURL(base: base).absoluteString,
|
|
"https://raw.githubusercontent.com/aptonline/fuelboard-data/main/latest.json"
|
|
)
|
|
}
|
|
}
|