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

320 lines
19 KiB
Swift

#if canImport(UIKit)
import XCTest
import UIKit
@testable import RichTextEditorUIKit
import RichTextEditorCore
final class CanvasSelectionMenuTests: XCTestCase {
func cell(_ id: String, _ t: String) -> Cell {
Cell(id: BlockID(id), blocks: [.paragraph(ParagraphBlock(id: BlockID(id + "p"), runs: [TextRun(text: t)]))])
}
/// [ paragraph "Hello world", table( "Alpha" | "Beta" ) ]
func canvas() -> DocumentCanvasView {
let v = DocumentCanvasView()
v.setBlocks([
.paragraph(ParagraphBlock(id: BlockID("h"), runs: [TextRun(text: "Hello world")])),
.table(TableBlock(id: BlockID("t"), columns: [ColumnSpec(width: 120), ColumnSpec(width: 120)],
rows: [Row(id: BlockID("r0"), cells: [cell("a", "Alpha"), cell("b", "Beta")])])),
], width: 320)
v.frame = CGRect(x: 0, y: 0, width: 320, height: 600); v.layoutIfNeeded()
return v
}
func region(_ v: DocumentCanvasView, _ id: String) -> LeafTextRegion {
v.allLeafRegions().first { $0.ref == .paragraph(BlockID(id)) }!
}
func test_tapOutcome_onCaret_togglesMenu() {
let v = canvas()
let r = region(v, "h"); v.setSelectionForTesting(anchor: r.globalStart + 3, head: r.globalStart + 3)
XCTAssertEqual(v.tapOutcome(forResolvedPosition: r.globalStart + 3, point: .zero), .toggleMenu)
}
func test_tapOutcome_elsewhere_movesCaret() {
let v = canvas()
let r = region(v, "h"); v.setSelectionForTesting(anchor: r.globalStart + 3, head: r.globalStart + 3)
XCTAssertEqual(v.tapOutcome(forResolvedPosition: r.globalStart + 6, point: .zero), .setCaret(r.globalStart + 6))
}
func test_tapOutcome_insideSelection_togglesMenu() {
let v = canvas()
let r = region(v, "h"); v.setSelectionForTesting(anchor: r.globalStart, head: r.globalStart + 5) // "Hello" selected
let rects = v.selectionRects(globalFrom: v.selFrom, globalTo: v.selTo)
let onSelection = CGPoint(x: rects[0].midX, y: rects[0].midY) // a tap ON the rendered selection
// A tap on the selection toggles the menu and KEEPS the selection (does not collapse).
XCTAssertEqual(v.tapOutcome(forResolvedPosition: r.globalStart + 2, point: onSelection), .toggleMenu)
}
func test_tapOutcome_outsideSelection_setsCaret() {
let v = canvas()
let r = region(v, "h"); v.setSelectionForTesting(anchor: r.globalStart, head: r.globalStart + 3) // "Hel" selected
let caret = v.caretRect(for: DocumentTextPosition(r.globalStart + 6)) // past "Hel", off the selection
// A tap OUTSIDE the selection collapses to a caret there.
XCTAssertEqual(v.tapOutcome(forResolvedPosition: r.globalStart + 6, point: CGPoint(x: caret.midX, y: caret.midY)),
.setCaret(r.globalStart + 6))
}
/// The reported bug: a tap in the EMPTY AREA adjacent to the selection (so `closestGlobalPosition` resolves
/// to a boundary offset INSIDE [selFrom, selTo]) but whose POINT is not on the rendered selection must
/// collapse the selection + move the caret — NOT toggle/keep the menu. The composer's compact layout makes
/// this the common "tap to deselect" gesture; the old offset-only check treated it as "inside".
func test_tapOutcome_emptyAreaResolvingInsideRange_setsCaret() {
let v = canvas()
let r = region(v, "h"); v.setSelectionForTesting(anchor: r.globalStart, head: r.globalStart + r.length) // whole line selected
let farOutside = CGPoint(x: 10_000, y: -10_000) // nowhere near any selection rect
XCTAssertEqual(v.tapOutcome(forResolvedPosition: r.globalStart + r.length, point: farOutside),
.setCaret(r.globalStart + r.length), "an empty-area tap must collapse the selection, not keep it")
}
/// Repro for the reported bug: tapping the MIDPOINT of the selection's highlight must NOT collapse it.
/// Exercises the full single-tap handler (closestGlobalPosition → tapOutcome → toggle/setCaret), not
/// just the pure tapOutcome. If this passes but the live app still collapses, the cause is the
/// framework calling our selectedTextRange setter (not our handler).
func test_performSingleTap_insideSelection_keepsSelection() {
let v = canvas()
let r = region(v, "h"); v.setSelectionForTesting(anchor: r.globalStart, head: r.globalStart + 5) // "Hello" selected
let rects = v.selectionRects(globalFrom: v.selFrom, globalTo: v.selTo)
XCTAssertFalse(rects.isEmpty, "selection should have a highlight rect")
let mid = CGPoint(x: rects[0].midX, y: rects[0].midY)
v.performSingleTap(at: mid)
XCTAssertEqual(v.selFrom, r.globalStart, "tap inside the selection must keep it (not collapse)")
XCTAssertEqual(v.selTo, r.globalStart + 5)
}
/// Tapping OUTSIDE an active selection must collapse it to a caret at the tapped position (not keep the
/// selection, not jump the caret elsewhere). Ground-truth for the reported "tap outside doesn't deselect".
func test_performSingleTap_outsideSelection_clearsAndMovesCaret() {
let v = canvas()
let r = region(v, "h"); v.setSelectionForTesting(anchor: r.globalStart, head: r.globalStart + 5) // "Hello" selected
let caret = v.caretRect(for: DocumentTextPosition(r.globalStart + 8)) // inside "world", outside the selection
v.performSingleTap(at: CGPoint(x: caret.midX, y: caret.midY))
XCTAssertEqual(v.selFrom, v.selTo, "tap outside the selection must collapse it")
XCTAssertEqual(v.head, r.globalStart + 8, "the caret moves to the tapped position")
}
/// Flicker fix: a toggle-tap presents only when the menu is neither showing nor just-dismissed. The
/// system auto-dismisses the menu on the tap (willDismiss → justDismissed) before our delayed handler
/// runs, so `(menuVisible:false, justDismissed:true)` MUST dismiss (not re-present = the flicker).
func test_menuToggleAction_suppressesRepresentRightAfterDismiss() {
let v = canvas()
XCTAssertEqual(v.menuToggleAction(menuVisible: false, justDismissed: false, wasFirstResponder: true), .present) // caret, no menu → show
XCTAssertEqual(v.menuToggleAction(menuVisible: true, justDismissed: false, wasFirstResponder: true), .dismiss) // menu up → hide
XCTAssertEqual(v.menuToggleAction(menuVisible: false, justDismissed: true, wasFirstResponder: true), .dismiss) // this tap closed it → don't reopen
XCTAssertEqual(v.menuToggleAction(menuVisible: true, justDismissed: true, wasFirstResponder: true), .dismiss)
}
/// Focus-tap fix: a tap that BRINGS the field into first-responder (`wasFirstResponder == false`) must only
/// place the caret — never open the menu — even when it lands on the (default) caret with no menu showing.
/// This is the empty-field bug: `head` defaults to 0, a focusing tap resolves to 0, `tapOutcome` → `.toggleMenu`,
/// and the menu was presented on the very first tap. A second tap on the caret (now first responder) still shows it.
func test_menuToggleAction_focusingTap_neverPresents() {
let v = canvas()
XCTAssertEqual(v.menuToggleAction(menuVisible: false, justDismissed: false, wasFirstResponder: false), .dismiss,
"a tap that focuses the field must not open the menu")
XCTAssertEqual(v.menuToggleAction(menuVisible: false, justDismissed: false, wasFirstResponder: true), .present,
"tapping the caret in an already-focused field still toggles the menu open")
}
func test_selectWord_atCaret_selectsWord() {
let v = canvas()
let r = region(v, "h"); v.selectWord(at: r.globalStart + 2) // inside "Hello"
XCTAssertEqual(v.selFrom, r.globalStart + 0)
XCTAssertEqual(v.selTo, r.globalStart + 5)
}
func test_selectWord_insideCell() {
let v = canvas()
let a = region(v, "ap")
v.selectWord(at: a.globalStart + 1) // inside "Alpha"
XCTAssertEqual(v.selFrom, a.globalStart)
XCTAssertEqual(v.selTo, a.globalStart + 5)
}
func test_selectParagraph_selectsWholeRegion() {
let v = canvas()
let r = region(v, "h"); v.selectParagraph(at: r.globalStart + 4)
XCTAssertEqual(v.selFrom, r.globalStart)
XCTAssertEqual(v.selTo, r.globalStart + r.length) // "Hello world"
}
// MARK: A gesture-driven RANGE selection must notify the host (the double-tap "flash then deselect" bug).
// `selectWord`/`selectParagraph`/`selectAllText` go through `applySelection`, which (unlike `setCaret`) did
// not fire `onSelectionChange`. The chat composer tracks the editor selection through that hook; without it
// a word selection never reaches the panel's interface state, and the next state re-apply (`setInputContent`)
// collapses the visible selection back to the stale caret. (No-op in the article editor — no such round-trip.)
func test_selectWord_firesOnSelectionChange() {
let v = canvas()
let r = region(v, "h")
var fired = false
v.onSelectionChange = { fired = true }
v.selectWord(at: r.globalStart + 2) // double-tap inside "Hello"
XCTAssertTrue(fired, "a gesture word-selection must notify the host so the composer tracks the range")
}
func test_selectParagraph_firesOnSelectionChange() {
let v = canvas()
let r = region(v, "h")
var fired = false
v.onSelectionChange = { fired = true }
v.selectParagraph(at: r.globalStart + 4) // triple-tap
XCTAssertTrue(fired, "a gesture paragraph-selection must notify the host")
}
func test_selectAllText_firesOnSelectionChange() {
let v = canvas()
var fired = false
v.onSelectionChange = { fired = true }
v.selectAllText() // Select All menu / Cmd+A
XCTAssertTrue(fired, "Select All must notify the host")
}
// MARK: Tap latency fix — manual multi-tap escalation (no `require(toFail:)` gate → instant caret).
// handleTap counts taps itself with an injected timestamp, so caret/word/paragraph is deterministic.
private func tapPoint(_ v: DocumentCanvasView, _ r: LeafTextRegion, offset: Int) -> CGPoint {
let caret = v.caretRect(for: DocumentTextPosition(r.globalStart + offset))
return CGPoint(x: caret.midX, y: caret.midY)
}
func test_handleTap_singleTap_placesCollapsedCaret() {
let v = canvas()
let r = region(v, "h")
v.handleTap(at: tapPoint(v, r, offset: 2), time: 100)
XCTAssertEqual(v.selFrom, v.selTo, "a single tap is a collapsed caret")
XCTAssertGreaterThanOrEqual(v.selFrom, r.globalStart)
XCTAssertLessThanOrEqual(v.selFrom, r.globalStart + r.length)
}
func test_handleTap_quickSecondTap_selectsWord() {
let v = canvas()
let r = region(v, "h")
let p = tapPoint(v, r, offset: 2)
v.handleTap(at: p, time: 100)
v.handleTap(at: p, time: 100.1) // within multiTapWindow, same point → escalate to word
XCTAssertEqual(v.selFrom, r.globalStart, "the quick second tap selects the word 'Hello'")
XCTAssertEqual(v.selTo, r.globalStart + 5)
}
func test_handleTap_slowSecondTap_isFreshCaretNotWord() {
let v = canvas()
let r = region(v, "h")
let p = tapPoint(v, r, offset: 2)
v.handleTap(at: p, time: 100)
v.handleTap(at: p, time: 100 + DocumentCanvasView.multiTapWindow + 0.5) // past the window
XCTAssertEqual(v.selFrom, v.selTo, "a tap past the multi-tap window is a fresh caret, not a word")
}
func test_handleTap_tripleTap_selectsParagraph() {
let v = canvas()
let r = region(v, "h")
let p = tapPoint(v, r, offset: 4)
v.handleTap(at: p, time: 100)
v.handleTap(at: p, time: 100.1)
v.handleTap(at: p, time: 100.2) // third quick tap → whole paragraph
XCTAssertEqual(v.selFrom, r.globalStart)
XCTAssertEqual(v.selTo, r.globalStart + r.length)
}
// MARK: Double-tap still selects a word even when the follow-up tap fires the near-cursor loupe.
// The proximity-adaptive loupe delay is tiny (loupeDelayNearCursor) right on the just-placed caret, so the
// 2nd tap of a double-tap fires the long-press instead of completing as a tap. `handleLoupeBegan` detects
// the rapid repeat and escalates to a word/paragraph selection + menu (returning true so the caller
// suppresses the loupe cursor-drag), reusing the same window/slop/tapCount state as `handleTap`.
func test_loupeBegan_asDoubleTap_selectsWord_andReportsConsumed() {
let v = canvas()
_ = v.becomeFirstResponder()
let r = region(v, "h")
let p = tapPoint(v, r, offset: 2) // inside "Hello"
v.handleTap(at: p, time: 100) // 1st tap → caret (stamps multi-tap state)
let consumed = v.handleLoupeBegan(at: p, time: 100.1) // 2nd "tap" arrives as a loupe .began
XCTAssertTrue(consumed, "the repeat tap is consumed as a multi-tap so the loupe cursor-drag is suppressed")
XCTAssertEqual(v.selFrom, r.globalStart, "double-tap selects the word 'Hello'")
XCTAssertEqual(v.selTo, r.globalStart + 5)
}
func test_loupeBegan_thirdRepeat_selectsParagraph() {
let v = canvas()
_ = v.becomeFirstResponder()
let r = region(v, "h")
let p = tapPoint(v, r, offset: 4)
v.handleTap(at: p, time: 100) // caret
XCTAssertTrue(v.handleLoupeBegan(at: p, time: 100.1)) // → word
XCTAssertTrue(v.handleLoupeBegan(at: p, time: 100.2)) // → whole paragraph
XCTAssertEqual(v.selFrom, r.globalStart)
XCTAssertEqual(v.selTo, r.globalStart + r.length)
}
func test_loupeBegan_lonePress_isNotConsumed() {
// No recent tap ⇒ a genuine long-press ⇒ NOT a multi-tap ⇒ run the normal loupe (returns false).
let v = canvas()
_ = v.becomeFirstResponder()
let r = region(v, "h")
v.setCaret(global: r.globalStart) // caret far from the press point
let consumed = v.handleLoupeBegan(at: tapPoint(v, r, offset: 9), time: 500)
XCTAssertFalse(consumed, "a lone long-press is a normal loupe, not a double-tap")
}
func test_loupeBegan_slowSecondPress_isNotConsumed() {
let v = canvas()
_ = v.becomeFirstResponder()
let r = region(v, "h")
let p = tapPoint(v, r, offset: 2)
v.handleTap(at: p, time: 100)
let consumed = v.handleLoupeBegan(at: p, time: 100 + DocumentCanvasView.multiTapWindow + 0.5)
XCTAssertFalse(consumed, "past the multi-tap window a press is a normal loupe, not a double-tap")
}
func test_selectAllText_selectsRenderableBounds() {
let v = canvas()
v.selectAllText()
XCTAssertEqual(v.selFrom, (v.beginningOfDocument as! DocumentTextPosition).offset)
XCTAssertEqual(v.selTo, (v.endOfDocument as! DocumentTextPosition).offset)
XCTAssertLessThan(v.selFrom, v.selTo)
}
func test_selectWordParagraph_atImageGap_isNoOp() {
let v = DocumentCanvasView()
v.setBlocks([
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "Hi")])),
.media(MediaBlock(id: BlockID("img"), mediaID: "x", naturalSize: Size2D(width: 40, height: 40))),
], width: 320)
v.frame = CGRect(x: 0, y: 0, width: 320, height: 600); v.layoutIfNeeded()
let gap = v.boxes.first { $0 is MediaBlockBox }!.nodeStart
v.setSelectionForTesting(anchor: gap, head: gap)
v.selectWord(at: gap)
XCTAssertEqual(v.selFrom, gap); XCTAssertEqual(v.selTo, gap) // no word at a structural gap → unchanged
v.selectParagraph(at: gap)
XCTAssertEqual(v.selFrom, gap); XCTAssertEqual(v.selTo, gap)
}
func test_canPerformAction_select_onlyWhenCollapsedWithText() {
let v = canvas()
let r = region(v, "h"); v.setSelectionForTesting(anchor: r.globalStart + 2, head: r.globalStart + 2) // collapsed
XCTAssertTrue(v.canPerformAction(#selector(UIResponderStandardEditActions.select(_:)), withSender: nil))
v.setSelectionForTesting(anchor: r.globalStart, head: r.globalStart + 5) // selection
XCTAssertFalse(v.canPerformAction(#selector(UIResponderStandardEditActions.select(_:)), withSender: nil))
}
func test_canPerformAction_select_disabledOnImageGap() {
let v = DocumentCanvasView()
v.setBlocks([
.paragraph(ParagraphBlock(id: BlockID("p"), runs: [TextRun(text: "Hi")])),
.media(MediaBlock(id: BlockID("img"), mediaID: "x", naturalSize: Size2D(width: 40, height: 40))),
], width: 320)
v.frame = CGRect(x: 0, y: 0, width: 320, height: 600); v.layoutIfNeeded()
let gap = v.boxes.first { $0 is MediaBlockBox }!.nodeStart
v.setSelectionForTesting(anchor: gap, head: gap)
XCTAssertFalse(v.canPerformAction(#selector(UIResponderStandardEditActions.select(_:)), withSender: nil),
"Select is meaningless at an image gap (no word)")
}
func test_canPerformAction_selectAll_whenNotAllSelected() {
let v = canvas()
let r = region(v, "h"); v.setSelectionForTesting(anchor: r.globalStart, head: r.globalStart + 2)
XCTAssertTrue(v.canPerformAction(#selector(UIResponderStandardEditActions.selectAll(_:)), withSender: nil))
v.selectAllText()
XCTAssertFalse(v.canPerformAction(#selector(UIResponderStandardEditActions.selectAll(_:)), withSender: nil))
}
func test_selectAction_selectsWordAtCaret() {
let v = canvas()
let r = region(v, "h"); v.setSelectionForTesting(anchor: r.globalStart + 8, head: r.globalStart + 8) // inside "world"
v.select(nil)
XCTAssertEqual(v.selFrom, r.globalStart + 6) // "world" starts after "Hello "
XCTAssertEqual(v.selTo, r.globalStart + 11)
}
func test_selectAllText_emptyDocument_doesNotCrashAndCollapses() {
let v = DocumentCanvasView()
v.setBlocks([], width: 320)
v.frame = CGRect(x: 0, y: 0, width: 320, height: 600); v.layoutIfNeeded()
v.selectAllText()
XCTAssertEqual(v.selFrom, v.selTo) // nothing renderable to select
}
func test_nearerSelectionEndpoint_picksByDistance() {
let v = canvas()
let r = region(v, "h"); v.setSelectionForTesting(anchor: r.globalStart + 2, head: r.globalStart + 8)
XCTAssertEqual(v.nearerSelectionEndpoint(toGlobal: r.globalStart + 7), .head)
XCTAssertEqual(v.nearerSelectionEndpoint(toGlobal: r.globalStart + 3), .anchor)
}
func test_nearerSelectionEndpoint_nilWhenCollapsed() {
let v = canvas()
let r = region(v, "h"); v.setSelectionForTesting(anchor: r.globalStart + 4, head: r.globalStart + 4)
XCTAssertNil(v.nearerSelectionEndpoint(toGlobal: r.globalStart + 4))
}
}
#endif