pi-remote-ios/Tests/CoreTests/AppStateLifecycleTests.swift

192 lines
8.0 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 extension
//
// Provides `lifecycleTransitions` so the test file compiles before the impl.
// The Empty publisher never emits all tests that wait for emissions FAIL.
//
// Impl agent: add a real `lifecycleTransitions: AnyPublisher<Bool, Never>` to
// AppState.swift backed by a PassthroughSubject. REMOVE this stub once added.
// =============================================================================
extension AppState {
/// T-2.10: Emits `true` when the app enters background,
/// `false` when the app returns to foreground.
///
/// STUB Empty publisher, never emits all T-2.10 emission tests FAIL.
var lifecycleTransitions: AnyPublisher<Bool, Never> {
Empty<Bool, Never>().eraseToAnyPublisher()
}
}