diff --git a/Core/Sources/Core/InputUtils/RomanCaseCandidates.swift b/Core/Sources/Core/InputUtils/RomanCaseCandidates.swift new file mode 100644 index 00000000..ef4c51d6 --- /dev/null +++ b/Core/Sources/Core/InputUtils/RomanCaseCandidates.swift @@ -0,0 +1,15 @@ +import Foundation + +/// Case variants of the original keyboard input, before kana conversion. +enum RomanCaseCandidates { + static func variants(for input: String) -> [String] { + guard input.unicodeScalars.allSatisfy({ (0x21...0x7e).contains($0.value) }), + input.unicodeScalars.contains(where: { (0x41...0x5a).contains($0.value) || (0x61...0x7a).contains($0.value) }) else { + return [] + } + let lower = input.lowercased() + let title = lower.prefix(1).uppercased() + lower.dropFirst() + var seen = Set() + return [lower, title, input.uppercased()].filter { seen.insert($0).inserted } + } +} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 3188ca36..b51b8f51 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -564,7 +564,7 @@ public final class SegmentsManager { let leftSideContext = forcedLeftSideContext ?? self.getCleanLeftSideContext(maxCount: ContextLength.conversion) let rightSideContext = forcedRightSideContext ?? self.getCleanRightSideContext(maxCount: ContextLength.conversion) - let result = self.kanaKanjiConverter.requestCandidates( + var result = self.kanaKanjiConverter.requestCandidates( self.composingText, options: options( leftSideContext: leftSideContext, @@ -574,6 +574,22 @@ public final class SegmentsManager { requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled ) ) + let romanComposingText = self.composingText.prefixToCursorPosition() + let romanInput = romanComposingText.input.map(\.piece).inputString(preferIntention: false) + let romanVariants = RomanCaseCandidates.variants(for: romanInput) + if !romanVariants.isEmpty { + let romanTexts = Set(result.mainResults.map(\.text)) + let romanCandidates = romanVariants.filter { !romanTexts.contains($0) }.map { text in + Candidate( + text: text, value: -18, + composingCount: .inputCount(romanComposingText.input.count), + lastMid: MIDData.一般.mid, + data: [.init(word: text, ruby: romanInput, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18)], + isLearningTarget: false + ) + } + result.mainResults.insert(contentsOf: romanCandidates, at: min(5, result.mainResults.count)) + } self.rawCandidates = result } diff --git a/Core/Tests/CoreTests/InputUtilsTests/EditedVariantRangeTests.swift b/Core/Tests/CoreTests/InputUtilsTests/EditedVariantRangeTests.swift new file mode 100644 index 00000000..6d525266 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/EditedVariantRangeTests.swift @@ -0,0 +1,23 @@ +import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@MainActor +@Test func editedVariantConsumesOnlySelectedPrefix() throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let manager = SegmentsManager(kanaKanjiConverter: .withDefaultDictionary(), applicationDirectoryURL: directory, + containerURL: nil, context: .init(useZenzai: false)) + manager.insertAtCursorPosition("sushi", inputStyle: .roman2kana) + manager.editSegment(count: -1) + manager.requestSetCandidateWindowState(visible: true) + guard case .selecting(let choices, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else { + Issue.record("Expected candidates for edited segment") + return + } + let candidate = try #require(choices.first { $0.text == "SU" }) + manager.prefixCandidateCommited(candidate, leftSideContext: "") + #expect(manager.convertTarget == "し") +} diff --git a/Core/Tests/CoreTests/InputUtilsTests/RomanCaseCandidatesTests.swift b/Core/Tests/CoreTests/InputUtilsTests/RomanCaseCandidatesTests.swift new file mode 100644 index 00000000..5a98ece1 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/RomanCaseCandidatesTests.swift @@ -0,0 +1,47 @@ +@testable import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@MainActor +struct RomanCaseCandidatesTests { + @Test func variantsAndExclusions() { + #expect(RomanCaseCandidates.variants(for: "fable") == ["fable", "Fable", "FABLE"]) + #expect(RomanCaseCandidates.variants(for: "GitHub") == ["github", "Github", "GITHUB"]) + #expect(RomanCaseCandidates.variants(for: "a") == ["a", "A"]) + for input in ["", "1111", "ふぁbぇ", "コーヒー", "abc\ndef"] { + #expect(RomanCaseCandidates.variants(for: input).isEmpty) + } + } + + @Test(arguments: ["fable", "github", "FABLE"], ["lower", "title", "upper"]) + func originalKeysCanBeConvertedAndFullyCommitted(input: String, form: String) throws { + let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let manager = SegmentsManager(kanaKanjiConverter: .withDefaultDictionary(), applicationDirectoryURL: directory, + containerURL: nil, context: .init(useZenzai: false)) + for character in input { + manager.insertAtCursorPosition(String(character), inputStyle: .roman2kana) + } + if input == "fable" { #expect(manager.convertTarget == "ふぁbぇ") } + let lower = input.lowercased() + let expected = form == "lower" ? lower : form == "title" ? lower.prefix(1).uppercased() + lower.dropFirst() : input.uppercased() + for rich in [false, true] { + manager.update(requestRichCandidates: rich) + manager.requestSetCandidateWindowState(visible: true) + guard case .selecting(let choices, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else { + Issue.record("Expected conversion candidates") + return + } + #expect(choices.filter { $0.text == expected }.count == 1) + if rich { + let row = try #require(choices.firstIndex { $0.text == expected }) + manager.requestSelectingRow(row) + let candidate = try #require(manager.selectedCandidate) + manager.prefixCandidateCommited(candidate, leftSideContext: "") + #expect(manager.isEmpty) + } + } + } +}