import AgentKit import ListViewKit import SwiftUI import TermindApplication import TermindPresentation import Testing import UIKit @testable import TermindIOS @Suite(.serialized) @MainActor struct MobileAssistantTranscriptTests { @Test func userBubbleShrinksForShortTextAndWrapsAtTheAvailableWidth() { func measuredWidth(_ text: String) -> CGFloat { let controller = UIHostingController(rootView: MobileAssistantUserBubble(text: text)) return controller.sizeThatFits(in: CGSize(width: 320, height: 1_000)).width } let short = measuredWidth("Hi") let long = measuredWidth(String(repeating: "A long message that should wrap. ", count: 20)) #expect(short < 160) #expect(long > short) #expect(long <= 320) } @Test func imageFlowKeepsEveryUserRowTrailingAndAssistantRowsLeading() { let tile = CGSize(width: 104, height: 104) let sizes = Array(repeating: tile, count: 5) let user = MobileAssistantImageFlowLayout.placements( for: sizes, in: 320, spacing: 8, alignment: .trailing ) #expect(user.map(\.index) == [0, 1, 2, 3, 4]) #expect(user.map(\.size) == sizes) #expect(user[0].origin == CGPoint(x: 104, y: 0)) #expect(user[1].origin == CGPoint(x: 216, y: 0)) #expect(user[2].origin == CGPoint(x: 104, y: 112)) #expect(user[3].origin == CGPoint(x: 216, y: 112)) #expect(user[4].origin == CGPoint(x: 216, y: 224)) #expect(user[1].origin.x + user[1].size.width == 320) #expect(user[3].origin.x + user[3].size.width == 320) #expect(user[4].origin.x + user[4].size.width == 320) let assistant = MobileAssistantImageFlowLayout.placements( for: sizes, in: 320, spacing: 8, alignment: .leading ) #expect(assistant[0].origin.x == 0) #expect(assistant[2].origin.x == 0) #expect(assistant[4].origin.x == 0) } @Test func scanningStopsForReducedMotionMeasurementAndInactiveScenes() async throws { // This owns a foreground window, so run it with the opt-in visual checks // rather than competing with unrelated native toolbar tests. guard ProcessInfo.processInfo.environment["TERMIND_ASSISTANT_SCREENSHOTS"] == "1" else { return } let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) for (displayed, reduced, active) in [ (true, false, true), (true, true, true), (false, false, true), (true, false, false), ] { let window = UIWindow(windowScene: scene) let controller = UIHostingController( rootView: Text("Working…").font(.title).foregroundStyle(.black) .modifier(MobileAssistantScanning(active: true)) .frame(maxWidth: .infinity, maxHeight: .infinity).background(.white) .environment(\.mobileListRowIsDisplayed, displayed) .environment(\.mobileListReduceMotion, reduced) .environment(\.mobileListSceneIsActive, active) ) window.rootViewController = controller window.windowLevel = .alert + 3 window.makeKeyAndVisible() defer { window.isHidden = true window.rootViewController = nil } controller.view.layoutIfNeeded() try await Task.sleep(for: .milliseconds(300)) func snapshot() throws -> Data { let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in window.drawHierarchy(in: window.bounds, afterScreenUpdates: true) } return try #require(image.pngData()) } let first = try snapshot() try await Task.sleep(for: .milliseconds(250)) let second = try snapshot() let directory = FileManager.default.temporaryDirectory.appending(path: "assistant-screenshots") try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) try first.write(to: directory.appending(path: "motion-\(displayed)-\(reduced)-\(active)-first.png")) try second.write(to: directory.appending(path: "motion-\(displayed)-\(reduced)-\(active)-second.png")) if displayed && !reduced && active { #expect(first != second, "The live title should scan.") } else { #expect(first == second, "A static title must not animate.") } } } @Test func streamedUpdatesAndDisclosureRevisionsKeepTheHostedView() throws { let harness = Harness() let first = message("First") let second = message("Second") harness.update([first, second]) let row = try #require(harness.list.rowView(for: first.id)) let hosted = try #require(row.subviews.first?.subviews.first) harness.renderedIDs.removeAll() var streaming = first streaming.text += " delta" harness.update([streaming, second]) #expect(harness.list.rowView(for: first.id) === row) #expect(row.subviews.first?.subviews.first === hosted) #expect(!harness.renderedIDs.contains(second.id)) harness.renderedIDs.removeAll() harness.revisions[first.id] = 1 harness.update([streaming, second]) #expect(harness.renderedIDs.contains(first.id)) #expect(!harness.renderedIDs.contains(second.id)) #expect(row.subviews.first?.subviews.first === hosted) } @Test func appendingDoesNotReplaceExistingRowsOrFollowWhileReadingHistory() async throws { let harness = Harness() let items = (0..<30).map { message("Message \($0)") } harness.update(items) harness.list.setContentOffset(CGPoint(x: 0, y: 120), animated: false) harness.list.layoutIfNeeded() let offset = harness.list.contentOffset.y let visible = try #require(harness.list.indicesForVisibleRows.first) let row = try #require(harness.list.rowView(for: items[visible].id)) harness.update(items + [message("New tail")], followsBottom: true) try await Task.sleep(for: .milliseconds(30)) #expect(harness.list.rowView(for: items[visible].id) === row) #expect(abs(harness.list.contentOffset.y - offset) < 1) } @Test func visibleWorkCollapsesOnlyAfterItFullyLeavesTheMobileViewport() async throws { typealias Adapter = MobileNativeList let presentation = AssistantTranscriptPresentationState() let coordinator = Adapter.Coordinator() let list = ListView() list.frame = CGRect(x: 0, y: 0, width: 390, height: 180) list.rows { ListRow(MobileHostingListRow.self).height { _, _ in 100 }.configure { row, item, _ in coordinator.configure(row, item: item, surface: .plain) } } coordinator.observeViewport(of: list) defer { coordinator.stopObservingViewport() } let id = UUID() let tail = (0..<12).map { _ in AssistantTranscriptEntry(id: UUID(), content: .empty) } func update(_ state: AgentRunState) { let run = AgentRunSummary(id: id, state: state, startedAt: .now) let entry = AssistantTranscriptEntry( id: id, content: .work( .init( run: run, items: [], orderedAt: .now, appearsAfterAnswer: false ))) coordinator.update( listView: list, items: [entry] + tail, renderer: { _ in AnyView(Text("Work")) }, onSelect: nil, onRefresh: nil, followsBottom: false, surface: .plain, refreshID: 0, locale: Locale(identifier: "en"), colorScheme: .light, dynamicTypeSize: .large, prepareUpdate: { presentation.prepareWorkCompletionTransitions(from: $0, to: $1, visibleIDs: $2) }, onViewportChange: { presentation.collapseWorkOutsideViewport(visibleIDs: $0) } ) list.layoutIfNeeded() } update(.running) update(.completed) #expect(presentation.hasPendingAutoCollapse(id)) let frame = list.rectForRow(with: id) list.setContentOffset(CGPoint(x: 0, y: frame.maxY - 1), animated: false) list.layoutIfNeeded() try await Task.sleep(for: .milliseconds(30)) #expect(presentation.isExpanded(.work(id))) list.setContentOffset(CGPoint(x: 0, y: frame.maxY + 1), animated: false) list.layoutIfNeeded() try await Task.sleep(for: .milliseconds(30)) #expect(!presentation.isExpanded(.work(id))) #expect(!presentation.hasPendingAutoCollapse(id)) } @Test func nativeListTapCallbackRecognizesAlongsideHostedRowControls() throws { typealias Adapter = MobileNativeList let coordinator = Adapter.Coordinator() let list = ListView() var tapCount = 0 coordinator.installTapRecognizer(on: list) coordinator.update( listView: list, items: [], renderer: { _ in AnyView(Text("Message")) }, onSelect: nil, onRefresh: nil, followsBottom: false, surface: .plain, refreshID: 0, locale: Locale(identifier: "en"), colorScheme: .light, dynamicTypeSize: .large, onTap: { tapCount += 1 } ) let tap = try #require(list.gestureRecognizers?.compactMap { $0 as? UITapGestureRecognizer }.first) #expect(tap.cancelsTouchesInView == false) #expect(tap.delegate === coordinator) #expect( coordinator.gestureRecognizer( tap, shouldRecognizeSimultaneouslyWith: UITapGestureRecognizer() )) coordinator.listTapped() #expect(tapCount == 1) } @Test func hostedRowsLeaveSelectionAndContextMenuTapCoordinationToSwiftUI() throws { let row = MobileHostingListRow() row.configure(rootView: AnyView(Text("File")), selectable: true, backgroundColor: .systemBackground) let contentContainer = try #require(row.subviews.first) let gestures = contentContainer.gestureRecognizers ?? [] #expect(!gestures.contains { $0 is UITapGestureRecognizer }) #expect(gestures.contains { $0 is UILongPressGestureRecognizer }) } @Test func independentToolHostingRootRetainsNavigationWhenReconfiguredOffscreen() async throws { let row = MobileHostingListRow() row.frame = CGRect(x: 0, y: 0, width: 390, height: 120) var navigation: (@MainActor (AgentToolDetail.NavigationReference) -> Void)? var destinations: [UUID] = [] var handlers: [Int] = [] let destination = UUID() func configure(handler: Int) { row.configure( rootView: AnyView( MobileAssistantTranscriptRowRoot(navigate: { reference in guard case .chat(let id) = reference else { return } destinations.append(id) handlers.append(handler) }) { AssistantToolDetails(items: [ .field(.init(label: "Chat", value: "Open conversation", navigation: .chat(destination))) ]) .background { ToolNavigationProbe { navigation = $0 } } }), selectable: false, backgroundColor: .systemBackground ) row.setNeedsLayout() row.layoutIfNeeded() _ = row.systemLayoutSizeFitting( CGSize(width: 390, height: UIView.layoutFittingCompressedSize.height), withHorizontalFittingPriority: .required, verticalFittingPriority: .fittingSizeLevel ) } configure(handler: 1) try await Task.sleep(for: .milliseconds(20)) let hosted = try #require(row.subviews.first?.subviews.first) let firstNavigation = try #require(navigation) firstNavigation(.chat(destination)) #expect(destinations == [destination]) #expect(handlers == [1]) navigation = nil configure(handler: 2) try await Task.sleep(for: .milliseconds(20)) #expect(row.subviews.first?.subviews.first === hosted) let currentNavigation = try #require(navigation) let nextDestination = UUID() currentNavigation(.chat(nextDestination)) #expect(destinations == [destination, nextDestination]) #expect(handlers == [1, 2]) #expect(row.window == nil) } private struct ToolNavigationProbe: UIViewRepresentable { @Environment(\.assistantToolNavigation) private var navigate typealias Navigation = @MainActor (AgentToolDetail.NavigationReference) -> Void let receive: (Navigation?) -> Void func makeUIView(context: Context) -> UIView { receive(navigate) return UIView() } func updateUIView(_ uiView: UIView, context: Context) { receive(navigate) } } @Test(.requiresKeyWindow) func hostedToolNavigationUsesTheCurrentReferenceAndHandlerAfterRowReuse() async throws { let scene = try #require(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first) let window = UIWindow(windowScene: scene) let controller = UIViewController() window.rootViewController = controller window.makeKeyAndVisible() defer { window.isHidden = true window.rootViewController = nil } let row = MobileHostingListRow() row.frame = CGRect(x: 0, y: 0, width: 390, height: 120) controller.view.addSubview(row) var selected: [UUID] = [] var handlers: [Int] = [] let firstID = UUID() let secondID = UUID() func configure(reference: UUID, handler: Int) { row.configure( rootView: AnyView( MobileAssistantTranscriptRowRoot(navigate: { reference in guard case .chat(let id) = reference else { return } selected.append(id) handlers.append(handler) }) { AssistantToolDetails(items: [ .field(.init(label: "Chat", value: "Open conversation", navigation: .chat(reference))) ]) }), selectable: false, backgroundColor: .systemBackground ) controller.view.layoutIfNeeded() row.layoutIfNeeded() } configure(reference: firstID, handler: 1) try await Task.sleep(for: .milliseconds(50)) let hosted = try #require(row.subviews.first?.subviews.first) let firstButton = try #require(navigationButton(in: row)) #expect(firstButton.accessibilityActivate()) #expect(selected == [firstID]) #expect(handlers == [1]) configure(reference: secondID, handler: 2) try await Task.sleep(for: .milliseconds(50)) #expect(row.subviews.first?.subviews.first === hosted) let reusedButton = try #require(navigationButton(in: row)) #expect(reusedButton.accessibilityActivate()) #expect(selected == [firstID, secondID]) #expect(handlers == [1, 2]) } private func navigationButton(in root: NSObject) -> NSObject? { var seen = Set() func visit(_ element: NSObject) -> NSObject? { guard seen.insert(ObjectIdentifier(element)).inserted else { return nil } if element.accessibilityTraits.contains(.button), element.accessibilityLabel == "Open conversation" { return element } var children: [NSObject] = (element as? UIView)?.subviews ?? [] let count = element.accessibilityElementCount() if count != NSNotFound, count > 0 { children += (0.. ChatMessage { .init(role: .assistant, text: text, isStreaming: true) } private final class Harness { let coordinator = MobileNativeList.Coordinator() let list = ListView() var revisions: [UUID: UInt64] = [:] var renderedIDs: [UUID] = [] init() { list.frame = CGRect(x: 0, y: 0, width: 390, height: 280) list.rows { ListRow(MobileHostingListRow.self).height { _, _ in 80 }.configure { [weak self] row, item, _ in self?.coordinator.configure(row, item: item, surface: .plain) } } } func update(_ items: [ChatMessage], followsBottom: Bool = false) { coordinator.update( listView: list, items: items, renderer: { [weak self] item in self?.renderedIDs.append(item.id) return AnyView(Text(item.text)) }, onSelect: nil, onRefresh: nil, followsBottom: followsBottom, surface: .plain, refreshID: 0, locale: Locale(identifier: "en"), colorScheme: .light, dynamicTypeSize: .large, rowRevision: { [weak self] in self?.revisions[$0.id] ?? 0 } ) list.layoutIfNeeded() } } }