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>
320 lines
19 KiB
Swift
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
|