import XCTest import AppKit #if canImport(cmux_DEV) @testable import cmux_DEV #elseif canImport(cmux) @testable import cmux #endif // MARK: - Test helpers /// Helper to make `NSApp.currentEvent` non-nil for insertText calls. /// NSTextInputClient.insertText guards on currentEvent because it should /// only fire during actual key event processing. In tests we simulate this /// by posting and immediately processing a synthetic key event. private func withSyntheticCurrentEvent(_ body: () -> Void) { _ = NSApplication.shared // ensure NSApp exists guard let event = NSEvent.keyEvent( with: .keyDown, location: .zero, modifierFlags: [], timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: 0, context: nil, characters: "", charactersIgnoringModifiers: "", isARepeat: false, keyCode: 0 ) else { body() return } NSApp.postEvent(event, atStart: true) // Process the event so that currentEvent becomes non-nil. // Use a short timeout since we just posted the event. if let posted = NSApp.nextEvent(matching: .keyDown, until: Date(timeIntervalSinceNow: 0.05), inMode: .default, dequeue: true) { // We're now inside event processing; currentEvent should be set. // However, currentEvent is only set during sendEvent. We need to // actually invoke sendEvent. Since we can't do that cleanly in a // unit test, we use a different approach: call insertText indirectly // via a direct test of the accumulator + unmarkText path. _ = posted } body() } // 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) } // 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) } }