Files
ichmagmaus111andClaude Opus 5.5 0622057a4f Ghostgram: open-source release
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 17:57:09 +01:00

380 lines
22 KiB
Swift

#if canImport(UIKit)
import XCTest
import UIKit
@testable import RichTextEditorUIKit
import RichTextEditorCore
final class BlockViewTests: XCTestCase {
private func paragraphCanvas() -> DocumentCanvasView {
let v = DocumentCanvasView()
v.setParagraphs([
ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "Alpha")]),
ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "Beta")]),
], width: 300)
v.frame = CGRect(x: 0, y: 0, width: 300, height: 200); v.layoutIfNeeded()
return v
}
func test_paragraphs_areBackedByBlockViews() {
let v = paragraphCanvas()
XCTAssertTrue(v.boxes[0].rendersAsBlockView, "paragraphs now render via block views")
XCTAssertNotNil(v.blockViews[BlockID("a")], "each paragraph has a pooled block view")
XCTAssertNotNil(v.blockViews[BlockID("b")])
let view = v.blockViews[BlockID("a")]!
XCTAssertEqual(view.frame.minY, v.boxes[0].frame.minY, accuracy: 0.01)
XCTAssertEqual(view.frame.height, v.boxes[0].frame.height, accuracy: 0.01)
}
func test_renderSignature_changesWithContent_stableOtherwise() {
let v = DocumentCanvasView()
v.setParagraphs([ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "Hi")])], width: 300)
v.frame = CGRect(x: 0, y: 0, width: 300, height: 100); v.layoutIfNeeded()
let box = v.boxes[0] as! BlockBox
let s1 = box.renderSignature
v.layoutIfNeeded() // re-layout, no content change
XCTAssertEqual(box.renderSignature, s1, "signature stable when nothing changed")
box.layout.attributedString = NSAttributedString(string: "Hi there")
XCTAssertNotEqual(box.renderSignature, s1, "signature changes when text changes")
}
func test_renderSignature_changesAfterCharacterFormatToggle() {
let v = DocumentCanvasView()
v.setParagraphs([ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "Hello")])], width: 300)
v.frame = CGRect(x: 0, y: 0, width: 300, height: 100); v.layoutIfNeeded()
let box = v.boxes[0] as! BlockBox
let s1 = box.renderSignature
v.setSelectionForTesting(anchor: box.textStart, head: box.textStart + box.textLength) // select the whole word
v.toggleBold()
XCTAssertNotEqual(box.renderSignature, s1,
"toggling bold changes the render signature (so a view-backed paragraph repaints)")
}
func test_renderSignature_changesAfterSetLink() {
// Links mutate storage via the OTHER chokepoint (applyCharacterAttribute) — it must bump too.
let v = DocumentCanvasView()
v.setParagraphs([ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "Hello")])], width: 300)
v.frame = CGRect(x: 0, y: 0, width: 300, height: 100); v.layoutIfNeeded()
let box = v.boxes[0] as! BlockBox
let s1 = box.renderSignature
v.setSelectionForTesting(anchor: box.textStart, head: box.textStart + box.textLength)
v.setLink("https://example.com")
XCTAssertNotEqual(box.renderSignature, s1,
"setting a link changes the render signature (so a view-backed paragraph repaints)")
}
func test_splitParagraph_repaintsTheReusedUpperView() {
// Splitting a paragraph (Return mid-text) replaces the surviving UPPER block's BlockBox with a
// brand-new instance (fresh BlockLayout, renderVersion == 0) that keeps the original BlockID — so
// the canvas REUSES the same BlockBackingView for it. The lower half gets a fresh BlockID + fresh
// view (always repaints). The upper view's repaint is gated by `renderSignature`, which encodes the
// per-instance `renderVersion` (a counter that resets to 0 for every new box). When the split keeps
// the upper block's HEIGHT and style unchanged and the prior render was at renderVersion 0, the new
// box's signature COLLIDES with the old one and the gate wrongly skips setNeedsDisplay — leaving the
// stale full-text bitmap. Height stays unchanged when the split paragraph already has a following
// sibling (its bottom inset is already the shrunk inter-paragraph value, before and after), so split
// the MIDDLE of three body paragraphs.
let v = DocumentCanvasView()
v.setParagraphs([
ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "First")]),
ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "HelloWorld")]),
ParagraphBlock(id: BlockID("c"), runs: [TextRun(text: "Third")]),
], width: 300)
v.frame = CGRect(x: 0, y: 0, width: 300, height: 400); v.layoutIfNeeded()
v.simulateParentLayout()
let upperView = v.blockViews[BlockID("b")]!
let heightBefore = v.boxes[1].frame.height
let before = upperView.setNeedsDisplayCountForTesting
v.setCaret(global: v.boxes[1].textStart + 5) // caret between "Hello" and "World"
v.insertText("\n") // routes to insertParagraphBreak (split)
v.layoutIfNeeded()
XCTAssertTrue(v.blockViews[BlockID("b")] === upperView, "upper half keeps BlockID 'b' ⇒ same view reused")
XCTAssertEqual((v.boxes[1] as! BlockBox).textLength, 5, "upper box now holds the truncated 'Hello'")
XCTAssertEqual(v.boxes[1].frame.height, heightBefore, accuracy: 0.01,
"precondition: the split keeps the upper block's height unchanged (so the signature collides — the bug path)")
XCTAssertGreaterThan(upperView.setNeedsDisplayCountForTesting, before,
"the reused upper view must be repainted after the split (else it shows stale full text)")
}
func test_selectionRects_regionFilter_excludesFilteredRegions() {
let v = paragraphCanvas()
let all = v.selectionRects(globalFrom: 0, globalTo: v.documentSizeValue)
let none = v.selectionRects(globalFrom: 0, globalTo: v.documentSizeValue, regionFilter: { _ in false })
XCTAssertFalse(all.isEmpty)
XCTAssertTrue(none.isEmpty, "a false filter yields no rects")
}
private func imageCanvas() -> DocumentCanvasView {
let v = DocumentCanvasView()
v.imageProvider = { _ in UIGraphicsImageRenderer(size: CGSize(width: 100, height: 60)).image { c in
UIColor.systemBlue.setFill(); c.fill(CGRect(x: 0, y: 0, width: 100, height: 60)) } }
v.setBlocks([
.paragraph(ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "Above")])),
.media(MediaBlock(id: BlockID("img"), mediaID: "x", naturalSize: Size2D(width: 100, height: 60),
caption: [TextRun(text: "Cap")])),
.paragraph(ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "Below")])),
], width: 300)
v.frame = CGRect(x: 0, y: 0, width: 300, height: 400); v.layoutIfNeeded()
v.simulateParentLayout()
return v
}
func test_imageBlock_isBackedByABlockView() {
let v = imageCanvas()
XCTAssertTrue(v.boxes[1].rendersAsBlockView)
XCTAssertNotNil(v.blockViews[BlockID("img")], "the image has a pooled block view")
// The view's frame is the block's DRAWN extent (blockViewFrame), so its bounds-sized backing
// store covers the full-bleed image. UIKit pixel-rounds frames; compare at 0.01pt.
let viewFrame = v.blockViews[BlockID("img")]!.frame
let drawn = v.boxes[1].blockViewFrame
XCTAssertEqual(viewFrame.origin.x, drawn.origin.x, accuracy: 0.01)
XCTAssertEqual(viewFrame.origin.y, drawn.origin.y, accuracy: 0.01)
XCTAssertEqual(viewFrame.size.width, drawn.size.width, accuracy: 0.01)
XCTAssertEqual(viewFrame.size.height, drawn.size.height, accuracy: 0.01)
XCTAssertNotNil(v.blockViews[BlockID("a")], "paragraphs are view-backed too")
}
func test_imageCaption_caretAndSelectionGeometryUnchanged() {
let v = imageCanvas()
let cap = v.allLeafRegions().first { $0.ref == .caption(BlockID("img")) }!
let imgBox = v.boxes[1] as! MediaBlockBox
let stripLeft = cap.canvasOrigin.x
let stripRight = cap.canvasOrigin.x + imgBox.layoutWidth
// Caption is CENTERED. The caret at the START of "Cap" is indented from the left edge, and the caret
// at the END is the same distance in from the right edge — text symmetric about the strip's center.
// (Before centering, the start caret sat AT the left edge.) This is the one test that drives the
// centered caption through caretRect(for:).
let startCaret = v.caretRect(for: DocumentTextPosition(cap.globalStart))
let endCaret = v.caretRect(for: DocumentTextPosition(cap.globalStart + cap.length))
let leftGap = startCaret.minX - stripLeft
let rightGap = stripRight - endCaret.minX
XCTAssertGreaterThan(leftGap, 1.0, "start caret is indented (centered), not at the left edge")
XCTAssertEqual(leftGap, rightGap, accuracy: 2.0, "caption text is centered (symmetric gaps)")
// Still within the content strip (canvas-coordinate geometry intact under block-view backing).
XCTAssertGreaterThanOrEqual(startCaret.minX, stripLeft - 1.0, "caret not left of the strip")
XCTAssertLessThanOrEqual(endCaret.minX, stripRight + 1.0, "caret not right of the strip")
let rects = v.selectionRects(globalFrom: cap.globalStart, globalTo: cap.globalStart + cap.length)
XCTAssertFalse(rects.isEmpty)
}
func test_crossBlockSelection_spanningImage_stillCoversAllThree() {
let v = imageCanvas()
let rects = v.selectionRects(globalFrom: v.boxes[0].textStart + 1,
globalTo: v.boxes[2].textStart + 1)
func hits(_ box: CanvasBlock) -> Bool { rects.contains { box.frame.intersects($0) } }
XCTAssertTrue(hits(v.boxes[0]))
XCTAssertTrue(hits(v.boxes[2])) // selection continues past the image into the paragraph below
XCTAssertTrue(hits(v.boxes[1]), "selection also covers the image's caption (continuous through the image)")
}
func test_imageCanvas_rendersNonBlank() {
let v = imageCanvas()
let image = UIGraphicsImageRenderer(bounds: v.bounds).image { _ in
v.drawHierarchy(in: v.bounds, afterScreenUpdates: true)
}
XCTAssertNotNil(image.cgImage)
}
/// A minimal hosted media view (the medium is now a host-supplied overlay view, not a CPU-drawn bitmap).
private final class StubMediaView: UIView, RichTextMediaItemView {
func update(size: CGSize) {}
var onControlTapped: ((RichTextMediaControlKind, Int?, UIView, CGRect) -> Void)?
}
func test_fullBleedMediaView_coversTheBleed() {
// The medium is now a host-supplied overlay view positioned at `mediaRect()` (not drawn into the
// block's backing store), so the bleed is covered by THAT view, not the block backing view. The
// block backing view is now caption-only (blockViewFrame == frame).
let v = imageCanvas()
v.mediaViewProvider = { _, _, _, _ in StubMediaView() }
v.setNeedsLayout(); v.layoutIfNeeded()
let imgBox = v.boxes[1] as! MediaBlockBox
let r = imgBox.mediaRect()
XCTAssertLessThan(r.minX, imgBox.frame.minX, "a top-level image bleeds left past its inset content frame")
XCTAssertGreaterThan(r.maxX, imgBox.frame.maxX, "...and right past it")
// The block backing view is now the inset frame only (the medium overlays it separately).
// UIKit pixel-rounds view frames, so compare at 0.01pt.
let blockView = v.blockViews[BlockID("img")]!
XCTAssertEqual(blockView.frame.minX, imgBox.frame.minX, accuracy: 0.01, "block backing view is caption-only (no longer covers the bleed)")
XCTAssertEqual(blockView.frame.minY, imgBox.frame.minY, accuracy: 0.01)
XCTAssertEqual(blockView.frame.width, imgBox.frame.width, accuracy: 0.01)
XCTAssertEqual(blockView.frame.height, imgBox.frame.height, accuracy: 0.01)
// The hosted media view is positioned at mediaRect() and so covers the full bleed.
let media = v.hostedMediaViewForTesting(BlockID("img"))
XCTAssertNotNil(media, "a registered provider realizes a hosted media view")
XCTAssertEqual(media!.frame.minX, r.minX, accuracy: 0.01, "media view covers the left bleed")
XCTAssertEqual(media!.frame.maxX, r.maxX, accuracy: 0.01, "media view covers the right bleed")
}
func test_blockView_isReusedAcrossEditAndUndo() {
let v = imageCanvas()
let before = v.blockViews[BlockID("img")]
XCTAssertNotNil(before)
// Edit a paragraph (rebuilds the boxes array; the image keeps its BlockID).
v.setCaret(global: v.boxes[0].textStart + 1)
v.insertText("Z")
v.layoutIfNeeded()
XCTAssertTrue(v.blockViews[BlockID("img")] === before, "same view instance reused after an edit")
v.effectiveUndoManager?.undo()
v.layoutIfNeeded()
XCTAssertTrue(v.blockViews[BlockID("img")] === before, "same view instance survives undo")
}
func test_removingTheImage_tearsDownItsBlockView() {
let v = imageCanvas()
XCTAssertNotNil(v.blockViews[BlockID("img")])
// Select-all + delete removes the image block.
v.selectedTextRange = DocumentTextRange(DocumentTextPosition(0),
DocumentTextPosition(v.documentSizeValue))
v.deleteBackward()
v.layoutIfNeeded()
XCTAssertNil(v.blockViews[BlockID("img")], "removed block's view is dropped from the pool")
XCTAssertNil(v.subviews.first { ($0 as? BlockBackingView)?.box?.id == BlockID("img") },
"and removed from the view hierarchy")
}
private func tableCanvas() -> 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()
v.setBlocks([.table(TableBlock(id: BlockID("t"),
columns: [ColumnSpec(width: 120), ColumnSpec(width: 120)],
rows: [Row(id: BlockID("r0"), cells: [cell("a", "Alpha"), cell("b", "Beta")]),
Row(id: BlockID("r1"), cells: [cell("c", "Gamma"), cell("d", "Delta")])]))], width: 320)
v.frame = CGRect(x: 0, y: 0, width: 320, height: 320); v.layoutIfNeeded()
return v
}
func test_cellParagraphs_areHostedAsViewsInTheTableContentView() {
let v = tableCanvas()
let tv = v.blockViews[BlockID("t")] as! TableBackingView
tv.layoutIfNeeded()
for id in ["ap", "bp", "cp", "dp"] {
XCTAssertNotNil(tv.cellBlockViews[BlockID(id)], "cell paragraph \(id) is view-backed")
}
let apView = tv.cellBlockViews[BlockID("ap")]!
XCTAssertTrue(apView.isDescendant(of: tv.scroll), "cell paragraph view lives inside the table scroll")
}
func test_table_isBackedByABlockView_andSkippedByCanvasDraw() {
let v = tableCanvas()
XCTAssertTrue(v.boxes[0].rendersAsBlockView)
XCTAssertNotNil(v.blockViews[BlockID("t")])
let t = v.boxes[0] as! TableBlockBox
let view = v.blockViews[BlockID("t")]!
// The grid (incl. its borders) fits the content-strip width, and the view frame == the exact grid
// extent so the bounds-sized backing store doesn't clip the right outer border (a UIView clips its
// own draw to its bounds; clipsToBounds is irrelevant to own-draw).
XCTAssertEqual(t.gridWidth, t.frame.width, accuracy: 0.5, "table grid fits the content-strip width")
XCTAssertEqual(view.frame.minX, t.frame.minX, accuracy: 0.01)
XCTAssertEqual(view.frame.minY, t.frame.minY, accuracy: 0.01)
XCTAssertEqual(view.frame.width, t.gridWidth, accuracy: 0.01, "view frame width == grid width (covers the right border)")
XCTAssertEqual(view.frame.height, t.frame.height, accuracy: 0.01)
}
func test_tableGridWidth_matchesSiblingBlockWidth() {
// A table must share the same content width as every other block — its grid (incl. borders) must
// not overflow the page margin that paragraphs/quotes respect.
func cell(_ id: String, _ s: String) -> Cell {
Cell(id: BlockID(id), blocks: [.paragraph(ParagraphBlock(id: BlockID(id + "p"), runs: [TextRun(text: s)]))])
}
let v = DocumentCanvasView()
v.setBlocks([
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "Para")])),
.table(TableBlock(id: BlockID("t"),
columns: [ColumnSpec(width: 140), ColumnSpec(width: 110), ColumnSpec(width: 110)],
rows: [Row(id: BlockID("r0"), cells: [cell("a", "A"), cell("b", "B"), cell("c", "C")])])),
], width: 390)
v.frame = CGRect(x: 0, y: 0, width: 390, height: 400); v.layoutIfNeeded()
let para = v.boxes[0], table = v.boxes[1] as! TableBlockBox
XCTAssertEqual(table.gridWidth, para.frame.width, accuracy: 0.5,
"table grid width matches a sibling paragraph's content width")
XCTAssertEqual(table.frame.minX + table.gridWidth, para.frame.maxX, accuracy: 0.5,
"table right edge aligns with the paragraph right edge")
}
func test_crossCellSelection_unionStillCoversBothCells() {
let v = tableCanvas()
let rA = v.allLeafRegions().first { $0.ref == .paragraph(BlockID("ap")) }!
let rB = v.allLeafRegions().first { $0.ref == .paragraph(BlockID("bp")) }!
let rects = v.selectionRects(globalFrom: rA.globalStart + 2, globalTo: rB.globalStart + 2)
XCTAssertTrue(rects.contains { $0.minX < rB.canvasOrigin.x - 1 }, "covers part of cell A")
XCTAssertTrue(rects.contains { $0.maxX > rB.canvasOrigin.x - 1 }, "continues into cell B")
}
func test_structuralSelection_outlineRectUnchanged() {
let v = tableCanvas()
// Park the caret inside the table (activeTable() requires head inside a cell).
let t = v.boxes[0] as! TableBlockBox
v.setSelectionForTesting(anchor: t.cellTextStart(row: 1, column: 0)!, head: t.cellTextStart(row: 1, column: 0)!)
v.selectTableColumn(0)
XCTAssertNotNil(v.tableSelectionOutlineRect(), "structural outline geometry still computes")
}
func test_tableCanvas_rendersNonBlank() {
let v = tableCanvas()
let image = UIGraphicsImageRenderer(bounds: v.bounds).image { _ in
v.drawHierarchy(in: v.bounds, afterScreenUpdates: true)
}
XCTAssertNotNil(image.cgImage)
}
func test_chromeOverlay_isTopmostAndExtendsLeftForGrip() {
let v = tableCanvas()
let overlay = v.subviews.compactMap { $0 as? BlockChromeOverlay }.first
XCTAssertNotNil(overlay, "the canvas owns a chrome overlay")
XCTAssertEqual(v.subviews.last as? BlockChromeOverlay, overlay, "overlay is the topmost subview")
// Covers the canvas and EXTENDS LEFT of x=0 so the row grip can draw in the left gutter / field padding
// (a draw(_:) is bounded by the frame). bounds.origin matches frame.origin → still draws in canvas coords.
XCTAssertLessThan(overlay!.frame.minX, 0, "overlay extends left of the canvas for the row grip")
XCTAssertEqual(overlay!.frame.maxX, v.bounds.maxX, accuracy: 0.5, "overlay still covers the canvas to the right")
XCTAssertEqual(overlay!.frame.height, v.bounds.height, accuracy: 0.5)
XCTAssertEqual(overlay!.bounds.origin, overlay!.frame.origin, "bounds.origin matches frame.origin → draws in canvas coordinates")
XCTAssertFalse(overlay!.isUserInteractionEnabled, "overlay never takes touches")
}
func test_paragraphCanvas_rendersNonBlank_viaSubviews() {
let v = paragraphCanvas()
XCTAssertFalse(v.blockViews.isEmpty, "paragraphs are view-backed")
XCTAssertNotNil(v.subviews.first { ($0 as? BlockBackingView)?.box?.id == BlockID("a") })
let img = UIGraphicsImageRenderer(bounds: v.bounds).image { _ in
v.drawHierarchy(in: v.bounds, afterScreenUpdates: true)
}
XCTAssertNotNil(img.cgImage)
}
func test_longDocument_noBlockViewExceedsMaxSurface() {
let v = DocumentCanvasView()
let paras = (0..<400).map { ParagraphBlock(id: BlockID("p\($0)"), runs: [TextRun(text: "Paragraph \($0)")]) }
v.setParagraphs(paras, width: 300)
v.frame = CGRect(x: 0, y: 0, width: 300, height: v.intrinsicContentSize.height); v.layoutIfNeeded()
let maxDim: CGFloat = 8192
for box in v.boxes {
XCTAssertLessThan(box.blockViewFrame.height, maxDim, "each paragraph layer is bounded")
XCTAssertLessThan(box.blockViewFrame.width, maxDim)
}
XCTAssertGreaterThan(v.intrinsicContentSize.height, maxDim,
"the document as a whole exceeds a single surface — the case this fixes")
}
func test_wideTable_getsTableBackingView_withScrollableContentSize() {
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()
let t = v.boxes[0] as! TableBlockBox
let view = v.blockViews[BlockID("t")]
XCTAssertTrue(view is TableBackingView, "a table is backed by a TableBackingView")
let tv = view as! TableBackingView
tv.layoutIfNeeded()
XCTAssertEqual(tv.scroll.contentSize.width, t.gridWidth, accuracy: 0.5, "scroll content == full grid width")
XCTAssertGreaterThan(tv.scroll.contentSize.width, tv.bounds.width, "content wider than the window → scrollable")
}
}
#endif