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

253 lines
10 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 var cancellables = Set<AnyCancellable>()
override func tearDown() async throws {
cancellables.removeAll()
}
// =========================================================================
// 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).
// MARK: - CG-2: Post-Face-ID reconnect signal
@MainActor
final class PostFaceIDReconnectTests: XCTestCase {
private var cancellables = Set<AnyCancellable>()
override func tearDown() async throws {
cancellables.removeAll()
}
/// CG-2 (depends on N-2): When elapsed > 60 s and Face-ID succeeds,
/// appWillForeground must emit TWO `false` transitions:
/// 1. The first emission fires before Face-ID (isLocked=true at that
/// point MainTerminalView skips the reconnect via the guard).
/// 2. After successful auth isLocked=false, a second emission fires
/// so MainTerminalView actually re-resumes the connection.
///
/// Face-ID is disabled in this test (faceid.enabled not set), so
/// FaceIDGate.authenticate() returns `true` immediately without UI.
func test_cg2_postFaceID_emitsForegroundTwiceOnAuthSuccess() async throws {
let state = AppState.shared
// Ensure Face ID is disabled FaceIDGate.authenticate() returns true.
UserDefaults.standard.removeObject(forKey: "faceid.enabled")
// Prime a "recent" background event, then rewind the timestamp so
// elapsed > 60 s (bypasses the grace-period guard).
state.appDidBackground()
state.lastForegroundedAt = Date().addingTimeInterval(-70)
var falseEmissions = 0
let exp = expectation(description: "two false emissions after Face-ID success")
exp.expectedFulfillmentCount = 2
state.lifecycleTransitions
.filter { $0 == false }
.sink { _ in
falseEmissions += 1
if falseEmissions <= 2 { exp.fulfill() }
}
.store(in: &cancellables)
await state.appWillForeground()
await fulfillment(of: [exp], timeout: 3.0)
XCTAssertEqual(falseEmissions, 2,
"CG-2 / N-2: exactly two false emissions expected " +
"(one pre-auth, one post-auth-success)")
XCTAssertFalse(state.isLocked,
"CG-2: isLocked must be false after successful Face-ID auth")
}
/// CG-2 guard: within the 60 s grace period only ONE false emission occurs
/// (no spurious second emission from the N-2 fix).
func test_cg2_withinGracePeriod_emitsForegroundOnce() async throws {
let state = AppState.shared
UserDefaults.standard.removeObject(forKey: "faceid.enabled")
state.appDidBackground()
// Do NOT rewind timestamp elapsed 0 s, within grace period.
var falseCount = 0
state.lifecycleTransitions
.filter { $0 == false }
.sink { _ in falseCount += 1 }
.store(in: &cancellables)
await state.appWillForeground()
// Allow any async propagation to settle.
try await Task.sleep(nanoseconds: 100_000_000)
XCTAssertEqual(falseCount, 1,
"CG-2 guard: within grace period must emit exactly ONE false " +
"(no spurious second emission from N-2 fix)")
}
}