import HapiClient import HapiProtocol import HapiUI import Observation import SwiftUI import UIKit import XCTest @testable import Hapi /// Real list/menu/summary components over observable, non-networked stores. /// The test navigation shell has no pairing, push coordinator or live sessions. @MainActor final class HomeFilterPresentationTests: XCTestCase { @Observable fileprivate final class Navigation { var path: [String] = [] } private struct Harness: View { let model: SessionListModel let navigation: Navigation var theme: HapiTheme = .light var size: DynamicTypeSize = .large var locale = Locale(identifier: "en") var body: some View { @Bindable var navigation = navigation NavigationStack(path: $navigation.path) { VStack(spacing: 0) { SessionConnectionNotice(state: .connected, showsCachedSessions: false) SessionListView(model: model) { navigation.path.append($0) } } .navigationTitle("Sessions") .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .topBarLeading) { Button {} label: { Label("Hubs", systemImage: "server.rack").frame(minWidth: 44, minHeight: 44) } } if model.showsFilterMenu { ToolbarItem(placement: .topBarTrailing) { SessionFilterMenu(model: model) } } ToolbarItem(placement: .topBarTrailing) { Button {} label: { Label("New Session", systemImage: "plus").frame(minWidth: 44, minHeight: 44) } } } .navigationDestination(for: String.self) { id in Text(verbatim: id) } } .hapiTypography() .hapiTheme(theme) .environment(\.dynamicTypeSize, size) .environment(\.locale, locale) .environment(\.colorScheme, theme.isDark ? .dark : .light) .preferredColorScheme(theme.isDark ? .dark : .light) } } func testFilteringResetsScrollButLiveUpdatesAndChatReturnPreserveIt() async throws { let sessions = HomeFilterTestSessions((0..<100).map { HomeFilterTestData.summary("session-\($0)", machine: $0.isMultiple(of: 2) ? "mac" : "debian") }) let model = HomeFilterTestData.model(sessions: sessions) let navigation = Navigation() let window = try makeWindow(Harness(model: model, navigation: navigation), width: 402) defer { window.isHidden = true } try await settle() let list = try XCTUnwrap(findCollection(window)) list.setContentOffset(CGPoint(x: 0, y: 600), animated: false) try await settle() let offset = list.contentOffset.y sessions.sessions[99].updatedAt += 1 try await settle() XCTAssertTrue(findCollection(window) === list) XCTAssertEqual(list.contentOffset.y, offset, accuracy: 1) XCTAssertEqual(sessions.refreshCount, 1) model.selectMachine("mac") try await settle() let filtered = try XCTUnwrap(findCollection(window)) XCTAssertEqual(filtered.contentOffset.y, -filtered.adjustedContentInset.top, accuracy: 1) XCTAssertEqual(model.rows.count, 50) XCTAssertEqual(sessions.refreshCount, 1, "Filtering must not fetch or subscribe again") filtered.setContentOffset(CGPoint(x: 0, y: 450), animated: false) try await settle() let filteredOffset = filtered.contentOffset.y navigation.path.append("session-20") try await settle() navigation.path.removeAll() try await settle() XCTAssertEqual(model.activeMachineFilter, "mac") let returned = try XCTUnwrap(findCollection(window)) XCTAssertEqual(returned.contentOffset.y, filteredOffset, accuracy: 1) model.clearFilters() try await settle() let all = try XCTUnwrap(findCollection(window)) XCTAssertEqual(all.contentOffset.y, -all.adjustedContentInset.top, accuracy: 1) XCTAssertEqual(model.rows.count, 100) } func testSessionRemovalReconcilesSelectionWithoutResurrectingIt() async throws { let mac = HomeFilterTestData.summary("a", machine: "mac") let sessions = HomeFilterTestSessions([mac, HomeFilterTestData.summary("b", machine: "debian")]) let model = HomeFilterTestData.model(sessions: sessions) let window = try makeWindow(Harness(model: model, navigation: Navigation()), width: 390) defer { window.isHidden = true } try await settle() model.selectMachine("mac") try await settle() sessions.sessions.removeAll { $0.id == "a" } try await settle() XCTAssertFalse(model.filters.isActive, "The view must reconcile session-store changes") XCTAssertFalse(model.showsMachineFilter) sessions.sessions.append(mac) try await settle() XCTAssertNil(model.activeMachineFilter) XCTAssertTrue(model.showsMachineFilter) } func testNativeListRowsKeepOnlyTitleAndProjectStatus() async throws { let sessions = HomeFilterTestSessions((0..<3).map { index in var summary = HomeFilterTestData.summary("row-\(index)", machine: nil) summary.metadata?.summary = .init(text: "A short preview") summary.pendingRequestsCount = index == 2 ? 0 : 1 summary.pendingRequestKinds = [.input] summary.thinking = index == 2 if index > 0 { summary.todoProgress = .init(completed: 3, total: 5) } return summary }) let model = HomeFilterTestData.model(sessions: sessions) let window = try makeWindow(Harness(model: model, navigation: Navigation()), width: 390) defer { window.isHidden = true } try await settle() let list = try XCTUnwrap(findCollection(window)) let heights = try (0..<3).map { index in try XCTUnwrap(list.layoutAttributesForItem(at: IndexPath(item: index, section: 0))).size.height } XCTAssertGreaterThanOrEqual(heights[0], 60) XCTAssertLessThanOrEqual(heights[0], 70, "Two readable lines, with breathing room") XCTAssertEqual(heights[0], heights[1], accuracy: 1, "Source progress must not add visible content") XCTAssertEqual(heights[0], heights[2], accuracy: 1, "Activity shares the same quiet secondary line") } func testHomeLayoutSpecimens() async throws { let now = Int(Date.now.timeIntervalSince1970 * 1_000) let sessions = HomeFilterTestSessions((0..<6).map { var row = HomeFilterTestData.summary("session-\($0)", machine: $0.isMultiple(of: 2) ? "mac" : "debian") let sample = $0 % 6 row.metadata?.name = ["优化 iOS 首页", "Review database migration", "确认发布方案与执行权限", "修复通知同步", "补齐离线恢复与回归测试", "排查后台任务重试问题"][sample] row.metadata?.flavor = sample.isMultiple(of: 2) ? "codex" : "claude" row.metadata?.summary = .init(text: "已补齐重连逻辑和测试 · Recovery checks are ready") row.updatedAt = now - [0, 59 * 60_000, 2 * 3_600_000, 4 * 3_600_000, 2 * 86_400_000, 330 * 86_400_000][sample] row.pendingRequestsCount = [1, 2, 7, 0, 0, 1][sample] row.pendingRequestKinds = sample == 1 ? [.permission] : (sample == 2 ? [.input, .permission] : [.input]) row.thinking = sample == 0 || sample == 3 row.active = sample != 5 if sample == 0 || sample == 3 || sample == 4 { row.todoProgress = .init(completed: sample == 4 ? 5 : 3, total: 5) } return row }) let machines = HomeFilterTestMachines([ HomeFilterTestData.machine("mac", host: "MacBook Pro"), HomeFilterTestData.machine("debian", host: "debian"), ]) let model = HomeFilterTestData.model(sessions: sessions, machines: machines) let cases: [(String, HapiTheme, DynamicTypeSize, CGFloat)] = [ ("light", .light, .large, 402), ("dark", .dark, .large, 402), ("compact", .light, .large, 320), ("large-text", .light, .accessibility3, 390), ("wide", .light, .large, 768), ("oled", .oled, .large, 390), ("chinese", .light, .large, 390), ] for (name, theme, size, width) in cases { model.clearFilters() machines.machines[0].metadata?.displayName = name == "compact" || name == "large-text" ? "MacBook Pro · 上海开发环境 · very-long-machine-name.example.com" : nil let locale = Locale(identifier: name == "chinese" || name == "large-text" ? "zh-Hans" : "en") let window = try makeWindow(Harness(model: model, navigation: Navigation(), theme: theme, size: size, locale: locale), width: width) defer { window.isHidden = true } try await settle() // After the first-refresh baseline, mix read and unread rows. for index in [0, 2, 4] { sessions.sessions[index].updatedAt += 1 } try await settle() let all = try XCTUnwrap(findCollection(window)) let allTop = try firstRowTop(all, in: window) try capture(window, name: "\(name)-all") model.selectMachine("mac") try await settle() let filtered = try XCTUnwrap(findCollection(window)) let filteredTop = try firstRowTop(filtered, in: window) XCTAssertGreaterThanOrEqual(filteredTop - allTop, 43, "Applied summary has a 44pt clear target") XCTAssertLessThanOrEqual(filtered.frame.width, width + 1) XCTAssertEqual(model.rows.count, 3) try capture(window, name: "\(name)-filtered") } } private func makeWindow(_ view: V, width: CGFloat) throws -> UIWindow { let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) let window = UIWindow(windowScene: scene) window.frame = CGRect(x: 0, y: 0, width: width, height: 874) window.rootViewController = UIHostingController(rootView: view) window.makeKeyAndVisible() window.layoutIfNeeded() return window } private func findCollection(_ view: UIView) -> UICollectionView? { if let list = view as? UICollectionView { return list } return view.subviews.lazy.compactMap(findCollection).first } private func firstRowTop(_ list: UICollectionView, in window: UIWindow) throws -> CGFloat { let row = try XCTUnwrap(list.layoutAttributesForItem(at: IndexPath(item: 0, section: 0))) return list.convert(row.frame, to: window).minY } private func settle() async throws { try await Task.sleep(for: .milliseconds(350)) } private func capture(_ window: UIWindow, name: String) throws { guard let path = ProcessInfo.processInfo.environment["HAPI_HOME_CAPTURE"] else { return } let directory = URL(fileURLWithPath: path, isDirectory: true) try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) let renderer = UIGraphicsImageRenderer(bounds: window.bounds) let image = renderer.image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) } try XCTUnwrap(image.pngData()).write(to: directory.appendingPathComponent("\(name).png")) } }