Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Tests / DashboardTests / TrafficAlertsTests.swift

Back to remindwall — Current RemindWall

Download this file

import AppTypes
import ComposableArchitecture
import Dao
import DependenciesTestSupport
import Foundation
import Testing
import TrafficAPI

@testable import Dashboard

@MainActor
@Suite("TrafficAlerts Feature Tests", .dependencies {
    $0.defaultDatabase = try! $0.appDatabase()
    $0.uuid = .incrementing
    // .syncedSetting stamps lastModified when tests set window/origin.
    $0.date = .constant(Date(timeIntervalSince1970: 0))
})
struct TrafficAlertsTests {

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

    /// April 6, 2026 is a Monday — inside 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 window.
    private static let sunday0700 = utc.date(from: DateComponents(
        year: 2026, month: 4, day: 5, hour: 7, minute: 0
    ))!

    private static let home = HomeOrigin(latitude: 47.52, longitude: -122.39, name: "Home")

    @discardableResult
    private func seedRoute(
        label: String = "School run",
        latitude: Double = 47.5423,
        longitude: Double = -122.3866,
        normalMinutes: Int = 12,
        sortOrder: Int = 0
    ) async -> MonitoredRoute.ID {
        @Dependency(\.defaultDatabase) var database
        @Dependency(\.uuid) var uuid
        let id = MonitoredRoute.ID(uuid())
        try! await database.write { db in
            try MonitoredRoute.insert {
                MonitoredRoute(
                    id: id,
                    label: label,
                    destinationLatitude: latitude,
                    destinationLongitude: longitude,
                    destinationName: label,
                    normalMinutes: normalMinutes,
                    sortOrder: sortOrder
                )
            }.execute(db)
        }
        return id
    }

    @Test("out-of-window tick clears the rail without touching the API")
    func outOfWindow() async {
        await seedRoute()

        let store = TestStore(initialState: TrafficAlertsFeature.State()) {
            TrafficAlertsFeature()
        } withDependencies: {
            $0.date = .constant(Self.sunday0700)
            $0.calendar = Self.utc
            // trafficAPI deliberately unstubbed: a call would fail the test.
        }
        store.state.$homeOrigin.withLock { $0 = Self.home }

        await store.send(.tick)
        // The seeded route's default window excludes Sunday; the exact
        // receive proves no API call happened.
        await store.receive(._routesLoaded([], error: nil))
    }

    @Test("in-window tick builds one display route per monitored route with late math")
    func inWindowFetch() async {
        await seedRoute(label: "School run", normalMinutes: 12, sortOrder: 0)
        await seedRoute(label: "Ferry run", latitude: 47.6026, longitude: -122.3393, normalMinutes: 25, sortOrder: 1)

        let store = TestStore(initialState: TrafficAlertsFeature.State()) {
            TrafficAlertsFeature()
        } withDependencies: {
            $0.date = .constant(Self.monday0700)
            $0.calendar = Self.utc
            $0.trafficAPI.calculateETA = { _, destination in
                // School run gets 18 min (late vs norm 12+5); ferry 20 (fine vs 25).
                destination.latitude == 47.5423
                    ? DriveETA(expectedTravelTime: 18 * 60, distanceMeters: 9000)
                    : DriveETA(expectedTravelTime: 20 * 60, distanceMeters: 12000)
            }
        }
        store.exhaustivity = .off
        store.state.$homeOrigin.withLock { $0 = Self.home }

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

        let routes = store.state.displayRoutes
        #expect(routes.map(\.label) == ["School run", "Ferry run"])
        #expect(routes.map(\.etaMinutes) == [18, 20])
        #expect(routes.map(\.isLate) == [true, false])
        #expect(store.state.lastError == nil)
    }

    @Test("routes without a home origin surface the fix, not a blank rail")
    func missingOrigin() async {
        await seedRoute()

        let store = TestStore(initialState: TrafficAlertsFeature.State()) {
            TrafficAlertsFeature()
        } withDependencies: {
            $0.date = .constant(Self.monday0700)
            $0.calendar = Self.utc
        }
        await store.send(.tick)
        await store.receive(._routesLoaded([], error: "home origin not set")) {
            $0.lastError = "home origin not set"
        }
        #expect(store.state.railCards.map(\.priority) == [.errorChip])
    }

