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>
198 lines
11 KiB
Swift
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
|