#if canImport(UIKit) import XCTest import UIKit @testable import RichTextEditorUIKit import RichTextEditorCore final class BlockViewVirtualizationTests: XCTestCase { /// A bare canvas of `n` single-line paragraphs, laid out at FULL document height (no scroll host). private func longCanvas(_ n: Int, width: CGFloat = 300) -> DocumentCanvasView { let v = DocumentCanvasView() v.setParagraphs((0.. 3000 && $0.frame.minY < 3300 } XCTAssertNotNil(midBox, "precondition: a box near y=3000 exists") XCTAssertTrue(v.isBlockViewRealizedForTesting(midBox!.id), "a box at the new viewport is realized (set shifted)") XCTAssertLessThan(v.realizedBlockViewCountForTesting, v.boxes.count / 4, "realized count is well below the full document (virtualization is effective)") } func test_reconcile_recycledView_isReused_notReallocated() { let v = longCanvas(200) v.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 0, width: 300, height: 300)) let p0view = v.blockViews[BlockID("p0")] XCTAssertNotNil(p0view) // Scroll just far enough to cull p0 (past the overscan band), NOT a whole-document jump: the // recycle queue is a LIFO capped at 24, so culling many screenfuls at once evicts p0's instance // through overflow and the round trip legitimately reallocates. Tightened when V2's 1pt // inter-paragraph gap made a y=3000 jump span far more paragraphs than it used to. v.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 700, width: 300, height: 300)) // cull p0 XCTAssertNil(v.blockViews[BlockID("p0")], "p0 culled") v.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 0, width: 300, height: 300)) // realize the top again XCTAssertTrue(v.blockViews.values.contains { $0 === p0view }, "the recycled instance is reused, not reallocated") XCTAssertNotNil(p0view?.box, "the reused view is rebound to a box") } func test_reconcile_noScrollHost_realizesEveryBox() { let v = longCanvas(50) // layoutIfNeeded already ran reconcile via viewportRect()==bounds XCTAssertEqual(v.realizedBlockViewCountForTesting, v.boxes.count, "no scroll host ⇒ all realized (invariance)") } func test_reconcile_overscanBand_realizesJustOffscreenBox_butNotFarOnes() { let v = longCanvas(200) v.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 0, width: 300, height: 300)) // overscan auto = 300 let justBelow = v.boxes.first { $0.frame.minY > 320 && $0.frame.minY < 560 }! XCTAssertTrue(v.isBlockViewRealizedForTesting(justBelow.id), "a box within the overscan band is realized") let farBelow = v.boxes.first { $0.frame.minY > 1200 }! XCTAssertFalse(v.isBlockViewRealizedForTesting(farBelow.id), "a box beyond the band is not realized") } func test_reconcile_realizedView_zOrder_aboveQuoteUnderlay_belowSelectionWash() { let v = longCanvas(60) // Target a mid-document box by its actual frame (robust to inter-block spacing) rather than a // hardcoded scroll offset, which drifts below the document when body paragraphs stack tight. let midY = v.boxes[30].frame.minY v.reconcileBlockViews(visibleRect: CGRect(x: 0, y: midY, width: 300, height: 300)) // realize mid-doc only let block = v.subviews.compactMap { $0 as? BlockBackingView }.first XCTAssertNotNil(block, "a block view was realized in the window") let iBlock = v.subviews.firstIndex(of: block!)! let iQuote = v.subviews.firstIndex(of: v.blockquoteUnderlay)! let iWash = v.subviews.firstIndex(of: v.selectionHighlight)! XCTAssertGreaterThan(iBlock, iQuote, "block view sits above the back-most blockquote underlay") XCTAssertLessThan(iBlock, iWash, "block view sits below the selection-wash overlay") } func test_viewportDidChange_reRealizesAgainstTheCurrentViewport() { // Embed a bare canvas inside a real scroll view so viewportRect() reads the scroll window. let scroll = UIScrollView(frame: CGRect(x: 0, y: 0, width: 300, height: 300)) let v = DocumentCanvasView() v.setParagraphs((0..<200).map { ParagraphBlock(id: BlockID("p\($0)"), runs: [TextRun(text: "Paragraph \($0)")]) }, width: 300) v.frame = CGRect(x: 0, y: 0, width: 300, height: v.intrinsicContentSize.height) scroll.contentSize = v.frame.size scroll.addSubview(v) v.layoutIfNeeded() XCTAssertTrue(v.isBlockViewRealizedForTesting(BlockID("p0")), "top realized at offset 0") scroll.contentOffset = CGPoint(x: 0, y: 3000) v.viewportDidChange() XCTAssertFalse(v.isBlockViewRealizedForTesting(BlockID("p0")), "top recycled after the viewport moved down") } func test_blockViewOverscan_isTunable_throughTheFacade() { let editor = RichTextEditorView(frame: CGRect(x: 0, y: 0, width: 320, height: 480)) editor.blockViewOverscan = 999 XCTAssertEqual(editor.canvas.blockViewOverscan, 999, "the façade passes the overscan knob through to the canvas") } /// A bare canvas holding one WIDE (scrollable) table, laid out small enough that it scrolls. private func wideTableCanvas() -> DocumentCanvasView { func cell(_ id: String, _ t: String) -> Cell { Cell(id: BlockID(id), blocks: [.paragraph(ParagraphBlock(id: BlockID(id + "p"), runs: [TextRun(text: t)]))]) } let v = DocumentCanvasView() let cols = (0..<6).map { _ in ColumnSpec(width: 100) } let cells = (0..<6).map { i in cell("c\(i)", "C\(i)") } v.setBlocks([.table(TableBlock(id: BlockID("t"), columns: cols, rows: [Row(id: BlockID("r0"), cells: cells)]))], width: 390) v.frame = CGRect(x: 0, y: 0, width: 390, height: 300); v.layoutIfNeeded() return v } func test_table_horizontalScroll_survivesCullAndRealize() { let v = wideTableCanvas() let tv = v.blockViews[BlockID("t")] as! TableBackingView tv.layoutIfNeeded() XCTAssertGreaterThan(tv.scroll.contentSize.width, tv.bounds.width, "precondition: the table is scrollable") tv.scroll.contentOffset.x = 120 // user scrolls right → tableDidScroll syncs the box let t = v.boxes[0] as! TableBlockBox XCTAssertEqual(t.contentOffsetX, 120, accuracy: 0.5, "the box records the H-scroll") v.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 5000, width: 390, height: 300)) // cull off-screen XCTAssertFalse(v.isBlockViewRealizedForTesting(BlockID("t")), "table culled") XCTAssertEqual(t.contentOffsetX, 120, accuracy: 0.5, "the box retains the H-scroll while culled") v.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 0, width: 390, height: 300)) // realize again (fresh view) let tv2 = v.blockViews[BlockID("t")] as! TableBackingView XCTAssertFalse(tv2 === tv, "a fresh TableBackingView was created on re-realize") tv2.layoutIfNeeded() XCTAssertEqual(tv2.scroll.contentOffset.x, 120, accuracy: 0.5, "the H-scroll is restored into the new view") } func test_visibleBlockquoteFills_noScrollHost_keepsAllRuns() { let v = DocumentCanvasView() v.setBlocks([ .paragraph(ParagraphBlock(id: BlockID("q0"), runs: [TextRun(text: "Q0")])), .paragraph(ParagraphBlock(id: BlockID("q1"), runs: [TextRun(text: "Q1")])), ], width: 300) v.frame = CGRect(x: 0, y: 0, width: 300, height: v.intrinsicContentSize.height); v.layoutIfNeeded() // Every run is on-screen via the bounds band. XCTAssertEqual(v.visibleBlockquoteFills(band: v.viewportBand()).count, v.blockQuoteFillRects().count, "no scroll host ⇒ every run kept (invariance)") } func test_reconcile_signalsWhenAFreshTableIsRealized() { let v = wideTableCanvas() // Cull the table (reconcile far below it): no fresh table created. XCTAssertFalse(v.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 5000, width: 390, height: 300)), "culling creates no fresh table ⇒ false") XCTAssertFalse(v.isBlockViewRealizedForTesting(BlockID("t")), "table culled") // Re-realize it ⇒ a fresh TableBackingView is created ⇒ signals true (this is what drives the // cell-emoji re-host in viewportDidChange). XCTAssertTrue(v.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 0, width: 390, height: 300)), "re-realizing a culled table creates a fresh view ⇒ true") // A stays-realized table does not re-signal. XCTAssertFalse(v.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 0, width: 390, height: 300)), "a stays-realized table does not re-signal") } func test_coordinateFree_queriesUnchangedForUnrealizedRegion() { let v = longCanvas(200) let fullHeight = v.intrinsicContentSize.height // A far-down paragraph; record its geometry while everything is realized (full-height layout). let far = v.boxes[180] let pos = far.textStart + 1 // an interior offset, so caretRect resolves a real TextKit rect (not the empty-line fallback) let caretRealized = v.caretRect(for: DocumentTextPosition(pos)) let rectsRealized = v.selectionRects(globalFrom: far.textStart, globalTo: far.textStart + far.textLength) XCTAssertFalse(rectsRealized.isEmpty, "precondition: the far paragraph has selection rects (so the post-cull equality isn't vacuous)") let hitRealized = v.closestGlobalPosition(to: CGPoint(x: far.frame.midX, y: far.frame.midY)) // Cull it (realize only the top), then re-query — geometry must be byte-identical (coordinate-free). v.reconcileBlockViews(visibleRect: CGRect(x: 0, y: 0, width: 300, height: 300)) XCTAssertFalse(v.isBlockViewRealizedForTesting(far.id), "the far paragraph is culled") XCTAssertEqual(v.caretRect(for: DocumentTextPosition(pos)), caretRealized, "caretRect independent of realization") XCTAssertEqual(v.selectionRects(globalFrom: far.textStart, globalTo: far.textStart + far.textLength), rectsRealized, "selectionRects independent of realization") XCTAssertEqual(v.closestGlobalPosition(to: CGPoint(x: far.frame.midX, y: far.frame.midY)), hitRealized, "hit-testing independent of realization") XCTAssertEqual(v.intrinsicContentSize.height, fullHeight, accuracy: 0.5, "content height still spans the whole document even with the far box culled (scroll reaches it)") XCTAssertGreaterThan(fullHeight, far.frame.maxY, "precondition: the document extends past the culled far box") } } #endif