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

198 lines
11 KiB
Swift

#if canImport(UIKit)
import XCTest
@testable import RichTextEditorUIKit
@testable import RichTextEditorCore
@available(iOS 13.0, *)
final class CodeBlockEditingTests: XCTestCase {
func makeCanvas(_ blocks: [Block]) -> DocumentCanvasView {
let c = DocumentCanvasView()
c.setBlocks(blocks, width: 320)
return c
}
func test_codeBlock_enterInsertsNewlineDoesNotSplit() {
let canvas = makeCanvas([.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "ab")]))])
// A code block is a container [languagePara, codePara]; derive the code text start rather than
// hard-coding it, since it moves with the language line's length.
canvas.setCaret(global: canvas.boxes[0].textStart + 1) // after "a"
canvas.insertText("\n")
XCTAssertEqual(canvas.boxes.count, 1) // still ONE code block
guard case let .code(cb) = canvas.boxes[0].currentBlock() else { return XCTFail("expected .code") }
XCTAssertEqual(cb.text, "a\nb")
}
func test_codeBlock_enterAtEnd_insertsNewlineDoesNotSplit() {
let canvas = makeCanvas([.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "line1")]))])
canvas.setCaret(global: canvas.boxes[0].textStart + 5) // after "line1"
canvas.insertText("\n")
XCTAssertEqual(canvas.boxes.count, 1)
guard case let .code(cb) = canvas.boxes[0].currentBlock() else { return XCTFail("expected .code") }
XCTAssertEqual(cb.text, "line1\n")
}
func test_codeBlock_enterAtStart_insertsNewlineAtFront() {
let canvas = makeCanvas([.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "ab")]))])
canvas.setCaret(global: canvas.boxes[0].textStart) // start of the code text
canvas.insertText("\n")
XCTAssertEqual(canvas.boxes.count, 1)
guard case let .code(cb) = canvas.boxes[0].currentBlock() else { return XCTFail("expected .code") }
XCTAssertEqual(cb.text, "\nab")
}
func test_codeBlock_caretAdvancesAfterNewline() {
let canvas = makeCanvas([.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "ab")]))])
let codeStart = canvas.boxes[0].textStart
canvas.setCaret(global: codeStart + 1) // after "a"
canvas.insertText("\n")
// caret should land after the inserted "\n", i.e. before "b"
XCTAssertEqual(canvas.head, codeStart + 2)
guard case let .code(cb) = canvas.boxes[0].currentBlock() else { return XCTFail("expected .code") }
XCTAssertEqual(cb.text, "a\nb")
}
func test_codeBlock_enterReplacesSelectionWithNewline() {
let canvas = makeCanvas([.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "abcd")]))])
// "bc" occupies the two globals after the code text start.
canvas.setSelectionAnchor(global: canvas.boxes[0].textStart + 1)
canvas.setSelectionHead(global: canvas.boxes[0].textStart + 3)
canvas.insertText("\n")
XCTAssertEqual(canvas.boxes.count, 1)
guard case let .code(cb) = canvas.boxes[0].currentBlock() else { return XCTFail("expected .code") }
XCTAssertEqual(cb.text, "a\nd")
}
// MARK: Task 7 — boundary/exit rules (mirror the quote affordances)
// (A) Backspace in a fully-EMPTY code block converts it to a body paragraph.
func test_codeBlock_backspaceInEmptyConvertsToBody() {
let canvas = makeCanvas([.code(CodeBlock(id: BlockID("c1"), runs: []))])
canvas.setCaret(global: canvas.boxes[0].textStart) // the single position inside the empty code block
canvas.deleteBackward()
XCTAssertEqual(canvas.boxes.count, 1)
guard case let .paragraph(p) = canvas.boxes[0].currentBlock() else { return XCTFail("expected .paragraph") }
XCTAssertEqual(p.style, .body)
}
// (B) Enter on an empty trailing line EXITS the code block to a body paragraph.
func test_codeBlock_enterOnTrailingBlankLineExits() {
let canvas = makeCanvas([.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "x\n")]))])
canvas.setCaret(global: canvas.documentSize - 1) // end of "x\n"
canvas.insertText("\n")
XCTAssertEqual(canvas.boxes.count, 2)
guard case let .code(cb) = canvas.boxes[0].currentBlock() else { return XCTFail("expected .code first") }
XCTAssertEqual(cb.text, "x") // trailing blank line removed
XCTAssertTrue({ if case .paragraph = canvas.boxes[1].currentBlock() { return true }; return false }())
}
// A NON-empty trailing line is NOT an exit — Enter just inserts another newline (regression guard).
func test_codeBlock_enterAtEndOfNonBlankLine_doesNotExit() {
let canvas = makeCanvas([.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "x")]))])
canvas.setCaret(global: canvas.documentSize - 1) // end of "x"
canvas.insertText("\n")
XCTAssertEqual(canvas.boxes.count, 1)
guard case let .code(cb) = canvas.boxes[0].currentBlock() else { return XCTFail("expected .code") }
XCTAssertEqual(cb.text, "x\n")
}
// Backspace in an empty paragraph AFTER a code block. iOS does NOT deliver a collapsed caret here — it
// overrides the selection with an object-replacement RANGE from the code block's text end to the empty
// paragraph's start (device-log verified: setRange [8,10] then deleteBackward selFrom=8 selTo=10). The
// empty paragraph must be removed and the caret parked at the code block's end — not a generic
// selection-replace that mangles the boundary and strands the empty paragraph.
func test_codeBlock_backspaceObjectReplacementRangeAfter_removesEmptyParagraph() {
let canvas = makeCanvas([
.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "code123")])),
.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [])),
])
let code = canvas.boxes[0], p = canvas.boxes[1]
canvas.setSelectionAnchor(global: code.textStart + code.textLength) // the object range's anchor (code end)
canvas.setSelectionHead(global: p.textStart) // ...to the empty paragraph's start
canvas.deleteBackward()
XCTAssertEqual(canvas.boxes.count, 1, "the empty paragraph after the code block is removed")
XCTAssertTrue(canvas.boxes.first is CodeBlockBox, "the code block remains")
XCTAssertEqual(canvas.head, canvas.boxes[0].textStart + canvas.boxes[0].textLength,
"caret lands at the code block's end")
}
// Typing into a just-created (EMPTY) code block must use the monospace code font, not the body default.
// The empty-storage typing path recovers a BlockBox paragraph's style/mapper; a CodeBlockBox isn't a
// BlockBox, so without a code-specific case the first typed character lands non-monospace at body size.
func test_codeBlock_emptyTypingAttributesAreMonospace() {
let canvas = makeCanvas([.code(CodeBlock(id: BlockID("c1"), runs: []))])
let codeStart = canvas.boxes[0].textStart // inside the empty code block
canvas.setCaret(global: codeStart)
let font = canvas.typingAttributesAtGlobal(codeStart)[.font] as? UIFont
XCTAssertEqual(font?.pointSize ?? 0, CodeBlockBox.fontSize, accuracy: 0.5,
"typing into an empty code block uses the 15pt code font, not the 17pt body default")
XCTAssertTrue(font?.fontDescriptor.symbolicTraits.contains(.traitMonoSpace) ?? false,
"typing into an empty code block uses a monospaced font")
}
// MARK: Task 8 — composer flat mapping + cross-block delete
func test_composerFlatRange_countsCodeInterior() {
// [body "ab"] \n [code "cd\nef"] → composer flat string "ab\ncd\nef".
let canvas = makeCanvas([
.paragraph(ParagraphBlock(id: BlockID("p1"), runs: [TextRun(text: "ab")])),
.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "cd\nef")])),
])
canvas.setCaret(global: canvas.documentSize) // caret at very end of doc
let flatLen = ("ab\ncd\nef" as NSString).length // 8
XCTAssertEqual(canvas.composerSelectedRange.location, flatLen,
"code block interior must contribute to the flat composer offset")
}
func test_crossBlockDelete_keepsCodeBlock() {
let canvas = makeCanvas([
.paragraph(ParagraphBlock(id: BlockID("p1"), runs: [TextRun(text: "ab")])),
.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "cd")])),
])
// Select from the second char of the body ("b") to PARTWAY into the code text (before 'd').
// Paragraph "ab": textStart = boxes[0].textStart (= 1), textLength = 2.
// Code "cd": textStart = boxes[1].textStart (= 5), textLength = 2.
// Using codeHead = textStart + 1 (after 'c', before 'd') ensures the selection is partial inside
// the code block (not fully covering it), so the truncate path fires and the block is kept.
let bodyAnchor = canvas.boxes[0].textStart + 1 // after "a", before "b"
let codeHead = canvas.boxes[1].textStart + 1 // after "c", before "d" — partial coverage
canvas.setSelectionAnchor(global: bodyAnchor)
canvas.setSelectionHead(global: codeHead)
canvas.deleteBackward()
XCTAssertTrue(canvas.boxes.contains { $0 is CodeBlockBox },
"code block must survive a cross-block delete that ends inside it")
}
func test_crossBlockDelete_fullyCoveredCodeBlockIsDropped() {
let canvas = makeCanvas([
.paragraph(ParagraphBlock(id: BlockID("p1"), runs: [TextRun(text: "ab")])),
.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "cd")])),
])
// Select from inside the body through the END of the document — fully covers the code block.
canvas.setSelectionAnchor(global: 2)
canvas.setSelectionHead(global: canvas.documentSize)
canvas.deleteBackward()
XCTAssertFalse(canvas.boxes.contains { $0 is CodeBlockBox }) // fully-covered code block dropped
}
// (C) Tapping below a trailing code block inserts a body paragraph. Needs laid-out frames.
private func laidOutCanvas(_ blocks: [Block]) -> DocumentCanvasView {
let c = DocumentCanvasView()
c.setBlocks(blocks, width: 300)
c.frame = CGRect(x: 0, y: 0, width: 300, height: 600)
c.layoutIfNeeded()
return c
}
func test_tapBelowTrailingCodeBlock_addsBodyParagraph() {
let v = laidOutCanvas([.code(CodeBlock(id: BlockID("c1"), runs: [TextRun(text: "code")]))])
let lastMaxY = v.boxes[0].frame.maxY
XCTAssertGreaterThan(lastMaxY, 0, "precondition: the code box is laid out")
v.performSingleTap(at: CGPoint(x: 20, y: lastMaxY + 40))
XCTAssertEqual(v.boxes.count, 2)
guard case let .paragraph(p) = v.boxes[1].currentBlock() else { return XCTFail("expected .paragraph") }
XCTAssertEqual(p.style, .body, "tapping below the trailing code block starts a body paragraph after it")
XCTAssertEqual(v.head, v.boxes[1].textStart, "caret moves into the new paragraph")
}
}
#endif