Files
hapi/ios/HapiTests/TranscriptTypographyTests.swift
T
weishu 10042f79c6 feat(ios): refine chat typography and reading layout
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.
2026-09-10 16:22:36 +08:00

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)
}
}