Files
hapi/ios/HapiTests/ToolTranscriptPresentationTests.swift
T
weishu 6c0ef32350 feat(ios): add native tool inspector and compact tool groups
Move tool output into a live sheet with group navigation and full-content copying. Keep lightweight grouped summaries inline, add agent process pages, and preserve transcript reading position.\n\nAdd real transcript and sheet regression coverage with localized summaries.
2026-09-11 11:30:13 +08:00

168 lines
9.3 KiB
Swift

import HapiClient
import HapiUI
import SwiftUI
import UIKit
import XCTest
@testable import Hapi
@testable import HapiProtocol
/// Render the real ChatModel → ChatTranscriptView → recycled UIKit rows, not an
/// inline ToolGroupBlockView specimen. HTTP is faked; SSE targets closed loopback.
@MainActor
final class ToolTranscriptPresentationTests: XCTestCase {
private struct Harness: View {
let model: ChatModel
let theme: HapiTheme
let size: DynamicTypeSize
var body: some View {
NavigationStack {
ChatTranscriptView(model: model)
.safeAreaInset(edge: .bottom, spacing: 0) {
ChatComposerView(interactor: model.interactor)
}
.navigationTitle("HAPI · UI specimen")
.navigationBarTitleDisplayMode(.inline)
}
.hapiTypography()
.hapiTheme(theme)
.environment(\.dynamicTypeSize, size)
.environment(\.colorScheme, theme.isDark ? .dark : .light)
.preferredColorScheme(theme.isDark ? .dark : .light)
}
}
func testRealTranscriptKeepsExpandedGroupContiguousAndCompact() async throws {
let credentials = InMemoryCredentialStore()
let payload = Data(#"{"uid":1,"exp":4102444800,"ns":"test"}"#.utf8).base64EncodedString()
try credentials.store(HubCredentials(hubUrl: "http://127.0.0.1:1", accessToken: "test", jwt: "e30.\(payload).test"))
let hub = try XCTUnwrap(HubSession(hubUrl: "http://127.0.0.1:1/tool-transcript-\(UUID().uuidString)",
credentialStore: credentials, performer: ToolTranscriptHTTP()))
let model = ChatModel(session: hub, sessionId: "tool-transcript")
defer { model.stop(); hub.shutdown() }
model.start()
try await eventually { !model.blocks.isEmpty && !model.isSyncingTail }
let groups = model.blocks.compactMap { block -> ToolGroupBlock? in
guard case .toolGroup(let group) = block else { return nil }
return group
}
XCTAssertEqual(groups.map(\.tools.count), [42, 5])
let first = try XCTUnwrap(groups.first)
let last = try XCTUnwrap(groups.last)
XCTAssertEqual(toolGroupTitle(first), "42 tool calls")
model.expandedToolGroups[first.id] = false
model.expandedToolGroups[last.id] = true
let cases: [(String, HapiTheme, DynamicTypeSize, CGFloat)] = [
("light-transcript", .light, .large, 390),
("dark-transcript", .dark, .large, 390),
("small-transcript", .light, .large, 320),
("ax-transcript", .light, .accessibility3, 390),
]
for (name, theme, size, width) in cases {
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: 844)
let host = UIHostingController(rootView: Harness(model: model, theme: theme, size: size))
window.rootViewController = host
window.makeKeyAndVisible()
defer { window.isHidden = true }
host.view.layoutIfNeeded()
let collection = try XCTUnwrap(findCollection(host.view))
try await eventually { collection.numberOfItems(inSection: 0) == 11 }
try await Task.sleep(for: .milliseconds(250))
for index in 5...9 {
let previous = try XCTUnwrap(collection.layoutAttributesForItem(at: IndexPath(item: index - 1, section: 0)))
let current = try XCTUnwrap(collection.layoutAttributesForItem(at: IndexPath(item: index, section: 0)))
XCTAssertEqual(current.frame.minY, previous.frame.maxY, accuracy: 0.5, "\(name): group segments must touch")
XCTAssertEqual(current.frame.minX, previous.frame.minX, accuracy: 0.5)
XCTAssertEqual(current.frame.width, previous.frame.width, accuracy: 0.5)
XCTAssertGreaterThanOrEqual(current.frame.height, 44)
}
if size == .large {
let top = try XCTUnwrap(collection.layoutAttributesForItem(at: IndexPath(item: 4, section: 0)))
let bottom = try XCTUnwrap(collection.layoutAttributesForItem(at: IndexPath(item: 9, section: 0)))
XCTAssertLessThanOrEqual(bottom.frame.maxY - top.frame.minY, 330, "Five tools and header should not fill the screen")
} else {
let imageRow = try XCTUnwrap(collection.layoutAttributesForItem(at: IndexPath(item: 8, section: 0)))
XCTAssertGreaterThan(imageRow.frame.height, 60, "System-font tool rows must also scale, not just markdown")
}
try capture(window, name: name)
model.expandedToolGroups[first.id] = true
try await eventually { collection.numberOfItems(inSection: 0) == 53 }
model.expandedToolGroups[first.id] = false
try await eventually { collection.numberOfItems(inSection: 0) == 11 }
}
}
private func eventually(_ condition: () -> Bool) async throws {
for _ in 0..<150 {
if condition() { return }
try await Task.sleep(for: .milliseconds(20))
}
XCTFail("Transcript did not settle")
}
private func findCollection(_ view: UIView) -> UICollectionView? {
if let collection = view as? UICollectionView { return collection }
return view.subviews.lazy.compactMap(findCollection).first
}
private func capture(_ window: UIWindow, name: String) throws {
guard let path = ProcessInfo.processInfo.environment["HAPI_TOOL_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"))
}
}
private struct ToolTranscriptHTTP: HTTPPerforming {
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
let url = request.url!
guard url.lastPathComponent == "messages" else {
return (Data(#"{"error":"unused test endpoint"}"#.utf8),
HTTPURLResponse(url: url, statusCode: 404, httpVersion: nil, headerFields: nil)!)
}
let after = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems?.contains { $0.name == "afterAt" } == true
let rows = messages()
let response = MessagesResponse(messages: after ? [] : rows, page: MessagesPage(
direction: after ? .after : .latest, limit: 200, epoch: 1, reset: false,
nextBeforeSeq: after ? nil : rows.first?.seq, nextBeforeAt: after ? nil : rows.first?.createdAt,
nextAfterSeq: after ? rows.last?.seq : nil, nextAfterAt: after ? rows.last?.createdAt : nil,
snapshotHeadSeq: rows.last?.seq, snapshotHeadAt: rows.last?.createdAt, hasMore: false
))
return (try JSONEncoder().encode(response), HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!)
}
private func messages() -> [DecryptedMessage] {
var rows: [DecryptedMessage] = []
func append(_ data: JSONValue) {
let seq = rows.count + 1
rows.append(DecryptedMessage(id: "row-\(seq)", seq: seq,
content: ["role": "agent", "content": ["type": "codex", "data": data]],
createdAt: seq * 1000, invokedAt: seq * 1000))
}
func tool(_ name: String, input: JSONValue, error: Bool = false) {
let id = "tool-\(rows.count)"
append(["type": "tool-call", "callId": .string(id), "name": .string(name), "input": input])
append(["type": "tool-call-result", "callId": .string(id), "output": "Done", "is_error": .bool(error)])
}
append(["type": "message", "message": "工具详情独立阅读;关闭后保留聊天位置。"])
for index in 0..<42 {
if index < 17 { tool("Bash", input: ["command": "git diff --stat"], error: index == 0) }
else if index < 23 { tool("Edit", input: ["file_path": "/workspace/ChatView.swift"]) }
else { tool("exec", input: ["code": "await tools.exec_command({cmd: 'swift test'})"]) }
}
append(["type": "message", "message": "实现与测试已完成。正在核对浅色/深色 UI,并清理测试生成文件。"])
tool("view_image", input: ["path": "/tmp/hapi-tool-inspector-review-20260911-final/light-summary.png"])
tool("view_image", input: ["path": "/tmp/hapi-tool-inspector-review-20260911-final/dark-detail.png"])
tool("Bash", input: ["command": "rm ios/Packages/HapiKit/Package.resolved && git diff --check && git status --short"])
tool("exec", input: ["code": "await tools.mcp__hapi__display_image({path: '/tmp/light-summary.png'})"])
tool("mcp__hapi__display_image", input: ["path": "/tmp/light-summary.png"])
append(["type": "message", "message": "检查完成,可以继续对话。"])
return rows
}
}