Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Tests / DashboardTests / BusArrivalsTests.swift

Back to remindwall — Current RemindWall

Download this file

import AppTypes
import ComposableArchitecture
import Dao
import DependenciesTestSupport
import Foundation
import Testing
import TransitAPI

@testable import Dashboard

@MainActor
@Suite("BusArrivals Feature Tests", .dependencies {
    $0.defaultDatabase = try! $0.appDatabase()
    $0.uuid = .incrementing
    // .syncedSetting stamps lastModified when test bodies write enabled/window;
    // per-store date overrides still win inside each reducer.
    $0.date = .constant(Date(timeIntervalSince1970: 0))
})
struct BusArrivalsTests {

    private static let utc: Calendar = {
        var c = Calendar(identifier: .gregorian)
        c.timeZone = TimeZone(identifier: "UTC")!
        return c
    }()

    /// April 6, 2026 is a Monday — covered by the default Mon–Fri window.
    private static let monday0700 = utc.date(from: DateComponents(
        year: 2026, month: 4, day: 6, hour: 7, minute: 0
    ))!

    /// April 5, 2026 is a Sunday — outside the default Mon–Fri window.
    private static let sunday0700 = utc.date(from: DateComponents(
        year: 2026, month: 4, day: 5, hour: 7, minute: 0
    ))!

    @Test("startMonitoring fires immediate tick then loops")
    func startMonitoring() async {
        let clock = TestClock()

        let store = TestStore(initialState: BusArrivalsFeature.State()) {
            BusArrivalsFeature()
        } withDependencies: {
            $0.continuousClock = clock
            $0.date = .constant(Self.monday0700)
            $0.calendar = Self.utc
        }

        store.exhaustivity = .off

        await store.send(.startMonitoring)
        await store.receive(\.tick)
        await store.receive(\._arrivalsLoaded)

        await store.finish()
    }

    @Test("tick outside the watch's window clears arrivals without calling api")
    func outsideWindowClears() async throws {
        let apiCalled = LockIsolated(false)

        @Dependency(\.defaultDatabase) var database
        @Dependency(\.uuid) var uuid
        try await database.write { db in
            try MonitoredStop.insert {
                MonitoredStop(
                    id: MonitoredStop.ID(uuid()),
                    label: "School", stopId: "1_1", routeId: "1_a",
                    routeShortName: "A", sortOrder: 0
                )
            }.execute(db)
        }

        var initial = BusArrivalsFeature.State()
        try await initial.$monitoredStops.load(MonitoredStop.all.order(by: \.sortOrder))

        let store = TestStore(initialState: initial) {
            BusArrivalsFeature()
        } withDependencies: {
            $0.date = .constant(Self.sunday0700)
            $0.calendar = Self.utc
            $0.transitAPI.fetchArrivals = { _, _ in
                apiCalled.setValue(true)
                return []
            }
            $0.transitKeyStore.read = { "key" }
        }

        await store.send(.tick)
        await store.receive(._arrivalsLoaded([], error: nil))

        #expect(apiCalled.value == false)
    }

    @Test("tick with no monitored stops short-circuits")
    func inWindowNoStops() async {
        let store = TestStore(initialState: BusArrivalsFeature.State()) {
            BusArrivalsFeature()
        } withDependencies: {
            $0.date = .constant(Self.monday0700)
            $0.calendar = Self.utc
            $0.transitKeyStore.read = { "key" }
        }

        await store.send(.tick)
        await store.receive(._arrivalsLoaded([], error: nil))
    }

    @Test("per-watch gating: only enabled, in-window stops are fetched")
    func perWatchGating() async throws {
        @Dependency(\.defaultDatabase) var database
        @Dependency(\.uuid) var uuid
        try await database.write { db in
            try MonitoredStop.insert {
                MonitoredStop(
                    id: MonitoredStop.ID(uuid()),
                    label: "Active", stopId: "1_active", routeId: "1_a",
                    routeShortName: "A", sortOrder: 0
                )
                MonitoredStop(
                    id: MonitoredStop.ID(uuid()),
                    label: "Switched off", stopId: "1_off", routeId: "1_b",
                    routeShortName: "B", sortOrder: 1,
                    enabled: false
                )
                MonitoredStop(
                    id: MonitoredStop.ID(uuid()),
                    label: "Weekend window", stopId: "1_weekend", routeId: "1_c",
                    routeShortName: "C", sortOrder: 2,
                    window: AlertWindow(
                        weekdays: [.Saturday, .Sunday],
                        startHour: 6, startMinute: 0, endHour: 12, endMinute: 0
                    )
                )
            }.execute(db)
        }

        let fetchedStops = LockIsolated<[String]>([])
        var initial = BusArrivalsFeature.State()
        try await initial.$monitoredStops.load(MonitoredStop.all.order(by: \.sortOrder))

        let store = TestStore(initialState: initial) {
            BusArrivalsFeature()
        } withDependencies: {
            $0.date = .constant(Self.monday0700)
            $0.calendar = Self.utc
            $0.transitKeyStore.read = { "key" }
            $0.transitAPI.fetchArrivals = { _, stopId in
                fetchedStops.withValue { $0.append(stopId) }
                return []
            }
        }
        store.exhaustivity = .off

        await store.send(.tick)
        await store.receive(\._arrivalsLoaded)

        #expect(fetchedStops.value == ["1_active"])
    }

