import APIClient import Foundation import Testing import UIKit import WireProtocol @testable import WebTerm /// T-iOS-28 · 会话缩略图(offscreen SwiftTerm)。 /// /// 覆盖面(Steps 测试先行): /// - 缓存键 (sessionId, lastOutputAt):未变化的会话绝不重渲染;lastOutputAt /// 变化 → 新键重渲染;nil(P1 前旧服务器)→ 渲染一次后永久命中(无失效 /// 信号时宁可陈旧也不无界重渲染,文档化降级); /// - 并发上限调度器:离屏渲染受具名常量上限(2)约束,列表滚动绝不无界 /// spawn 终端;排队 FIFO、permit 转移;全程零真实等待(continuation 屏障, /// 同 FakeClock.waitForSleepers 手法); /// - 404/错误/校验失败 → placeholder(且同键缓存失败结果,防滚动打爆故障主机); /// - 预览数据是不可信字节:id 不匹配 / data 超限 / 几何出 resizeRange → /// placeholder 且 renderer 不被调用(data 只喂 SwiftTerm,永不字符串处理); /// - 真像素冒烟(仅模拟器):小 ANSI 样例 → 非 nil、非单色快照。 @MainActor @Suite("SessionThumbnail") struct SessionThumbnailTests { // MARK: - Fixtures private static func makeEndpoint() -> HostEndpoint { guard let url = URL(string: "http://192.168.1.20:3000"), let endpoint = HostEndpoint(baseURL: url) else { fatalError("fixture endpoint must be valid") } return endpoint } private static func makeRequest( sessionId: UUID = UUID(), lastOutputAt: Int? = 1_000 ) -> SessionThumbnailRequest { SessionThumbnailRequest( endpoint: makeEndpoint(), sessionId: sessionId, lastOutputAt: lastOutputAt ) } private static func makePreview( for request: SessionThumbnailRequest, cols: Int = 80, rows: Int = 24, data: String = "hello" ) -> SessionPreview { SessionPreview(id: request.sessionId, cols: cols, rows: rows, data: data) } /// 主 actor 上的调用计数器(loader/renderer 侧记录)。 @MainActor private final class Recorder { private(set) var loaderCalls: [SessionThumbnailRequest] = [] private(set) var rendererCalls: [(data: String, cols: Int, rows: Int)] = [] func recordLoad(_ request: SessionThumbnailRequest) { loaderCalls = loaderCalls + [request] } func recordRender(data: String, cols: Int, rows: Int) { rendererCalls = rendererCalls + [(data, cols, rows)] } } /// 可控放行的 loader 闸(测试决定何时放行;open 后一路直通)。 /// 屏障用 continuation,不轮询不真睡(与 FakeClock.waitForSleepers 同手法)。 @MainActor private final class LoaderHold { private var held: [CheckedContinuation] = [] private var barriers: [(target: Int, cont: CheckedContinuation)] = [] private var isOpen = false var heldCount: Int { held.count } func pass() async { if isOpen { return } await withCheckedContinuation { cont in held = held + [cont] notifyBarriers() } } /// 挂起直到至少 target 个 loader 停在闸上。 func waitUntilHeld(count target: Int) async { if held.count >= target { return } await withCheckedContinuation { cont in barriers = barriers + [(target, cont)] } } /// 放行当前所有被扣住的 loader,并让后续 loader 直通。 func open() { isOpen = true let releasing = held held = [] for cont in releasing { cont.resume() } } private func notifyBarriers() { let met = barriers.filter { $0.target <= held.count } barriers = barriers.filter { $0.target > held.count } for barrier in met { barrier.cont.resume() } } } private static func makePipeline( recorder: Recorder, gate: SessionThumbnailRenderGate = SessionThumbnailRenderGate( limit: SessionThumbnailTunable.maxConcurrentRenders ), maxCacheEntries: Int = SessionThumbnailTunable.maxCacheEntries, hold: LoaderHold? = nil, loaderError: (any Error)? = nil, preview: ((SessionThumbnailRequest) -> SessionPreview)? = nil, rendered: UIImage? = UIImage() ) -> SessionThumbnailPipeline { SessionThumbnailPipeline( loader: { request in if let hold { await hold.pass() } recorder.recordLoad(request) if let loaderError { throw loaderError } return preview?(request) ?? Self.makePreview(for: request) }, renderer: { data, cols, rows in recorder.recordRender(data: data, cols: cols, rows: rows) return rendered }, gate: gate, maxCacheEntries: maxCacheEntries ) } // MARK: - 缓存键 (sessionId, lastOutputAt) @Test("同键第二次请求命中缓存——loader 只调一次,返回同一图像") func sameKeyHitsCache() async { let recorder = Recorder() let pipeline = Self.makePipeline(recorder: recorder) let request = Self.makeRequest() let first = await pipeline.thumbnail(for: request) let second = await pipeline.thumbnail(for: request) #expect(recorder.loaderCalls.count == 1) #expect(first == second) #expect(!first.isPlaceholder) } @Test("lastOutputAt 变化(会话有新输出)→ 新键重新渲染") func newerLastOutputAtRerenders() async { let recorder = Recorder() let pipeline = Self.makePipeline(recorder: recorder) let sessionId = UUID() _ = await pipeline.thumbnail(for: Self.makeRequest(sessionId: sessionId, lastOutputAt: 1_000)) _ = await pipeline.thumbnail(for: Self.makeRequest(sessionId: sessionId, lastOutputAt: 2_000)) #expect(recorder.loaderCalls.count == 2) } @Test("lastOutputAt 为 nil(P1 前服务器)→ 渲染一次后命中缓存(文档化降级)") func nilLastOutputAtCachesOnce() async { let recorder = Recorder() let pipeline = Self.makePipeline(recorder: recorder) let request = Self.makeRequest(lastOutputAt: nil) _ = await pipeline.thumbnail(for: request) _ = await pipeline.thumbnail(for: request) #expect(recorder.loaderCalls.count == 1) } @Test("并发去重:同键两个并发请求 → loader 一次、两个调用者拿到同一结果") func concurrentSameKeyDeduplicates() async { let recorder = Recorder() let hold = LoaderHold() let pipeline = Self.makePipeline(recorder: recorder, hold: hold) let request = Self.makeRequest() let taskA = Task { await pipeline.thumbnail(for: request) } let taskB = Task { await pipeline.thumbnail(for: request) } await hold.waitUntilHeld(count: 1) // 只有一个 loader 在飞 hold.open() let a = await taskA.value let b = await taskB.value #expect(recorder.loaderCalls.count == 1) #expect(a == b) } // MARK: - 并发上限调度器(滚动不得无界 spawn 离屏终端) @Test("5 个不同键并发:同时在渲染管线内的至多 maxConcurrentRenders(2),其余排队") func concurrencyCapHoldsUnderBurst() async { let recorder = Recorder() let gate = SessionThumbnailRenderGate(limit: SessionThumbnailTunable.maxConcurrentRenders) let hold = LoaderHold() let pipeline = Self.makePipeline(recorder: recorder, gate: gate, hold: hold) let requests = (0..<5).map { _ in Self.makeRequest() } let tasks = requests.map { request in Task { await pipeline.thumbnail(for: request) } } await hold.waitUntilHeld(count: 2) // 两个已过闸进入 loader await gate.waitUntilWaiting(count: 3) // 其余三个停在渲染闸上 #expect(gate.activeCount == 2) #expect(gate.peakActiveCount == 2) #expect(hold.heldCount == 2) hold.open() for task in tasks { _ = await task.value } #expect(gate.peakActiveCount == SessionThumbnailTunable.maxConcurrentRenders) #expect(recorder.loaderCalls.count == 5) #expect(gate.activeCount == 0) } @Test("gate FIFO:permit 按排队顺序转移") func gateIsFifo() async { let gate = SessionThumbnailRenderGate(limit: 1) await gate.acquire() let order = OrderBox() let third = Task { await gate.acquire() order.append(3) gate.release() } await gate.waitUntilWaiting(count: 1) let fourth = Task { await gate.acquire() order.append(4) gate.release() } await gate.waitUntilWaiting(count: 2) gate.release() // permit 依次传给 3 再 4 _ = await third.value _ = await fourth.value #expect(order.values == [3, 4]) #expect(gate.activeCount == 0) } @MainActor private final class OrderBox { private(set) var values: [Int] = [] func append(_ value: Int) { values = values + [value] } } // MARK: - placeholder 路径(404/错误/校验失败) @Test("loader 抛错(如 404 sessionNotFound)→ placeholder,且失败同键缓存不重试") func loaderFailureYieldsCachedPlaceholder() async { let recorder = Recorder() let pipeline = Self.makePipeline( recorder: recorder, loaderError: APIClientError.sessionNotFound ) let request = Self.makeRequest() let first = await pipeline.thumbnail(for: request) let second = await pipeline.thumbnail(for: request) #expect(first.isPlaceholder) #expect(second.isPlaceholder) #expect(recorder.loaderCalls.count == 1) // 失败也按键缓存 #expect(recorder.rendererCalls.isEmpty) } @Test("renderer 返回 nil(快照失败)→ placeholder") func rendererNilYieldsPlaceholder() async { let recorder = Recorder() let pipeline = Self.makePipeline(recorder: recorder, rendered: nil) let result = await pipeline.thumbnail(for: Self.makeRequest()) #expect(result.isPlaceholder) #expect(recorder.rendererCalls.count == 1) } // MARK: - 不可信服务器数据(renderer 不被调用即为「拒于边界」) @Test("preview.id 与请求的 sessionId 不匹配 → placeholder,renderer 不调") func mismatchedPreviewIdRejected() async { let recorder = Recorder() let pipeline = Self.makePipeline(recorder: recorder, preview: { _ in SessionPreview(id: UUID(), cols: 80, rows: 24, data: "x") // id ≠ request.sessionId }) let result = await pipeline.thumbnail(for: Self.makeRequest()) #expect(result.isPlaceholder) #expect(recorder.rendererCalls.isEmpty) } @Test("data 超出 maxPreviewDataBytes → placeholder,renderer 不调") func oversizedPreviewDataRejected() async { let recorder = Recorder() let oversized = String( repeating: "a", count: SessionThumbnailTunable.maxPreviewDataBytes + 1 ) let pipeline = Self.makePipeline(recorder: recorder, preview: { request in Self.makePreview(for: request, data: oversized) }) let result = await pipeline.thumbnail(for: Self.makeRequest()) #expect(result.isPlaceholder) #expect(recorder.rendererCalls.isEmpty) } @Test("几何出服务器 resizeRange(0 / 1001)→ placeholder,renderer 不调", arguments: [ (0, 24), (80, 0), (1_001, 24), (80, 1_001), ]) func outOfRangeGeometryRejected(cols: Int, rows: Int) async { let recorder = Recorder() let pipeline = Self.makePipeline(recorder: recorder, preview: { request in Self.makePreview(for: request, cols: cols, rows: rows) }) let result = await pipeline.thumbnail(for: Self.makeRequest()) #expect(result.isPlaceholder) #expect(recorder.rendererCalls.isEmpty) } @Test("几何钳制:合法值透传;1→2(镜像 web max(2,·));大值钳到缩略图上限") func geometryClamping() { let normal = SessionThumbnailPipeline.clampedGeometry(cols: 80, rows: 24) #expect(normal?.cols == 80 && normal?.rows == 24) let tiny = SessionThumbnailPipeline.clampedGeometry(cols: 1, rows: 1) #expect(tiny?.cols == SessionThumbnailTunable.minRenderGrid) #expect(tiny?.rows == SessionThumbnailTunable.minRenderGrid) let huge = SessionThumbnailPipeline.clampedGeometry(cols: 1_000, rows: 1_000) #expect(huge?.cols == SessionThumbnailTunable.maxRenderCols) #expect(huge?.rows == SessionThumbnailTunable.maxRenderRows) #expect(SessionThumbnailPipeline.clampedGeometry(cols: 0, rows: 24) == nil) #expect(SessionThumbnailPipeline.clampedGeometry(cols: 80, rows: 1_001) == nil) } @Test("渲染几何真被钳制后才交给 renderer") func rendererReceivesClampedGeometry() async { let recorder = Recorder() let pipeline = Self.makePipeline(recorder: recorder, preview: { request in Self.makePreview(for: request, cols: 1_000, rows: 1) }) _ = await pipeline.thumbnail(for: Self.makeRequest()) #expect(recorder.rendererCalls.count == 1) #expect(recorder.rendererCalls.first?.cols == SessionThumbnailTunable.maxRenderCols) #expect(recorder.rendererCalls.first?.rows == SessionThumbnailTunable.minRenderGrid) } // MARK: - LRU 缓存 @Test("LRU:容量 3 时命中会前移——插 A,B,C、命中 A、插 D → 逐出 B,A/C/D 仍在") func lruEvictsLeastRecentlyUsed() async { let recorder = Recorder() let pipeline = Self.makePipeline(recorder: recorder, maxCacheEntries: 3) let a = Self.makeRequest() let b = Self.makeRequest() let c = Self.makeRequest() let d = Self.makeRequest() _ = await pipeline.thumbnail(for: a) _ = await pipeline.thumbnail(for: b) _ = await pipeline.thumbnail(for: c) _ = await pipeline.thumbnail(for: a) // bump A → LRU 现在是 B _ = await pipeline.thumbnail(for: d) // 逐出 B #expect(recorder.loaderCalls.count == 4) _ = await pipeline.thumbnail(for: b) // B 已被逐出 → 重新加载 #expect(recorder.loaderCalls.count == 5) _ = await pipeline.thumbnail(for: a) // C 被 B 的回插逐出也无妨——A 仍应在 #expect(recorder.loaderCalls.count == 5) } // MARK: - 常量与文案 @Test("具名常量在位:并发上限 2、缓存上限 > 0、数据上限覆盖服务器默认 24KB") func tunablesArePinned() { #expect(SessionThumbnailTunable.maxConcurrentRenders == 2) #expect(SessionThumbnailTunable.maxCacheEntries > 0) // 服务器默认 previewBytes = 24*1024(src/config.ts:46,env 可调)—— // 客户端防御上限必须至少覆盖默认值。 #expect(SessionThumbnailTunable.maxPreviewDataBytes >= 24 * 1024) #expect(SessionThumbnailTunable.minRenderGrid == 2) } @Test("用户可见文案:中文具名常量、非空") func copyIsNonEmptyChinese() { #expect(!SessionThumbnailCopy.thumbnailLabel.isEmpty) } // MARK: - 像素冒烟(仅模拟器;真 SwiftTerm 离屏渲染) #if targetEnvironment(simulator) @Test("离屏渲染小 ANSI 样例 → 非 nil、宽>高、非单色、宽度受快照预算约束") func rendererSmokeProducesNonBlankImage() throws { let sample = "\u{1b}[31mRED\u{1b}[0m plain \u{1b}[44m BLUE-BG \u{1b}[0m" let image = SessionThumbnailRenderer.render(data: sample, cols: 20, rows: 5) let snapshot = try #require(image) #expect(snapshot.size.width > 0 && snapshot.size.height > 0) #expect(snapshot.size.width > snapshot.size.height) // 20×5 网格是横幅 #expect(snapshot.size.width <= SessionThumbnailRenderer.snapshotTargetWidth + 1) #expect(Self.hasMultipleColors(snapshot)) // 空白/纯色 = 渲染管线断了 } /// 把快照解到 RGBA 缓冲里扫描:出现 ≥2 种像素值即「画上东西了」。 private static func hasMultipleColors(_ image: UIImage) -> Bool { guard let cgImage = image.cgImage else { return false } let width = min(cgImage.width, 64) let height = min(cgImage.height, 64) let bytesPerPixel = 4 var buffer = [UInt8](repeating: 0, count: width * height * bytesPerPixel) guard let context = CGContext( data: &buffer, width: width, height: height, bitsPerComponent: 8, bytesPerRow: width * bytesPerPixel, space: CGColorSpaceCreateDeviceRGB(), bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue ) else { return false } context.draw(cgImage, in: CGRect(x: 0, y: 0, width: width, height: height)) let first = Array(buffer.prefix(bytesPerPixel)) for pixel in stride(from: 0, to: buffer.count, by: bytesPerPixel) where Array(buffer[pixel..<(pixel + bytesPerPixel)]) != first { return true } return false } #endif }