* Fix voice dictation text insertion path in GhosttyNSView. * Fix AX selected text decoding to use explicit length
933 lines
39 KiB
Swift
933 lines
39 KiB
Swift
import XCTest
|
|
import AppKit
|
|
|
|
#if canImport(cmux_DEV)
|
|
@testable import cmux_DEV
|
|
#elseif canImport(cmux)
|
|
@testable import cmux
|
|
#endif
|
|
|
|
// MARK: - NSTextInputClient protocol: marked text (preedit) lifecycle
|
|
|
|
/// Tests that the GhosttyNSView NSTextInputClient implementation correctly
|
|
/// manages marked text state for CJK IME composition (Korean jamo combining,
|
|
/// Chinese pinyin candidate selection, Japanese hiragana-to-kanji conversion).
|
|
final class CJKIMEMarkedTextTests: XCTestCase {
|
|
|
|
// MARK: - Korean (한글) jamo combining
|
|
|
|
/// Korean IME sends partial jamo as marked text, then replaces/commits.
|
|
/// e.g. ㅎ -> 하 -> 한 as the user types consonants and vowels.
|
|
func testKoreanJamoCombiningSetMarkedTextCreatesMarkedState() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
XCTAssertFalse(view.hasMarkedText(), "Should start with no marked text")
|
|
|
|
// First jamo: ㅎ (hieut)
|
|
view.setMarkedText("ㅎ", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText(), "Should have marked text after first jamo")
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: 0, length: 1))
|
|
|
|
// Combined syllable: 하 (ha)
|
|
view.setMarkedText("하", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText(), "Should still have marked text during composition")
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: 0, length: 1))
|
|
|
|
// Further combined: 한 (han)
|
|
view.setMarkedText("한", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: 0, length: 1))
|
|
}
|
|
|
|
/// When insertText is called during a keyDown (accumulator active), the
|
|
/// committed text should be accumulated and marked text cleared.
|
|
func testKoreanInsertTextCommitsAndClearsMarkedText() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Simulate composition in progress
|
|
view.setMarkedText("한", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// insertText clears marked text via unmarkText even when currentEvent is nil.
|
|
// The guard on currentEvent causes an early return, but we can verify the
|
|
// marked text management through the accumulator path.
|
|
//
|
|
// Simulate the keyDown-time accumulator flow: set accumulator, call insertText
|
|
// with a real event context, verify accumulation.
|
|
view.setKeyTextAccumulatorForTesting([])
|
|
|
|
// Directly test unmarkText + accumulator (the core of insertText's behavior)
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText(), "unmarkText should clear marked text (as insertText does)")
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: NSNotFound, length: 0))
|
|
|
|
// Verify the accumulator would receive the text
|
|
var acc = view.keyTextAccumulatorForTesting ?? []
|
|
acc.append("한")
|
|
view.setKeyTextAccumulatorForTesting(acc)
|
|
XCTAssertEqual(view.keyTextAccumulatorForTesting, ["한"], "Committed Korean text should be accumulated")
|
|
view.setKeyTextAccumulatorForTesting(nil)
|
|
}
|
|
|
|
/// Third-party voice input apps often commit text outside an active keyDown
|
|
/// event. `insertText` should still clear marked text in that path.
|
|
func testInsertTextWithoutCurrentEventClearsMarkedText() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
view.setMarkedText("한", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
view.insertText("한", replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertFalse(view.hasMarkedText(), "insertText should clear marked text even without an active currentEvent")
|
|
}
|
|
|
|
/// The responder-chain `insertText:` action (single argument) should route
|
|
/// to NSTextInputClient insertion so external text-injection tools work.
|
|
func testResponderChainInsertTextSelectorClearsMarkedText() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
view.setMarkedText("ni", selectedRange: NSRange(location: 2, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
view.insertText("你")
|
|
XCTAssertFalse(view.hasMarkedText(), "single-argument insertText should follow the same commit path")
|
|
}
|
|
|
|
// MARK: - Chinese (中文) pinyin candidate selection
|
|
|
|
/// Chinese pinyin IME types Roman letters as marked text, then the user
|
|
/// selects a character from a candidate list which triggers insertText.
|
|
func testChinesePinyinMarkedTextDuringTyping() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// User types "n" -> marked text shows "n"
|
|
view.setMarkedText("n", selectedRange: NSRange(location: 1, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: 0, length: 1))
|
|
|
|
// User types "i" -> marked text shows "ni"
|
|
view.setMarkedText("ni", selectedRange: NSRange(location: 2, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: 0, length: 2))
|
|
|
|
// User types "h" -> marked text shows "nih"
|
|
view.setMarkedText("nih", selectedRange: NSRange(location: 3, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: 0, length: 3))
|
|
|
|
// User types "a" -> marked text shows "niha" with potential candidates
|
|
view.setMarkedText("niha", selectedRange: NSRange(location: 4, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: 0, length: 4))
|
|
|
|
// User types "o" -> marked text shows "nihao"
|
|
view.setMarkedText("nihao", selectedRange: NSRange(location: 5, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
}
|
|
|
|
func testChinesePinyinCandidateSelectionClearsMarkedText() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Pinyin composition "nihao" in progress
|
|
view.setMarkedText("nihao", selectedRange: NSRange(location: 5, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// Simulate: user selects candidate 你好 from the list.
|
|
// insertText calls unmarkText internally; verify that path.
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText(), "Marked text should be cleared after candidate selection")
|
|
}
|
|
|
|
// MARK: - Japanese (日本語) hiragana-to-kanji conversion
|
|
|
|
/// Japanese IME first shows hiragana as marked text, then converts to kanji
|
|
/// candidates. The user confirms to commit via insertText.
|
|
func testJapaneseHiraganaComposition() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// User types "ni" -> hiragana に
|
|
view.setMarkedText("に", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// User types "ho" -> hiragana にほ
|
|
view.setMarkedText("にほ", selectedRange: NSRange(location: 0, length: 2), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: 0, length: 2))
|
|
|
|
// User types "n" -> hiragana にほん
|
|
view.setMarkedText("にほん", selectedRange: NSRange(location: 0, length: 3), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// User types "go" -> hiragana にほんご
|
|
view.setMarkedText("にほんご", selectedRange: NSRange(location: 0, length: 4), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: 0, length: 4))
|
|
}
|
|
|
|
func testJapaneseKanjiConversionKeepsMarkedTextUntilCommit() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Hiragana にほんご in composition
|
|
view.setMarkedText("にほんご", selectedRange: NSRange(location: 0, length: 4), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// Space bar triggers conversion, showing kanji candidate 日本語
|
|
// (this is still marked text, just converted)
|
|
view.setMarkedText("日本語", selectedRange: NSRange(location: 0, length: 3), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText(), "Kanji candidates should still be marked text")
|
|
|
|
// User confirms the kanji selection (Enter or number key) -> unmarkText
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText(), "Marked text should be cleared after kanji confirmation")
|
|
}
|
|
|
|
// MARK: - unmarkText clears composition state
|
|
|
|
func testUnmarkTextClearsCompositionState() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Set up marked text (any CJK language)
|
|
view.setMarkedText("ㅎ", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText(), "unmarkText should clear marked text")
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: NSNotFound, length: 0),
|
|
"markedRange should return NSNotFound after unmarkText")
|
|
}
|
|
|
|
func testUnmarkTextIsIdempotent() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Call unmarkText when there's no marked text -- should be a no-op
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
|
|
// Call again -- still no-op
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
}
|
|
|
|
// MARK: - Attributed string variant
|
|
|
|
func testSetMarkedTextAcceptsAttributedString() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
let attrStr = NSAttributedString(string: "漢字", attributes: [.font: NSFont.systemFont(ofSize: 14)])
|
|
view.setMarkedText(attrStr, selectedRange: NSRange(location: 0, length: 2), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: 0, length: 2))
|
|
}
|
|
|
|
func testInsertTextWithAttributedStringClearsMarkedText() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
view.setMarkedText("test", selectedRange: NSRange(location: 0, length: 4), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// insertText internally calls unmarkText; verify that path
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
}
|
|
|
|
// MARK: - selectedRange / validAttributesForMarkedText
|
|
|
|
func testSelectedRangeReturnsNotFound() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
let range = view.selectedRange()
|
|
XCTAssertEqual(range.location, NSNotFound)
|
|
}
|
|
|
|
func testValidAttributesForMarkedTextReturnsEmpty() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
XCTAssertTrue(view.validAttributesForMarkedText().isEmpty)
|
|
}
|
|
}
|
|
|
|
// MARK: - performKeyEquivalent bypasses during IME composition
|
|
|
|
/// Tests that performKeyEquivalent does not intercept key events when the
|
|
/// terminal view has active CJK IME composition (marked text). Without this,
|
|
/// CJK IME input would be broken because key events would be consumed by
|
|
/// shortcut handling instead of flowing through to the input method.
|
|
final class CJKIMEPerformKeyEquivalentTests: XCTestCase {
|
|
|
|
func testPerformKeyEquivalentReturnsFalseDuringIMEComposition() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Simulate active IME composition
|
|
view.setMarkedText("ㅎ", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// Create a key event (unmodified 'a' key -- typical during Korean typing)
|
|
guard let event = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: 0,
|
|
context: nil,
|
|
characters: "a",
|
|
charactersIgnoringModifiers: "a",
|
|
isARepeat: false,
|
|
keyCode: 0 // kVK_ANSI_A
|
|
) else {
|
|
XCTFail("Failed to create key event")
|
|
return
|
|
}
|
|
|
|
// performKeyEquivalent should return false to let the event flow to keyDown/IME
|
|
let consumed = view.performKeyEquivalent(with: event)
|
|
XCTAssertFalse(consumed, "performKeyEquivalent must not consume events during CJK IME composition")
|
|
}
|
|
|
|
func testPerformKeyEquivalentReturnsFalseForModifiedKeyDuringIMEComposition() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Simulate active Japanese composition
|
|
view.setMarkedText("にほん", selectedRange: NSRange(location: 0, length: 3), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// Shift key during composition (e.g., to type katakana in some IMEs)
|
|
guard let event = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: [.shift],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: 0,
|
|
context: nil,
|
|
characters: "A",
|
|
charactersIgnoringModifiers: "a",
|
|
isARepeat: false,
|
|
keyCode: 0
|
|
) else {
|
|
XCTFail("Failed to create key event")
|
|
return
|
|
}
|
|
|
|
let consumed = view.performKeyEquivalent(with: event)
|
|
XCTAssertFalse(consumed, "performKeyEquivalent must not consume shift+key during CJK IME composition")
|
|
}
|
|
|
|
func testPerformKeyEquivalentReturnsFalseForSpaceDuringIMEComposition() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Space bar is used to trigger kanji conversion in Japanese IME
|
|
view.setMarkedText("にほんご", selectedRange: NSRange(location: 0, length: 4), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
guard let event = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: 0,
|
|
context: nil,
|
|
characters: " ",
|
|
charactersIgnoringModifiers: " ",
|
|
isARepeat: false,
|
|
keyCode: 49 // kVK_Space
|
|
) else {
|
|
XCTFail("Failed to create key event")
|
|
return
|
|
}
|
|
|
|
let consumed = view.performKeyEquivalent(with: event)
|
|
XCTAssertFalse(consumed, "performKeyEquivalent must not consume space during CJK IME composition (needed for kanji conversion)")
|
|
}
|
|
|
|
func testPerformKeyEquivalentReturnsFalseForReturnDuringComposition() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Active Japanese kanji conversion
|
|
view.setMarkedText("日本語", selectedRange: NSRange(location: 0, length: 3), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
guard let event = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: 0,
|
|
context: nil,
|
|
characters: "\r",
|
|
charactersIgnoringModifiers: "\r",
|
|
isARepeat: false,
|
|
keyCode: 36 // kVK_Return
|
|
) else {
|
|
XCTFail("Failed to create return event")
|
|
return
|
|
}
|
|
|
|
let consumed = view.performKeyEquivalent(with: event)
|
|
XCTAssertFalse(consumed, "Return during CJK IME composition must not be consumed (needed for candidate confirmation)")
|
|
}
|
|
|
|
func testPerformKeyEquivalentReturnsFalseForEscapeDuringComposition() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Active Chinese pinyin composition
|
|
view.setMarkedText("nihao", selectedRange: NSRange(location: 5, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
guard let event = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: 0,
|
|
context: nil,
|
|
characters: "\u{1B}",
|
|
charactersIgnoringModifiers: "\u{1B}",
|
|
isARepeat: false,
|
|
keyCode: 53 // kVK_Escape
|
|
) else {
|
|
XCTFail("Failed to create escape event")
|
|
return
|
|
}
|
|
|
|
let consumed = view.performKeyEquivalent(with: event)
|
|
XCTAssertFalse(consumed, "Escape during CJK IME composition must not be consumed (needed for composition cancel)")
|
|
}
|
|
|
|
/// Regression: after IME composition is complete, performKeyEquivalent
|
|
/// should resume normal behavior (no longer bypass).
|
|
func testPerformKeyEquivalentResumesAfterCompositionEnds() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Start composition
|
|
view.setMarkedText("ㅎ", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// End composition
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
|
|
// Now performKeyEquivalent should process events normally again.
|
|
// Without a surface it returns false, but the point is that it does
|
|
// NOT return false at the hasMarkedText() guard — it proceeds further.
|
|
// We verify that hasMarkedText is false so the guard doesn't trigger.
|
|
guard let event = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: 0,
|
|
context: nil,
|
|
characters: "a",
|
|
charactersIgnoringModifiers: "a",
|
|
isARepeat: false,
|
|
keyCode: 0
|
|
) else {
|
|
XCTFail("Failed to create key event")
|
|
return
|
|
}
|
|
|
|
// The view has no window/surface, so it returns false at the
|
|
// firstResponder or surface check, but importantly NOT at the
|
|
// hasMarkedText guard.
|
|
let consumed = view.performKeyEquivalent(with: event)
|
|
XCTAssertFalse(consumed)
|
|
XCTAssertFalse(view.hasMarkedText(), "Composition ended; hasMarkedText should be false")
|
|
}
|
|
}
|
|
|
|
// MARK: - Shortcut handler IME bypass precondition
|
|
|
|
/// Tests the precondition that the app-level shortcut handler (local event monitor)
|
|
/// checks: GhosttyNSView.hasMarkedText() must accurately reflect IME composition state.
|
|
/// The monitor uses this to bail out during active CJK composition.
|
|
final class CJKIMEShortcutBypassTests: XCTestCase {
|
|
|
|
func testHasMarkedTextTracksCJKCompositionLifecycle() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// No marked text -- shortcuts should be eligible to fire
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
|
|
// Active Korean composition -- shortcuts must be bypassed
|
|
view.setMarkedText("한", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText(), "hasMarkedText must return true during composition to enable shortcut bypass")
|
|
|
|
// After unmarkText (commit or cancel) -- shortcuts should be eligible again
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText(), "hasMarkedText must return false after commit to re-enable shortcuts")
|
|
}
|
|
|
|
func testHasMarkedTextTransitionsThroughChineseComposition() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
|
|
// Pinyin letters as marked text
|
|
view.setMarkedText("zhong", selectedRange: NSRange(location: 5, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// Candidate selection commits -> unmarkText
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
}
|
|
|
|
func testHasMarkedTextTransitionsThroughJapaneseComposition() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
|
|
// Hiragana composition
|
|
view.setMarkedText("とうきょう", selectedRange: NSRange(location: 0, length: 5), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// Kanji conversion (still marked)
|
|
view.setMarkedText("東京", selectedRange: NSRange(location: 0, length: 2), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// Confirm -> unmarkText
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
}
|
|
}
|
|
|
|
// MARK: - Multi-character composition sequences
|
|
|
|
/// Tests more complex IME scenarios involving multiple composition steps.
|
|
final class CJKIMECompositionSequenceTests: XCTestCase {
|
|
|
|
/// Korean: type multiple syllable blocks, each going through
|
|
/// composition -> commit -> next block.
|
|
func testKoreanMultiSyllableSequence() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// First syllable: 안 (an)
|
|
view.setMarkedText("ㅇ", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
view.setMarkedText("아", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
view.setMarkedText("안", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// When next syllable starts, current syllable is committed via unmarkText
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
|
|
// Second syllable: 녕 (nyeong)
|
|
view.setMarkedText("ㄴ", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
view.setMarkedText("녀", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
view.setMarkedText("녕", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
}
|
|
|
|
/// Japanese: romaji -> hiragana composition -> kanji conversion -> commit.
|
|
func testJapaneseRomajiToKanjiFullSequence() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// 1. Romaji input "t" -> still composing
|
|
view.setMarkedText("t", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// 2. Romaji input "to" -> hiragana と
|
|
view.setMarkedText("と", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// 3. Continue "kyo" -> ときょ
|
|
view.setMarkedText("とk", selectedRange: NSRange(location: 0, length: 2), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
view.setMarkedText("ときょ", selectedRange: NSRange(location: 0, length: 3), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// 4. Complete: とうきょう (Tokyo in hiragana)
|
|
view.setMarkedText("とうきょう", selectedRange: NSRange(location: 0, length: 5), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// 5. Space triggers kanji conversion -> 東京
|
|
view.setMarkedText("東京", selectedRange: NSRange(location: 0, length: 2), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText(), "Kanji candidates are still marked text")
|
|
|
|
// 6. Enter confirms -> unmarkText (insertText calls this internally)
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
}
|
|
|
|
/// Chinese: partial pinyin with backspace to correct.
|
|
func testChinesePinyinWithCorrection() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Type "zho" (partial for 中)
|
|
view.setMarkedText("z", selectedRange: NSRange(location: 1, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
view.setMarkedText("zh", selectedRange: NSRange(location: 2, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
view.setMarkedText("zho", selectedRange: NSRange(location: 3, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// Backspace corrects to "zh"
|
|
view.setMarkedText("zh", selectedRange: NSRange(location: 2, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText(), "Backspace during composition should keep marked text")
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: 0, length: 2))
|
|
|
|
// Re-type correctly "zhong"
|
|
view.setMarkedText("zhong", selectedRange: NSRange(location: 5, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// Select candidate 中 -> commit
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
}
|
|
|
|
/// Canceling composition via Escape: unmarkText should be called.
|
|
func testCancelCompositionClearsMarkedText() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Start composition
|
|
view.setMarkedText("ㅎ", selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// Cancel via Escape (IME calls unmarkText or setMarkedText with empty string)
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
XCTAssertEqual(view.markedRange(), NSRange(location: NSNotFound, length: 0))
|
|
}
|
|
|
|
/// Verify that canceling composition via setMarkedText with empty string works.
|
|
/// Some IMEs cancel composition this way instead of calling unmarkText.
|
|
func testCancelCompositionViaEmptySetMarkedText() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Start composition
|
|
view.setMarkedText("にほん", selectedRange: NSRange(location: 0, length: 3), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
|
|
// Cancel by setting empty marked text
|
|
view.setMarkedText("", selectedRange: NSRange(location: 0, length: 0), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertFalse(view.hasMarkedText(), "Empty setMarkedText should clear composition state")
|
|
}
|
|
|
|
/// Verify rapid composition transitions (e.g., switching between IMEs
|
|
/// or quickly typing multiple characters).
|
|
func testRapidCompositionTransitions() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Rapidly cycle: compose -> commit -> compose -> commit
|
|
for char in ["ㅎ", "하", "한"] {
|
|
view.setMarkedText(char, selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
}
|
|
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
|
|
for char in ["ㄱ", "구", "글"] {
|
|
view.setMarkedText(char, selectedRange: NSRange(location: 0, length: 1), replacementRange: NSRange(location: NSNotFound, length: 0))
|
|
XCTAssertTrue(view.hasMarkedText())
|
|
}
|
|
|
|
view.unmarkText()
|
|
XCTAssertFalse(view.hasMarkedText())
|
|
}
|
|
}
|
|
|
|
// MARK: - IME firstRect placement and sizing
|
|
|
|
/// Regression tests for IME candidate/preedit anchor rectangle reporting.
|
|
/// If width/height are discarded here, macOS can place preedit UI incorrectly.
|
|
final class CJKIMEFirstRectTests: XCTestCase {
|
|
|
|
func testFirstRectUsesIMEProvidedWidthAndHeight() {
|
|
let frame = NSRect(x: 0, y: 0, width: 800, height: 600)
|
|
let view = GhosttyNSView(frame: frame)
|
|
view.cellSize = CGSize(width: 10, height: 20)
|
|
view.setIMEPointForTesting(x: 120, y: 240, width: 64, height: 26)
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 100, y: 100, width: 800, height: 600),
|
|
styleMask: [.titled],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
let content = NSView(frame: frame)
|
|
window.contentView = content
|
|
content.addSubview(view)
|
|
view.frame = frame
|
|
|
|
defer {
|
|
view.clearIMEPointForTesting()
|
|
window.orderOut(nil)
|
|
}
|
|
|
|
let rect = view.firstRect(forCharacterRange: NSRange(location: 0, length: 1), actualRange: nil)
|
|
|
|
let expectedViewRect = NSRect(x: 120, y: frame.height - 240, width: 64, height: 26)
|
|
let expectedScreenRect = window.convertToScreen(view.convert(expectedViewRect, to: nil))
|
|
|
|
XCTAssertEqual(rect.origin.x, expectedScreenRect.origin.x, accuracy: 0.001)
|
|
XCTAssertEqual(rect.origin.y, expectedScreenRect.origin.y, accuracy: 0.001)
|
|
XCTAssertEqual(rect.width, 64, accuracy: 0.001)
|
|
XCTAssertEqual(rect.height, 26, accuracy: 0.001)
|
|
}
|
|
|
|
func testFirstRectFallsBackToCellHeightWhenIMEHeightIsZero() {
|
|
let frame = NSRect(x: 0, y: 0, width: 640, height: 480)
|
|
let view = GhosttyNSView(frame: frame)
|
|
view.cellSize = CGSize(width: 9, height: 18)
|
|
view.setIMEPointForTesting(x: 80, y: 120, width: 36, height: 0)
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 40, y: 40, width: 640, height: 480),
|
|
styleMask: [.titled],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
let content = NSView(frame: frame)
|
|
window.contentView = content
|
|
content.addSubview(view)
|
|
view.frame = frame
|
|
|
|
defer {
|
|
view.clearIMEPointForTesting()
|
|
window.orderOut(nil)
|
|
}
|
|
|
|
let rect = view.firstRect(forCharacterRange: NSRange(location: 0, length: 1), actualRange: nil)
|
|
XCTAssertEqual(rect.width, 36, accuracy: 0.001)
|
|
XCTAssertEqual(rect.height, 18, accuracy: 0.001)
|
|
}
|
|
}
|
|
|
|
// MARK: - Key text accumulator during CJK IME composition
|
|
|
|
/// Tests that the keyTextAccumulator correctly manages text during the keyDown
|
|
/// event flow, which is critical for CJK IME composition to work.
|
|
final class CJKIMEKeyTextAccumulatorTests: XCTestCase {
|
|
|
|
func testAccumulatorStartsNil() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
XCTAssertNil(view.keyTextAccumulatorForTesting)
|
|
}
|
|
|
|
func testAccumulatorCanBeSetAndRead() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
view.setKeyTextAccumulatorForTesting([])
|
|
XCTAssertEqual(view.keyTextAccumulatorForTesting, [])
|
|
|
|
view.setKeyTextAccumulatorForTesting(["한"])
|
|
XCTAssertEqual(view.keyTextAccumulatorForTesting, ["한"])
|
|
|
|
view.setKeyTextAccumulatorForTesting(nil)
|
|
XCTAssertNil(view.keyTextAccumulatorForTesting)
|
|
}
|
|
|
|
func testAccumulatorCollectsMultipleIMECommits() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
// Simulate a keyDown event that triggers multiple insertText calls
|
|
// (can happen with some IME behaviors)
|
|
view.setKeyTextAccumulatorForTesting([])
|
|
|
|
var acc = view.keyTextAccumulatorForTesting!
|
|
acc.append("你")
|
|
acc.append("好")
|
|
view.setKeyTextAccumulatorForTesting(acc)
|
|
|
|
XCTAssertEqual(view.keyTextAccumulatorForTesting, ["你", "好"])
|
|
view.setKeyTextAccumulatorForTesting(nil)
|
|
}
|
|
|
|
/// When the accumulator is nil (not in keyDown), insertText should not
|
|
/// try to accumulate. This is the "direct send" path for IME events
|
|
/// that arrive outside of keyDown processing.
|
|
func testAccumulatorNilMeansDirectSendPath() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
|
|
view.setKeyTextAccumulatorForTesting(nil)
|
|
// insertText with nil accumulator and no surface/currentEvent is a no-op,
|
|
// but the important thing is that it doesn't crash or accumulate.
|
|
XCTAssertNil(view.keyTextAccumulatorForTesting)
|
|
}
|
|
}
|
|
|
|
// MARK: - Shift+Space fallback suppression (IME source-switch shortcut)
|
|
|
|
final class CJKIMEShiftSpaceFallbackTests: XCTestCase {
|
|
func testSuppressesShiftSpaceFallbackWhenNoMarkedTextAndNoIMECommit() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
guard let event = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: [.shift],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: 0,
|
|
context: nil,
|
|
characters: " ",
|
|
charactersIgnoringModifiers: " ",
|
|
isARepeat: false,
|
|
keyCode: 49
|
|
) else {
|
|
XCTFail("Failed to create Shift+Space event")
|
|
return
|
|
}
|
|
|
|
XCTAssertTrue(
|
|
view.shouldSuppressShiftSpaceFallbackTextForTesting(event: event, markedTextBefore: false),
|
|
"Shift+Space should suppress synthesized space fallback when IME did not commit text"
|
|
)
|
|
}
|
|
|
|
func testDoesNotSuppressRegularSpaceFallback() {
|
|
let view = GhosttyNSView(frame: .zero)
|
|
guard let event = NSEvent.keyEvent(
|
|
with: .keyDown,
|
|
location: .zero,
|
|
modifierFlags: [],
|
|
timestamp: ProcessInfo.processInfo.systemUptime,
|
|
windowNumber: 0,
|
|
context: nil,
|
|
characters: " ",
|
|
charactersIgnoringModifiers: " ",
|
|
isARepeat: false,
|
|
keyCode: 49
|
|
) else {
|
|
XCTFail("Failed to create Space event")
|
|
return
|
|
}
|
|
|
|
XCTAssertFalse(
|
|
view.shouldSuppressShiftSpaceFallbackTextForTesting(event: event, markedTextBefore: false),
|
|
"Only Shift+Space should be suppressed"
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Space release regression (Codex hold-to-talk in cmux)
|
|
|
|
@MainActor
|
|
final class GhosttySpaceReleaseRegressionTests: XCTestCase {
|
|
func testSyntheticSpaceReleaseCarriesUnshiftedCodepoint() {
|
|
_ = NSApplication.shared
|
|
|
|
let surface = TerminalSurface(
|
|
tabId: UUID(),
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: nil,
|
|
workingDirectory: nil
|
|
)
|
|
let hostedView = surface.hostedView
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer {
|
|
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil
|
|
window.orderOut(nil)
|
|
}
|
|
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
hostedView.frame = contentView.bounds
|
|
hostedView.autoresizingMask = [.width, .height]
|
|
contentView.addSubview(hostedView)
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
window.displayIfNeeded()
|
|
contentView.layoutSubtreeIfNeeded()
|
|
hostedView.setVisibleInUI(true)
|
|
hostedView.setActive(true)
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
|
|
var releaseEvent: ghostty_input_key_s?
|
|
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in
|
|
if keyEvent.action == GHOSTTY_ACTION_RELEASE, keyEvent.keycode == 49 {
|
|
releaseEvent = keyEvent
|
|
}
|
|
}
|
|
|
|
let sent = hostedView.debugSendSyntheticKeyPressAndReleaseForUITest(
|
|
characters: " ",
|
|
charactersIgnoringModifiers: " ",
|
|
keyCode: 49
|
|
)
|
|
XCTAssertTrue(sent, "Expected synthetic Space key press/release to be dispatched")
|
|
|
|
guard let releaseEvent else {
|
|
XCTFail("Expected to capture synthetic Space key release event")
|
|
return
|
|
}
|
|
|
|
XCTAssertEqual(releaseEvent.action, GHOSTTY_ACTION_RELEASE)
|
|
XCTAssertEqual(releaseEvent.keycode, 49)
|
|
XCTAssertEqual(releaseEvent.unshifted_codepoint, " ".unicodeScalars.first!.value)
|
|
XCTAssertEqual(releaseEvent.consumed_mods.rawValue, GHOSTTY_MODS_NONE.rawValue)
|
|
XCTAssertFalse(releaseEvent.composing)
|
|
XCTAssertNil(releaseEvent.text)
|
|
}
|
|
}
|
|
|
|
final class GhosttyBackquoteRegressionTests: XCTestCase {
|
|
func testShiftBackquoteEscFallbackSendsLiteralTilde() {
|
|
_ = NSApplication.shared
|
|
|
|
let surface = TerminalSurface(
|
|
tabId: UUID(),
|
|
context: GHOSTTY_SURFACE_CONTEXT_SPLIT,
|
|
configTemplate: nil,
|
|
workingDirectory: nil
|
|
)
|
|
let hostedView = surface.hostedView
|
|
|
|
let window = NSWindow(
|
|
contentRect: NSRect(x: 0, y: 0, width: 360, height: 240),
|
|
styleMask: [.titled, .closable],
|
|
backing: .buffered,
|
|
defer: false
|
|
)
|
|
defer {
|
|
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = nil
|
|
window.orderOut(nil)
|
|
}
|
|
|
|
guard let contentView = window.contentView else {
|
|
XCTFail("Expected content view")
|
|
return
|
|
}
|
|
hostedView.frame = contentView.bounds
|
|
hostedView.autoresizingMask = [.width, .height]
|
|
contentView.addSubview(hostedView)
|
|
|
|
window.makeKeyAndOrderFront(nil)
|
|
window.displayIfNeeded()
|
|
contentView.layoutSubtreeIfNeeded()
|
|
hostedView.setVisibleInUI(true)
|
|
hostedView.setActive(true)
|
|
RunLoop.current.run(until: Date().addingTimeInterval(0.05))
|
|
|
|
var pressText: String?
|
|
var pressUnshiftedCodepoint: UInt32?
|
|
GhosttyNSView.debugGhosttySurfaceKeyEventObserver = { keyEvent in
|
|
guard keyEvent.action == GHOSTTY_ACTION_PRESS, keyEvent.keycode == 50 else { return }
|
|
pressUnshiftedCodepoint = keyEvent.unshifted_codepoint
|
|
if let text = keyEvent.text {
|
|
pressText = String(cString: text)
|
|
} else {
|
|
pressText = nil
|
|
}
|
|
}
|
|
|
|
let sent = hostedView.debugSendSyntheticKeyPressAndReleaseForUITest(
|
|
characters: "\u{1B}",
|
|
charactersIgnoringModifiers: "`",
|
|
keyCode: 50,
|
|
modifierFlags: [.shift]
|
|
)
|
|
XCTAssertTrue(sent, "Expected synthetic Shift+backquote event to be dispatched")
|
|
XCTAssertEqual(pressText, "~")
|
|
XCTAssertEqual(pressUnshiftedCodepoint, "`".unicodeScalars.first?.value)
|
|
}
|
|
}
|