mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-03 22:27:13 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
183 lines
11 KiB
Swift
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
|