diff --git a/Core/Sources/Core/InputUtils/NumericSeparatorCandidates.swift b/Core/Sources/Core/InputUtils/NumericSeparatorCandidates.swift new file mode 100644 index 00000000..11315957 --- /dev/null +++ b/Core/Sources/Core/InputUtils/NumericSeparatorCandidates.swift @@ -0,0 +1,39 @@ +enum NumericSeparatorCandidates { + /// 数字の区切りと単独の中黒に、記号の別表記を追加する。 + static func variants(for reading: String) -> [String] { + if reading == "・" { + return ["/", "/"] + } + let separator: Character + let replacement: String + if reading.contains("。") { + separator = "。" + replacement = "." + } else if reading.contains("・") { + separator = "・" + replacement = "/" + } else { + return [] + } + let groups = reading.split(separator: separator, omittingEmptySubsequences: false) + var normalized: [String] = [] + for group in groups { + guard !group.isEmpty else { + return [] + } + var digits = "" + for scalar in group.unicodeScalars { + switch scalar.value { + case 0x30...0x39: + digits.unicodeScalars.append(scalar) + case 0xFF10...0xFF19: + digits.unicodeScalars.append(UnicodeScalar(scalar.value - 0xFEE0)!) + default: + return [] + } + } + normalized.append(digits) + } + return [normalized.joined(separator: replacement)] + } +} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 3188ca36..87061a83 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,21 @@ public final class SegmentsManager { requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled ) ) + let separatorInput = self.composingText.prefixToCursorPosition() + let separatorVariants = NumericSeparatorCandidates.variants(for: separatorInput.convertTarget) + if !separatorVariants.isEmpty { + let separatorTexts = Set(result.mainResults.map(\.text)) + let separatorCandidates = separatorVariants.filter { !separatorTexts.contains($0) }.map { text in + Candidate( + text: text, value: -18, + composingCount: .inputCount(separatorInput.input.count), + lastMid: MIDData.一般.mid, + data: [.init(word: text, ruby: separatorInput.convertTarget.toKatakana(), cid: CIDData.記号.cid, mid: MIDData.一般.mid, value: -18)], + isLearningTarget: false + ) + } + result.mainResults.insert(contentsOf: separatorCandidates, 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..ead590b0 --- /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("10・10", inputStyle: .direct) + 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 == "10/1" }) + manager.prefixCandidateCommited(candidate, leftSideContext: "") + #expect(manager.convertTarget == "0") +} diff --git a/Core/Tests/CoreTests/InputUtilsTests/NumericSeparatorCandidatesTests.swift b/Core/Tests/CoreTests/InputUtilsTests/NumericSeparatorCandidatesTests.swift new file mode 100644 index 00000000..46572179 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/NumericSeparatorCandidatesTests.swift @@ -0,0 +1,47 @@ +@testable import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@MainActor +struct NumericSeparatorCandidatesTests { + @Test func numericGroupsAndStandaloneMiddleDot() { + #expect(NumericSeparatorCandidates.variants(for: "5。1") == ["5.1"]) + #expect(NumericSeparatorCandidates.variants(for: "5。1") == ["5.1"]) + #expect(NumericSeparatorCandidates.variants(for: "10・10") == ["10/10"]) + #expect(NumericSeparatorCandidates.variants(for: "2026・9・05") == ["2026/9/05"]) + #expect(NumericSeparatorCandidates.variants(for: "1。2。3") == ["1.2.3"]) + #expect(NumericSeparatorCandidates.variants(for: "・") == ["/", "/"]) + for input in ["", "。", "5。", "。1", "10・・10", "1。2・3", "文章。", "名前・名前", "10/10", "5.1", "10", "5 。1"] { + #expect(NumericSeparatorCandidates.variants(for: input).isEmpty) + } + } + + @Test(arguments: [("5。1", "5.1"), ("10・10", "10/10"), ("・", "/"), ("・", "/"), ("5。1", "5.1")]) + func candidatesPreserveInputAndCommitWholeValue(input: String, expected: 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)) + manager.insertAtCursorPosition(input, inputStyle: .direct) + 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(manager.convertTarget == input) + #expect(choices.contains { $0.text == input }) + #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) + } + } + } +}