mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-25 22:28:04 +00:00
294 lines
16 KiB
Swift
294 lines
16 KiB
Swift
import HapiClient
|
|
import HapiProtocol
|
|
import HapiUI
|
|
import SwiftUI
|
|
import UIKit
|
|
import XCTest
|
|
@testable import Hapi
|
|
|
|
private struct ScratchlistOfflineHTTP: HTTPPerforming {
|
|
func perform(_ request: URLRequest) async throws -> (Data, HTTPURLResponse) { throw URLError(.notConnectedToInternet) }
|
|
}
|
|
|
|
@MainActor
|
|
final class ScratchlistPresentationTests: XCTestCase {
|
|
private func interactor() -> ChatInteractor {
|
|
let url = URL(string: "https://scratchlist.invalid")!
|
|
let http = ScratchlistOfflineHTTP()
|
|
let auth = AuthManager(baseURL: url, credentialStore: InMemoryCredentialStore(), performer: http)
|
|
let api = APIClient(baseURL: url, authManager: auth, performer: http)
|
|
let interactor = ChatInteractor(sessionId: "preview", api: api, sessionStore: SessionListStore(api: api), windows: MessageWindowControllers(provider: api))
|
|
let stamp = Int(Date().timeIntervalSince1970 * 1000) - 120_000
|
|
interactor.scratchlist = ScratchlistTestStore(entries: [
|
|
ScratchlistEntry(entryId: "first", text: "检查登录后偶发的白屏,先补回归测试,再修复根因。", createdAt: stamp, updatedAt: stamp),
|
|
ScratchlistEntry(entryId: "second", text: "主流程完成后,再补充深色模式和大字号的界面截图。", createdAt: stamp - 180_000, updatedAt: stamp - 180_000,
|
|
attachments: [ScratchlistAttachment(id: "reference", filename: "dark-mode-reference.png", mimeType: "image/png", size: 100, path: "hub-reference")]),
|
|
ScratchlistEntry(entryId: "third", text: "Review the release notes", createdAt: stamp - 600_000, updatedAt: stamp - 600_000),
|
|
])
|
|
interactor.setComposerDestination(.scratchlist)
|
|
return interactor
|
|
}
|
|
|
|
private struct Harness: View {
|
|
let interactor: ChatInteractor
|
|
let size: DynamicTypeSize
|
|
let dark: Bool
|
|
var locale = "zh-Hans"
|
|
var body: some View {
|
|
NavigationStack {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
Text("先梳理登录流程,修复问题后补上测试。")
|
|
.padding(14).background(.quaternary, in: RoundedRectangle(cornerRadius: 16))
|
|
Text("正在检查会话恢复和登录状态切换。你可以先把后续想法暂存在草稿夹,不打断当前任务。")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}.padding(16)
|
|
}
|
|
.safeAreaInset(edge: .bottom, spacing: 0) {
|
|
ChatComposerView(interactor: interactor)
|
|
}
|
|
.navigationTitle("登录流程优化")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
}
|
|
.hapiTypography()
|
|
.environment(\.dynamicTypeSize, size)
|
|
.environment(\.locale, Locale(identifier: locale))
|
|
.preferredColorScheme(dark ? .dark : .light)
|
|
}
|
|
}
|
|
|
|
func testComposerRendersAtCompactRegularAndAccessibleSizes() async throws {
|
|
for (name, width, size, dark, locale) in [
|
|
("light", CGFloat(402), DynamicTypeSize.large, false, "zh-Hans"),
|
|
("dark", CGFloat(402), DynamicTypeSize.large, true, "zh-Hans"),
|
|
("compact", CGFloat(320), DynamicTypeSize.large, false, "zh-Hans"),
|
|
("large-text", CGFloat(390), DynamicTypeSize.accessibility3, false, "zh-Hans"),
|
|
("english", CGFloat(320), DynamicTypeSize.large, false, "en"),
|
|
] {
|
|
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: 874)
|
|
let interactor = interactor()
|
|
let host = UIHostingController(rootView: Harness(interactor: interactor, size: size, dark: dark, locale: locale))
|
|
window.rootViewController = host
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
try await Task.sleep(for: .milliseconds(350))
|
|
window.layoutIfNeeded()
|
|
XCTAssertEqual(host.view.bounds.width, width, accuracy: 1)
|
|
XCTAssertEqual(interactor.composerDestination, .scratchlist)
|
|
XCTAssertEqual(interactor.scratchlistCount, 3)
|
|
let format = UIGraphicsImageRendererFormat()
|
|
format.scale = 2
|
|
let image = UIGraphicsImageRenderer(bounds: window.bounds, format: format).image { _ in
|
|
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
}
|
|
let attachment = XCTAttachment(image: image)
|
|
attachment.name = "scratchlist-\(name)"
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
let url = FileManager.default.temporaryDirectory.appendingPathComponent("scratchlist-\(name).png")
|
|
try XCTUnwrap(image.pngData()).write(to: url)
|
|
print("SCRATCHLIST_CAPTURE=\(url.path)")
|
|
}
|
|
}
|
|
|
|
func testDrawerShowsOneRecentDraftAndOnlyHeaderWhenTypingOrUsingLargeText() throws {
|
|
let interactor = interactor()
|
|
let store = try XCTUnwrap(interactor.scratchlist as? ScratchlistTestStore)
|
|
let entries = store.session.entries
|
|
func measure(count: Int, focused: Bool, size: DynamicTypeSize, locale: String) -> CGSize {
|
|
store.session.entries = Array(entries.prefix(count))
|
|
let view = ScratchlistDrawerView(store: store, sessionId: "preview", interactor: interactor,
|
|
keyboardFocused: focused, onOpen: { _, _ in })
|
|
.hapiTypography().environment(\.dynamicTypeSize, size).environment(\.locale, Locale(identifier: locale))
|
|
let host = UIHostingController(rootView: view)
|
|
return host.sizeThatFits(in: CGSize(width: 320, height: 2000))
|
|
}
|
|
for locale in ["en", "zh-Hans"] {
|
|
let summary = measure(count: 3, focused: true, size: .large, locale: locale)
|
|
XCTAssertLessThan(summary.height, 70, "Typing should leave just one header, not a draft preview")
|
|
XCTAssertLessThanOrEqual(summary.width, 320)
|
|
XCTAssertEqual(measure(count: 0, focused: true, size: .large, locale: locale).height, summary.height, accuracy: 1)
|
|
let one = measure(count: 1, focused: false, size: .large, locale: locale)
|
|
XCTAssertGreaterThan(one.height, summary.height + 40)
|
|
XCTAssertLessThan(one.height, 150)
|
|
XCTAssertEqual(measure(count: 3, focused: false, size: .large, locale: locale).height, one.height, accuracy: 1)
|
|
for size in [DynamicTypeSize.accessibility3, .accessibility5] {
|
|
let empty = measure(count: 0, focused: false, size: size, locale: locale)
|
|
let populated = measure(count: 3, focused: false, size: size, locale: locale)
|
|
XCTAssertEqual(populated.height, empty.height, accuracy: 1, "Large text uses a summary even without the keyboard")
|
|
XCTAssertLessThanOrEqual(populated.width, 320)
|
|
XCTAssertLessThan(populated.height, 170)
|
|
}
|
|
}
|
|
}
|
|
|
|
func testRowsRemainCompactAndAdaptActionsAtAccessibleSizes() throws {
|
|
let interactor = interactor()
|
|
let entry = try XCTUnwrap(interactor.scratchlist?.state("preview").entries[1])
|
|
for locale in ["en", "zh-Hans"] {
|
|
for size in [DynamicTypeSize.large, .accessibility3, .accessibility5] {
|
|
for compact in [false, true] {
|
|
let row = ScratchlistEntryRow(entry: entry, interactor: interactor,
|
|
onOpen: {}, onEdit: {}, onDelete: {}, compact: compact)
|
|
.hapiTypography().environment(\.dynamicTypeSize, size)
|
|
.environment(\.locale, Locale(identifier: locale))
|
|
let host = UIHostingController(rootView: row)
|
|
let fitting = host.sizeThatFits(in: CGSize(width: 256, height: 2000))
|
|
XCTAssertLessThanOrEqual(fitting.width, 256)
|
|
XCTAssertGreaterThanOrEqual(fitting.height, 44)
|
|
if size == .large { XCTAssertLessThan(fitting.height, compact ? 70 : 100) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func testFocusingTheRealComposerCollapsesThePreviewWithoutChangingInput() async throws {
|
|
let interactor = interactor()
|
|
interactor.setComposerText("补充一条回归测试")
|
|
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: 390, height: 844)
|
|
let host = UIHostingController(rootView: ChatComposerView(interactor: interactor)
|
|
.hapiTypography().environment(\.locale, Locale(identifier: "zh-Hans")))
|
|
window.rootViewController = host
|
|
window.makeKeyAndVisible()
|
|
defer { window.endEditing(true); window.isHidden = true }
|
|
try await Task.sleep(for: .milliseconds(250))
|
|
let proposal = CGSize(width: 390, height: 2000)
|
|
let browsingHeight = host.sizeThatFits(in: proposal).height
|
|
interactor.focusComposer()
|
|
func hasFirstResponder(_ view: UIView) -> Bool {
|
|
view.isFirstResponder || view.subviews.contains(where: hasFirstResponder)
|
|
}
|
|
for _ in 0..<50 {
|
|
if hasFirstResponder(window) { break }
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
XCTAssertTrue(hasFirstResponder(window))
|
|
try await Task.sleep(for: .milliseconds(250))
|
|
XCTAssertLessThan(host.sizeThatFits(in: proposal).height, browsingHeight - 40)
|
|
XCTAssertEqual(interactor.composerText, "补充一条回归测试")
|
|
XCTAssertEqual(interactor.composerDestination, .scratchlist)
|
|
XCTAssertEqual(interactor.scratchlistCount, 3)
|
|
}
|
|
|
|
func testInlineScratchlistErrorsDoNotAlsoProduceToasts() throws {
|
|
let hub = try XCTUnwrap(HubSession(hubUrl: "http://127.0.0.1:1/scratchlist-notices-\(UUID().uuidString)",
|
|
credentialStore: InMemoryCredentialStore(), performer: ScratchlistOfflineHTTP()))
|
|
let model = ChatModel(session: hub, sessionId: "preview")
|
|
defer { model.stop(); hub.shutdown() }
|
|
model.start()
|
|
let message = "Couldn't park the draft — check the hub connection"
|
|
model.interactor.reportScratchlistError(message)
|
|
model.interactor.onEvent?(.notice(message))
|
|
XCTAssertNil(model.notice)
|
|
// Dismissing the inline error must not uncover a duplicate toast.
|
|
model.interactor.retryScratchlistComposerOperation()
|
|
XCTAssertNil(model.notice)
|
|
model.interactor.onEvent?(.notice("Draft parked to scratchlist"))
|
|
XCTAssertEqual(model.notice, "Draft parked to scratchlist")
|
|
model.interactor.onEvent?(.notice("An unrelated error"))
|
|
XCTAssertEqual(model.notice, "An unrelated error")
|
|
}
|
|
}
|
|
|
|
extension ScratchlistPresentationTests {
|
|
private struct ScrollRow: Identifiable, Equatable { let id: String }
|
|
private struct ScrollHarness: View {
|
|
let interactor: ChatInteractor
|
|
let rows = (0..<100).map { ScrollRow(id: "message-\($0)") }
|
|
var body: some View {
|
|
AnchoredTranscriptList(items: rows, historyVersion: 0, jumpToken: 0,
|
|
historyControlID: "history", onViewport: { _ in }, onLayout: { _, _ in }) { row in
|
|
AnyView(Text(verbatim: row.id).frame(maxWidth: .infinity).frame(height: 90))
|
|
}
|
|
.safeAreaInset(edge: .bottom, spacing: 0) { ChatComposerView(interactor: interactor) }
|
|
.hapiTypography()
|
|
}
|
|
}
|
|
|
|
func testTogglingDrawerPreservesTranscriptIdentityAndReadingAnchor() async throws {
|
|
let interactor = interactor()
|
|
interactor.setComposerDestination(.chat)
|
|
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: 390, height: 844)
|
|
let host = UIHostingController(rootView: ScrollHarness(interactor: interactor))
|
|
window.rootViewController = host
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
try await Task.sleep(for: .milliseconds(250))
|
|
func find(_ view: UIView) -> UICollectionView? {
|
|
if let collection = view as? UICollectionView { return collection }
|
|
return view.subviews.lazy.compactMap(find).first
|
|
}
|
|
let list = try XCTUnwrap(find(host.view))
|
|
list.delegate?.scrollViewWillBeginDragging?(list)
|
|
list.scrollToItem(at: IndexPath(item: 25, section: 0), at: .top, animated: false)
|
|
list.contentOffset.y += 13
|
|
list.delegate?.scrollViewDidEndDragging?(list, willDecelerate: false)
|
|
try await Task.sleep(for: .milliseconds(180))
|
|
func anchor() -> (String, CGFloat)? {
|
|
list.visibleCells.filter { $0.frame.maxY > list.contentOffset.y }
|
|
.sorted { $0.frame.minY < $1.frame.minY }.first.map {
|
|
($0.accessibilityIdentifier ?? "", $0.frame.minY - list.contentOffset.y)
|
|
}
|
|
}
|
|
let before = try XCTUnwrap(anchor())
|
|
for mode in [ComposerDestination.scratchlist, .chat] {
|
|
interactor.setComposerDestination(mode)
|
|
try await Task.sleep(for: .milliseconds(250))
|
|
XCTAssertTrue(find(host.view) === list)
|
|
let after = try XCTUnwrap(anchor())
|
|
XCTAssertEqual(after.0, before.0)
|
|
XCTAssertEqual(after.1, before.1, accuracy: 1)
|
|
}
|
|
}
|
|
|
|
func testInventoryAndSingleStackEditorRender() async throws {
|
|
let interactor = interactor()
|
|
let store = try XCTUnwrap(interactor.scratchlist)
|
|
let http = ScratchlistOfflineHTTP()
|
|
let url = URL(string: "https://scratchlist.invalid")!
|
|
let api = APIClient(baseURL: url,
|
|
authManager: AuthManager(baseURL: url, credentialStore: InMemoryCredentialStore(), performer: http), performer: http)
|
|
let loader = ScratchlistAttachmentLoader(api: api, sessionId: "preview")
|
|
for (name, detail, editing, width, size, dark) in [
|
|
("inventory", false, false, CGFloat(402), DynamicTypeSize.large, false),
|
|
("inventory-dark", false, false, CGFloat(402), DynamicTypeSize.large, true),
|
|
("inventory-compact", false, false, CGFloat(320), DynamicTypeSize.large, false),
|
|
("inventory-large-text", false, false, CGFloat(390), DynamicTypeSize.accessibility3, false),
|
|
("detail", true, false, CGFloat(402), DynamicTypeSize.large, false),
|
|
("editor", true, true, CGFloat(402), DynamicTypeSize.large, false),
|
|
("empty", false, false, CGFloat(402), DynamicTypeSize.large, false),
|
|
] {
|
|
if name == "empty" { (store as? ScratchlistTestStore)?.session.entries = [] }
|
|
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: 874)
|
|
let view = ScratchlistView(store: store, sessionId: "preview", attachments: loader, interactor: interactor,
|
|
initialEntry: detail ? store.state("preview").entries[1] : nil, initiallyEditing: editing)
|
|
.hapiTypography().environment(\.locale, Locale(identifier: "zh-Hans"))
|
|
.environment(\.dynamicTypeSize, size).preferredColorScheme(dark ? .dark : .light)
|
|
window.rootViewController = UIHostingController(rootView: view)
|
|
window.makeKeyAndVisible()
|
|
defer { window.isHidden = true }
|
|
try await Task.sleep(for: .milliseconds(350))
|
|
let image = UIGraphicsImageRenderer(bounds: window.bounds).image { _ in
|
|
window.drawHierarchy(in: window.bounds, afterScreenUpdates: true)
|
|
}
|
|
let name = "scratchlist-\(name)"
|
|
let attachment = XCTAttachment(image: image)
|
|
attachment.name = name
|
|
attachment.lifetime = .keepAlways
|
|
add(attachment)
|
|
let file = FileManager.default.temporaryDirectory.appendingPathComponent("\(name).png")
|
|
try XCTUnwrap(image.pngData()).write(to: file)
|
|
print("SCRATCHLIST_CAPTURE=\(file.path)")
|
|
XCTAssertEqual(store.state("preview").entries.count, name == "scratchlist-empty" ? 0 : 3)
|
|
}
|
|
}
|
|
}
|