mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-08 19:19:42 +00:00
254 lines
13 KiB
Swift
254 lines
13 KiB
Swift
import HapiClient
|
|
@testable import HapiProtocol
|
|
import Observation
|
|
import SwiftUI
|
|
import UIKit
|
|
import XCTest
|
|
@testable import Hapi
|
|
@testable import HapiUI
|
|
|
|
/// Real native question cards with deterministic data, no live conversations.
|
|
@MainActor
|
|
final class QuestionCardPresentationTests: XCTestCase {
|
|
private static let input = #"{"questions":[{"id":"skill","header":"超能力选择","question":"如果能瞬间学会一项技能,你最想选哪个?","options":[{"label":"精通一门外语 (Recommended)","description":"旅行、看剧、交流都不再有语言障碍。"},{"label":"精通一种乐器","description":"随时演奏自己喜欢的音乐。"},{"label":"拥有顶级厨艺","description":"想吃什么美食,都能自己做出来。"}]}]}"#
|
|
|
|
private struct NoHTTP: HTTPPerforming {
|
|
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) {
|
|
throw URLError(.notConnectedToInternet)
|
|
}
|
|
}
|
|
|
|
@Observable @MainActor
|
|
final class Driver {
|
|
var tool: ChatToolCall
|
|
let presentation = ChatPresentationState()
|
|
let interactor: ChatInteractor
|
|
@ObservationIgnored var height: CGFloat = 0
|
|
@ObservationIgnored var appeared = 0
|
|
init(tool: ChatToolCall) {
|
|
self.tool = tool
|
|
let url = URL(string: "http://127.0.0.1:1")!
|
|
let auth = AuthManager(baseURL: url, credentialStore: InMemoryCredentialStore(), performer: NoHTTP())
|
|
let api = APIClient(baseURL: url, authManager: auth, performer: NoHTTP())
|
|
interactor = ChatInteractor(sessionId: "question-specimen", api: api,
|
|
sessionStore: SessionListStore(api: api), windows: MessageWindowControllers(provider: api))
|
|
}
|
|
|
|
var block: ToolCallBlock {
|
|
ToolCallBlock(id: tool.id, localId: nil, createdAt: 0, invokedAt: nil, durationMs: nil,
|
|
usage: nil, model: nil, tool: tool, children: [], meta: nil)
|
|
}
|
|
|
|
var draft: QuestionAnswerDraft {
|
|
get { presentation.values[.init(id: "reply", field: QuestionAnswerDraft.storageField)] as? QuestionAnswerDraft ?? .init() }
|
|
set { presentation.values[.init(id: "reply", field: QuestionAnswerDraft.storageField)] = newValue }
|
|
}
|
|
}
|
|
|
|
private struct Card: View {
|
|
let driver: Driver
|
|
var body: some View {
|
|
ToolCallBlockView(block: driver.block, basePath: nil)
|
|
.environment(\.chatInteractions, driver.interactor)
|
|
.environment(\.chatPresentationState, driver.presentation)
|
|
.background(GeometryReader { geometry in
|
|
Color.clear
|
|
.onAppear { driver.height = geometry.size.height; driver.appeared += 1 }
|
|
.onChange(of: geometry.size.height) { _, height in driver.height = height }
|
|
})
|
|
.hapiReadingColumn()
|
|
}
|
|
}
|
|
|
|
private struct Specimen: View {
|
|
let driver: Driver
|
|
let theme: HapiTheme
|
|
let typeSize: DynamicTypeSize
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
UserTextBlockView(block: UserTextBlock(
|
|
id: "user", localId: nil, createdAt: 0, invokedAt: nil,
|
|
text: "再问我一个问题", attachments: nil, status: nil, originalText: nil, meta: nil
|
|
)).hapiReadingColumn()
|
|
Card(driver: driver)
|
|
}
|
|
.padding(.vertical, 16)
|
|
}
|
|
.background(theme.background)
|
|
.safeAreaInset(edge: .bottom, spacing: 0) { ChatComposerView(interactor: driver.interactor) }
|
|
.navigationTitle("问答测试")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
.hapiTheme(theme)
|
|
.hapiTypography()
|
|
.environment(\.dynamicTypeSize, typeSize)
|
|
.preferredColorScheme(theme.isDark ? .dark : .light)
|
|
}
|
|
}
|
|
|
|
private func makeTool(_ input: String = QuestionCardPresentationTests.input) throws -> ChatToolCall {
|
|
ChatToolCall(id: "question", name: "functions.request_user_input", state: .running,
|
|
input: try JSONDecoder().decode(JSONValue.self, from: Data(input.utf8)), createdAt: 0,
|
|
permission: ToolPermission(id: "reply", status: .pending))
|
|
}
|
|
|
|
func testQuestionCardsFitThemesAndTypeSizesAndCollapseOnlyWithRecordedAnswers() async throws {
|
|
for (name, theme, size, width) in [
|
|
("light", HapiTheme.light, DynamicTypeSize.large, CGFloat(390)),
|
|
("dark", .dark, .large, 390), ("oled", .oled, .large, 390),
|
|
("small", .light, .large, 320), ("large-type", .light, .accessibility3, 390),
|
|
("long-option", .light, .large, 320),
|
|
] {
|
|
let source = name == "long-option"
|
|
? #"{"questions":[{"id":"skill","header":"部署与数据存储方案","question":"原型应该采用哪种 **部署方案**?请考虑后续维护成本。","options":[{"label":"使用本地 SQLite 数据库并将应用部署到自有服务器 (Recommended)","description":"保留完整数据控制权,使用 `sqlite` 文件进行备份。"},{"label":"使用托管数据库与云平台","description":"减少服务器维护,但需要管理额外成本。"}]}]}"#
|
|
: Self.input
|
|
let driver = Driver(tool: try makeTool(source))
|
|
let (window, host) = try host(Specimen(driver: driver, theme: theme, typeSize: size), width: width)
|
|
defer { window.isHidden = true }
|
|
try await settle { driver.height > 100 }
|
|
XCTAssertTrue(driver.draft.selections.isEmpty)
|
|
let initial = driver.height
|
|
if size == .large { XCTAssertLessThan(initial, 720, "The default card must not require a second viewport") }
|
|
try capture(window, name: name)
|
|
if size.isAccessibilitySize, let scroll = findScroll(host.view) {
|
|
let bottom = max(-scroll.adjustedContentInset.top,
|
|
scroll.contentSize.height - scroll.bounds.height + scroll.adjustedContentInset.bottom)
|
|
scroll.setContentOffset(CGPoint(x: 0, y: bottom), animated: false)
|
|
try await Task.sleep(for: .milliseconds(150))
|
|
try capture(window, name: "large-type-bottom")
|
|
scroll.setContentOffset(CGPoint(x: 0, y: -scroll.adjustedContentInset.top), animated: false)
|
|
}
|
|
let form = QuestionAnswerForm(tool: driver.tool)
|
|
driver.draft.select(0, at: 0, in: form)
|
|
if name == "light" {
|
|
try await Task.sleep(for: .milliseconds(150))
|
|
try capture(window, name: "selected")
|
|
}
|
|
driver.draft.toggleText(at: 0, in: form)
|
|
driver.draft.setText("希望先学好英语。\nKeep my selection and this note.", at: 0, in: form)
|
|
try await settle { driver.height > initial + 30 }
|
|
let edited = driver.draft
|
|
driver.tool.description = "An SSE update unrelated to the answers"
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
XCTAssertEqual(driver.draft, edited)
|
|
// Local selections are not a recorded answer or a successful POST.
|
|
XCTAssertFalse(questionToolDetails(driver.tool).hasAnswers)
|
|
XCTAssertNil(host.presentedViewController, "Answering stays in the conversation")
|
|
if name == "light" { try capture(window, name: "light-note") }
|
|
driver.tool.permission = ToolPermission(id: "reply", status: .resolved)
|
|
driver.tool.state = .completed
|
|
let chosen = try XCTUnwrap(form.fields.first?.options.first?.label)
|
|
driver.tool.result = .object(["answers": .object(["skill": .object([
|
|
"answers": .array([.string(chosen)]),
|
|
])])])
|
|
try await settle { driver.height < initial }
|
|
XCTAssertEqual(questionAnswerSummary(questionToolDetails(driver.tool).questions[0]), QuestionOptionTitle(chosen).text)
|
|
if name == "light" { try capture(window, name: "answered") }
|
|
}
|
|
}
|
|
|
|
private struct Row: Identifiable, Equatable {
|
|
let index: Int
|
|
var id: String { String(index) }
|
|
}
|
|
|
|
private struct Transcript: View {
|
|
let driver: Driver
|
|
var body: some View {
|
|
AnchoredTranscriptList(
|
|
items: (0..<40).map { Row(index: $0) }, historyVersion: 0, jumpToken: 0,
|
|
historyControlID: "-1", onViewport: { _ in }, onLayout: { _, _ in }
|
|
) { row in
|
|
AnyView(Group {
|
|
if row.index == 10 { Card(driver: driver) }
|
|
else { Text("Message \(row.id)").frame(maxWidth: .infinity).frame(height: 100) }
|
|
})
|
|
}
|
|
.hapiTypography()
|
|
}
|
|
}
|
|
|
|
func testSteppingEditingRecyclingAndCompletionPreserveTranscriptStateAndAnchor() async throws {
|
|
let input = #"{"questions":[{"id":"a","header":"First","question":"Choose one","options":[{"label":"A"},{"label":"B"}]},{"id":"b","header":"Second","question":"Choose multiple","multiple":true,"options":[{"label":"C"},{"label":"D"}]}]}"#
|
|
let driver = Driver(tool: try makeTool(input))
|
|
let (window, host) = try host(Transcript(driver: driver), width: 390)
|
|
defer { window.isHidden = true }
|
|
let collection = try XCTUnwrap(findCollection(host.view))
|
|
try await settle { collection.numberOfItems(inSection: 0) == 40 }
|
|
func browse(_ index: Int) async throws {
|
|
collection.delegate?.scrollViewWillBeginDragging?(collection)
|
|
collection.scrollToItem(at: IndexPath(item: index, section: 0), at: .top, animated: false)
|
|
try await settle { collection.cellForItem(at: IndexPath(item: index, section: 0)) != nil }
|
|
}
|
|
try await browse(10)
|
|
let cell = try XCTUnwrap(collection.cellForItem(at: IndexPath(item: 10, section: 0)))
|
|
let offset = cell.frame.minY - collection.contentOffset.y
|
|
let form = QuestionAnswerForm(tool: driver.tool)
|
|
driver.draft.select(0, at: 0, in: form)
|
|
driver.draft.select(1, at: 1, in: form)
|
|
driver.draft.toggleText(at: 1, in: form)
|
|
driver.draft.setText("A multiline draft\nthat must survive recycling", at: 1, in: form)
|
|
try await Task.sleep(for: .milliseconds(300))
|
|
XCTAssertEqual(cell.frame.minY - collection.contentOffset.y, offset, accuracy: 1)
|
|
let draft = driver.draft
|
|
let appearances = driver.appeared
|
|
try await browse(32)
|
|
try await browse(10)
|
|
XCTAssertEqual(driver.draft, draft)
|
|
XCTAssertGreaterThan(driver.appeared, appearances, "The card must actually leave and reenter the viewport")
|
|
XCTAssertEqual(driver.draft.page, 1, "Restoring the selected step must not advance it")
|
|
driver.draft.previous(in: form)
|
|
driver.draft.setText("Retained note", at: 0, in: form)
|
|
let anchor = try XCTUnwrap(collection.cellForItem(at: IndexPath(item: 10, section: 0)))
|
|
let anchorOffset = anchor.frame.minY - collection.contentOffset.y
|
|
let expandedHeight = driver.height
|
|
driver.tool.permission = ToolPermission(id: "reply", status: .approved, answers: .object([
|
|
"a": .object(["answers": .array([.string("A")])]),
|
|
"b": .object(["answers": .array([.string("D")])]),
|
|
]))
|
|
try await settle { driver.height < expandedHeight }
|
|
XCTAssertEqual(anchor.frame.minY - collection.contentOffset.y, anchorOffset, accuracy: 1)
|
|
}
|
|
|
|
private func host<V: View>(_ view: V, width: CGFloat) throws -> (UIWindow, UIHostingController<V>) {
|
|
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: view)
|
|
window.rootViewController = host
|
|
window.makeKeyAndVisible()
|
|
host.view.layoutIfNeeded()
|
|
return (window, host)
|
|
}
|
|
|
|
private func findCollection(_ view: UIView) -> UICollectionView? {
|
|
if let collection = view as? UICollectionView { return collection }
|
|
return view.subviews.lazy.compactMap(findCollection).first
|
|
}
|
|
|
|
private func findScroll(_ view: UIView) -> UIScrollView? {
|
|
if let scroll = view as? UIScrollView { return scroll }
|
|
return view.subviews.lazy.compactMap(findScroll).first
|
|
}
|
|
|
|
private func settle(_ condition: () -> Bool) async throws {
|
|
for _ in 0..<100 {
|
|
try await Task.sleep(for: .milliseconds(30))
|
|
if condition() { try await Task.sleep(for: .milliseconds(150)); return }
|
|
}
|
|
XCTFail("Question layout did not settle")
|
|
}
|
|
|
|
private func capture(_ window: UIWindow, name: String) throws {
|
|
guard let path = ProcessInfo.processInfo.environment["HAPI_QUESTION_CAPTURE"] else { return }
|
|
let directory = URL(fileURLWithPath: path, isDirectory: true)
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
|
|
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
}
|
|
try XCTUnwrap(image.pngData()).write(to: directory.appendingPathComponent("question-\(name).png"))
|
|
}
|
|
}
|