mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-06 18:39:47 +00:00
Use unified 16pt Dynamic Type body text, clearer Markdown spacing, and a shared reading column. Preserve transcript anchors across typography changes and bound tool command previews to two lines. Add layout and typography regression tests.
186 lines
8.1 KiB
Swift
186 lines
8.1 KiB
Swift
import Observation
|
|
import SwiftUI
|
|
import UIKit
|
|
import XCTest
|
|
@testable import HapiUI
|
|
|
|
@MainActor
|
|
final class TranscriptTypographyTests: XCTestCase {
|
|
struct Row: Identifiable, Equatable {
|
|
let id: String
|
|
let text: String
|
|
}
|
|
|
|
@Observable @MainActor
|
|
final class Driver {
|
|
let rows = (0..<80).map {
|
|
Row(id: String($0), text: "Message \($0) · 中英文阅读。" + String(repeating: "Dynamic Type keeps long answers readable. ", count: 3))
|
|
}
|
|
var size = DynamicTypeSize.large
|
|
var weight: LegibilityWeight? = .regular
|
|
var width: CGFloat = 390
|
|
var version = 0
|
|
@ObservationIgnored var layouts: [Int] = []
|
|
@ObservationIgnored var typography: HapiTypography?
|
|
}
|
|
|
|
private struct Probe: UIViewRepresentable {
|
|
let driver: Driver
|
|
func makeUIView(context: Context) -> UIView { UIView() }
|
|
func updateUIView(_ view: UIView, context: Context) {
|
|
driver.typography = context.environment.hapiTypography
|
|
}
|
|
}
|
|
|
|
private struct TextRow: View {
|
|
let row: Row
|
|
@Environment(\.hapiTypography) private var typography
|
|
var body: some View {
|
|
Text(row.text)
|
|
.font(typography.bodyFont)
|
|
.lineSpacing(typography.bodyLineSpacing)
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
private struct Harness: View {
|
|
let driver: Driver
|
|
var body: some View {
|
|
AnchoredTranscriptList(items: driver.rows, historyVersion: driver.version, jumpToken: 0,
|
|
historyControlID: "history", onViewport: { _ in },
|
|
onLayout: { version, _ in driver.layouts.append(version) }) { row in
|
|
AnyView(TextRow(row: row))
|
|
}
|
|
.overlay { Probe(driver: driver).frame(width: 0, height: 0) }
|
|
.frame(width: driver.width, height: 700)
|
|
.hapiTypography()
|
|
.environment(\.dynamicTypeSize, driver.size)
|
|
.environment(\.legibilityWeight, driver.weight)
|
|
}
|
|
}
|
|
|
|
private func host(_ driver: Driver) throws -> (UIWindow, 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(_ view: UIView) -> UICollectionView? {
|
|
if let collection = view as? UICollectionView { return collection }
|
|
return view.subviews.lazy.compactMap(find).first
|
|
}
|
|
return (window, try XCTUnwrap(find(host.view)))
|
|
}
|
|
|
|
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(160))
|
|
return
|
|
}
|
|
}
|
|
XCTFail("Typography layout did not settle", file: file, line: line)
|
|
}
|
|
|
|
private func browse(_ view: UICollectionView, index: Int) async throws -> UICollectionViewCell {
|
|
view.delegate?.scrollViewWillBeginDragging?(view)
|
|
view.scrollToItem(at: IndexPath(item: index, section: 0), at: .top, animated: false)
|
|
await settle()
|
|
view.contentOffset.y += 31
|
|
view.layoutIfNeeded()
|
|
await settle()
|
|
return try XCTUnwrap(view.cellForItem(at: IndexPath(item: index, section: 0)))
|
|
}
|
|
|
|
func testEqualItemsResizeAtLargeTextAndKeepTheHistoryAnchor() async throws {
|
|
let driver = Driver()
|
|
let (window, view) = try host(driver)
|
|
defer { window.isHidden = true }
|
|
await settle { driver.layouts.contains(0) }
|
|
XCTAssertEqual(try XCTUnwrap(driver.typography).bodySize, 16, accuracy: 0.01)
|
|
let farHeight = try await browse(view, index: 60).frame.height
|
|
let cell = try await browse(view, index: 25)
|
|
let oldHeight = cell.frame.height
|
|
let offset = cell.frame.minY - view.contentOffset.y
|
|
driver.size = .xxxLarge
|
|
await settle { (driver.typography?.bodySize ?? 0) > 16 && cell.frame.height > oldHeight }
|
|
XCTAssertEqual(cell.frame.minY - view.contentOffset.y, offset, accuracy: 1)
|
|
let typography = try XCTUnwrap(driver.typography)
|
|
XCTAssertEqual(typography.inlineCodeSize / typography.bodySize, 15 / 16, accuracy: 0.001)
|
|
let recycled = try await browse(view, index: 60)
|
|
XCTAssertGreaterThan(recycled.frame.height, farHeight)
|
|
}
|
|
|
|
func testMaximumAccessibilitySizeAndBoldTextPreserveBottomFollowing() async throws {
|
|
let driver = Driver()
|
|
let (window, view) = try host(driver)
|
|
defer { window.isHidden = true }
|
|
await settle { driver.layouts.contains(0) }
|
|
driver.size = .accessibility5
|
|
driver.weight = .bold
|
|
await settle { (driver.typography?.bodySize ?? 0) > 40 && driver.typography?.boldText == true }
|
|
XCTAssertEqual(view.contentOffset.y, view.contentSize.height - view.bounds.height, accuracy: 1)
|
|
let cell = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 79, section: 0)))
|
|
XCTAssertGreaterThan(cell.frame.height, 500)
|
|
driver.size = .large
|
|
await settle { driver.typography?.bodySize == 16 }
|
|
XCTAssertEqual(view.contentOffset.y, view.contentSize.height - view.bounds.height, accuracy: 1)
|
|
}
|
|
|
|
func testShrinkingTextClampsAnOffsetBeyondTheNewRowHeight() async throws {
|
|
let driver = Driver()
|
|
driver.size = .accessibility5
|
|
let (window, view) = try host(driver)
|
|
defer { window.isHidden = true }
|
|
await settle { driver.layouts.contains(0) }
|
|
let cell = try await browse(view, index: 25)
|
|
view.contentOffset.y = cell.frame.minY + 400
|
|
view.layoutIfNeeded()
|
|
await settle()
|
|
driver.size = .large
|
|
await settle { driver.typography?.bodySize == 16 }
|
|
let resized = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0)))
|
|
XCTAssertEqual(resized.frame.maxY - view.contentOffset.y, 1, accuracy: 1)
|
|
}
|
|
|
|
func testGrowingTextDoesNotClampADeepOffsetToAnUnmeasuredEstimate() async throws {
|
|
let driver = Driver()
|
|
driver.size = .accessibility3
|
|
let (window, view) = try host(driver)
|
|
defer { window.isHidden = true }
|
|
await settle { driver.layouts.contains(0) }
|
|
let cell = try await browse(view, index: 25)
|
|
view.contentOffset.y = cell.frame.minY + 350
|
|
view.layoutIfNeeded()
|
|
await settle()
|
|
let previousSize = try XCTUnwrap(driver.typography).bodySize
|
|
driver.size = .accessibility5
|
|
await settle { (driver.typography?.bodySize ?? 0) > previousSize }
|
|
let grown = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0)))
|
|
XCTAssertEqual(view.contentOffset.y - grown.frame.minY, 350, accuracy: 1)
|
|
}
|
|
|
|
func testReadingWidthAndBoldTextChangesKeepHistoryInPlace() async throws {
|
|
let driver = Driver()
|
|
let (window, view) = try host(driver)
|
|
defer { window.isHidden = true }
|
|
await settle { driver.layouts.contains(0) }
|
|
let cell = try await browse(view, index: 25)
|
|
let offset = cell.frame.minY - view.contentOffset.y
|
|
for width: CGFloat in [1024, 768, 507, 320] {
|
|
driver.width = width
|
|
await settle { abs(view.bounds.width - width) < 1 }
|
|
let frame = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0))).frame
|
|
XCTAssertEqual(frame.width, HapiReadingLayout.contentWidth(in: width), accuracy: 1)
|
|
XCTAssertEqual(frame.midX, width / 2, accuracy: 1)
|
|
XCTAssertEqual(frame.minY - view.contentOffset.y, offset, accuracy: 1)
|
|
}
|
|
driver.weight = .bold
|
|
await settle { driver.typography?.boldText == true }
|
|
let frame = try XCTUnwrap(view.cellForItem(at: IndexPath(item: 25, section: 0))).frame
|
|
XCTAssertEqual(frame.minY - view.contentOffset.y, offset, accuracy: 1)
|
|
}
|
|
}
|