remindwall / RWPureSwift / Tests / DashboardTests / BusArrivalsTests.swift
Back to remindwall — Current RemindWall
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)
}
}