|
| 1 | +import Testing |
| 2 | +import AppKit |
| 3 | +@testable import CodeEditTextView |
| 4 | + |
| 5 | +/// Regression tests for IME commits (Pinyin / Rime / any system that uses marked text). |
| 6 | +/// |
| 7 | +/// `TextView.insertText(_:replacementRange:)` used to call `unmarkText()` first and then |
| 8 | +/// `_insertText` with the same `replacementRange` AppKit had supplied, which by then pointed |
| 9 | +/// at characters that no longer existed because `unmarkText` had already shrunk the document. |
| 10 | +/// The result was either content corruption (range still in bounds, but pointing at the wrong |
| 11 | +/// chars) or a cursor that landed at `documentLength` after a clamped out-of-bounds replace — |
| 12 | +/// the latter is what users see as "scrolls to end of script after typing a Chinese word". |
| 13 | +/// See TableProApp/TablePro#1012. |
| 14 | +@Suite |
| 15 | +@MainActor |
| 16 | +struct IMEInputTests { |
| 17 | + private func makeLaidOutTextView(_ text: String) -> TextView { |
| 18 | + let textView = TextView(string: text) |
| 19 | + textView.frame = NSRect(x: 0, y: 0, width: 1_000, height: 1_000) |
| 20 | + textView.updateFrameIfNeeded() |
| 21 | + textView.layoutManager.layoutLines(in: NSRect(x: 0, y: 0, width: 1_000, height: 1_000)) |
| 22 | + return textView |
| 23 | + } |
| 24 | + |
| 25 | + /// Builds marked text "ceshi" character-by-character at the current selection, |
| 26 | + /// matching how an IME progressively shows the in-progress romaji string. |
| 27 | + private func typeMarkedCeshi(on textView: TextView) { |
| 28 | + for (index, segment) in ["c", "ce", "ces", "cesh", "ceshi"].enumerated() { |
| 29 | + textView.setMarkedText( |
| 30 | + segment, |
| 31 | + selectedRange: NSRange(location: index + 1, length: 0), |
| 32 | + replacementRange: NSRange(location: NSNotFound, length: 0) |
| 33 | + ) |
| 34 | + } |
| 35 | + } |
| 36 | + |
| 37 | + @Test("IME commit on an empty middle line preserves surrounding content") |
| 38 | + func imeCommitInTheMiddleDoesNotCorruptText() throws { |
| 39 | + let textView = makeLaidOutTextView("alpha\n\nbeta") |
| 40 | + textView.selectionManager.setSelectedRange(NSRange(location: 6, length: 0)) |
| 41 | + |
| 42 | + typeMarkedCeshi(on: textView) |
| 43 | + |
| 44 | + // After typing five characters of marked text starting at offset 6, the IME owns |
| 45 | + // the range (6, 5) and may pass it as `replacementRange` at commit. |
| 46 | + textView.insertText("测试", replacementRange: NSRange(location: 6, length: 5)) |
| 47 | + |
| 48 | + #expect(textView.string == "alpha\n测试\nbeta") |
| 49 | + let caret = try #require(textView.selectionManager.textSelections.first) |
| 50 | + #expect(caret.range == NSRange(location: 8, length: 0)) |
| 51 | + } |
| 52 | + |
| 53 | + @Test("IME commit at end of document keeps caret at the inserted text, not at length") |
| 54 | + func imeCommitAtEndKeepsCaretAtInsertedText() throws { |
| 55 | + let textView = makeLaidOutTextView("alpha") |
| 56 | + textView.selectionManager.setSelectedRange(NSRange(location: 5, length: 0)) |
| 57 | + |
| 58 | + typeMarkedCeshi(on: textView) |
| 59 | + textView.insertText("测试", replacementRange: NSRange(location: 5, length: 5)) |
| 60 | + |
| 61 | + #expect(textView.string == "alpha测试") |
| 62 | + let caret = try #require(textView.selectionManager.textSelections.first) |
| 63 | + #expect(caret.range == NSRange(location: 7, length: 0)) |
| 64 | + } |
| 65 | + |
| 66 | + @Test("IME commit with NSNotFound replacement range still inserts at the marked range") |
| 67 | + func imeCommitWithNotFoundReplacementRange() throws { |
| 68 | + let textView = makeLaidOutTextView("alpha\n\nbeta") |
| 69 | + textView.selectionManager.setSelectedRange(NSRange(location: 6, length: 0)) |
| 70 | + |
| 71 | + typeMarkedCeshi(on: textView) |
| 72 | + textView.insertText( |
| 73 | + "测试", |
| 74 | + replacementRange: NSRange(location: NSNotFound, length: 0) |
| 75 | + ) |
| 76 | + |
| 77 | + #expect(textView.string == "alpha\n测试\nbeta") |
| 78 | + let caret = try #require(textView.selectionManager.textSelections.first) |
| 79 | + #expect(caret.range == NSRange(location: 8, length: 0)) |
| 80 | + } |
| 81 | + |
| 82 | + @Test("Marked-text state is cleared after a commit") |
| 83 | + func markedTextStateClearedAfterCommit() { |
| 84 | + let textView = makeLaidOutTextView("alpha\n\nbeta") |
| 85 | + textView.selectionManager.setSelectedRange(NSRange(location: 6, length: 0)) |
| 86 | + |
| 87 | + typeMarkedCeshi(on: textView) |
| 88 | + textView.insertText("测试", replacementRange: NSRange(location: 6, length: 5)) |
| 89 | + |
| 90 | + #expect(textView.hasMarkedText() == false) |
| 91 | + #expect(textView.markedRange().location == NSNotFound) |
| 92 | + } |
| 93 | + |
| 94 | + @Test("Plain Latin insertText path is unaffected") |
| 95 | + func plainInsertTextIsUnaffected() { |
| 96 | + let textView = makeLaidOutTextView("alpha") |
| 97 | + textView.selectionManager.setSelectedRange(NSRange(location: 5, length: 0)) |
| 98 | + |
| 99 | + // No setMarkedText calls — this is the non-IME path. |
| 100 | + textView.insertText( |
| 101 | + " beta", |
| 102 | + replacementRange: NSRange(location: NSNotFound, length: 0) |
| 103 | + ) |
| 104 | + |
| 105 | + #expect(textView.string == "alpha beta") |
| 106 | + #expect(textView.selectionManager.textSelections.first?.range == NSRange(location: 10, length: 0)) |
| 107 | + } |
| 108 | +} |
0 commit comments