    @Test("an API failure collapses to a single error chip")
    func apiFailure() async {
        await seedRoute()

        let store = TestStore(initialState: TrafficAlertsFeature.State()) {
            TrafficAlertsFeature()
        } withDependencies: {
            $0.date = .constant(Self.monday0700)
            $0.calendar = Self.utc
            $0.trafficAPI.calculateETA = { _, _ in throw TrafficAPIError.noRoute }
        }
        store.exhaustivity = .off
        store.state.$homeOrigin.withLock { $0 = Self.home }

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

        #expect(store.state.displayRoutes.isEmpty)
        #expect(store.state.lastError != nil)
        #expect(store.state.railCards.count == 1)
        #expect(store.state.railCards.first?.priority == .errorChip)
    }

    @Test("per-watch gating: a disabled route never drives to the API")
    func disabledRouteSkipped() async {
        await seedRoute(label: "Active", normalMinutes: 12, sortOrder: 0)
        @Dependency(\.defaultDatabase) var database
        @Dependency(\.uuid) var uuid
        try! await database.write { db in
            try MonitoredRoute.insert {
                MonitoredRoute(
                    id: MonitoredRoute.ID(uuid()),
                    label: "Switched off",
                    destinationLatitude: 47.61, destinationLongitude: -122.20,
                    destinationName: "Bellevue",
                    normalMinutes: 30, sortOrder: 1,
                    enabled: false
                )
            }.execute(db)
        }

        let fetched = LockIsolated<[Double]>([])
        let store = TestStore(initialState: TrafficAlertsFeature.State()) {
            TrafficAlertsFeature()
        } withDependencies: {
            $0.date = .constant(Self.monday0700)
            $0.calendar = Self.utc
            $0.trafficAPI.calculateETA = { _, destination in
                fetched.withValue { $0.append(destination.latitude) }
                return DriveETA(expectedTravelTime: 10 * 60, distanceMeters: 5000)
            }
        }
        store.exhaustivity = .off
        store.state.$homeOrigin.withLock { $0 = Self.home }

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

        #expect(fetched.value == [47.5423])
    }

    @Test("late math: over normal-plus-threshold is late, at the boundary is not")
    func lateBoundary() {
        let route = MonitoredRoute(
            id: MonitoredRoute.ID(UUID()),
            label: "School run",
            destinationLatitude: 0, destinationLongitude: 0,
            destinationName: "School",
            normalMinutes: 12,
            sortOrder: 0
        )
        // Threshold is 5: 17 min is the boundary (not late), 18 is late.
        #expect(!makeDisplayRoute(route: route, eta: DriveETA(expectedTravelTime: 17 * 60, distanceMeters: 0)).isLate)
        #expect(makeDisplayRoute(route: route, eta: DriveETA(expectedTravelTime: 18 * 60, distanceMeters: 0)).isLate)
        // Round-up feeds the comparison: 17:10 driving is 18 min, late.
        #expect(makeDisplayRoute(route: route, eta: DriveETA(expectedTravelTime: 17 * 60 + 10, distanceMeters: 0)).isLate)
    }

    @Test("rail cards map late to slowRoute and on-time to onTimeRoute")
    func railCardPriorities() {
        var state = TrafficAlertsFeature.State()
        state.displayRoutes = [
            DisplayRoute(
                id: MonitoredRoute.ID(UUID()), label: "School run",
                etaMinutes: 18, normalMinutes: 12, etaSeconds: 18 * 60, isLate: true
            ),
            DisplayRoute(
                id: MonitoredRoute.ID(UUID()), label: "Ferry run",
                etaMinutes: 20, normalMinutes: 25, etaSeconds: 20 * 60, isLate: false
            ),
        ]

        #expect(state.railCards.map(\.priority) == [.slowRoute, .onTimeRoute])
        #expect(state.railCards.first?.content == .drive(label: "School run", etaText: "18 min", isLate: true))
        // Error chip only appears when there is nothing to show.
        state.lastError = "timeout"
        #expect(state.railCards.count == 2)
    }
}