mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-06 18:39:47 +00:00
Preserve hosted rows while propagating current SwiftUI environments and render captures. Add differential refresh regressions, opt-in real-clock iOS/Android profiling, and reproducible performance results. Validation: typecheck; CLI/hub/web/shared/relay suites (Web rerun with NODE_OPTIONS=--no-experimental-webstorage for Node 26/jsdom compatibility); existing iOS Debug/Release and Android regression runs.
189 lines
7.9 KiB
Swift
189 lines
7.9 KiB
Swift
import Observation
|
|
import SwiftUI
|
|
import UIKit
|
|
import XCTest
|
|
@testable import HapiUI
|
|
|
|
@MainActor
|
|
final class TranscriptRefreshTests: XCTestCase {
|
|
struct Row: Identifiable, Equatable {
|
|
let id: String
|
|
var height: CGFloat = 120
|
|
}
|
|
|
|
struct Sample: Equatable {
|
|
let basePath: String
|
|
let dark: Bool
|
|
let size: DynamicTypeSize
|
|
let locale: String
|
|
let direction: LayoutDirection
|
|
}
|
|
|
|
@Observable @MainActor
|
|
final class Driver {
|
|
var rows = (0..<80).map { Row(id: String($0)) }
|
|
var version = 0
|
|
var basePath = "/before"
|
|
var dark = false
|
|
var size = DynamicTypeSize.large
|
|
var locale = Locale(identifier: "en")
|
|
var direction = LayoutDirection.leftToRight
|
|
var actionToken = 0
|
|
@ObservationIgnored var lastAction = -1
|
|
@ObservationIgnored var layouts: [Int] = []
|
|
@ObservationIgnored var samples: [String: Sample] = [:]
|
|
@ObservationIgnored var actions: [String: HapiOpenURLAction] = [:]
|
|
}
|
|
|
|
private struct Harness: View {
|
|
let driver: Driver
|
|
var body: some View {
|
|
let token = driver.actionToken
|
|
ListView(driver: driver)
|
|
.hapiTheme(driver.dark ? .dark : .light)
|
|
.environment(\.dynamicTypeSize, driver.size)
|
|
.environment(\.locale, driver.locale)
|
|
.environment(\.layoutDirection, driver.direction)
|
|
.environment(\.hapiOpenURL, HapiOpenURLAction { _ in
|
|
Task { @MainActor in driver.lastAction = token }
|
|
})
|
|
}
|
|
}
|
|
|
|
private struct ListView: View {
|
|
let driver: Driver
|
|
var body: some View {
|
|
// An ordinary value capture, not a live observable read in a row.
|
|
let basePath = driver.basePath
|
|
AnchoredTranscriptList(
|
|
items: driver.rows, historyVersion: driver.version, jumpToken: 0,
|
|
historyControlID: "history", onViewport: { _ in },
|
|
onLayout: { version, _ in driver.layouts.append(version) }
|
|
) { row in
|
|
AnyView(ProbeRow(row: row, basePath: basePath, driver: driver))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ProbeRow: View {
|
|
let row: Row
|
|
let basePath: String
|
|
let driver: Driver
|
|
@Environment(\.dynamicTypeSize) private var size
|
|
var body: some View {
|
|
Text(row.id)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: row.height + (size.isAccessibilitySize ? 60 : 0))
|
|
.background(Probe(id: row.id, basePath: basePath, driver: driver))
|
|
}
|
|
}
|
|
|
|
private struct Probe: UIViewRepresentable {
|
|
let id: String
|
|
let basePath: String
|
|
let driver: Driver
|
|
func makeUIView(context: Context) -> UIView { UIView() }
|
|
func updateUIView(_ view: UIView, context: Context) {
|
|
let environment = context.environment
|
|
driver.samples[id] = Sample(basePath: basePath, dark: environment.hapiTheme.isDark,
|
|
size: environment.dynamicTypeSize, locale: environment.locale.identifier,
|
|
direction: environment.layoutDirection)
|
|
driver.actions[id] = environment.hapiOpenURL
|
|
}
|
|
}
|
|
|
|
private func host(_ driver: Driver) throws -> (UIWindow, TranscriptCollectionController<Row>, UICollectionView) {
|
|
let scene = try XCTUnwrap(UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }.first)
|
|
let window = UIWindow(windowScene: scene)
|
|
let host = UIHostingController(rootView: Harness(driver: driver))
|
|
window.rootViewController = host
|
|
window.makeKeyAndVisible()
|
|
host.view.layoutIfNeeded()
|
|
func find(_ controller: UIViewController) -> TranscriptCollectionController<Row>? {
|
|
if let result = controller as? TranscriptCollectionController<Row> { return result }
|
|
return controller.children.lazy.compactMap(find).first
|
|
}
|
|
let controller = try XCTUnwrap(find(host))
|
|
return (window, controller, try XCTUnwrap(controller.view as? UICollectionView))
|
|
}
|
|
|
|
private func settle(_ condition: () -> Bool = { true }, file: StaticString = #filePath, line: UInt = #line) async {
|
|
for _ in 0..<100 {
|
|
try? await Task.sleep(for: .milliseconds(20))
|
|
if condition() {
|
|
try? await Task.sleep(for: .milliseconds(100))
|
|
return
|
|
}
|
|
}
|
|
XCTFail("Transcript did not settle", file: file, line: line)
|
|
}
|
|
|
|
private func browse(_ view: UICollectionView, index: Int) async {
|
|
view.delegate?.scrollViewWillBeginDragging?(view)
|
|
view.scrollToItem(at: IndexPath(item: index, section: 0), at: .top, animated: false)
|
|
await settle()
|
|
}
|
|
|
|
func testOffscreenUpdatesKeepVisibleHostingConfigurationsAndStillResizeChangedRows() async throws {
|
|
let driver = Driver()
|
|
let (window, controller, view) = try host(driver)
|
|
defer { window.isHidden = true }
|
|
await settle { driver.layouts.contains(0) }
|
|
await browse(view, index: 25)
|
|
let cell = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0)))
|
|
let offset = cell.frame.minY - view.contentOffset.y
|
|
#if DEBUG
|
|
let configurations = controller.cellConfigurationCount
|
|
#endif
|
|
for version in 1...3 {
|
|
driver.rows[79].height += 20
|
|
driver.version = version
|
|
await settle { driver.layouts.contains(version) }
|
|
}
|
|
#if DEBUG
|
|
XCTAssertEqual(controller.cellConfigurationCount, configurations,
|
|
"Offscreen streaming must not reinstall unchanged visible hosting roots")
|
|
#endif
|
|
XCTAssertEqual(cell.frame.minY - view.contentOffset.y, offset, accuracy: 1)
|
|
driver.rows[25].height += 70
|
|
await settle { abs(cell.frame.height - 190) < 1 }
|
|
XCTAssertEqual(cell.frame.minY - view.contentOffset.y, offset, accuracy: 1)
|
|
await browse(view, index: 79)
|
|
let tail = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 79, section: 0)))
|
|
XCTAssertEqual(tail.frame.height, 180, accuracy: 1, "Recycled cells must use the latest offscreen value")
|
|
}
|
|
|
|
func testEqualItemsReceiveLatestEnvironmentValueCapturesAndActions() async throws {
|
|
let driver = Driver()
|
|
let (window, controller, view) = try host(driver)
|
|
defer { window.isHidden = true }
|
|
await settle { driver.layouts.contains(0) }
|
|
await browse(view, index: 25)
|
|
#if DEBUG
|
|
let configurations = controller.cellConfigurationCount
|
|
#endif
|
|
driver.basePath = "/after"
|
|
driver.dark = true
|
|
driver.actionToken = 1
|
|
let expected = Sample(basePath: "/after", dark: true, size: .large, locale: "en", direction: .leftToRight)
|
|
await settle { driver.samples["25"] == expected }
|
|
XCTAssertEqual(driver.samples["25"], expected)
|
|
#if DEBUG
|
|
XCTAssertEqual(controller.cellConfigurationCount, configurations)
|
|
#endif
|
|
try XCTUnwrap(driver.actions["25"])(URL(string: "hapi-file://?path=test")!)
|
|
await settle { driver.lastAction == 1 }
|
|
// UIKit may itself request cells again when layout-direction traits
|
|
// change; only ordinary data/context updates have a zero-config budget.
|
|
driver.locale = Locale(identifier: "ar")
|
|
driver.direction = .rightToLeft
|
|
driver.size = .accessibility2
|
|
let recycled = Sample(basePath: "/after", dark: true, size: .accessibility2, locale: "ar", direction: .rightToLeft)
|
|
await settle { driver.samples["25"] == recycled }
|
|
let cell = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0)))
|
|
XCTAssertEqual(cell.frame.height, 180, accuracy: 1, "Environment-only changes must still self-size")
|
|
await browse(view, index: 60)
|
|
XCTAssertEqual(driver.samples["60"], recycled)
|
|
}
|
|
}
|