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

183 lines
11 KiB
Swift

#if canImport(UIKit)
import XCTest
import UIKit
@testable import RichTextEditorUIKit
import RichTextEditorCore
final class BlockStackTests: XCTestCase {
func test_recompute_assignsNodeStartsAndReturnsTokenSize() {
let mapper = AttributedStringMapper()
let stack = BlockStack(boxes: [
BlockBox(paragraph: ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "One")]), mapper: mapper, width: 300),
BlockBox(paragraph: ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "Two")]), mapper: mapper, width: 300),
])
let size = stack.recompute(baseOffset: 0)
// "One"(3)+2 + "Two"(3)+2 = 10; globalStarts 1 and 6
XCTAssertEqual(size, 10)
XCTAssertEqual(stack.boxes[0].nodeStart, 1)
XCTAssertEqual(stack.boxes[1].nodeStart, 6)
}
func test_recompute_withBaseOffset_shiftsGlobalStarts() {
let mapper = AttributedStringMapper()
let stack = BlockStack(boxes: [
BlockBox(paragraph: ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "One")]), mapper: mapper, width: 300),
])
_ = stack.recompute(baseOffset: 100)
XCTAssertEqual(stack.boxes[0].nodeStart, 101) // baseOffset + 1
XCTAssertEqual(stack.boxes[0].leafRegions()[0].globalStart, 101)
}
func test_layout_stacksVerticallyAndReturnsHeight() {
let mapper = AttributedStringMapper()
let stack = BlockStack(boxes: [
BlockBox(paragraph: ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "One")]), mapper: mapper, width: 300),
BlockBox(paragraph: ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "Two")]), mapper: mapper, width: 300),
])
let h = stack.layout(origin: CGPoint(x: 10, y: 20), width: 300)
XCTAssertEqual(stack.boxes[0].frame.minX, 10, accuracy: 0.5)
XCTAssertEqual(stack.boxes[0].frame.minY, 20, accuracy: 0.5)
XCTAssertEqual(stack.boxes[1].frame.minY, stack.boxes[0].frame.maxY, accuracy: 0.5)
XCTAssertGreaterThan(h, 0)
}
/// The intra-paragraph advance between two adjacent lines of a single body paragraph — the target
/// spacing for consecutive list items.
private func intraParagraphLineAdvance() -> CGFloat {
let ref = BlockBox(paragraph: ParagraphBlock(id: BlockID("ref"), runs: [TextRun(text: "AAAA\nBBBB")]),
mapper: AttributedStringMapper(), width: 300)
ref.setWidth(300)
return ref.layout.caretRect(atOffset: 5).minY - ref.layout.caretRect(atOffset: 0).minY
}
private func listBox(_ id: String) -> BlockBox {
BlockBox(paragraph: ParagraphBlock(id: BlockID(id), list: ListMembership(marker: .bullet),
runs: [TextRun(text: "Item")]),
mapper: AttributedStringMapper(), width: 300)
}
/// Two adjacent list items are ITEMS of one InstantPage `.list` block, so they take V2's in-list
/// gap — not the paragraph-to-paragraph 1pt rule, and not the old "tight as intra-paragraph lines".
func test_consecutiveListItems_takeTheInListGap() {
let stack = BlockStack(boxes: [listBox("a"), listBox("b")])
stack.layout(origin: .zero, width: 300)
let expected = richTextSpacingBetweenBlocks(upper: .paragraph, lower: .paragraph,
kind: .list, metrics: .default)
XCTAssertEqual((stack.boxes[1] as! BlockBox).topInset, expected, accuracy: 0.01)
// Frames stay contiguous (no overlap) on both engines — the core stacking invariant.
XCTAssertEqual(stack.boxes[1].frame.minY, stack.boxes[0].frame.maxY, accuracy: 0.5)
}
/// Two body paragraphs take V2's minimum separation (1pt) — they are held apart by their own line
/// boxes, so the rhythm adds only a hairline. (Was 0 under the editor's own pre-parity model.)
func test_consecutiveBodyBlocks_takeTheMinimumSeparation() {
let mapper = AttributedStringMapper()
let stack = BlockStack(boxes: [
BlockBox(paragraph: ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "One")]), mapper: mapper, width: 300),
BlockBox(paragraph: ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "Two")]), mapper: mapper, width: 300),
])
stack.layout(origin: .zero, width: 300)
let a = stack.boxes[0] as! BlockBox, b = stack.boxes[1] as! BlockBox
// The whitespace between A's last glyph row and B's first: the gap is carried by B's topInset, and
// A's own height is the V2-corrected text height, so the two agree.
let gap = b.textOrigin.y - (a.textOrigin.y + a.layout.correctedBoundingHeight)
XCTAssertEqual(gap, 1.0, accuracy: 0.01)
XCTAssertEqual(b.topInset, 1.0, accuracy: 0.01)
}
/// A code block (`.preformatted`) takes V2's fall-through rule against a paragraph:
/// padding + base + padding. A framed block cannot own an external inset, so the gap above it is
/// carried by the paragraph ABOVE (its `bottomInset`) — which is also what keeps the canvas
/// contiguous for hit-testing and arrow-key escape.
func test_codeBlockNeighbors_takeTheV2FallThroughGap() {
let mapper = AttributedStringMapper()
func body(_ id: String) -> BlockBox {
BlockBox(paragraph: ParagraphBlock(id: BlockID(id), runs: [TextRun(text: "x")]), mapper: mapper, width: 300)
}
let code = CodeBlockBox(code: CodeBlock(id: BlockID("c"), runs: [TextRun(text: "let x = 1")]), mapper: mapper, width: 300)
let above = body("above"), below = body("below")
BlockStack(boxes: [above, code, below]).layout(origin: .zero, width: 300)
let expected = richTextSpacingBetweenBlocks(upper: .paragraph, lower: .preformatted,
kind: .topLevel, metrics: .default)
XCTAssertEqual(above.bottomInset, expected, accuracy: 0.01, "the paragraph above owns the gap")
XCTAssertEqual(below.topInset,
richTextSpacingBetweenBlocks(upper: .preformatted, lower: .paragraph,
kind: .topLevel, metrics: .default),
accuracy: 0.01)
// Far side: the sequence's leading edge.
XCTAssertEqual(above.topInset,
richTextSpacingBetweenBlocks(upper: nil, lower: .paragraph, kind: .topLevel, metrics: .default),
accuracy: 0.01, "far side is the document edge gap")
}
/// Same shape for a table, whose bounded grid takes the same fall-through rule.
func test_tableNeighbors_takeTheV2FallThroughGap() {
let mapper = AttributedStringMapper()
func body(_ id: String) -> BlockBox {
BlockBox(paragraph: ParagraphBlock(id: BlockID(id), runs: [TextRun(text: "x")]), mapper: mapper, width: 300)
}
let table = TableBlockBox(table: TableBlock(id: BlockID("t"),
columns: [ColumnSpec(width: 100), ColumnSpec(width: 100)],
rows: [Row(id: BlockID("r0"), isHeader: true, cells: [
Cell(id: BlockID("a"), blocks: [.paragraph(ParagraphBlock(id: BlockID("ap")))]),
Cell(id: BlockID("b"), blocks: [.paragraph(ParagraphBlock(id: BlockID("bp")))])])]),
mapper: mapper, width: 300)
let above = body("above"), below = body("below")
BlockStack(boxes: [above, table, below]).layout(origin: .zero, width: 300)
XCTAssertEqual(above.bottomInset,
richTextSpacingBetweenBlocks(upper: .paragraph, lower: .table, kind: .topLevel, metrics: .default),
accuracy: 0.01, "the paragraph above owns the gap")
XCTAssertEqual(below.topInset,
richTextSpacingBetweenBlocks(upper: .table, lower: .paragraph, kind: .topLevel, metrics: .default),
accuracy: 0.01)
}
/// Media takes V2's own rules rather than the editor's former dedicated 6pt media inset. A bare
/// image next to text gets `max(1, paddings + 1)`; the far sides get the document edge gaps.
/// (`verticalInsetBase` is not consulted at all in the V2 model — the old test proved the media
/// inset was decoupled from that base, a distinction the rule table no longer has.)
func test_blockToMediaBoundary_usesTheV2MediaRules() {
let mapper = AttributedStringMapper()
func body(_ id: String) -> BlockBox {
BlockBox(paragraph: ParagraphBlock(id: BlockID(id), runs: [TextRun(text: "x")]), mapper: mapper, width: 300)
}
let media = MediaBlockBox(media: MediaBlock(id: BlockID("m"), mediaID: "x",
naturalSize: Size2D(width: 100, height: 50), caption: []),
mapper: mapper, width: 300)
let heading = BlockBox(paragraph: ParagraphBlock(id: BlockID("h"), style: .heading1, runs: [TextRun(text: "H")]),
mapper: mapper, width: 300)
let below = body("below")
let stack = BlockStack(boxes: [heading, media, below])
stack.layout(origin: .zero, width: 300)
let image = RichTextBlockSpacingKind.media(hasCredit: false, isRawMedia: true)
XCTAssertEqual(heading.bottomInset,
richTextSpacingBetweenBlocks(upper: .heading, lower: image, kind: .topLevel, metrics: .default),
accuracy: 0.01, "the heading above owns the gap to the image")
XCTAssertEqual(below.topInset,
richTextSpacingBetweenBlocks(upper: image, lower: .paragraph, kind: .topLevel, metrics: .default),
accuracy: 0.01)
XCTAssertEqual(heading.topInset,
richTextSpacingBetweenBlocks(upper: nil, lower: .heading, kind: .topLevel, metrics: .default),
accuracy: 0.01, "far side is the document edge gap")
XCTAssertEqual(below.bottomInset,
richTextSpacingBetweenBlocks(upper: .paragraph, lower: nil, kind: .topLevel, metrics: .default),
accuracy: 0.01, "far side is the document edge gap")
}
/// The list-run -> body boundary takes V2's list/paragraph rule (the sum of both paddings), not the
/// editor's former "collapses to nothing".
func test_listItemToParagraphBoundary_takesTheListToParagraphGap() {
let mapper = AttributedStringMapper()
let stack = BlockStack(boxes: [
listBox("a"),
BlockBox(paragraph: ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "Plain")]), mapper: mapper, width: 300),
])
stack.layout(origin: .zero, width: 300)
let b = stack.boxes[1] as! BlockBox
XCTAssertEqual(b.topInset,
richTextSpacingBetweenBlocks(upper: .list, lower: .paragraph, kind: .topLevel, metrics: .default),
accuracy: 0.01)
}
}
#endif