Files
fuelboard/FuelBoardTests/Tests/FuelBoardSharedTests/FuelHistoryTests.swift
T
FuelBoard Contributor aab2d02ef5 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.
2026-08-15 10:12:23 +01:00

183 lines
7.5 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: 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"
)
}
}