Files
hapi/ios/HapiTests/TranscriptLayoutTests.swift
T

393 lines
19 KiB
Swift

import HapiUI
import Observation
import SwiftUI
import UIKit
import XCTest
@testable import Hapi
@testable import HapiProtocol
@MainActor
final class TranscriptLayoutTests: XCTestCase {
struct Row: Identifiable, Equatable {
let id: String
var height: CGFloat
}
@MainActor @Observable
final class Driver {
var rows: [Row]
var version = 0
var inspecting = false
var jump = 0
var layouts: [Int] = []
var viewport: TranscriptViewport?
@ObservationIgnored var viewports: [TranscriptViewport] = []
let presentation = ChatPresentationState()
init(_ rows: [Row]) { self.rows = rows }
}
private struct Harness: View {
var driver: Driver
var body: some View {
AnchoredTranscriptList(
items: driver.rows, historyVersion: driver.version, jumpToken: driver.jump,
historyControlID: "history",
isInspectionPresented: driver.inspecting,
onViewport: { driver.viewport = $0; driver.viewports.append($0) },
onLayout: { version, _ in driver.layouts.append(version) }
) { row in
AnyView(StatefulRow(row: row).environment(\.chatPresentationState, driver.presentation))
}
}
}
private struct StatefulRow: View {
let row: Row
@ChatStoredState private var expanded: Bool
init(row: Row) {
self.row = row
_expanded = ChatStoredState(wrappedValue: false, id: row.id, field: "expanded")
}
var body: some View {
Text(row.id).frame(maxWidth: .infinity).frame(height: row.height + (expanded ? 90 : 0))
}
}
private func rows(_ range: Range<Int>) -> [Row] {
range.map { Row(id: "message-\($0)", height: CGFloat(65 + ($0 % 5) * 29)) }
}
private func host(_ driver: Driver) -> (UIWindow, UICollectionView) {
let scene = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first!
let window = UIWindow(windowScene: scene)
window.frame = CGRect(x: 0, y: 0, width: 390, height: 844)
let host = UIHostingController(rootView: Harness(driver: driver))
window.rootViewController = host
window.makeKeyAndVisible()
host.view.frame = window.bounds
host.view.setNeedsLayout()
host.view.layoutIfNeeded()
func find(_ view: UIView) -> UICollectionView? {
if let collection = view as? UICollectionView { return collection }
return view.subviews.lazy.compactMap(find).first
}
return (window, find(host.view)!)
}
private func settle(_ condition: () -> Bool = { true }) async {
for _ in 0..<100 {
try? await Task.sleep(for: .milliseconds(20))
if condition() {
try? await Task.sleep(for: .milliseconds(80))
return
}
}
XCTFail("Transcript did not finish layout")
}
private func browse(_ view: UICollectionView, index: Int, inset: CGFloat = 31) async {
view.delegate?.scrollViewWillBeginDragging?(view)
view.scrollToItem(at: IndexPath(item: index, section: 0), at: .top, animated: false)
view.contentOffset.y += inset
view.layoutIfNeeded()
view.delegate?.scrollViewDidEndDragging?(view, willDecelerate: false)
await settle()
view.layoutIfNeeded()
}
private func readingAnchor(_ view: UICollectionView) -> (id: String, y: CGFloat)? {
view.visibleCells
.filter { $0.frame.maxY > view.contentOffset.y && $0.frame.minY < view.contentOffset.y + view.bounds.height && $0.accessibilityIdentifier != "chat-row-history" }
.sorted { $0.frame.minY < $1.frame.minY }
.first.map { ($0.accessibilityIdentifier!, $0.frame.minY - view.contentOffset.y) }
}
private func bottomDistance(_ view: UICollectionView) -> CGFloat {
max(-view.adjustedContentInset.top,
view.contentSize.height - view.bounds.height + view.adjustedContentInset.bottom) - view.contentOffset.y
}
private func browseBottom(_ view: UICollectionView, distance: CGFloat) async {
view.delegate?.scrollViewWillBeginDragging?(view)
view.contentOffset.y += bottomDistance(view) - distance
view.layoutIfNeeded()
view.delegate?.scrollViewDidEndDragging?(view, willDecelerate: false)
await settle()
XCTAssertEqual(bottomDistance(view), distance, accuracy: 1)
}
private func anchorY(_ id: String, in view: UICollectionView) -> CGFloat? {
view.visibleCells.first { $0.accessibilityIdentifier == id }.map { $0.frame.minY - view.contentOffset.y }
}
private func diagnostic(_ driver: Driver, _ view: UICollectionView) -> String {
let cells = view.visibleCells.map { ($0.accessibilityIdentifier ?? "?") + ":" + String(describing: $0.frame.minY - view.contentOffset.y) }
return "offset=\(view.contentOffset.y) height=\(view.contentSize.height) follows=\(String(describing: driver.viewport?.followsTail)) cells=\(cells)"
}
func testLatestAffordanceHysteresisNeverResumesFollowingAtSmallOffsets() async {
let driver = Driver(rows(0..<50))
let (window, view) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
view.delegate?.scrollViewWillBeginDragging?(view)
XCTAssertEqual(driver.viewport?.followsTail, false)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false, "Starting a drag must not reveal the button")
view.delegate?.scrollViewDidEndDragging?(view, willDecelerate: false)
await settle()
XCTAssertEqual(driver.viewport?.followsTail, true, "A zero-distance drag still ends at the actual bottom")
await browseBottom(view, distance: 30)
XCTAssertEqual(driver.viewport?.followsTail, false)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
let reports = driver.viewports.count
await browseBottom(view, distance: 50)
XCTAssertEqual(driver.viewports.count, reports, "Offsets inside the same band must not republish the viewport")
for (distance, away): (CGFloat, Bool) in [(81, true), (60, true), (23, false), (40, false)] {
await browseBottom(view, distance: distance)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, away)
XCTAssertEqual(driver.viewport?.followsTail, false, "Hiding the affordance must not snap back to latest")
}
await browseBottom(view, distance: 0)
XCTAssertEqual(driver.viewport?.followsTail, true)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
}
func testStreamingAndViewportResizeRevealLatestWithoutMovingTheSmallOffsetAnchor() async throws {
let driver = Driver(rows(0..<50))
let (window, view) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
await browseBottom(view, distance: 30)
let anchor = try XCTUnwrap(readingAnchor(view))
driver.rows[49].height += 20 // Streaming / a small media resize.
await settle { abs(self.bottomDistance(view) - 50) <= 1 }
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
XCTAssertEqual(driver.viewport?.followsTail, false)
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1)
driver.rows[49].height += 40
await settle { driver.viewport?.isAwayFromBottom == true }
XCTAssertEqual(bottomDistance(view), 90, accuracy: 1)
XCTAssertEqual(driver.viewport?.followsTail, false)
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1)
for height: CGFloat in [600, 844] { // Keyboard / composer viewport changes.
window.frame.size.height = height
window.layoutIfNeeded()
await settle()
XCTAssertEqual(driver.viewport?.followsTail, false)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, true)
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1)
}
driver.jump += 1
await settle { driver.viewport?.followsTail == true }
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
await browseBottom(view, distance: 40)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false, "Explicit latest resets the hysteresis latch")
}
func testInspectorPausesTailAndPreservesAnchorUntilExplicitJump() async throws {
let driver = Driver(rows(0..<50))
let (window, view) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
XCTAssertEqual(driver.viewport?.followsTail, true)
let anchor = try XCTUnwrap(readingAnchor(view))
driver.inspecting = true
await settle { driver.viewport?.followsTail == false }
XCTAssertEqual(driver.viewport?.needsOlder, false)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
driver.rows.append(contentsOf: rows(50..<65))
driver.version += 1
await settle { driver.layouts.contains(1) }
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1)
driver.inspecting = false
await settle()
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1)
XCTAssertEqual(driver.viewport?.followsTail, false)
driver.jump += 1
await settle { driver.viewport?.followsTail == true }
XCTAssertEqual(driver.viewport?.isAtBottom, true)
XCTAssertEqual(driver.viewport?.isAwayFromBottom, false)
}
func testInspectorPausesShortTranscriptHistoryDemand() async {
let driver = Driver(rows(0..<1))
let (window, _) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
XCTAssertEqual(driver.viewport?.needsOlder, true)
driver.inspecting = true
await settle { driver.viewport?.needsOlder == false }
driver.inspecting = false
await settle { driver.viewport?.needsOlder == true }
}
func testPrependKeepsTheCurrentPartialRowNotTheRequestStartRow() async throws {
let driver = Driver(rows(100..<180))
let (window, view) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
await browse(view, index: 12)
// The reader moves again while the hypothetical HTTP request waits.
await browse(view, index: 17, inset: 47)
let anchor = try XCTUnwrap(readingAnchor(view))
XCTAssertLessThanOrEqual(anchor.y, 0)
driver.rows = rows(80..<100) + Array(driver.rows.dropLast(20))
driver.version = 1
await settle { driver.layouts.contains(1) }
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view), "anchor=\(anchor); \(diagnostic(driver, view))"), anchor.y, accuracy: 1)
XCTAssertEqual(driver.viewport?.followsTail, false)
}
func testStreamingTailAndRowResizeDoNotMoveHistoryAnchor() async throws {
let driver = Driver(rows(0..<80))
let (window, view) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
await browse(view, index: 25)
let anchor = try XCTUnwrap(readingAnchor(view))
driver.rows[79].height += 400
await settle()
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view), "anchor=\(anchor); \(diagnostic(driver, view))"), anchor.y, accuracy: 1)
let id = anchor.id.replacingOccurrences(of: "chat-row-", with: "")
let index = try XCTUnwrap(driver.rows.firstIndex { $0.id == id })
driver.rows[index].height += 160
await settle()
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1)
let resized = try XCTUnwrap(view.visibleCells.first { $0.accessibilityIdentifier == anchor.id })
XCTAssertEqual(resized.frame.height, driver.rows[index].height, accuracy: 1)
}
func testTailFollowsSelfSizingAndViewportResize() async throws {
let driver = Driver(rows(0..<30))
let (window, view) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
let id = "chat-row-message-29"
XCTAssertEqual(try XCTUnwrap(view.visibleCells.first { $0.accessibilityIdentifier == id }).frame.height,
driver.rows[29].height, accuracy: 1)
driver.rows[29].height += 250
await settle()
XCTAssertEqual(view.contentOffset.y, view.contentSize.height - view.bounds.height, accuracy: 1)
window.frame.size.height -= 250 // composer / keyboard changes viewport
window.layoutIfNeeded()
await settle()
XCTAssertEqual(view.contentOffset.y, view.contentSize.height - view.bounds.height, accuracy: 1)
XCTAssertEqual(driver.viewport?.followsTail, true)
XCTAssertTrue(driver.viewports.allSatisfy { !$0.isAwayFromBottom },
"Following must hide the affordance even while self-sizing/viewport corrections are pending")
}
func testShortAndHiddenOnlyPagesStillAcknowledgeLayout() async {
let driver = Driver([Row(id: "history", height: 44), Row(id: "message", height: 55)])
let (window, _) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
XCTAssertEqual(driver.viewport?.needsOlder, true)
driver.version = 1 // hidden-only page; no new views or resize signal
await settle { driver.layouts.contains(1) }
XCTAssertEqual(driver.viewport?.needsOlder, true)
}
func testRepeatedRowIDsAreCoalescedBeforeEverySnapshotConsumer() async throws {
let id = "tool-group:exec-3a551efc-3d25-4093-831e-f7fd43482fae"
let driver = Driver([Row(id: id, height: 65), Row(id: id, height: 115)] + rows(0..<40))
let (window, view) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
XCTAssertEqual(view.numberOfItems(inSection: 0), 41)
await browse(view, index: 0, inset: 17)
let anchor = try XCTUnwrap(readingAnchor(view))
XCTAssertEqual(anchor.id, "chat-row-" + id)
let cell = try XCTUnwrap(view.visibleCells.first { $0.accessibilityIdentifier == anchor.id })
XCTAssertEqual(cell.frame.height, 115, accuracy: 1)
driver.rows = [Row(id: "older", height: 80)] + driver.rows + [Row(id: id, height: 135)]
driver.version = 1
await settle { driver.layouts.contains(1) }
XCTAssertEqual(view.numberOfItems(inSection: 0), 42)
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1)
let updated = try XCTUnwrap(view.visibleCells.first { $0.accessibilityIdentifier == anchor.id })
XCTAssertEqual(updated.frame.height, 135, accuracy: 1)
}
func testRegroupedHistoryRetainsBothGroupsAndTheExistingReadingAnchor() async throws {
func tool(_ id: String) -> ChatBlock {
.toolCall(ToolCallBlock(
id: id, localId: nil, createdAt: 0, invokedAt: nil, durationMs: nil, usage: nil, model: nil,
tool: ChatToolCall(id: id, name: "Read", state: .completed, createdAt: 0), children: [], meta: nil
))
}
func group(_ blocks: [ChatBlock], previous: [VisibleChatBlock] = [], more: Bool = false) -> [VisibleChatBlock] {
let oldGroups = previous.compactMap { if case .toolGroup(let group) = $0 { group } else { nil as ToolGroupBlock? } }
return buildVisibleChatBlocks(blocks, options: .init(hasMoreMessages: more, previousGroups: oldGroups))
}
func displayRows(_ blocks: [VisibleChatBlock]) -> [Row] {
blocks.map { Row(id: $0.stableId, height: 100) }
}
let original = group(["c", "d", "e"].map(tool))
let trimmed = group([tool("d"), tool("e")], previous: original)
let tail = rows(0..<40)
let driver = Driver(displayRows(trimmed) + tail)
let (window, view) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
await browse(view, index: 0, inset: 23)
let anchor = try XCTUnwrap(readingAnchor(view))
let separator = ChatBlock.agentText(AgentTextBlock(
id: "separator", localId: nil, createdAt: 0, invokedAt: nil,
durationMs: nil, usage: nil, model: nil, text: "Between groups", meta: nil
))
let prepended = group([tool("a"), tool("b"), tool("c"), separator, tool("d"), tool("e")], previous: trimmed, more: true)
driver.rows = displayRows(prepended) + tail
driver.version = 1
await settle { driver.layouts.contains(1) }
// Two distinct groups plus their separator; no duplicate-key crash
// and no silently dropped group in the collection's fallback path.
XCTAssertEqual(view.numberOfItems(inSection: 0), 43)
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1)
}
func testRecycledPresentationStateIsKeyedAndPruned() {
let state = ChatPresentationState()
let key = ChatPresentationState.Key(id: "tool-a", field: "expanded")
state.values[key] = true
state.values[.init(id: "tool-b", field: "answer")] = "unsent"
state.prune(to: ["tool-b"])
XCTAssertNil(state.values[key])
XCTAssertEqual(state.values[.init(id: "tool-b", field: "answer")] as? String, "unsent")
}
func testHostedCellKeepsExpansionAcrossRecycling() async throws {
let driver = Driver(rows(0..<80))
let (window, view) = host(driver)
defer { window.isHidden = true }
await settle { driver.layouts.contains(0) }
await browse(view, index: 15, inset: 0)
let anchor = try XCTUnwrap(readingAnchor(view))
let id = anchor.id.replacingOccurrences(of: "chat-row-", with: "")
let index = try XCTUnwrap(driver.rows.firstIndex { $0.id == id })
driver.presentation.values[.init(id: id, field: "expanded")] = true
await settle()
XCTAssertEqual(try XCTUnwrap(anchorY(anchor.id, in: view)), anchor.y, accuracy: 1)
for destination in [55, 35, 65, 45] {
await browse(view, index: destination)
XCTAssertLessThan(view.visibleCells.count, 25, diagnostic(driver, view))
}
// UIKit may cache offscreen hosting cells. Force their destruction so
// this test proves restoration, not merely reuse of a surviving view.
view.reloadData()
view.layoutIfNeeded()
await settle()
XCTAssertNil(anchorY(anchor.id, in: view), diagnostic(driver, view))
await browse(view, index: index, inset: 0)
let cell = try XCTUnwrap(view.visibleCells.first { $0.accessibilityIdentifier == anchor.id })
XCTAssertEqual(cell.frame.height, driver.rows[index].height + 90, accuracy: 1)
}
}