pi-remote-ios/Tests/CoreTests/SessionConnectionLifecycleT...

257 lines
11 KiB
Swift

// SessionConnectionLifecycleTests.swift
// T-2.10 Background lifecycle: suspend / resume / stale-frame-freeze /
// keep-alive foreground-only / idempotency / reconnect latency.
//
// Strategy: tests compile against stub extensions at the bottom of this file.
// The stubs return sentinel values that make every T-2.10-specific assertion
// fail. The impl agent replaces the stubs with real implementations inside
// SessionConnection.swift.
import Combine
import XCTest
@testable import piRemote
// MARK: - Helpers
private func fakeCredential() -> SidecarCredential {
SidecarCredential(
sidecarId: "test-sidecar",
host: "127.0.0.1",
port: 19991, // unreachable localhost port no real WS needed
bearerToken: "test-token",
tlsFingerprint: "deadbeef",
sidecarName: "test",
pairedAt: Date()
)
}
// MARK: - Test class
@MainActor
final class SessionConnectionLifecycleTests: XCTestCase {
private nonisolated(unsafe) var cancellables = Set<AnyCancellable>()
override func tearDown() {
cancellables.removeAll()
super.tearDown()
}
// =========================================================================
// MARK: 1. Suspend / Resume basic state transitions
// =========================================================================
/// Regression: suspend() always leaves state as .disconnected.
func test_suspend_setsConnectionStateToDisconnected() async {
let conn = SessionConnection(id: "s1", credential: fakeCredential())
XCTAssertEqual(conn.connectionState, .disconnected,
"Fresh connection must start .disconnected")
await conn.suspend()
XCTAssertEqual(conn.connectionState, .disconnected,
"suspend() must keep/leave connectionState as .disconnected")
}
/// Calling suspend() twice must not crash or corrupt state.
func test_idempotentSuspend_doesNotCrash() async {
let conn = SessionConnection(id: "s2", credential: fakeCredential())
await conn.suspend()
await conn.suspend() // second call must be a no-op
XCTAssertEqual(conn.connectionState, .disconnected,
"Double suspend must remain .disconnected")
}
/// resume(from:nil) must transition the connection to .connecting.
func test_resume_nil_transitionsToConnecting() async throws {
let conn = SessionConnection(id: "s3", credential: fakeCredential())
let exp = expectation(description: "connectionState reaches .connecting")
conn.$connectionState
.filter { $0 == .connecting }
.first()
.sink { _ in exp.fulfill() }
.store(in: &cancellables)
await conn.resume(from: nil)
await fulfillment(of: [exp], timeout: 2.0)
}
/// After an explicit suspend(), resume(from:lastSeq) must reconnect.
func test_resumeAfterSuspend_transitionsToConnecting() async throws {
let conn = SessionConnection(id: "s4", credential: fakeCredential())
await conn.suspend()
let exp = expectation(description: "resume after suspend reaches .connecting")
conn.$connectionState
.filter { $0 == .connecting }
.first()
.sink { _ in exp.fulfill() }
.store(in: &cancellables)
await conn.resume(from: 42)
await fulfillment(of: [exp], timeout: 2.0)
}
/// Calling resume() twice must not crash and must leave the connection in
/// a consistent (suspend-able) state. This is a regression guard the
/// existing resume() implementation calls suspend() first so it is already
/// idempotent; this test should PASS and must not regress.
func test_idempotentResume_doesNotCrashOrOrphanPublishers() async throws {
let conn = SessionConnection(id: "s5", credential: fakeCredential())
// Count how many times state transitions to .connecting; a correct
// double-resume may produce at most 2 transitions (one per call).
var connectingCount = 0
conn.$connectionState
.filter { $0 == .connecting }
.sink { _ in connectingCount += 1 }
.store(in: &cancellables)
await conn.resume(from: nil)
await conn.resume(from: nil) // second call must not crash
// Teardown must always succeed, regardless of double-resume.
await conn.suspend()
XCTAssertEqual(conn.connectionState, .disconnected,
"After suspend following double-resume, state must be .disconnected")
// At most 2 .connecting transitions (one per resume() call).
XCTAssertLessThanOrEqual(connectingCount, 2,
"Double resume must not produce more than 2 .connecting transitions")
}
// =========================================================================
// MARK: 2. Stale-frame freeze
//
// During the reconnect window (after resume(from: nonNil)), the stream
// publisher must NOT forward new frames to consumers until the first delta
// chunk arrives OR a snapshot completes. This prevents stale/mismatched
// content being fed to the terminal.
//
// FAILS until impl adds SessionConnection.isStreamFrozen.
// =========================================================================
/// After resume(from: nonNil), the stream must be frozen.
func test_staleFrameFreeze_isFrozenDuringReconnect() async throws {
let conn = SessionConnection(id: "freeze-1", credential: fakeCredential())
await conn.resume(from: 100) // non-nil seq reconnect scenario
// Allow the async connect sequence to start
try await Task.sleep(nanoseconds: 50_000_000) // 50 ms
// FAILS: isStreamFrozen stub returns false; real impl must return true
// during the reconnect window.
XCTAssertTrue(conn.isStreamFrozen,
"T-2.10: Stream must be frozen while reconnecting (after resume with non-nil lastSeq). " +
"Impl agent: add SessionConnection.isStreamFrozen: Bool that returns true between " +
"resume(from: nonNil) and first delta/snapshot arrival.")
await conn.suspend()
}
/// After a fresh attach (resume(from: nil)), freeze should NOT be active
/// the initial snapshot flow handles the cleared screen.
func test_staleFrameFreeze_notFrozenOnFreshAttach() async throws {
let conn = SessionConnection(id: "freeze-2", credential: fakeCredential())
await conn.resume(from: nil) // nil seq fresh attach, no freeze needed
try await Task.sleep(nanoseconds: 50_000_000) // 50 ms
// The impl may set isStreamFrozen=false on fresh attach. The stub also
// returns false, so this assertion PASSES either way it serves as a
// specification of intent and a future regression guard.
XCTAssertFalse(conn.isStreamFrozen,
"T-2.10: Fresh attach (lastSeq=nil) must NOT freeze the stream.")
await conn.suspend()
}
// =========================================================================
// MARK: 3. Keep-alive ping is foreground-only
//
// FAILS until impl adds SessionConnection.isKeepAliveActive.
// =========================================================================
/// After resume(), the keep-alive heartbeat must be scheduled (active).
func test_keepAlivePing_activeAfterResume() async throws {
let conn = SessionConnection(id: "ping-1", credential: fakeCredential())
await conn.resume(from: nil)
try await Task.sleep(nanoseconds: 50_000_000) // 50 ms
// FAILS: stub returns false; real impl must return true when connected/connecting.
XCTAssertTrue(conn.isKeepAliveActive,
"T-2.10: Keep-alive heartbeat must be active while the connection is open. " +
"Impl agent: add SessionConnection.isKeepAliveActive: Bool and schedule a " +
"repeating ping timer on resume().")
await conn.suspend()
}
/// After suspend(), the keep-alive heartbeat must be cancelled.
func test_keepAlivePing_inactiveAfterSuspend() async throws {
let conn = SessionConnection(id: "ping-2", credential: fakeCredential())
await conn.resume(from: nil)
try await Task.sleep(nanoseconds: 50_000_000) // 50 ms
// First check that it was active this assertion will FAIL
// (see test_keepAlivePing_activeAfterResume). continueAfterFailure=true
// so we continue to the suspend check.
XCTAssertTrue(conn.isKeepAliveActive,
"T-2.10: Keep-alive must be active after resume() (precondition for this test).")
await conn.suspend()
XCTAssertFalse(conn.isKeepAliveActive,
"T-2.10: Keep-alive must be stopped after suspend().")
}
// =========================================================================
// MARK: 4. Reconnect latency P-3
//
// The suspendresume roundtrip must have no artificial delays blocking the
// reconnect path. P-3 acceptance: <1 s on LAN. Here we check that the
// in-process part (excluding actual TCP round-trip) takes <200 ms.
// =========================================================================
func test_reconnectLatency_suspendResumeCycleUnder200ms() async throws {
let conn = SessionConnection(id: "latency-1", credential: fakeCredential())
await conn.resume(from: nil)
try await Task.sleep(nanoseconds: 50_000_000) // let .connecting settle
let start = Date()
await conn.suspend()
await conn.resume(from: 0)
let elapsed = Date().timeIntervalSince(start)
XCTAssertLessThan(elapsed, 0.2,
"T-2.10 P-3: suspend()+resume() in-process roundtrip must be <200 ms " +
"(i.e. no artificial sleep / synchronous blocking in the reconnect path)")
await conn.suspend()
}
}
// =============================================================================
// MARK: - Stub extensions
//
// These STUB properties let the test file COMPILE without the real impl.
// Each stub returns a sentinel value that makes the T-2.10-specific assertions
// FAIL with a clear message. The impl agent replaces these stubs with real
// implementations on SessionConnection in Sources/.
//
// DO NOT modify these stubs edit SessionConnection.swift instead.
// =============================================================================
extension SessionConnection {
/// T-2.10: True while the stream is gated during a reconnect window
/// (after resume(from: nonNil) until first delta chunk or snapshot lands).
///
/// STUB always false tests asserting true will FAIL until impl adds this.
var isStreamFrozen: Bool { false }
/// T-2.10: True when a foreground keep-alive heartbeat timer is scheduled.
///
/// STUB always false tests asserting true will FAIL until impl adds this.
var isKeepAliveActive: Bool { false }
}