    @Test("dedup: two monitored entries sharing stopId trigger one api call")
    func dedupesByStopId() async throws {
        @Dependency(\.defaultDatabase) var database
        @Dependency(\.uuid) var uuid

        try await database.write { db in
            try MonitoredStop.insert {
                MonitoredStop(
                    id: MonitoredStop.ID(uuid()),
                    label: "Bus A",
                    stopId: "1_75403",
                    routeId: "1_a",
                    routeShortName: "A",
                    sortOrder: 0
                )
                MonitoredStop(
                    id: MonitoredStop.ID(uuid()),
                    label: "Bus B",
                    stopId: "1_75403",
                    routeId: "1_b",
                    routeShortName: "B",
                    sortOrder: 1
                )
            }.execute(db)
        }

        let callCount = LockIsolated(0)

        var initial = BusArrivalsFeature.State()
        try await initial.$monitoredStops.load(MonitoredStop.all.order(by: \.sortOrder))

        let store = TestStore(initialState: initial) {
            BusArrivalsFeature()
        } withDependencies: {
            $0.date = .constant(Self.monday0700)
            $0.calendar = Self.utc
            $0.transitKeyStore.read = { "key" }
            $0.transitAPI.fetchArrivals = { _, stopId in
                callCount.withValue { $0 += 1 }
                #expect(stopId == "1_75403")
                return []
            }
        }
        store.exhaustivity = .off

        await store.send(.tick)
        await store.receive(\._arrivalsLoaded)

        #expect(callCount.value == 1, "expected one api call for the shared stopId, got \(callCount.value)")
    }

    @Test("client-side route filter picks correct route")
    func routeFilter() async throws {
        @Dependency(\.defaultDatabase) var database
        @Dependency(\.uuid) var uuid

        let stopId = "1_75403"
        try await database.write { db in
            try MonitoredStop.insert {
                MonitoredStop(
                    id: MonitoredStop.ID(uuid()),
                    label: "School",
                    stopId: stopId,
                    routeId: "1_b",
                    routeShortName: "B",
                    sortOrder: 0
                )
            }.execute(db)
        }

        let predictedB = Date(timeIntervalSince1970: 1_746_119_300)

        var initial = BusArrivalsFeature.State()
        try await initial.$monitoredStops.load(MonitoredStop.all.order(by: \.sortOrder))

        let store = TestStore(initialState: initial) {
            BusArrivalsFeature()
        } withDependencies: {
            $0.date = .constant(Self.monday0700)
            $0.calendar = Self.utc
            $0.transitKeyStore.read = { "key" }
            $0.transitAPI.fetchArrivals = { _, _ in
                [
                    ArrivalPrediction(
                        stopId: stopId, routeId: "1_a", tripId: "t1",
                        tripHeadsign: "Wrong",
                        scheduledArrival: Date(timeIntervalSince1970: 1_746_118_900),
                        predictedArrival: Date(timeIntervalSince1970: 1_746_118_900),
                        isPredicted: true,
                        lastUpdate: nil
                    ),
                    ArrivalPrediction(
                        stopId: stopId, routeId: "1_b", tripId: "t2",
                        tripHeadsign: "Right",
                        scheduledArrival: predictedB,
                        predictedArrival: predictedB,
                        isPredicted: true,
                        lastUpdate: nil
                    )
                ]
            }
        }
        store.exhaustivity = .off

        await store.send(.tick)
        await store.receive(\._arrivalsLoaded) { state in
            #expect(state.arrivals.count == 1)
            #expect(state.arrivals.first?.routeShortName == "B")
        }
    }

