feat(ios): W4 app wiring — DI graph, event fan-out, lifecycle, privacy shade
T-iOS-15: AppEnvironment production DI (real Keychain/probe/transports), EventFanOut (engine.events → TerminalVM + GateVM + activity bridge), scenePhase lifecycle (.background→close, suspend→rebuild+reopen reclaims size), privacy shade on != .active, spike screen deleted, cold-start routing Walkthrough proxies: hosted live-server smoke over the production DI graph (probe→store→ attach→echo→close) + real-Keychain kSecAttrAccessible assertion (closes T-iOS-7 deferral) Deviation closed: TerminalViewModel.lastSentDims (valid-only) + alive-engine .active → notifyForegrounded(dims:) (orchestrator fix on behalf of T-iOS-11 owner) Env finding: repo under ~/Documents → TCC blocks sim-spawned node; SimServerHarness dual-mode (self-bootstrap / WEBTERM_SERVER_URL via simctl launchctl setenv) Verified: 214 pkg + 76 app + 10 integration tests green; verify agent 8/8 PASS
This commit is contained in:
79
ios/App/WebTermTests/EventFanOutTests.swift
Normal file
79
ios/App/WebTermTests/EventFanOutTests.swift
Normal file
@@ -0,0 +1,79 @@
|
||||
import Testing
|
||||
@testable import WebTerm
|
||||
|
||||
/// T-iOS-15 · Fan-out adapter tests. `SessionEngine.events` is a
|
||||
/// single-consumer `AsyncStream`, but TerminalViewModel, GateViewModel AND the
|
||||
/// session-activity bridge must all observe it — `EventFanOut` is the wiring
|
||||
/// layer's broadcast seam. These tests pin its contract: every branch receives
|
||||
/// every element, in order, and source termination / cancel() propagate.
|
||||
@Suite("EventFanOut")
|
||||
struct EventFanOutTests {
|
||||
private static let branchCount = 3
|
||||
|
||||
@Test("每个分支都按序收到全部元素")
|
||||
func allBranchesReceiveAllElementsInOrder() async {
|
||||
// Arrange
|
||||
let (source, continuation) = AsyncStream<Int>.makeStream()
|
||||
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||
|
||||
// Act
|
||||
for value in 1...5 {
|
||||
continuation.yield(value)
|
||||
}
|
||||
continuation.finish()
|
||||
|
||||
// Assert: unbounded buffering — late consumption still sees everything.
|
||||
for branch in fanOut.branches {
|
||||
var received: [Int] = []
|
||||
for await value in branch {
|
||||
received.append(value)
|
||||
}
|
||||
#expect(received == [1, 2, 3, 4, 5])
|
||||
}
|
||||
}
|
||||
|
||||
@Test("分支数量与请求一致")
|
||||
func branchCountMatchesRequest() {
|
||||
let (source, _) = AsyncStream<Int>.makeStream()
|
||||
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||
#expect(fanOut.branches.count == Self.branchCount)
|
||||
}
|
||||
|
||||
@Test("源结束 → 所有分支结束(消费循环退出,不悬挂)")
|
||||
func sourceFinishFinishesEveryBranch() async {
|
||||
// Arrange
|
||||
let (source, continuation) = AsyncStream<String>.makeStream()
|
||||
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||
|
||||
// Act
|
||||
continuation.yield("only")
|
||||
continuation.finish()
|
||||
|
||||
// Assert: every branch loop terminates after draining.
|
||||
for branch in fanOut.branches {
|
||||
var count = 0
|
||||
for await _ in branch {
|
||||
count += 1
|
||||
}
|
||||
#expect(count == 1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test("cancel() → 分支立即终止(teardown 不泄漏消费任务)")
|
||||
func cancelTerminatesBranches() async {
|
||||
// Arrange
|
||||
let (source, continuation) = AsyncStream<Int>.makeStream()
|
||||
let fanOut = EventFanOut(source: source, branchCount: Self.branchCount)
|
||||
|
||||
// Act: cancel without ever finishing the source.
|
||||
fanOut.cancel()
|
||||
continuation.yield(42) // post-cancel input must not hang consumers
|
||||
|
||||
// Assert: iteration completes (content is unspecified mid-flight —
|
||||
// termination is the contract under test).
|
||||
for branch in fanOut.branches {
|
||||
for await _ in branch {}
|
||||
}
|
||||
#expect(Bool(true)) // reaching here = no hang
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user