174 lines
7.2 KiB
Swift
174 lines
7.2 KiB
Swift
// 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<AnyCancellable>()
|
|
|
|
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<Bool, Never> 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<Bool, Never> to AppState, send(true) in " +
|
|
"appDidBackground(), send(false) in appWillForeground(). Expose as " +
|
|
"AnyPublisher<Bool, Never>.")
|
|
}
|
|
}
|
|
|
|
// MARK: - Stub removed
|
|
// lifecycleTransitions is now implemented on AppState directly (T-2.10 impl).
|