remindwall / RWPureSwift / Tests / DashboardTests / BatteryAlertsTests.swift
Back to remindwall — Current RemindWall
import AppTypes
import ComposableArchitecture
import Dao
import DependenciesTestSupport
import Foundation
import HomeKitAsync
import Testing
@testable import Dashboard
@MainActor
@Suite("BatteryAlerts Feature Tests", .dependencies {
$0.defaultDatabase = try! $0.appDatabase()
$0.uuid = .incrementing
// .syncedSetting stamps lastModified when tests flip enabled/threshold;
// windowTick reads the calendar.
$0.date = .constant(Date(timeIntervalSince1970: 0))
$0.calendar = Calendar(identifier: .gregorian)
})
struct BatteryAlertsTests {
private func status(
_ name: String,
level: Int?,
isLow: Bool = false
) -> BatteryStatus {
BatteryStatus(
id: UUID(uuidString: "00000000-0000-0000-0000-\(String(format: "%012d", abs(name.hashValue % 1_000_000)))")!,
accessoryName: name,
roomName: nil,
levelPercent: level,
isLow: isLow
)
}
@Test("startMonitoring ticks immediately, then every 30 minutes")
func pollLoop() async {
let clock = TestClock()
let fetchCount = LockIsolated(0)
let store = TestStore(initialState: BatteryAlertsFeature.State()) {
BatteryAlertsFeature()
} withDependencies: {
$0.continuousClock = clock
$0.homeKitAsync.batteryStatuses = {
fetchCount.withValue { $0 += 1 }
return []
}
}
store.exhaustivity = .off
// Through the store, not before it - pre-store shared mutations
// surface as phantom changes on the first send.
store.state.$enabled.withLock { $0 = true }
await store.send(.startMonitoring)
await store.receive(\._statusesLoaded)
#expect(fetchCount.value == 1)
await clock.advance(by: .seconds(60 * 30))
await store.receive(\._statusesLoaded)
#expect(fetchCount.value == 2)
// Restart replaces the loop (cancelInFlight) rather than doubling it.
await store.send(.startMonitoring)
await store.receive(\._statusesLoaded)
#expect(fetchCount.value == 3)
}
@Test("disabled ticks never touch HomeKit and clear stale statuses")
func disabledTick() async {
let store = TestStore(initialState: {
var state = BatteryAlertsFeature.State()
state.statuses = [status("Old Sensor", level: 5)]
return state
}()) {
BatteryAlertsFeature()
}
// homeKitAsync deliberately unstubbed: a call would fail the test.
await store.send(.tick)
await store.receive(\._statusesLoaded) {
$0.statuses = []
}
}
@Test("chips surface only alertable batteries, honoring the synced threshold")
func thresholdFilter() async {
var state = BatteryAlertsFeature.State()
state.$enabled.withLock { $0 = true }
state.$thresholdPercent.withLock { $0 = 20 }
state.statuses = [
status("Front Door Sensor", level: 8),
status("Thermostat", level: 76),
status("Motion Sensor", level: 19),
]
#expect(state.ambientChips.map(\.text) == [
"Front Door Sensor · 8%",
"Motion Sensor · 19%",
])
// Tighten the threshold and the 19% chip disappears - the filter
// reads the SYNCED knob, not a baked-in constant.
state.$thresholdPercent.withLock { $0 = 10 }
#expect(state.ambientChips.map(\.text) == ["Front Door Sensor · 8%"])
}
@Test("the accessory's own low flag beats a healthy-looking level")
func lowFlagChip() async {
var state = BatteryAlertsFeature.State()
state.$enabled.withLock { $0 = true }
state.statuses = [status("Water Sensor", level: nil, isLow: true)]
#expect(state.ambientChips.map(\.text) == ["Water Sensor · low"])
}
@Test("disabled means no chips even with alertable statuses on hand")
func disabledNoChips() async {
var state = BatteryAlertsFeature.State()
state.statuses = [status("Front Door Sensor", level: 8)]
#expect(state.ambientChips.isEmpty)
}
@Test("out-of-window hides chips even with alertable batteries on hand")
func windowGatesChips() async {
var utc = Calendar(identifier: .gregorian)
utc.timeZone = TimeZone(identifier: "UTC")!
// April 5, 2026 is a Sunday - outside a Mon-Fri default window.
let sunday = utc.date(from: DateComponents(year: 2026, month: 4, day: 5, hour: 7))!
let store = TestStore(initialState: {
var state = BatteryAlertsFeature.State()
state.statuses = [status("Front Door Sensor", level: 8)]
return state
}()) {
BatteryAlertsFeature()
} withDependencies: {
$0.date = .constant(sunday)
$0.calendar = utc
}
store.exhaustivity = .off
store.state.$enabled.withLock { $0 = true }
store.state.$window.withLock { $0 = .default }
await store.send(.windowTick)
#expect(store.state.isInWindow == false)
#expect(store.state.ambientChips.isEmpty)
// No window means always-in (H1.6 decision).
store.state.$window.withLock { $0 = nil }
await store.send(.windowTick)
#expect(store.state.isInWindow)
#expect(store.state.ambientChips.count == 1)
}
@Test("an ignored accessory never chips - the hardwired door at 0% case")
func ignoredNeverChips() async {
var state = BatteryAlertsFeature.State()
state.$enabled.withLock { $0 = true }
state.statuses = [
status("Front Door", level: 0),
status("Motion Sensor", level: 12),
]
state.$ignored.withLock { $0 = BatteryIgnoreList.empty.toggling("Front Door") }
#expect(state.ambientChips.map(\.text) == ["Motion Sensor · 12%"])
// Unignoring brings it back.
state.$ignored.withLock { $0 = $0.toggling("Front Door") }
#expect(state.ambientChips.map(\.text) == ["Front Door · 0%", "Motion Sensor · 12%"])
}
@Test("chips sort lowest level first")
func chipOrdering() async {
var state = BatteryAlertsFeature.State()
state.$enabled.withLock { $0 = true }
state.statuses = [
status("B Sensor", level: 15),
status("A Sensor", level: 15),
status("C Sensor", level: 3),
]
#expect(state.ambientChips.map(\.text) == [
"C Sensor · 3%",
"A Sensor · 15%",
"B Sensor · 15%",
])
}
}