mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-24 22:10:59 +00:00
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.
168 lines
9.3 KiB
Swift
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
|
|
}
|
|
}
|