mirror of
https://github.com/ichmagmaus111/ghostgram.git
synced 2026-11-04 14:47:17 +01:00
Ghostgram for iOS, rebuilt from scratch on Telegram iOS 13.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1118 lines
71 KiB
Swift
1118 lines
71 KiB
Swift
#if canImport(UIKit)
|
|
import XCTest
|
|
import RichTextEditorCore
|
|
@testable import RichTextEditorUIKit
|
|
final class BlockQuoteEditTests: XCTestCase {
|
|
private func canvas(_ blocks: [Block]) -> DocumentCanvasView {
|
|
let c = DocumentCanvasView(); c.frame = CGRect(x:0,y:0,width:320,height:400)
|
|
c.setBlocks(blocks, width: 320); c.simulateParentLayout(); return c
|
|
}
|
|
|
|
/// A canvas laid out with real frames (needed for tap-point → position routing).
|
|
private func laidOutCanvas(_ blocks: [Block]) -> DocumentCanvasView {
|
|
let c = DocumentCanvasView()
|
|
c.setBlocks(blocks, width: 320)
|
|
c.frame = CGRect(x: 0, y: 0, width: 320, height: 600)
|
|
c.layoutIfNeeded()
|
|
return c
|
|
}
|
|
|
|
func test_wrapInBlockQuote_wrapsTwoParagraphs() {
|
|
let c = canvas([
|
|
.paragraph(ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "aa")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "bb")]))])
|
|
// Select across both paragraphs using anchor/head seam (same idiom as other UIKit tests)
|
|
c.setSelectionForTesting(anchor: 0, head: c.documentSizeValue)
|
|
c.wrapInBlockQuote()
|
|
let blocks = c.currentBlocks()
|
|
XCTAssertEqual(blocks.count, 1, "both paragraphs should be wrapped into one block quote")
|
|
guard case .blockQuote(let q) = blocks[0] else { return XCTFail("not wrapped in a block quote") }
|
|
XCTAssertEqual(q.children.count, 2, "both paragraphs preserved as children")
|
|
XCTAssertFalse(q.collapsed, "new block quote is expanded")
|
|
}
|
|
|
|
func test_wrapInBlockQuote_nestsWhenAlreadyInsideQuote() {
|
|
let inner = BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "hi")]))], collapsed: false)
|
|
let c = canvas([.blockQuote(inner)])
|
|
// Place caret inside the quote's child paragraph using the anchor/head seam
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let pos = box.children.boxes[0].leafRegions().first!.globalStart + 1
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.wrapInBlockQuote()
|
|
let blocks = c.currentBlocks()
|
|
guard case .blockQuote(let outer) = blocks[0] else { return XCTFail("outer block quote missing") }
|
|
guard case .blockQuote = outer.children.first else {
|
|
return XCTFail("did not nest — expected a block quote inside the block quote")
|
|
}
|
|
}
|
|
|
|
func test_unwrapBlockQuoteLevel_level1_childrenBecomeTopLevel() {
|
|
let bq = BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("a"), runs: [TextRun(text: "aa")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "bb")]))], collapsed: false)
|
|
let c = canvas([.blockQuote(bq)])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let pos = box.children.boxes[0].leafRegions().first!.globalStart + 1
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.unwrapBlockQuoteLevel()
|
|
let blocks = c.currentBlocks()
|
|
XCTAssertEqual(blocks.count, 2) // children spliced to top level
|
|
for b in blocks { if case .blockQuote = b { XCTFail("still wrapped") } }
|
|
}
|
|
|
|
func test_unwrapBlockQuoteLevel_nested_removesOneLevel() {
|
|
let inner = BlockQuote(id: BlockID("in"), children: [.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "hi")]))], collapsed: false)
|
|
let outer = BlockQuote(id: BlockID("out"), children: [.blockQuote(inner)], collapsed: false)
|
|
let c = canvas([.blockQuote(outer)])
|
|
let outerBox = c.boxes.first as! BlockQuoteBox
|
|
let innerBox = outerBox.children.boxes.compactMap { $0 as? BlockQuoteBox }.first!
|
|
let pos = innerBox.children.boxes[0].leafRegions().first!.globalStart + 1
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.unwrapBlockQuoteLevel() // removes the INNER level only
|
|
let blocks = c.currentBlocks()
|
|
guard case .blockQuote(let stillOuter) = blocks[0] else { return XCTFail("outer quote gone") }
|
|
// inner removed → outer now directly holds the paragraph
|
|
guard case .paragraph = stillOuter.children.first else { return XCTFail("inner level not removed") }
|
|
}
|
|
|
|
func test_enter_insideQuote_addsChildParagraph() {
|
|
let bq = BlockQuote(id: BlockID("q"), children: [.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "hi")]))], collapsed: false)
|
|
let c = canvas([.blockQuote(bq)])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let endOfChild = box.children.boxes[0].leafRegions().first!.globalStart + 2 // after "hi"
|
|
c.setSelectionForTesting(anchor: endOfChild, head: endOfChild)
|
|
c.insertText("\n") // Enter (use the same seam the other Enter tests use)
|
|
guard case .blockQuote(let q) = c.currentBlocks()[0] else { return XCTFail() }
|
|
XCTAssertEqual(q.children.count, 2) // Enter added a child paragraph (still inside the quote)
|
|
}
|
|
|
|
/// Reported bug: in a block quote with ONE paragraph, pressing Return at the END of that paragraph
|
|
/// (which adds an empty second line inside the quote), then Backspace, must merge the empty second
|
|
/// line back into the first — not mis-route into the block AFTER the quote.
|
|
func test_return_thenBackspace_loneQuoteChild_mergesEmptySecondLine_notFollowingBlock() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "text")]))
|
|
], collapsed: false, author: [])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let pos = box.children.boxes[0].leafRegions().first!.globalStart + 4 // end of "text"
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.insertText("\n") // Return: splits the child into ["text", ""]
|
|
|
|
// Intermediate state: the quote now has 2 children, caret at the empty second child's start.
|
|
XCTAssertEqual(box.children.boxes.count, 2, "Return should split the lone child into two")
|
|
let secondRegion = box.children.boxes[1].leafRegions().first!
|
|
XCTAssertEqual(secondRegion.length, 0, "the new second child is empty")
|
|
XCTAssertEqual(c.head, secondRegion.globalStart, "caret at the start of the empty second child")
|
|
|
|
c.deleteBackward()
|
|
let doc = c.currentBlocks()
|
|
guard case .blockQuote(let q) = doc[0] else { return XCTFail("quote gone") }
|
|
XCTAssertEqual(q.children.count, 1, "the empty second line should have merged back into the first")
|
|
guard case .paragraph(let onlyChild) = q.children.first else { return XCTFail("expected a paragraph child") }
|
|
XCTAssertEqual(onlyChild.text, "text", "content preserved")
|
|
XCTAssertEqual(c.head, box.children.boxes[0].leafRegions().first!.globalStart + 4,
|
|
"caret at the end of \"text\" (offset 4)")
|
|
guard doc.count > 1, case .paragraph(let afterPara) = doc[1] else { return XCTFail("expected the following paragraph") }
|
|
XCTAssertEqual(afterPara.text, "after", "the following paragraph must be UNCHANGED")
|
|
}
|
|
|
|
func test_backspace_objectReplacementRange_atNonFirstQuoteChild_mergesIntoSibling_notFollowingBlock() {
|
|
// iOS delivers Backspace at the START of a non-first quote child NOT as a collapsed caret but as an
|
|
// object-replacement RANGE spanning the paragraph break (previous child's end .. this child's start) —
|
|
// observed at runtime as `sel=3..5`. It must merge into the previous sibling, NOT fall to
|
|
// applySelectionReplaceOutcome (which mis-resolves inside the degenerate container and edited the block AFTER
|
|
// the quote — the reported "Return then Backspace deletes from the following paragraph" bug).
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p1"), runs: [TextRun(text: "text")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("p2"), runs: [])), // empty second line, as left by a Return
|
|
], collapsed: false, author: [])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let child1End = box.children.boxes[0].leafRegions().first!.globalStart + 4 // end of "text"
|
|
let child2Start = box.children.boxes[1].leafRegions().first!.globalStart // start of the empty 2nd child
|
|
c.setSelectionForTesting(anchor: child1End, head: child2Start) // the object-replacement range
|
|
c.deleteBackward()
|
|
|
|
let doc = c.currentBlocks()
|
|
guard case .blockQuote(let q) = doc[0] else { return XCTFail("quote gone") }
|
|
XCTAssertEqual(q.children.count, 1, "the empty second line should have merged back into the first")
|
|
guard case .paragraph(let onlyChild) = q.children.first else { return XCTFail("expected a paragraph child") }
|
|
XCTAssertEqual(onlyChild.text, "text", "content preserved")
|
|
guard doc.count > 1, case .paragraph(let afterPara) = doc[1] else { return XCTFail("expected the following paragraph") }
|
|
XCTAssertEqual(afterPara.text, "after", "the following paragraph must be UNCHANGED (was the bug)")
|
|
}
|
|
|
|
func test_backspace_midTextInQuoteChild_deletesInQuote_notFollowingBlock() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "hello")]))
|
|
], collapsed: false, author: [])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let mid = box.children.boxes[0].leafRegions().first!.globalStart + 3 // caret after "hel"
|
|
c.setSelectionForTesting(anchor: mid, head: mid)
|
|
c.deleteBackward() // delete the 'l' before the caret
|
|
let doc = c.currentBlocks()
|
|
guard case .blockQuote(let q) = doc[0], case .paragraph(let child) = q.children.first else { return XCTFail("quote gone") }
|
|
XCTAssertEqual(child.text, "helo", "a mid-text char is deleted inside the quote")
|
|
guard doc.count > 1, case .paragraph(let after) = doc[1] else { return XCTFail("following block gone") }
|
|
XCTAssertEqual(after.text, "after", "the following block is UNCHANGED")
|
|
}
|
|
|
|
func test_selectionDelete_midTextInQuoteChild_deletesInQuote_notFollowingBlock() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "hello world")]))
|
|
], collapsed: false, author: [])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let start = box.children.boxes[0].leafRegions().first!.globalStart
|
|
c.setSelectionForTesting(anchor: start + 5, head: start + 11) // select " world"
|
|
c.deleteBackward()
|
|
let doc = c.currentBlocks()
|
|
guard case .blockQuote(let q) = doc[0], case .paragraph(let child) = q.children.first else { return XCTFail("quote gone") }
|
|
XCTAssertEqual(child.text, "hello", "the mid-text selection is deleted inside the quote")
|
|
guard doc.count > 1, case .paragraph(let after) = doc[1] else { return XCTFail("following block gone") }
|
|
XCTAssertEqual(after.text, "after", "the following block is UNCHANGED")
|
|
}
|
|
|
|
// MARK: - vertical arrow nav must visit every quote line + the author (no skipping)
|
|
|
|
private func vnavLabel(_ c: DocumentCanvasView, _ pos: Int, _ box: BlockQuoteBox) -> String {
|
|
guard let (r, _) = c.leafRegion(containingGlobal: pos) else { return "none" }
|
|
if case .quoteAuthor = r.ref { return "author" }
|
|
for (i, child) in box.children.boxes.enumerated() {
|
|
if let cr = child.leafRegions().first, cr.globalStart == r.globalStart { return "l\(i + 1)" }
|
|
}
|
|
return "after"
|
|
}
|
|
|
|
private func largeAuthorSpacingCanvas() -> DocumentCanvasView {
|
|
// A LARGE author spacing (40pt) creates a gap > step/2 between the last quote line and the author —
|
|
// which used to stall the geometric step and escape the whole quote, skipping the author.
|
|
let c = DocumentCanvasView()
|
|
var qs = QuoteStyle.default; qs.authorSpacing = 40
|
|
c.applyQuoteStyle(qs)
|
|
c.setBlocks([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("l1"), runs: [TextRun(text: "one")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("l2"), runs: [TextRun(text: "two")])),
|
|
], collapsed: false, author: [TextRun(text: "the author")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
], width: 320)
|
|
c.frame = CGRect(x: 0, y: 0, width: 320, height: 600)
|
|
c.layoutIfNeeded()
|
|
return c
|
|
}
|
|
|
|
func test_verticalNav_largeAuthorSpacing_downVisitsAuthor_noSkip() {
|
|
let c = largeAuthorSpacingCanvas()
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
var pos = box.children.boxes[0].leafRegions().first!.globalStart
|
|
var seq: [String] = []
|
|
for _ in 0..<4 { pos = c.verticalPosition(from: pos, down: true); seq.append(vnavLabel(c, pos, box)); if seq.last == "after" { break } }
|
|
XCTAssertEqual(seq, ["l2", "author", "after"], "Down must visit the author even across a large author-spacing gap")
|
|
}
|
|
|
|
func test_verticalNav_largeAuthorSpacing_upVisitsAuthor_noSkip() {
|
|
let c = largeAuthorSpacingCanvas()
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
var pos = c.boxes[1].leafRegions().first!.globalStart // "after"
|
|
var seq: [String] = []
|
|
for _ in 0..<4 { pos = c.verticalPosition(from: pos, down: false); seq.append(vnavLabel(c, pos, box)); if seq.last == "l1" { break } }
|
|
XCTAssertEqual(seq, ["author", "l2", "l1"], "Up must visit the author even across a large author-spacing gap")
|
|
}
|
|
|
|
func test_verticalNav_downAndUp_visitEveryQuoteLineAndAuthor_noSkips() {
|
|
let c = laidOutCanvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("l1"), runs: [TextRun(text: "one")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("l2"), runs: [TextRun(text: "two")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("l3"), runs: [TextRun(text: "three")])),
|
|
], collapsed: false, author: [TextRun(text: "author")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let l1 = box.children.boxes[0].leafRegions().first!.globalStart
|
|
|
|
// Walk DOWN from line 1; must visit l2, l3, author, after — every line, in order.
|
|
var pos = l1
|
|
var down: [String] = []
|
|
for _ in 0..<4 { pos = c.verticalPosition(from: pos, down: true); down.append(vnavLabel(c, pos, box)) }
|
|
XCTAssertEqual(down, ["l2", "l3", "author", "after"], "Down must step through every line + author")
|
|
|
|
// Walk UP from the following block; must visit author, l3, l2, l1 — no skips.
|
|
let after = c.boxes[1].leafRegions().first!.globalStart
|
|
pos = after
|
|
var up: [String] = []
|
|
for _ in 0..<4 { pos = c.verticalPosition(from: pos, down: false); up.append(vnavLabel(c, pos, box)) }
|
|
XCTAssertEqual(up, ["author", "l3", "l2", "l1"], "Up must step through the author + every line")
|
|
}
|
|
|
|
// MARK: - Select-All → Backspace resets to a plain body paragraph
|
|
|
|
func test_selectAll_backspace_headingFirst_resetsToBodyParagraph() {
|
|
let c = canvas([
|
|
.paragraph(ParagraphBlock(id: BlockID("h"), style: .heading1, runs: [TextRun(text: "Title")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("b"), style: .body, runs: [TextRun(text: "body")])),
|
|
])
|
|
c.setSelectionForTesting(anchor: 0, head: c.documentSizeValue)
|
|
c.deleteBackward()
|
|
let doc = c.currentBlocks()
|
|
XCTAssertEqual(doc.count, 1, "one block remains")
|
|
guard case .paragraph(let p) = doc[0] else { return XCTFail("expected a paragraph") }
|
|
XCTAssertEqual(p.style, .body, "the remaining empty block must be a BODY paragraph, not a heading")
|
|
XCTAssertEqual(p.text, "")
|
|
}
|
|
|
|
func test_selectAll_backspace_quoteFirst_resetsToBodyParagraph() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "quoted")]))
|
|
], collapsed: false, author: [])),
|
|
.paragraph(ParagraphBlock(id: BlockID("b"), runs: [TextRun(text: "body")])),
|
|
])
|
|
c.setSelectionForTesting(anchor: 0, head: c.documentSizeValue)
|
|
c.deleteBackward()
|
|
let doc = c.currentBlocks()
|
|
XCTAssertEqual(doc.count, 1, "one block remains")
|
|
guard case .paragraph(let p) = doc[0] else { return XCTFail("expected a plain paragraph, not a quote") }
|
|
XCTAssertEqual(p.style, .body)
|
|
XCTAssertEqual(p.text, "")
|
|
}
|
|
|
|
// MARK: - Tab affordance for quotes (body → author end; author → out)
|
|
|
|
private func authorRegion(_ box: BlockQuoteBox) -> LeafTextRegion {
|
|
box.leafRegions().first { if case .quoteAuthor = $0.ref { return true } else { return false } }!
|
|
}
|
|
|
|
func test_tab_inQuoteBody_movesToEndOfAuthor() {
|
|
let c = laidOutCanvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "hello")]))
|
|
], collapsed: false, author: [TextRun(text: "Ada")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
c.setCaret(global: box.children.boxes[0].leafRegions().first!.globalStart + 2) // mid "hello"
|
|
XCTAssertTrue(c.handleQuoteTabForward())
|
|
let author = authorRegion(box)
|
|
XCTAssertEqual(c.head, author.globalStart + author.length, "caret at the END of the author string")
|
|
}
|
|
|
|
func test_tab_inAuthor_movesOutToFollowingBlock() {
|
|
let c = laidOutCanvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "hello")]))
|
|
], collapsed: false, author: [TextRun(text: "Ada")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
c.setCaret(global: authorRegion(box).globalStart + 1) // mid author
|
|
XCTAssertTrue(c.handleQuoteTabForward())
|
|
XCTAssertEqual(c.head, c.boxes[1].leafRegions().first!.globalStart, "caret moved OUT to the following block")
|
|
}
|
|
|
|
func test_tab_inAuthor_noFollowingBlock_staysPut() {
|
|
let c = laidOutCanvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "hello")]))
|
|
], collapsed: false, author: [TextRun(text: "Ada")])),
|
|
]) // no block after the quote
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let authorMid = authorRegion(box).globalStart + 1
|
|
c.setCaret(global: authorMid)
|
|
XCTAssertTrue(c.handleQuoteTabForward(), "Tab is consumed by the quote")
|
|
XCTAssertEqual(c.head, authorMid, "no following place → caret stays in the author")
|
|
}
|
|
|
|
func test_tab_inTopLevelParagraph_isNotHandledByQuoteTab() {
|
|
let c = laidOutCanvas([.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "plain")]))])
|
|
c.setCaret(global: c.boxes[0].leafRegions().first!.globalStart + 2)
|
|
XCTAssertFalse(c.handleQuoteTabForward(), "outside a quote, quote-Tab does not consume the key")
|
|
}
|
|
|
|
// MARK: - resolveBox-misroute audit fixes (caret in a quote must not act on the following block)
|
|
|
|
private func quoteThenAfter(_ afterStyle: ParagraphStyleName = .body) -> DocumentCanvasView {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "hi")]))
|
|
], collapsed: false, author: [])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), style: afterStyle, runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let mid = box.children.boxes[0].leafRegions().first!.globalStart + 1 // caret inside the quote line
|
|
c.setSelectionForTesting(anchor: mid, head: mid)
|
|
return c
|
|
}
|
|
|
|
func test_insertTable_caretInQuote_isNoOp_followingBlockUntouched() {
|
|
let c = quoteThenAfter()
|
|
let before = c.currentBlocks().count
|
|
c.insertTable(rows: 2, columns: 2)
|
|
XCTAssertEqual(c.currentBlocks().count, before, "insertTable is a no-op when the caret is in a quote")
|
|
guard case .paragraph(let after) = c.currentBlocks().last else { return XCTFail("following block changed") }
|
|
XCTAssertEqual(after.text, "after", "the following block is untouched")
|
|
}
|
|
|
|
func test_insertMedia_caretInQuote_isNoOp_followingBlockUntouched() {
|
|
let c = quoteThenAfter()
|
|
let before = c.currentBlocks().count
|
|
c.insertMedia(mediaID: "m", naturalSize: CGSize(width: 100, height: 100), kind: .image)
|
|
XCTAssertEqual(c.currentBlocks().count, before, "insertMedia is a no-op when the caret is in a quote")
|
|
guard case .paragraph(let after) = c.currentBlocks().last else { return XCTFail("following block changed") }
|
|
XCTAssertEqual(after.text, "after", "the following block is untouched")
|
|
}
|
|
|
|
func test_currentState_caretInQuote_paragraphStyleReflectsQuoteChild_notFollowingBlock() {
|
|
let c = quoteThenAfter(.heading1) // following block is a heading; the quote line is body
|
|
XCTAssertEqual(c.currentState().paragraphStyle, .body, "toolbar reflects the quote line's style, not the following heading")
|
|
}
|
|
|
|
func test_backspace_midTextRange_inQuoteChild_emptyFollowingBlock_deletesChar_notFollowingBlock() {
|
|
// The device repro: a mid-text Backspace inside a quote line arrives as the RANGE [local0, local1]
|
|
// (iOS delivers even a single-char backspace as a range). With an EMPTY block after the quote,
|
|
// resolveBox(selTo) mis-resolves the in-quote position to that empty block and the atom-after handler
|
|
// REMOVES it. It must instead delete the char inside the quote and leave the following block alone.
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p0"), runs: [TextRun(text: "a")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("p1"), runs: [TextRun(text: "bc")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("p2"), runs: [TextRun(text: "d")])),
|
|
], collapsed: false, author: [])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [])), // EMPTY following block
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let child1Start = box.children.boxes[1].leafRegions().first!.globalStart
|
|
c.setSelectionForTesting(anchor: child1Start, head: child1Start + 1) // the mid-text backspace range [local0, local1]
|
|
c.deleteBackward()
|
|
let doc = c.currentBlocks()
|
|
guard case .blockQuote(let q) = doc[0] else { return XCTFail("quote gone") }
|
|
XCTAssertEqual(q.children.count, 3, "the quote keeps all three lines")
|
|
guard case .paragraph(let child1) = q.children[1] else { return XCTFail("middle child gone") }
|
|
XCTAssertEqual(child1.text, "c", "the first char of the middle line is deleted")
|
|
XCTAssertEqual(doc.count, 2, "the empty following block is NOT removed (was the bug)")
|
|
}
|
|
|
|
func test_backspace_objectReplacementRange_atNonFirstQuoteChild_emptyFollowingBlock_notRemoved() {
|
|
// Same object-replacement RANGE, but the block AFTER the quote is EMPTY — the actual device repro.
|
|
// resolveBox mis-resolves the quote-child position to that empty following block; because a BlockQuoteBox
|
|
// IS a non-paragraph atom, the "empty paragraph after an atom" handler REMOVES the following block (no log,
|
|
// never reaching applySelectionReplaceOutcome). It must instead merge the quote's empty 2nd line and leave the
|
|
// following block alone.
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p1"), runs: [TextRun(text: "text")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("p2"), runs: [])),
|
|
], collapsed: false, author: [])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [])), // EMPTY following block
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let child1End = box.children.boxes[0].leafRegions().first!.globalStart + 4
|
|
let child2Start = box.children.boxes[1].leafRegions().first!.globalStart
|
|
c.setSelectionForTesting(anchor: child1End, head: child2Start)
|
|
c.deleteBackward()
|
|
|
|
let doc = c.currentBlocks()
|
|
guard case .blockQuote(let q) = doc[0] else { return XCTFail("quote gone") }
|
|
XCTAssertEqual(q.children.count, 1, "the empty second line should have merged back into the first")
|
|
XCTAssertEqual(doc.count, 2, "the empty following block must NOT be removed (was the bug)")
|
|
guard case .paragraph(let afterPara) = doc[1] else { return XCTFail("following block removed/changed") }
|
|
XCTAssertEqual(afterPara.id, BlockID("after"), "the same following block survives, not merged away")
|
|
}
|
|
|
|
func test_doubleReturn_emptyTrailingChild_exitsAfterQuote() {
|
|
let bq = BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "hi")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("e"), runs: []))], collapsed: false) // empty trailing child
|
|
let c = canvas([.blockQuote(bq)])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let pos = box.children.boxes[1].leafRegions().first!.globalStart // caret on the empty trailing child
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.insertText("\n")
|
|
let doc = c.currentBlocks()
|
|
XCTAssertEqual(doc.count, 2) // quote + a body paragraph after it
|
|
guard case .blockQuote(let q) = doc[0] else { return XCTFail() }
|
|
XCTAssertEqual(q.children.count, 1) // empty trailing child consumed
|
|
guard case .paragraph(let after) = doc[1], after.style == .body else { return XCTFail("no body after") }
|
|
}
|
|
|
|
func test_backspace_emptyLoneChild_unquotes() {
|
|
let bq = BlockQuote(id: BlockID("q"), children: [.paragraph(ParagraphBlock(id: BlockID("p"), runs: []))], collapsed: false)
|
|
let c = canvas([.blockQuote(bq)])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let pos = box.children.boxes[0].leafRegions().first!.globalStart
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.deleteBackward() // Backspace (same seam as other backspace tests)
|
|
let doc = c.currentBlocks()
|
|
for b in doc { if case .blockQuote = b { return XCTFail("still quoted") } } // un-quoted to a plain paragraph
|
|
}
|
|
|
|
/// Bug 1 regression: a LONE child with CONTENT at local 0 must also un-quote on Backspace.
|
|
/// Before the fix the branch required `child.textLength == 0`, so a lone child with content
|
|
/// would fall through and do a normal character delete instead of removing the quote wrapper.
|
|
func test_backspace_nonEmptyLoneChild_atStart_unquotes_preservingContent() {
|
|
let bq = BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "hello")]))], collapsed: false)
|
|
let c = canvas([.blockQuote(bq)])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
// Caret at local 0 (the very start of the child, before the "h")
|
|
let pos = box.children.boxes[0].leafRegions().first!.globalStart
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.deleteBackward()
|
|
let doc = c.currentBlocks()
|
|
// The quote wrapper is gone — children are spliced to the parent
|
|
for b in doc { if case .blockQuote = b { return XCTFail("quote wrapper should have been removed") } }
|
|
// Content is preserved (the text "hello" was NOT deleted)
|
|
guard case .paragraph(let p) = doc.first else { return XCTFail("expected a body paragraph") }
|
|
XCTAssertEqual(p.text, "hello", "content must be preserved after un-quoting the lone child")
|
|
}
|
|
|
|
func test_wrapInBlockQuote_insideTableCell_isNoOp() {
|
|
// A table with one cell containing "Hello". Position the caret inside the cell and attempt to
|
|
// wrap in a block quote — this should be a no-op (a table cellStack can't host a BlockQuoteBox).
|
|
let cell = Cell(id: BlockID("c"), blocks: [
|
|
.paragraph(ParagraphBlock(id: BlockID("cp"), runs: [TextRun(text: "Hello")]))])
|
|
let table = TableBlock(id: BlockID("t"),
|
|
columns: [ColumnSpec(width: 300)],
|
|
rows: [Row(id: BlockID("r"), cells: [cell])])
|
|
let c = canvas([.table(table)])
|
|
// Find the cell paragraph's leaf region and place the caret inside it.
|
|
guard let cellRegion = c.allLeafRegions().first(where: { $0.ref == .paragraph(BlockID("cp")) }) else {
|
|
return XCTFail("cell region not found")
|
|
}
|
|
let pos = cellRegion.globalStart + 2
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
let blocksBefore = c.currentBlocks()
|
|
c.wrapInBlockQuote()
|
|
// The document must be byte-identical to before — no block quote was created.
|
|
XCTAssertEqual(c.currentBlocks().count, blocksBefore.count, "no new top-level block should appear")
|
|
guard case .table(let outTable) = c.currentBlocks()[0] else { return XCTFail("table disappeared") }
|
|
guard case .paragraph(let cellPara) = outTable.rows[0].cells[0].blocks[0] else {
|
|
return XCTFail("cell content changed")
|
|
}
|
|
XCTAssertEqual(cellPara.text, "Hello", "cell content must be unchanged")
|
|
for b in c.currentBlocks() { if case .blockQuote = b { XCTFail("block quote was created inside a table") } }
|
|
}
|
|
|
|
// MARK: - Task 5: author-region caret / delete / toggle
|
|
|
|
/// A bold toggle whose selection lies entirely in a quote's author line is a no-op — the author is
|
|
/// always-bold (ambient), so toggling must not mutate its (rendered) attributes nor dirty the model.
|
|
func test_toggleBold_isNoOp_inAuthorRegion() {
|
|
let c = canvas([.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "hi")]))],
|
|
collapsed: false, author: [TextRun(text: "Ada")]))])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
guard let authorRegion = box.leafRegions().first(where: { $0.ref == .quoteAuthor(BlockID("q")) }) else {
|
|
return XCTFail("no author region")
|
|
}
|
|
// Snapshot the author's rendered attributes before the toggle.
|
|
let before = authorRegion.layout.attributedString.copy() as! NSAttributedString
|
|
// Select the whole author line ("Ada") and toggle bold.
|
|
c.setSelectionForTesting(anchor: authorRegion.globalStart, head: authorRegion.globalStart + authorRegion.length)
|
|
c.toggleBold()
|
|
// The author's stored/rendered attributes are UNCHANGED — bold in the author region is inert.
|
|
XCTAssertEqual(authorRegion.layout.attributedString, before,
|
|
"toggleBold in the author region must not mutate the author's attributes")
|
|
// And the model author round-trips bold-free (ambient bold is stripped on read-back).
|
|
guard case let .blockQuote(out) = box.currentBlock() else { return XCTFail() }
|
|
XCTAssertTrue(out.author.allSatisfy { $0.attributes.bold == false })
|
|
}
|
|
|
|
/// Backspace with a collapsed caret at the START (local 0) of a quote's author line relocates the caret
|
|
/// to the end of the quote's last child (recursive via `prevTextPosition`) — it never merges the author
|
|
/// into the body and never deletes the quote.
|
|
func test_backspace_atAuthorStart_relocatesToEndOfLastChild_neverDeletesQuote() {
|
|
let c = canvas([.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "body")]))],
|
|
collapsed: false, author: [TextRun(text: "Ada")]))])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
guard let authorRegion = box.leafRegions().first(where: { $0.ref == .quoteAuthor(BlockID("q")) }) else {
|
|
return XCTFail("no author region")
|
|
}
|
|
c.setSelectionForTesting(anchor: authorRegion.globalStart, head: authorRegion.globalStart) // caret at author local 0
|
|
c.deleteBackward()
|
|
// Quote still present, author intact, caret now at the end of the body child.
|
|
guard case let .blockQuote(out) = box.currentBlock() else { return XCTFail("quote deleted") }
|
|
XCTAssertEqual(out.author.map(\.text).joined(), "Ada")
|
|
XCTAssertEqual(out.children.count, 1)
|
|
let bodyRegion = box.leafRegions().first(where: { $0.ref == .paragraph(BlockID("p")) })!
|
|
XCTAssertEqual(c.head, bodyRegion.globalStart + bodyRegion.length) // end of last child
|
|
}
|
|
|
|
/// Arrow-right from the end of a block quote's last child enters a NON-empty author region.
|
|
func test_arrowRight_intoNonEmptyBlockQuoteAuthor_entersAuthorRegion() {
|
|
let c = canvas([.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "body")]))],
|
|
collapsed: false, author: [TextRun(text: "Ada")]))])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let childRegion = box.children.boxes[0].leafRegions().first!
|
|
let childEnd = childRegion.globalStart + childRegion.length
|
|
let authorRegion = box.leafRegions().first(where: { $0.ref == .quoteAuthor(BlockID("q")) })!
|
|
XCTAssertEqual(c.nextTextPosition(after: childEnd), authorRegion.globalStart,
|
|
"arrow-right from the last child's end enters the non-empty author region")
|
|
}
|
|
|
|
/// A tap in a block quote's author line area routes to the author region (directly editable).
|
|
func test_tapInBlockQuoteAuthorArea_placesCaretInAuthor() {
|
|
let v = laidOutCanvas([.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "body")]))],
|
|
collapsed: false, author: [TextRun(text: "Ada")]))])
|
|
let box = v.boxes.first as! BlockQuoteBox
|
|
let authorRegion = box.leafRegions().first(where: { $0.ref == .quoteAuthor(BlockID("q")) })!
|
|
let p = CGPoint(x: authorRegion.canvasOrigin.x + 4, y: authorRegion.canvasOrigin.y + 4)
|
|
let resolved = v.closestGlobalPosition(to: p)
|
|
XCTAssertTrue(resolved >= authorRegion.globalStart && resolved <= authorRegion.globalStart + authorRegion.length,
|
|
"a tap in the block-quote author area must resolve into the author region")
|
|
}
|
|
|
|
/// Select-All + Backspace on a block-quote-WITH-author removes the whole quote INCLUDING its author
|
|
/// (the quote's nodeSize covers the trailing author region), leaving an empty document.
|
|
func test_selectAll_delete_blockQuoteWithAuthor_removesQuoteAndAuthor() {
|
|
let c = canvas([.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "body")]))],
|
|
collapsed: false, author: [TextRun(text: "Ada")]))])
|
|
c.selectAllText()
|
|
c.deleteBackward()
|
|
XCTAssertFalse(c.boxes.contains { $0 is BlockQuoteBox },
|
|
"the block quote (with its author) is removed by Select-All + delete")
|
|
let joined = c.currentBlocks().compactMap { block -> String? in
|
|
if case .paragraph(let p) = block { return p.text } else { return nil }
|
|
}.joined()
|
|
XCTAssertFalse(joined.contains("Ada"), "author text must not survive")
|
|
XCTAssertFalse(joined.contains("body"), "body text must not survive")
|
|
}
|
|
|
|
/// The OS delivers Backspace at author-start as an object-replacement RANGE (anchored at the previous
|
|
/// child's text end, head at the author start) rather than a collapsed caret. That range spans only the
|
|
/// structural slots before the author, so it is collapsed to a caret and relocated exactly like the
|
|
/// collapsed-caret form — the quote/author survive and nothing is edited.
|
|
func test_backspace_authorStartObjectReplacementRange_relocatesWithoutEditing() {
|
|
let c = canvas([.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "body")]))],
|
|
collapsed: false, author: [TextRun(text: "Ada")]))])
|
|
let um = UndoManager(); c.undoManagerOverride = um
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let authorRegion = box.leafRegions().first(where: { $0.ref == .quoteAuthor(BlockID("q")) })!
|
|
let bodyRegion = box.children.boxes[0].leafRegions().first!
|
|
let bodyEnd = bodyRegion.globalStart + bodyRegion.length
|
|
// Object-replacement RANGE: anchor at the previous child's text end, head at the author start.
|
|
c.setSelectionForTesting(anchor: bodyEnd, head: authorRegion.globalStart)
|
|
c.deleteBackward()
|
|
guard case let .blockQuote(out) = box.currentBlock() else { return XCTFail("quote deleted") }
|
|
XCTAssertEqual(out.author.map(\.text).joined(), "Ada", "author preserved")
|
|
XCTAssertEqual(out.children.count, 1)
|
|
XCTAssertEqual(c.head, bodyEnd, "caret relocates to the end of the last child")
|
|
XCTAssertFalse(um.canUndo, "the RANGE form relocates without registering a content edit")
|
|
}
|
|
|
|
/// Runtime bug: the author is a SECOND leaf region on the box (outside its primary child stack), so
|
|
/// the plain `applyReplaceOutcome`/`activeStack` insert path used to mis-route a collapsed-caret author
|
|
/// insert into the FOLLOWING top-level paragraph instead of the author.
|
|
func test_insertText_atEmptyBlockQuoteAuthor_landsInAuthorNotNextParagraph() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "body")]))],
|
|
collapsed: false, author: [])),
|
|
.paragraph(ParagraphBlock(id: BlockID("next"), runs: [TextRun(text: "next")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
guard let authorRegion = box.leafRegions().first(where: { $0.ref == .quoteAuthor(BlockID("q")) }) else {
|
|
return XCTFail("no author region")
|
|
}
|
|
c.setSelectionForTesting(anchor: authorRegion.globalStart, head: authorRegion.globalStart) // caret at author local 0
|
|
c.insertText("X")
|
|
guard case let .blockQuote(out) = box.currentBlock() else { return XCTFail("expected .blockQuote") }
|
|
XCTAssertEqual(out.author.map(\.text).joined(), "X", "typed char must land in the author line")
|
|
XCTAssertEqual(out.children.count, 1, "the quote's children must be unchanged")
|
|
guard case .paragraph(let p) = out.children.first else { return XCTFail("expected a paragraph child") }
|
|
XCTAssertEqual(p.text, "body", "the quote's child paragraph must be unchanged")
|
|
guard case .paragraph(let next) = c.currentBlocks()[1] else { return XCTFail("expected the following paragraph") }
|
|
XCTAssertEqual(next.text, "next", "the following paragraph must be unaffected by the author insert")
|
|
}
|
|
|
|
/// Runtime bug: a selection-replace (select "Ada" then type "Bob") confined to a block quote's author
|
|
/// region must land the replacement in the author — not in the following top-level paragraph. Mirrors
|
|
/// `test_selectionReplace_viaInsertText_atPullQuoteAuthor_landsInAuthorNotNextParagraph`.
|
|
func test_selectionReplace_viaInsertText_atBlockQuoteAuthor_landsInAuthorNotNextParagraph() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "body")]))],
|
|
collapsed: false, author: [TextRun(text: "Ada")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("next"), runs: [TextRun(text: "next")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
guard let authorRegion = box.leafRegions().first(where: { $0.ref == .quoteAuthor(BlockID("q")) }) else {
|
|
return XCTFail("no author region")
|
|
}
|
|
c.setSelectionForTesting(anchor: authorRegion.globalStart, head: authorRegion.globalStart + authorRegion.length)
|
|
c.insertText("Bob")
|
|
guard case let .blockQuote(out) = box.currentBlock() else { return XCTFail("expected .blockQuote") }
|
|
XCTAssertEqual(out.author.map(\.text).joined(), "Bob", "the replacement must land in the author line")
|
|
XCTAssertEqual(out.children.count, 1, "the quote's children must be unchanged")
|
|
guard case .paragraph(let p) = out.children.first else { return XCTFail("expected a paragraph child") }
|
|
XCTAssertEqual(p.text, "body", "the quote's child paragraph must be unchanged")
|
|
guard case .paragraph(let next) = c.currentBlocks()[1] else { return XCTFail("expected the following paragraph") }
|
|
XCTAssertEqual(next.text, "next", "the following paragraph must be unaffected by the author replace")
|
|
}
|
|
|
|
// MARK: - Author appears/disappears live with content (Task 2)
|
|
|
|
func test_blockQuote_authorAppearsWhenBodyTyped_thenDisappears_caretUnmoved() {
|
|
let c = canvas([.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: []))], collapsed: false, author: []))])
|
|
let box = c.boxes[0] as! BlockQuoteBox
|
|
XCTAssertFalse(box.shouldShowAuthor)
|
|
let bodyStart = box.leafRegions()[0].globalStart
|
|
c.setCaret(global: bodyStart) // caret in the empty body paragraph
|
|
c.insertText("x")
|
|
XCTAssertTrue(box.shouldShowAuthor)
|
|
let caretAfterType = c.head
|
|
// Select the just-typed "x" and delete it via a RANGE (rather than a collapsed-caret Backspace,
|
|
// which — independent of this feature — hits a pre-existing `resolveBox` limitation for a lone
|
|
// top-level container box; see the Task 2 report). The selection-replace path correctly resolves
|
|
// into the quote's child via `activeStack`/`leafRegions()`.
|
|
c.setSelectionForTesting(anchor: bodyStart, head: caretAfterType)
|
|
c.deleteBackward()
|
|
XCTAssertFalse(box.shouldShowAuthor)
|
|
XCTAssertEqual(c.head, bodyStart, "caret stays in the body, unmoved by the author toggle")
|
|
}
|
|
|
|
/// When a block quote's body is emptied, its (empty) author region is removed and the caret — which
|
|
/// legitimately ends in the now-empty body after the selection-delete — remains a valid position in
|
|
/// that body paragraph. No stale-caret snap code is needed: emptying the body REQUIRES a caret/selection
|
|
/// in the body (as here), so a caret literally stranded inside the author cannot coexist with an empty
|
|
/// body; any purely-programmatic out-of-range caret is already covered by the existing `clampGlobal`.
|
|
func test_blockQuote_authorRemovedOnBodyEmptied_caretLandsInBody() {
|
|
let c = canvas([.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "ab")]))],
|
|
collapsed: false, author: []))])
|
|
let box = c.boxes[0] as! BlockQuoteBox
|
|
XCTAssertTrue(box.shouldShowAuthor) // author shown, because the body has "ab"
|
|
// Empty the body via a selection-replace covering the body text (the caret legitimately ends in the
|
|
// body). A collapsed-caret `deleteBackward()` is deliberately NOT used here — a pre-existing (not
|
|
// this-feature) `resolveBox` limitation no-ops a collapsed-caret backspace inside a lone container box;
|
|
// see the Task 2 report / `test_blockQuote_authorAppearsWhenBodyTyped_thenDisappears_caretUnmoved` above.
|
|
let bodyRegion = box.children.boxes[0].leafRegions().first!
|
|
c.setSelectionForTesting(anchor: bodyRegion.globalStart, head: bodyRegion.globalStart + bodyRegion.length) // "ab"
|
|
c.insertText("") // delete the selection → body empty → author hides
|
|
// 1. The author region is genuinely GONE.
|
|
XCTAssertFalse(box.shouldShowAuthor)
|
|
XCTAssertTrue(box.leafRegions().allSatisfy {
|
|
if case .quoteAuthor = $0.ref { return false } else { return true }
|
|
}, "no .quoteAuthor region may remain once the body is empty")
|
|
// 2. The caret is valid AND lands in the BODY paragraph, not the removed author.
|
|
guard let (region, _) = c.leafRegion(containingGlobal: c.head) else {
|
|
return XCTFail("caret must resolve to a real region after the author is removed")
|
|
}
|
|
XCTAssertEqual(region.ref, .paragraph(BlockID("p")), "caret must land in the body paragraph")
|
|
// 3. The caret is within the document.
|
|
XCTAssertLessThanOrEqual(c.head, c.documentSizeValue)
|
|
}
|
|
|
|
func test_editorState_blockQuoteDepth() {
|
|
let inner = BlockQuote(id: BlockID("in"), children: [.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "hi")]))], collapsed: false)
|
|
let outer = BlockQuote(id: BlockID("out"), children: [.blockQuote(inner)], collapsed: false)
|
|
let c = canvas([.paragraph(ParagraphBlock(id: BlockID("top"), runs: [TextRun(text: "x")])), .blockQuote(outer)])
|
|
// caret in the top-level paragraph → depth 0
|
|
let topPos = c.boxes[0].leafRegions().first!.globalStart
|
|
c.setSelectionForTesting(anchor: topPos, head: topPos)
|
|
XCTAssertEqual(c.blockQuoteDepth(at: c.head), 0)
|
|
// caret inside the doubly-nested paragraph → depth 2
|
|
let outerBox = c.boxes[1] as! BlockQuoteBox
|
|
let innerBox = outerBox.children.boxes.compactMap { $0 as? BlockQuoteBox }.first!
|
|
let deep = innerBox.children.boxes[0].leafRegions().first!.globalStart + 1
|
|
c.setSelectionForTesting(anchor: deep, head: deep)
|
|
XCTAssertEqual(c.blockQuoteDepth(at: c.head), 2)
|
|
}
|
|
|
|
// MARK: - Bug repro: collapsed-caret Backspace with text before it inside a block quote
|
|
|
|
/// Collapsed-caret Backspace at the END of a lone block quote's body must delete the last character —
|
|
/// not silently no-op. `resolveBox(at:)` can't resolve a position inside the container (its
|
|
/// `textLength == 0`; the real text lives in the child, found via `leafRegions()`), so without a
|
|
/// block-quote-aware branch this falls through to the container-degenerate-range fallback and does
|
|
/// nothing.
|
|
func test_backspace_collapsedCaret_loneBlockQuote_deletesLastCharOfBody() {
|
|
let c = canvas([.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "ab")]))],
|
|
collapsed: false, author: []))])
|
|
let box = c.boxes[0] as! BlockQuoteBox
|
|
let pos = box.leafRegions()[0].globalStart + 2 // end of "ab"
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.deleteBackward()
|
|
guard case .blockQuote(let out) = box.currentBlock() else { return XCTFail("quote gone") }
|
|
guard case .paragraph(let p) = out.children.first else { return XCTFail("expected a paragraph child") }
|
|
XCTAssertEqual(p.text, "a", "backspace at the end of the quote body must delete one character")
|
|
}
|
|
|
|
/// Same bug, but with a block AFTER the quote: a collapsed-caret Backspace at the end of the quote's
|
|
/// body must delete from the body — not mis-route to (or merge with) the following top-level paragraph.
|
|
func test_backspace_collapsedCaret_blockQuoteFollowedByParagraph_deletesInBodyNotFollowingParagraph() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "ab")]))],
|
|
collapsed: false, author: [])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes[0] as! BlockQuoteBox
|
|
let pos = box.leafRegions()[0].globalStart + 2 // end of "ab"
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.deleteBackward()
|
|
guard case .blockQuote(let out) = box.currentBlock() else { return XCTFail("quote gone") }
|
|
guard case .paragraph(let p) = out.children.first else { return XCTFail("expected a paragraph child") }
|
|
XCTAssertEqual(p.text, "a", "backspace must delete from the quote body")
|
|
guard case .paragraph(let after) = c.currentBlocks()[1] else { return XCTFail("expected the following paragraph") }
|
|
XCTAssertEqual(after.text, "after", "the following paragraph must be unaffected")
|
|
}
|
|
|
|
/// Checks whether the author line has the SAME bug: collapsed-caret Backspace at the END of a non-empty
|
|
/// author must delete the author's last character (distinct from the already-covered `local == 0`
|
|
/// author-start relocation branch, which never touches this `local > 0` case).
|
|
func test_backspace_collapsedCaret_atEndOfNonEmptyAuthor_deletesLastCharOfAuthor() {
|
|
let c = canvas([.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "ab")]))],
|
|
collapsed: false, author: [TextRun(text: "Ada")]))])
|
|
let box = c.boxes[0] as! BlockQuoteBox
|
|
guard let authorRegion = box.leafRegions().first(where: { $0.ref == .quoteAuthor(BlockID("q")) }) else {
|
|
return XCTFail("no author region")
|
|
}
|
|
let pos = authorRegion.globalStart + authorRegion.length // end of "Ada"
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.deleteBackward()
|
|
guard case .blockQuote(let out) = box.currentBlock() else { return XCTFail("quote gone") }
|
|
XCTAssertEqual(out.author.map(\.text).joined(), "Ad",
|
|
"backspace at the end of a non-empty author must delete one character")
|
|
}
|
|
|
|
// MARK: - Bug repro: Return in a quote AUTHOR line must split like a media caption
|
|
|
|
/// Return at the END of a block quote's author must NOT insert a newline into the following
|
|
/// sibling block — it must split the author (whole text stays as author) and drop a NEW empty
|
|
/// body paragraph immediately after the quote, caret there. Before the fix, `insertText("\n")`
|
|
/// routed through `insertParagraphBreak`'s general path (the author-aware branch sits after the
|
|
/// `"\n"` dispatch and is never reached), whose `activeStack`/`resolveBox` can't resolve an author
|
|
/// position and mis-routes the break into the FOLLOWING top-level paragraph ("after").
|
|
func test_return_atEndOfBlockQuoteAuthor_splitsAuthor_newBodyParagraphAfterQuote() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "ab")]))],
|
|
collapsed: false, author: [TextRun(text: "Ada Lovelace")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
guard let authorRegion = box.leafRegions().first(where: { $0.ref == .quoteAuthor(BlockID("q")) }) else {
|
|
return XCTFail("no author region")
|
|
}
|
|
let pos = authorRegion.globalStart + authorRegion.length // end of "Ada Lovelace"
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.insertText("\n")
|
|
let doc = c.currentBlocks()
|
|
XCTAssertEqual(doc.count, 3, "quote + new empty body paragraph + the untouched following paragraph")
|
|
guard case .blockQuote(let q) = doc[0] else { return XCTFail("expected the quote first") }
|
|
XCTAssertEqual(q.author.map(\.text).joined(), "Ada Lovelace", "author text unchanged")
|
|
guard case .paragraph(let inserted) = doc[1] else { return XCTFail("expected a new body paragraph after the quote") }
|
|
XCTAssertEqual(inserted.text, "", "the new paragraph is empty (caret was at author end)")
|
|
XCTAssertEqual(inserted.style, .body)
|
|
guard case .paragraph(let afterPara) = doc[2] else { return XCTFail("expected the original following paragraph") }
|
|
XCTAssertEqual(afterPara.text, "after", "the following paragraph must be untouched")
|
|
// Caret lands at the start of the new paragraph.
|
|
let newBox = c.boxes[1] as! BlockBox
|
|
XCTAssertEqual(c.head, newBox.textStart)
|
|
}
|
|
|
|
/// Same, but the caret sits MID-author ("Ada" | " Lovelace"): the head half stays as the author,
|
|
/// the tail half becomes the new body paragraph after the quote.
|
|
func test_return_midBlockQuoteAuthor_splitsAtCaret_tailBecomesNewBodyParagraph() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "ab")]))],
|
|
collapsed: false, author: [TextRun(text: "Ada Lovelace")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
guard let authorRegion = box.leafRegions().first(where: { $0.ref == .quoteAuthor(BlockID("q")) }) else {
|
|
return XCTFail("no author region")
|
|
}
|
|
let pos = authorRegion.globalStart + 3 // after "Ada"
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.insertText("\n")
|
|
let doc = c.currentBlocks()
|
|
XCTAssertEqual(doc.count, 3)
|
|
guard case .blockQuote(let q) = doc[0] else { return XCTFail("expected the quote first") }
|
|
XCTAssertEqual(q.author.map(\.text).joined(), "Ada", "head half stays as the author")
|
|
guard case .paragraph(let inserted) = doc[1] else { return XCTFail("expected a new body paragraph after the quote") }
|
|
XCTAssertEqual(inserted.text, " Lovelace", "tail half becomes the new paragraph")
|
|
guard case .paragraph(let afterPara) = doc[2] else { return XCTFail("expected the original following paragraph") }
|
|
XCTAssertEqual(afterPara.text, "after", "the following paragraph must be untouched")
|
|
let newBox = c.boxes[1] as! BlockBox
|
|
XCTAssertEqual(c.head, newBox.textStart, "caret at the start of the new paragraph")
|
|
}
|
|
|
|
/// Same behavior for a PULL quote's author line.
|
|
func test_return_atEndOfPullQuoteAuthor_splitsAuthor_newBodyParagraphAfterQuote() {
|
|
let c = canvas([
|
|
.pullQuote(PullQuote(id: BlockID("pq"), runs: [TextRun(text: "Quote")], author: [TextRun(text: "Ada")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! PullQuoteBox
|
|
guard let authorRegion = box.leafRegions().first(where: { $0.ref == .quoteAuthor(BlockID("pq")) }) else {
|
|
return XCTFail("no author region")
|
|
}
|
|
let pos = authorRegion.globalStart + authorRegion.length // end of "Ada"
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.insertText("\n")
|
|
let doc = c.currentBlocks()
|
|
XCTAssertEqual(doc.count, 3, "pull quote + new empty body paragraph + the untouched following paragraph")
|
|
guard case .pullQuote(let pq) = doc[0] else { return XCTFail("expected the pull quote first") }
|
|
XCTAssertEqual(pq.author.map(\.text).joined(), "Ada", "author text unchanged")
|
|
guard case .paragraph(let inserted) = doc[1] else { return XCTFail("expected a new body paragraph after the pull quote") }
|
|
XCTAssertEqual(inserted.text, "", "the new paragraph is empty (caret was at author end)")
|
|
guard case .paragraph(let afterPara) = doc[2] else { return XCTFail("expected the original following paragraph") }
|
|
XCTAssertEqual(afterPara.text, "after", "the following paragraph must be untouched")
|
|
let newBox = c.boxes[1] as! BlockBox
|
|
XCTAssertEqual(c.head, newBox.textStart)
|
|
}
|
|
|
|
/// REGRESSION: Return inside a quote's BODY (a child paragraph, not the author) must still split INSIDE
|
|
/// the quote (adds a child paragraph) — not exit the quote, and not touch the author or the following
|
|
/// sibling block. Guards that the new author-aware Return branch above only fires for a caret actually
|
|
/// IN the author region.
|
|
func test_return_insideBlockQuoteBody_stillSplitsInsideQuote_notAfter() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"),
|
|
children: [.paragraph(ParagraphBlock(id: BlockID("p"), style: .body, runs: [TextRun(text: "ab")]))],
|
|
collapsed: false, author: [TextRun(text: "Ada")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let bodyRegion = box.children.boxes[0].leafRegions().first!
|
|
let pos = bodyRegion.globalStart + 1 // between "a" and "b"
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.insertText("\n")
|
|
let doc = c.currentBlocks()
|
|
XCTAssertEqual(doc.count, 2, "quote (now with 2 children) + the untouched following paragraph")
|
|
guard case .blockQuote(let q) = doc[0] else { return XCTFail("expected the quote first") }
|
|
XCTAssertEqual(q.children.count, 2, "Return split the body into two children, still inside the quote")
|
|
XCTAssertEqual(q.author.map(\.text).joined(), "Ada", "author untouched")
|
|
guard case .paragraph(let after) = doc[1] else { return XCTFail("expected the following paragraph") }
|
|
XCTAssertEqual(after.text, "after", "the following paragraph must be untouched")
|
|
}
|
|
|
|
// MARK: - Bug repro: Backspace at the START of an EMPTY first child in a MULTI-child quote
|
|
|
|
/// Reported bug: Backspace at the start of a block quote's EMPTY first line, when the quote has MORE
|
|
/// children after it, must remove that empty first line — not mis-route to (and delete from) the
|
|
/// FOLLOWING top-level block. `resolveBox` cannot resolve a local-0 position inside the degenerate
|
|
/// container and falls back to the following sibling; the fix intercepts this via `activeStack` before
|
|
/// that fallback runs.
|
|
func test_backspace_emptyFirstChild_multiChildQuote_removesEmptyLine_notFollowingBlock() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("empty"), runs: [])),
|
|
.paragraph(ParagraphBlock(id: BlockID("second"), runs: [TextRun(text: "second")])),
|
|
], collapsed: false)),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let pos = box.leafRegions().first!.globalStart // start of the empty first child (local 0)
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.deleteBackward()
|
|
let doc = c.currentBlocks()
|
|
guard case .blockQuote(let q) = doc[0] else { return XCTFail("quote gone") }
|
|
XCTAssertEqual(q.children.count, 1, "the empty first line should be removed")
|
|
guard case .paragraph(let onlyChild) = q.children.first else { return XCTFail("expected a paragraph child") }
|
|
XCTAssertEqual(onlyChild.text, "second", "the remaining child is the former second child")
|
|
let newFirstRegion = box.children.boxes.first!.leafRegions().first!
|
|
XCTAssertEqual(c.head, newFirstRegion.globalStart, "caret at the start of the (now only) child")
|
|
guard doc.count > 1, case .paragraph(let afterPara) = doc[1] else { return XCTFail("expected the following paragraph") }
|
|
XCTAssertEqual(afterPara.text, "after", "the following paragraph must be UNCHANGED")
|
|
}
|
|
|
|
/// Sibling case (also fixed): Backspace at the START of a NON-first, non-empty child merges it into
|
|
/// its previous sibling within the quote — the following top-level block must stay unaffected.
|
|
func test_backspace_secondChildStart_multiChildQuote_mergesIntoPreviousSibling() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("one"), runs: [TextRun(text: "one")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("two"), runs: [TextRun(text: "two")])),
|
|
], collapsed: false)),
|
|
.paragraph(ParagraphBlock(id: BlockID("after"), runs: [TextRun(text: "after")])),
|
|
])
|
|
let box = c.boxes.first as! BlockQuoteBox
|
|
let pos = box.children.boxes[1].leafRegions().first!.globalStart // start of "two"
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.deleteBackward()
|
|
let doc = c.currentBlocks()
|
|
guard case .blockQuote(let q) = doc[0] else { return XCTFail("quote gone") }
|
|
XCTAssertEqual(q.children.count, 1, "the two children should have merged into one")
|
|
guard case .paragraph(let merged) = q.children.first else { return XCTFail("expected a paragraph child") }
|
|
XCTAssertEqual(merged.text, "onetwo", "the children merge into one paragraph")
|
|
XCTAssertEqual(c.head, box.children.boxes[0].leafRegions().first!.globalStart + 3,
|
|
"caret at the join (offset 3, len(\"one\"))")
|
|
guard doc.count > 1, case .paragraph(let afterPara) = doc[1] else { return XCTFail("expected the following paragraph") }
|
|
XCTAssertEqual(afterPara.text, "after", "the following paragraph must be UNCHANGED")
|
|
}
|
|
|
|
// MARK: - Backspace at the START of a NON-empty FIRST child of a multi-child quote → un-quote that line
|
|
|
|
/// Case 1a: a preceding top-level block, then a quote whose first child is NON-empty and has a sibling
|
|
/// after it. Backspace at the very start of the first child extracts it as a plain top-level paragraph
|
|
/// immediately BEFORE the quote; the remaining children stay quoted; the preceding block is untouched.
|
|
func test_backspace_nonEmptyFirstChild_multiChildQuote_unquotesLine_precededByBlock() {
|
|
let c = canvas([
|
|
.paragraph(ParagraphBlock(id: BlockID("intro"), runs: [TextRun(text: "Intro")])),
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("first"), runs: [TextRun(text: "First")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("second"), runs: [TextRun(text: "Second")])),
|
|
], collapsed: false)),
|
|
])
|
|
let box = c.boxes[1] as! BlockQuoteBox
|
|
let pos = box.children.boxes[0].leafRegions().first!.globalStart // start of "First"
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.deleteBackward()
|
|
let doc = c.currentBlocks()
|
|
XCTAssertEqual(doc.count, 3, "Intro, un-quoted First, and the quote (now just Second)")
|
|
guard case .paragraph(let intro) = doc[0] else { return XCTFail("Intro missing") }
|
|
XCTAssertEqual(intro.text, "Intro", "preceding block unchanged")
|
|
guard case .paragraph(let first) = doc[1] else { return XCTFail("expected First as a plain top-level paragraph") }
|
|
XCTAssertEqual(first.text, "First")
|
|
XCTAssertEqual(first.style, .body, "un-quoted line is a plain body paragraph")
|
|
guard case .blockQuote(let q) = doc[2] else { return XCTFail("expected the quote to survive") }
|
|
XCTAssertEqual(q.children.count, 1)
|
|
guard case .paragraph(let second) = q.children.first else { return XCTFail("expected Second still quoted") }
|
|
XCTAssertEqual(second.text, "Second")
|
|
// Caret at the start of the newly un-quoted "First" paragraph.
|
|
let newBox = c.boxes[1] as! BlockBox
|
|
XCTAssertEqual(c.head, newBox.textStart)
|
|
}
|
|
|
|
/// Case 1b: same, but the quote is the document's FIRST block (no preceding top-level block).
|
|
func test_backspace_nonEmptyFirstChild_multiChildQuote_unquotesLine_quoteIsFirstBlock() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("first"), runs: [TextRun(text: "First")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("second"), runs: [TextRun(text: "Second")])),
|
|
], collapsed: false)),
|
|
])
|
|
let box = c.boxes[0] as! BlockQuoteBox
|
|
let pos = box.children.boxes[0].leafRegions().first!.globalStart // start of "First"
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.deleteBackward()
|
|
let doc = c.currentBlocks()
|
|
XCTAssertEqual(doc.count, 2, "un-quoted First, then the quote (now just Second)")
|
|
guard case .paragraph(let first) = doc[0] else { return XCTFail("expected First as a plain top-level paragraph") }
|
|
XCTAssertEqual(first.text, "First")
|
|
guard case .blockQuote(let q) = doc[1] else { return XCTFail("expected the quote to survive") }
|
|
XCTAssertEqual(q.children.count, 1)
|
|
guard case .paragraph(let second) = q.children.first else { return XCTFail("expected Second still quoted") }
|
|
XCTAssertEqual(second.text, "Second")
|
|
let newBox = c.boxes[0] as! BlockBox
|
|
XCTAssertEqual(c.head, newBox.textStart)
|
|
}
|
|
|
|
/// Case 1, NESTED: the quote sits inside an outer quote. Un-quoting the first child moves it up ONE
|
|
/// level only — it becomes a direct child of the OUTER quote, not a top-level paragraph.
|
|
func test_backspace_nonEmptyFirstChild_nestedQuote_unquotesIntoOuterQuoteLevel() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("outer"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("lead"), runs: [TextRun(text: "Lead")])),
|
|
.blockQuote(BlockQuote(id: BlockID("inner"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("first"), runs: [TextRun(text: "First")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("second"), runs: [TextRun(text: "Second")])),
|
|
], collapsed: false)),
|
|
], collapsed: false)),
|
|
])
|
|
let outerBox = c.boxes[0] as! BlockQuoteBox
|
|
let innerBox = outerBox.children.boxes.compactMap { $0 as? BlockQuoteBox }.first!
|
|
let pos = innerBox.children.boxes[0].leafRegions().first!.globalStart
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.deleteBackward()
|
|
guard case .blockQuote(let outer) = c.currentBlocks()[0] else { return XCTFail("outer quote gone") }
|
|
XCTAssertEqual(outer.children.count, 3, "Lead, un-quoted First (now at the outer level), inner quote (now just Second)")
|
|
guard case .paragraph(let lead) = outer.children[0] else { return XCTFail() }
|
|
XCTAssertEqual(lead.text, "Lead")
|
|
guard case .paragraph(let first) = outer.children[1] else { return XCTFail("expected First spliced into the OUTER quote") }
|
|
XCTAssertEqual(first.text, "First")
|
|
guard case .blockQuote(let inner) = outer.children[2] else { return XCTFail("expected inner quote to survive") }
|
|
XCTAssertEqual(inner.children.count, 1)
|
|
guard case .paragraph(let second) = inner.children.first else { return XCTFail() }
|
|
XCTAssertEqual(second.text, "Second")
|
|
}
|
|
|
|
// MARK: - Backspace at the START of a LIST-ITEM child of a block quote → peel one list level in place
|
|
|
|
/// Case 2: a list item that is a CHILD of a block quote. A NESTED item (level > 0) outdents by one
|
|
/// level and stays a list item, still inside the quote; a further Backspace at level 0 breaks the list
|
|
/// (plain paragraph), still inside the quote — the quote itself is untouched by either step (peeling
|
|
/// the list is not the same as un-quoting).
|
|
func test_backspace_listItemChildOfQuote_nestedLevel_outdentsThenBreaksList_stayingInQuote() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("item"), list: ListMembership(marker: .bullet, level: 1),
|
|
runs: [TextRun(text: "item")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("next"), runs: [TextRun(text: "next")])),
|
|
], collapsed: false)),
|
|
])
|
|
let box = c.boxes[0] as! BlockQuoteBox
|
|
let pos = box.children.boxes[0].leafRegions().first!.globalStart // start of the list item
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.deleteBackward()
|
|
var doc = c.currentBlocks()
|
|
guard case .blockQuote(let q1) = doc[0] else { return XCTFail("quote gone after first backspace") }
|
|
guard case .paragraph(let item1) = q1.children.first else { return XCTFail() }
|
|
XCTAssertEqual(item1.list?.level, 0, "list level decremented to 0")
|
|
XCTAssertEqual(item1.list?.marker, .bullet, "still a list item")
|
|
XCTAssertEqual(item1.text, "item", "content unchanged")
|
|
XCTAssertEqual(q1.children.count, 2, "still quoted, still 2 children")
|
|
|
|
// A SECOND Backspace at the (still level-0) item start now breaks the list.
|
|
let box2 = c.boxes[0] as! BlockQuoteBox
|
|
let pos2 = box2.children.boxes[0].leafRegions().first!.globalStart
|
|
c.setSelectionForTesting(anchor: pos2, head: pos2)
|
|
c.deleteBackward()
|
|
doc = c.currentBlocks()
|
|
guard case .blockQuote(let q2) = doc[0] else { return XCTFail("quote gone after second backspace") }
|
|
guard case .paragraph(let item2) = q2.children.first else { return XCTFail() }
|
|
XCTAssertNil(item2.list, "list membership cleared — plain paragraph")
|
|
XCTAssertEqual(item2.style, .body)
|
|
XCTAssertEqual(item2.text, "item", "content unchanged")
|
|
XCTAssertEqual(q2.children.count, 2, "still quoted (peeling the list does not un-quote)")
|
|
}
|
|
|
|
/// Case 2, TOP-LEVEL list level: a quoted list item that starts at level 0 breaks straight to a plain
|
|
/// paragraph on the FIRST Backspace (no intermediate outdent step, since there is no level to cancel).
|
|
func test_backspace_listItemChildOfQuote_topLevel_breaksListImmediately() {
|
|
let c = canvas([
|
|
.blockQuote(BlockQuote(id: BlockID("q"), children: [
|
|
.paragraph(ParagraphBlock(id: BlockID("item"), list: ListMembership(marker: .ordered, level: 0),
|
|
runs: [TextRun(text: "item")])),
|
|
.paragraph(ParagraphBlock(id: BlockID("next"), runs: [TextRun(text: "next")])),
|
|
], collapsed: false)),
|
|
])
|
|
let box = c.boxes[0] as! BlockQuoteBox
|
|
let pos = box.children.boxes[0].leafRegions().first!.globalStart
|
|
c.setSelectionForTesting(anchor: pos, head: pos)
|
|
c.deleteBackward()
|
|
let doc = c.currentBlocks()
|
|
guard case .blockQuote(let q) = doc[0] else { return XCTFail("quote gone") }
|
|
guard case .paragraph(let item) = q.children.first else { return XCTFail() }
|
|
XCTAssertNil(item.list, "list membership cleared on the very first backspace at level 0")
|
|
XCTAssertEqual(item.style, .body)
|
|
XCTAssertEqual(item.text, "item")
|
|
XCTAssertEqual(q.children.count, 2, "still quoted")
|
|
}
|
|
}
|
|
#endif
|