    @Test("error path populates lastError")
    func errorPath() async throws {
        @Dependency(\.defaultDatabase) var database
        @Dependency(\.uuid) var uuid

        try await database.write { db in
            try MonitoredStop.insert {
                MonitoredStop(
                    id: MonitoredStop.ID(uuid()),
                    label: "School",
                    stopId: "1_1",
                    routeId: "1_a",
                    routeShortName: "A",
                    sortOrder: 0
                )
            }.execute(db)
        }

        var initial = BusArrivalsFeature.State()
        try await initial.$monitoredStops.load(MonitoredStop.all.order(by: \.sortOrder))

        let store = TestStore(initialState: initial) {
            BusArrivalsFeature()
        } withDependencies: {
            $0.date = .constant(Self.monday0700)
            $0.calendar = Self.utc
            $0.transitKeyStore.read = { "key" }
            $0.transitAPI.fetchArrivals = { _, _ in throw TransitAPIError.unauthorized }
        }
        store.exhaustivity = .off

        await store.send(.tick)
        await store.receive(\._arrivalsLoaded) { state in
            #expect(state.lastError != nil)
            #expect(state.arrivals.isEmpty)
        }
    }

    @Test("makeDisplay flags lateness > 90s as late")
    func makeDisplayLate() {
        let stop = MonitoredStop(
            id: .init(rawValue: UUID()),
            label: "Bus", stopId: "1_1", routeId: "1_a",
            routeShortName: "A", sortOrder: 0
        )
        let now = Date(timeIntervalSince1970: 1_000_000)
        let scheduled = Date(timeIntervalSince1970: 1_000_120)  // +120s
        let predicted = Date(timeIntervalSince1970: 1_000_300)  // +180s after scheduled
        let arrival = ArrivalPrediction(
            stopId: "1_1", routeId: "1_a", tripId: "t",
            tripHeadsign: "X",
            scheduledArrival: scheduled,
            predictedArrival: predicted,
            isPredicted: true,
            lastUpdate: nil
        )
        let display = makeDisplay(stop: stop, soonest: arrival, now: now)
        #expect(display?.isLate == true)
        #expect(display?.isLive == true)
    }

    @Test("makeDisplay flags lateness ≤ 90s as on time")
    func makeDisplayOnTime() {
        let stop = MonitoredStop(
            id: .init(rawValue: UUID()),
            label: "Bus", stopId: "1_1", routeId: "1_a",
            routeShortName: "A", sortOrder: 0
        )
        let now = Date(timeIntervalSince1970: 1_000_000)
        let scheduled = Date(timeIntervalSince1970: 1_000_120)
        let predicted = Date(timeIntervalSince1970: 1_000_180) // +60s late, under threshold
        let arrival = ArrivalPrediction(
            stopId: "1_1", routeId: "1_a", tripId: "t",
            tripHeadsign: "X",
            scheduledArrival: scheduled,
            predictedArrival: predicted,
            isPredicted: true,
            lastUpdate: nil
        )
        let display = makeDisplay(stop: stop, soonest: arrival, now: now)
        #expect(display?.isLate == false)
        #expect(display?.isLive == true)
    }

    @Test("makeDisplay returns nil when no arrival")
    func makeDisplayNil() {
        let stop = MonitoredStop(
            id: .init(rawValue: UUID()),
            label: "Bus", stopId: "1_1", routeId: "1_a",
            routeShortName: "A", sortOrder: 0
        )
        #expect(makeDisplay(stop: stop, soonest: nil, now: Date()) == nil)
    }

    @Test("makeDisplay marks scheduled-only arrival as not live")
    func makeDisplayScheduledOnly() {
        let stop = MonitoredStop(
            id: .init(rawValue: UUID()),
            label: "Bus", stopId: "1_1", routeId: "1_a",
            routeShortName: "A", sortOrder: 0
        )
        let now = Date(timeIntervalSince1970: 1_000_000)
        let arrival = ArrivalPrediction(
            stopId: "1_1", routeId: "1_a", tripId: "t",
            tripHeadsign: "X",
            scheduledArrival: Date(timeIntervalSince1970: 1_000_300),
            predictedArrival: nil,
            isPredicted: false,
            lastUpdate: nil
        )
        let display = makeDisplay(stop: stop, soonest: arrival, now: now)
        #expect(display?.isLive == false)
        #expect(display?.isLate == false)
        #expect(display?.etaText.contains("scheduled") == true)
    }
}