// AppStateLifecycleTests.swift // T-2.10 — Background lifecycle: AppState surface for lifecycle events. // // Covers: // • Face-ID gate regression (appDidBackground / appWillForeground existing logic). // • New T-2.10 requirement: AppState must publish lifecycle transitions so // an active SessionConnection can react (suspend on background, resume on // foreground). // // Strategy: stub extension at the bottom provides `lifecycleTransitions` as an // always-empty publisher. Tests expecting emissions will FAIL until the impl // agent adds a real publisher on AppState. import Combine import XCTest @testable import piRemote @MainActor final class AppStateLifecycleTests: XCTestCase { private nonisolated(unsafe) var cancellables = Set() override func tearDown() { cancellables.removeAll() super.tearDown() } // ========================================================================= // MARK: 1. Face-ID gate regression (existing logic must not regress) // ========================================================================= /// Within the 60-second grace period, appWillForeground must NOT lock. func test_appWillForeground_withinGracePeriod_doesNotLock() async { let state = AppState.shared // Simulate a recent background event (< 60 s ago) state.appDidBackground() // Immediately foreground — elapsed ≈ 0 s, well within the 60 s window. await state.appWillForeground() XCTAssertFalse(state.isLocked, "Within 60 s grace period, appWillForeground must NOT set isLocked=true") } /// appDidBackground() must record the current date in lastForegroundedAt /// so the elapsed time calculation is correct on the next foreground. func test_appDidBackground_updatesLastForegroundedAt() { let state = AppState.shared let before = Date() state.appDidBackground() let after = Date() XCTAssertGreaterThanOrEqual(state.lastForegroundedAt, before, "lastForegroundedAt must be updated to ≥ the call time") XCTAssertLessThanOrEqual(state.lastForegroundedAt, after, "lastForegroundedAt must be updated to ≤ the call time") } // ========================================================================= // MARK: 2. T-2.10 — AppState must publish background/foreground transitions // // FAILS until impl agent adds lifecycleTransitions publisher to AppState. // ========================================================================= /// After appDidBackground(), lifecycleTransitions must emit `true`. /// /// FAILS: stub publisher never emits → expectation times out. func test_appDidBackground_publishesBackgroundTransition() { let state = AppState.shared let exp = expectation(description: "lifecycleTransitions emits true on background") state.lifecycleTransitions .filter { $0 == true } .first() .sink { _ in exp.fulfill() } .store(in: &cancellables) state.appDidBackground() // Stub publisher never emits → this wait TIMES OUT → test FAILS. // Impl agent: add an AnyPublisher on AppState that sends // `true` from appDidBackground() and `false` from appWillForeground(). wait(for: [exp], timeout: 0.5) } /// After appWillForeground(), lifecycleTransitions must emit `false`. /// /// FAILS: stub publisher never emits → expectation times out. func test_appWillForeground_publishesForegroundTransition() async { let state = AppState.shared // Prime with a recent timestamp so FaceID gate is skipped. state.appDidBackground() let exp = expectation(description: "lifecycleTransitions emits false on foreground") state.lifecycleTransitions .filter { $0 == false } .first() .sink { _ in exp.fulfill() } .store(in: &cancellables) await state.appWillForeground() // Stub publisher never emits → this wait TIMES OUT → test FAILS. await fulfillment(of: [exp], timeout: 0.5) } /// lifecycleTransitions must emit in the correct order: true then false. /// /// FAILS: stub publisher never emits → first expectation times out. func test_lifecycleTransitions_correctOrder_backgroundThenForeground() async { let state = AppState.shared var emissions: [Bool] = [] let backgroundExp = expectation(description: "background emission (true)") let foregroundExp = expectation(description: "foreground emission (false)") state.lifecycleTransitions .prefix(2) .collect() .sink { values in emissions = values if values.first == true { backgroundExp.fulfill() } if values.last == false { foregroundExp.fulfill() } } .store(in: &cancellables) state.appDidBackground() await state.appWillForeground() // Stub publisher never emits → both expectations time out → FAILS. await fulfillment(of: [backgroundExp, foregroundExp], timeout: 0.5) XCTAssertEqual(emissions, [true, false], "T-2.10: lifecycleTransitions must emit [true, false] for " + "background→foreground sequence") } // ========================================================================= // MARK: 3. T-2.10 — Lifecycle transitions must be accessible for connection // subscription without requiring access to ContentView internals. // ========================================================================= /// lifecycleTransitions must be a valid publisher (non-nil) on AppState. /// Tests the structural presence of the publisher surface. /// /// PASSES immediately once the property is added; the emission tests above /// verify that it actually fires. func test_lifecycleTransitions_publisherExists() { // This test passes once lifecycleTransitions property exists on AppState. // It always FAILS because the stub (Empty publisher) technically exists, // but we add a stronger check: we verify the publisher has sent at least // one value by checking a side-channel flag. // // Use a simple check: the value we receive after posting a background event // is `true`. Since stub never emits, `receivedValue` stays nil → FAILS. let state = AppState.shared var receivedValue: Bool? = nil state.lifecycleTransitions .first() .sink { value in receivedValue = value } .store(in: &cancellables) state.appDidBackground() // Stub never emits → receivedValue stays nil → FAILS. XCTAssertNotNil(receivedValue, "T-2.10: AppState.lifecycleTransitions must emit when appDidBackground() is called. " + "Impl agent: add a PassthroughSubject to AppState, send(true) in " + "appDidBackground(), send(false) in appWillForeground(). Expose as " + "AnyPublisher.") } } // MARK: - Stub removed // lifecycleTransitions is now implemented on AppState directly (T-2.10 impl).