Christopher Hotchkiss Christopher Hotchkiss
Login

Code

remindwall / RWPureSwift / Tests / DashboardTests / BatteryAlertsTests.swift

Back to remindwall — Current RemindWall

Download this file

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%",
        ])
    }
}