P1: Favourites Trends graph — FuelHistoryStore + TrendsView (Swift Charts)
- Shared/FuelHistory.swift: GitHub mirror read path (latest.json pointer + history/YYYY-MM-DD.json day files); day math (UTC noon keys), slim day decode with the shared price band, series + deltaSeries (vs-cheapest rebase), favourites-only app-group cache (90-day prune), parallel per-day fetches, 404/network = graph gap never error. - FuelPriceProvider: shared priceBand + mapGrades (single source for live + history decoding). - FuelBoard/TrendsView.swift: line chart in the Favourites tab via toolbar button + sheet; fuel capsule (fuels with favourites only), 7/30/90 range, Price/vs-cheapest toggle, per-station legend, price-display style on y-axis, empty/loading/retry states with honest copy; no widget in v1. - Localizable.strings: Trends keys. - 83 tests (13 history + URL regression): day math, band guard, series gaps, delta rebasing, prune, and the appendingPathComponent regression (relative URL resolution dropped /main — all fetches 404'd). - Sim-verified: favourites rows + TOP/deltas; sheet controls + building-up state with live first-snapshot date (archive has 1 day; lines render once >=2 snapshots). - Backlog: P1 Trends DONE (unmerged); P0 REMAINING = live provider chain + telemetry beacon.
This commit is contained in:
@@ -0,0 +1,182 @@
|
||||
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: 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"
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user