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" ) } }