mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-12 20:07:31 +00:00
317 lines
16 KiB
Swift
317 lines
16 KiB
Swift
import HapiClient
|
|
import HapiProtocol
|
|
import Observation
|
|
import SwiftUI
|
|
import UIKit
|
|
import XCTest
|
|
@testable import Hapi
|
|
|
|
/// The production toolbar presenter over the non-networked config harness.
|
|
/// Run on both iPhone and iPad; synthetic widths alone cannot test adaptation.
|
|
@MainActor
|
|
final class SessionConfigPresentationTests: XCTestCase {
|
|
@Observable
|
|
fileprivate final class Presentation {
|
|
let model: SessionConfigModel
|
|
var isPresented = false
|
|
var notice: String?
|
|
init(model: SessionConfigModel) { self.model = model }
|
|
}
|
|
|
|
private struct Host: View {
|
|
let presentation: Presentation
|
|
var size: DynamicTypeSize = .large
|
|
var dark = false
|
|
var locale = Locale(identifier: "en")
|
|
|
|
var body: some View {
|
|
@Bindable var presentation = presentation
|
|
NavigationStack {
|
|
Text("Session settings specimen")
|
|
.navigationTitle("HAPI")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.toolbar {
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
SessionConfigButton(isPresented: $presentation.isPresented) {
|
|
SessionConfigView(model: presentation.model, notice: presentation.notice)
|
|
.environment(\.dynamicTypeSize, size)
|
|
.environment(\.locale, locale)
|
|
}
|
|
.environment(\.dynamicTypeSize, size)
|
|
}
|
|
ToolbarItem(placement: .topBarTrailing) {
|
|
Image(systemName: "ellipsis.circle")
|
|
}
|
|
}
|
|
}
|
|
.environment(\.dynamicTypeSize, size)
|
|
.environment(\.locale, locale)
|
|
.preferredColorScheme(dark ? .dark : .light)
|
|
}
|
|
}
|
|
|
|
func testMenusKeepTheSamePageAndSheetHeightWhenSelecting() async throws {
|
|
let harness = try await SessionConfigTestHarness(model: "sonnet")
|
|
let presentation = Presentation(model: harness.model)
|
|
let (window, host) = try await show(Host(presentation: presentation))
|
|
defer { window.isHidden = true }
|
|
let sheet = try XCTUnwrap(host.presentedViewController)
|
|
let rootList = try XCTUnwrap(findList(sheet.view))
|
|
XCTAssertEqual(itemCount(rootList), 3)
|
|
XCTAssertTrue(rootList.visibleCells.allSatisfy { $0.bounds.height >= 44 })
|
|
let rootHeight = sheet.view.bounds.height
|
|
try capture(window, name: "root")
|
|
|
|
harness.model.selectModel("sonnet")
|
|
harness.model.selectPermission(.default)
|
|
let initialPosts = await harness.http.posts
|
|
XCTAssertTrue(initialPosts.isEmpty, "Reselecting the current value is read-only")
|
|
harness.model.selectModel("opus")
|
|
try await configEventually { !harness.model.isApplying }
|
|
harness.model.selectEffort("high")
|
|
try await configEventually { !harness.model.isApplying }
|
|
harness.model.selectPermission(.bypassPermissions)
|
|
try await configEventually { !harness.model.isApplying }
|
|
try await settle()
|
|
XCTAssertTrue(host.presentedViewController === sheet)
|
|
XCTAssertNil(sheet.presentedViewController, "Selections must not open another modal")
|
|
XCTAssertTrue(findList(sheet.view) === rootList, "No detail page replaces the settings list")
|
|
XCTAssertEqual(itemCount(rootList), 3)
|
|
XCTAssertEqual(sheet.view.bounds.height, rootHeight, accuracy: 2)
|
|
XCTAssertEqual(harness.model.modelLabel, "Opus")
|
|
XCTAssertEqual(harness.model.permission, .bypassPermissions)
|
|
try capture(window, name: "selected")
|
|
presentation.isPresented = false
|
|
try await configEventually { host.presentedViewController == nil }
|
|
let posts = await harness.http.posts
|
|
XCTAssertEqual(posts.count, 3, "Dismissal does not apply or revert settings")
|
|
}
|
|
|
|
func testCodexRootShowsCollaborationAlongsidePermissionsModelAndEffort() async throws {
|
|
let harness = try await SessionConfigTestHarness(flavor: "codex")
|
|
let presentation = Presentation(model: harness.model)
|
|
let (window, host) = try await show(Host(presentation: presentation, locale: Locale(identifier: "zh-Hans")))
|
|
defer { window.isHidden = true }
|
|
try await configEventually { harness.model.showsEffort }
|
|
try await settle()
|
|
let sheet = try XCTUnwrap(host.presentedViewController)
|
|
let list = try XCTUnwrap(findList(sheet.view))
|
|
XCTAssertEqual(itemCount(list), 4)
|
|
XCTAssertTrue(list.visibleCells.allSatisfy { $0.bounds.height >= 44 })
|
|
harness.model.selectCollaborationMode(.plan)
|
|
try await settle()
|
|
XCTAssertTrue(host.presentedViewController === sheet)
|
|
XCTAssertEqual(harness.model.collaborationMode, .plan)
|
|
XCTAssertEqual(harness.model.permission, .default)
|
|
XCTAssertEqual(itemCount(list), 4)
|
|
}
|
|
|
|
func testBusyAndFailureFeedbackRemainInsideTheOpenSheet() async throws {
|
|
let harness = try await SessionConfigTestHarness(model: "sonnet")
|
|
let presentation = Presentation(model: harness.model)
|
|
harness.interactor.onEvent = {
|
|
if case .notice(let message) = $0 { presentation.notice = message }
|
|
}
|
|
let (window, host) = try await show(Host(presentation: presentation))
|
|
defer { window.isHidden = true }
|
|
let sheet = try XCTUnwrap(host.presentedViewController)
|
|
await harness.http.holdPosts()
|
|
await harness.http.rejectChanges()
|
|
defer { Task { await harness.http.releasePosts() } }
|
|
harness.model.selectModel("opus")
|
|
try await settle()
|
|
XCTAssertTrue(harness.model.isApplying)
|
|
XCTAssertTrue(host.presentedViewController === sheet)
|
|
try capture(window, name: "applying")
|
|
await harness.http.releasePosts()
|
|
try await configEventually { presentation.notice == "HTTP 409 (Config rejected)" && !harness.model.isApplying }
|
|
try await settle()
|
|
XCTAssertEqual(harness.model.modelLabel, "Sonnet")
|
|
XCTAssertTrue(host.presentedViewController === sheet)
|
|
try capture(window, name: "failure")
|
|
presentation.notice = nil
|
|
try await settle()
|
|
XCTAssertEqual(itemCount(try XCTUnwrap(findList(sheet.view))), 3)
|
|
}
|
|
|
|
func testFailedCatalogCanRetryInlineWithoutLeavingSettings() async throws {
|
|
let harness = try await SessionConfigTestHarness(flavor: "codex")
|
|
await harness.http.setModelsFailure(true)
|
|
let presentation = Presentation(model: harness.model)
|
|
let (window, host) = try await show(Host(presentation: presentation))
|
|
defer { window.isHidden = true }
|
|
try await configEventually { harness.model.modelLoadFailed }
|
|
try await settle()
|
|
let sheet = try XCTUnwrap(host.presentedViewController)
|
|
XCTAssertEqual(itemCount(try XCTUnwrap(findList(sheet.view))), 3, "Model status stays in its row beside the other settings")
|
|
try capture(window, name: "catalog-failure")
|
|
await harness.http.setModelsFailure(false)
|
|
harness.model.loadModels()
|
|
try await configEventually { !harness.model.config.modelOptionsLoading }
|
|
try await settle()
|
|
XCTAssertTrue(host.presentedViewController === sheet)
|
|
XCTAssertEqual(harness.model.currentModel, "deep")
|
|
XCTAssertEqual(itemCount(try XCTUnwrap(findList(sheet.view))), 4)
|
|
}
|
|
|
|
func testLayoutSpecimensSupportNarrowWideDarkChineseAndLargeType() async throws {
|
|
let cases: [(String, CGSize, DynamicTypeSize, Bool, String)] = [
|
|
("compact", CGSize(width: 320, height: 667), .large, false, "en"),
|
|
("dark", CGSize(width: 390, height: 844), .large, true, "en"),
|
|
("chinese", CGSize(width: 390, height: 844), .large, false, "zh-Hans"),
|
|
("large-type", CGSize(width: 390, height: 844), .accessibility3, false, "zh-Hans"),
|
|
("wide", CGSize(width: 820, height: 1180), .large, false, "en"),
|
|
("landscape", CGSize(width: 1180, height: 820), .large, false, "en"),
|
|
("wide-large-type", CGSize(width: 820, height: 1180), .accessibility3, false, "zh-Hans"),
|
|
]
|
|
for (name, dimensions, typeSize, dark, locale) in cases {
|
|
let shortModel = name == "compact" || name == "chinese"
|
|
let harness = try await SessionConfigTestHarness(
|
|
model: shortModel ? "sonnet" : "custom-model-with-a-very-long-name-用于长名称换行验证"
|
|
)
|
|
harness.store.updateDetailLocal("config") { $0.permissionMode = .bypassPermissions }
|
|
let presentation = Presentation(model: harness.model)
|
|
let (window, host) = try await show(
|
|
Host(presentation: presentation, size: typeSize, dark: dark, locale: Locale(identifier: locale)),
|
|
dimensions: dimensions
|
|
)
|
|
let sheet = try XCTUnwrap(host.presentedViewController)
|
|
let list = try XCTUnwrap(findList(sheet.view))
|
|
XCTAssertEqual(itemCount(list), 3)
|
|
XCTAssertTrue(list.visibleCells.allSatisfy { $0.bounds.height >= 44 })
|
|
if !typeSize.isAccessibilitySize {
|
|
XCTAssertLessThan(
|
|
try XCTUnwrap(list.cellForItem(at: IndexPath(item: 0, section: 1))).bounds.height, 120,
|
|
"The warning subtitle must not stretch into a separate Form row"
|
|
)
|
|
}
|
|
if typeSize.isAccessibilitySize && host.traitCollection.horizontalSizeClass == .compact {
|
|
let controller = try XCTUnwrap(adaptiveSheet(of: sheet))
|
|
XCTAssertEqual(controller.detents.count, 1, "Accessibility text uses a large sheet")
|
|
}
|
|
try capture(window, name: name)
|
|
presentation.isPresented = false
|
|
try await configEventually { host.presentedViewController == nil }
|
|
window.isHidden = true
|
|
}
|
|
}
|
|
|
|
func testNativeDevicePresentationIsAnchoredOnIPadAndAdaptsOnIPhone() async throws {
|
|
let harness = try await SessionConfigTestHarness(model: "sonnet")
|
|
let presentation = Presentation(model: harness.model)
|
|
// No fake dimensions or trait overrides: this test needs a real iPad run.
|
|
let (window, host) = try await show(Host(presentation: presentation), dimensions: nil)
|
|
defer { window.isHidden = true }
|
|
// Optional pause for AXe-driven menu/Done interaction over this fake session.
|
|
if let value = ProcessInfo.processInfo.environment["HAPI_SESSION_CONFIG_INTERACTIVE_SECONDS"],
|
|
let seconds = Double(value), seconds > 0 {
|
|
try await Task.sleep(for: .seconds(min(seconds, 180)))
|
|
}
|
|
let panel = try XCTUnwrap(host.presentedViewController)
|
|
XCTAssertEqual(itemCount(try XCTUnwrap(findList(panel.view))), 3)
|
|
if host.traitCollection.horizontalSizeClass == .regular {
|
|
let popover = try XCTUnwrap(panel.presentationController as? UIPopoverPresentationController)
|
|
if popover.sourceItem != nil {
|
|
XCTAssertNotNil(popover.sourceItem as? UIBarButtonItem, "Toolbar anchors use a native bar item")
|
|
} else {
|
|
let source = try XCTUnwrap(popover.sourceView)
|
|
let rect = popover.sourceRect.isEmpty || popover.sourceRect.isNull ? source.bounds : popover.sourceRect
|
|
let anchor = source.convert(rect, to: window)
|
|
XCTAssertLessThan(anchor.width, 100, "Anchor must be the gear, not the page")
|
|
XCTAssertGreaterThan(anchor.midX, window.bounds.midX)
|
|
XCTAssertLessThan(anchor.maxY, window.bounds.height / 3)
|
|
}
|
|
let panelFrame = panel.view.convert(panel.view.bounds, to: window)
|
|
XCTAssertGreaterThan(panelFrame.midX, window.bounds.midX, "Popover belongs beside the toolbar, not in the center")
|
|
XCTAssertLessThan(panelFrame.minY, window.bounds.height / 3)
|
|
XCTAssertLessThanOrEqual(panel.view.bounds.width, 420)
|
|
} else {
|
|
let sheet = try XCTUnwrap(adaptiveSheet(of: panel))
|
|
XCTAssertEqual(sheet.detents.count, 2)
|
|
}
|
|
try capture(window, name: "device-root")
|
|
harness.model.selectModel("opus")
|
|
try await configEventually { !harness.model.isApplying }
|
|
try await settle()
|
|
XCTAssertTrue(host.presentedViewController === panel)
|
|
XCTAssertEqual(itemCount(try XCTUnwrap(findList(panel.view))), 3)
|
|
}
|
|
|
|
func testSizeClassAdaptationPreservesOpenConfigurationAndDoesNotResubmit() async throws {
|
|
let harness = try await SessionConfigTestHarness(flavor: "codex")
|
|
let presentation = Presentation(model: harness.model)
|
|
let (window, host) = try await show(Host(presentation: presentation), dimensions: CGSize(width: 820, height: 1180))
|
|
defer { window.isHidden = true }
|
|
try await configEventually { harness.model.showsEffort }
|
|
harness.model.selectModel("fast")
|
|
try await configEventually { !harness.model.isApplying }
|
|
presentation.notice = "Config rejected"
|
|
for sizeClass in [UIUserInterfaceSizeClass.compact, .regular] {
|
|
host.traitOverrides.horizontalSizeClass = sizeClass
|
|
try await Task.sleep(for: .seconds(2))
|
|
XCTAssertTrue(presentation.isPresented)
|
|
let panel = try XCTUnwrap(host.presentedViewController)
|
|
XCTAssertEqual(itemCount(try XCTUnwrap(findList(panel.view))), 4)
|
|
XCTAssertEqual(harness.model.currentModel, "fast")
|
|
XCTAssertEqual(presentation.notice, "Config rejected")
|
|
}
|
|
let posts = await harness.http.posts
|
|
XCTAssertEqual(posts.count, 1)
|
|
let requests = await harness.http.modelRequests
|
|
XCTAssertEqual(requests, 1, "Adaptation must not reload the catalog")
|
|
try capture(window, name: "adapted")
|
|
}
|
|
|
|
private func show(
|
|
_ content: Host, dimensions: CGSize? = CGSize(width: 390, height: 844)
|
|
) async throws -> (UIWindow, UIHostingController<Host>) {
|
|
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
|
let window = UIWindow(windowScene: scene)
|
|
window.frame = CGRect(origin: .zero, size: dimensions ?? scene.coordinateSpace.bounds.size)
|
|
let host = UIHostingController(rootView: content)
|
|
if let dimensions {
|
|
host.traitOverrides.horizontalSizeClass = dimensions.width < 600 ? .compact : .regular
|
|
}
|
|
window.rootViewController = host
|
|
window.makeKeyAndVisible()
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
content.presentation.isPresented = true
|
|
try await configEventually { host.presentedViewController != nil }
|
|
try await settle()
|
|
return (window, host)
|
|
}
|
|
|
|
private func adaptiveSheet(of controller: UIViewController) -> UISheetPresentationController? {
|
|
// An adapted popover keeps its UIPopoverPresentationController; the
|
|
// actual sheet configuration is exposed through the adaptive accessor.
|
|
controller.popoverPresentationController?.adaptiveSheetPresentationController
|
|
?? controller.sheetPresentationController
|
|
}
|
|
|
|
private func settle() async throws {
|
|
try await Task.sleep(for: .milliseconds(650))
|
|
}
|
|
|
|
private func findList(_ view: UIView) -> UICollectionView? {
|
|
if let list = view as? UICollectionView, list.window != nil, !list.isHidden { return list }
|
|
for child in view.subviews.reversed() {
|
|
if let list = findList(child) { return list }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
private func itemCount(_ list: UICollectionView) -> Int {
|
|
(0..<list.numberOfSections).reduce(0) { $0 + list.numberOfItems(inSection: $1) }
|
|
}
|
|
|
|
private func capture(_ window: UIWindow, name: String) throws {
|
|
guard let path = ProcessInfo.processInfo.environment["HAPI_SESSION_CONFIG_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("\(name).png"))
|
|
}
|
|
}
|