mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-07 19:06:11 +00:00
Keep normal multi-screen prompts inline and bound oversized messages to a preview. Add a screen-owned paged reader with exact full-text copy and preserved reading state, plus threshold, Unicode, layout, and presentation tests.
144 lines
6.9 KiB
Swift
144 lines
6.9 KiB
Swift
import HapiClient
|
|
import HapiUI
|
|
import Observation
|
|
import SwiftUI
|
|
import UIKit
|
|
import XCTest
|
|
@testable import Hapi
|
|
@testable import HapiProtocol
|
|
|
|
@MainActor
|
|
final class LongMessageLayoutTests: XCTestCase {
|
|
@Observable @MainActor
|
|
final class Driver {
|
|
var showBubble = true
|
|
@ObservationIgnored var open: ((UserTextBlock) -> Void)?
|
|
}
|
|
|
|
private struct ActionProbe: UIViewRepresentable {
|
|
let driver: Driver
|
|
func makeUIView(context: Context) -> UIView { UIView() }
|
|
func updateUIView(_ view: UIView, context: Context) {
|
|
driver.open = context.environment.openChatMessage
|
|
}
|
|
}
|
|
|
|
private struct Harness: View {
|
|
let driver: Driver
|
|
let model: ChatModel
|
|
let block: UserTextBlock
|
|
var body: some View {
|
|
Group {
|
|
if driver.showBubble { UserTextBlockView(block: block) }
|
|
else { Text("Row trimmed") }
|
|
}
|
|
.background(ActionProbe(driver: driver).frame(width: 0, height: 0))
|
|
.modifier(MessagePresentationHost(model: model, owner: "chat"))
|
|
.hapiTypography()
|
|
}
|
|
}
|
|
|
|
private func block(_ text: String) -> UserTextBlock {
|
|
UserTextBlock(id: "long-log", localId: nil, createdAt: 0, invokedAt: nil, text: text,
|
|
attachments: nil, status: nil, originalText: nil, meta: nil)
|
|
}
|
|
|
|
private var log: String {
|
|
String(repeating: "2026-09-11T06:14:36.6177640Z CompileSwift /workspace/Hapi/ChatView.swift: error: actor isolation\n", count: 3_000)
|
|
}
|
|
|
|
func testLongLogRowStaysBoundedWhenRecreatedAndAtAccessibilitySizes() {
|
|
for (width, size): (CGFloat, DynamicTypeSize) in [(320, .large), (390, .accessibility3)] {
|
|
func measure(_ source: String) -> CGFloat {
|
|
let host = UIHostingController(rootView: UserTextBlockView(block: block(source))
|
|
.hapiTypography().environment(\.dynamicTypeSize, size))
|
|
return host.sizeThatFits(in: CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)).height
|
|
}
|
|
let height = measure(log)
|
|
XCTAssertGreaterThan(height, 100, "The actual preview and full-message affordance must render")
|
|
XCTAssertLessThan(height, 1_800, "Even the accessibility-sized preview must stay bounded")
|
|
XCTAssertEqual(measure(log + log), height, accuracy: 1,
|
|
"Recycling/longer payloads must not reintroduce unbounded layout")
|
|
}
|
|
}
|
|
|
|
func testNormalMultiScreenPromptIsNotLineLimited() {
|
|
let source = Array(repeating: "A normal prompt line.", count: 80).joined(separator: "\n")
|
|
let host = UIHostingController(rootView: UserTextBlockView(block: block(source))
|
|
.hapiTypography().environment(\.dynamicTypeSize, .large))
|
|
let height = host.sizeThatFits(in: CGSize(width: 390, height: CGFloat.greatestFiniteMagnitude)).height
|
|
XCTAssertGreaterThan(height, 1_400, "All 80 lines must render, not just the folded preview")
|
|
}
|
|
|
|
func testFullMessageSheetSurvivesItsBubbleBeingRemoved() async throws {
|
|
let credentials = InMemoryCredentialStore()
|
|
let hubURL = "http://127.0.0.1:1/long-message-\(UUID().uuidString)"
|
|
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: hubURL, credentialStore: credentials, performer: UnusedMessageHTTP()))
|
|
let model = ChatModel(session: hub, sessionId: "long-message")
|
|
defer { model.stop(); hub.shutdown() }
|
|
let driver = Driver()
|
|
let source = block(log)
|
|
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
|
let window = UIWindow(windowScene: scene)
|
|
let host = UIHostingController(rootView: Harness(driver: driver, model: model, block: source))
|
|
window.rootViewController = host
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
host.view.layoutIfNeeded()
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
try XCTUnwrap(driver.open)(source)
|
|
try await Task.sleep(for: .milliseconds(600))
|
|
let sheet = try XCTUnwrap(host.presentedViewController)
|
|
XCTAssertTrue(sheet.presentationController is UISheetPresentationController)
|
|
XCTAssertTrue(model.isInspectingContent)
|
|
driver.showBubble = false
|
|
try await Task.sleep(for: .milliseconds(150))
|
|
XCTAssertTrue(host.presentedViewController === sheet)
|
|
// Session removal invalidates the screen-owned route through SwiftUI,
|
|
// just as Close/interactive dismissal does (not an out-of-band UIKit
|
|
// dismiss, which doesn't update a SwiftUI sheet's binding).
|
|
model.toolInspection.invalidate()
|
|
for _ in 0..<100 {
|
|
if host.presentedViewController == nil && !model.isInspectingContent { break }
|
|
try await Task.sleep(for: .milliseconds(20))
|
|
}
|
|
XCTAssertNil(host.presentedViewController)
|
|
XCTAssertFalse(model.isInspectingContent)
|
|
XCTAssertFalse(model.followsTail)
|
|
}
|
|
|
|
/// Optional local diagnostic, not a CI timing gate. The legacy path is
|
|
/// deliberately unbounded to compare the same input on the same Simulator.
|
|
func testProfileLegacyAndBoundedLayout() throws {
|
|
try XCTSkipUnless(ProcessInfo.processInfo.environment["HAPI_LONG_MESSAGE_PROFILE"] == "1")
|
|
let source: String
|
|
if let path = ProcessInfo.processInfo.environment["HAPI_LONG_MESSAGE_FILE"] {
|
|
source = try String(contentsOfFile: path, encoding: .utf8)
|
|
} else {
|
|
source = log
|
|
}
|
|
let legacy = AnyView(HStack(alignment: .bottom, spacing: 0) {
|
|
Spacer(minLength: 48)
|
|
Text(verbatim: source).font(.system(size: 16)).lineSpacing(3)
|
|
.fixedSize(horizontal: false, vertical: true).textSelection(.enabled)
|
|
.padding(.horizontal, 16).padding(.vertical, 12)
|
|
})
|
|
let bounded = AnyView(UserTextBlockView(block: block(source)).hapiTypography())
|
|
for (name, root) in [("legacy", legacy), ("bounded", bounded)] {
|
|
let start = CACurrentMediaTime()
|
|
let host = UIHostingController(rootView: root)
|
|
let size = host.sizeThatFits(in: CGSize(width: 370, height: CGFloat.greatestFiniteMagnitude))
|
|
print("HAPI_LONG_MESSAGE_LAYOUT \(name) chars=\(source.count) height=\(size.height) ms=\((CACurrentMediaTime() - start) * 1_000)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private actor UnusedMessageHTTP: HTTPPerforming {
|
|
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
|
(Data(#"{"error":"unused test endpoint"}"#.utf8),
|
|
HTTPURLResponse(url: request.url!, statusCode: 404, httpVersion: nil, headerFields: nil)!)
|
|
}
|
|
}
|