From 560b2924f9ede4841a33326c57cf08f8c85feb35 Mon Sep 17 00:00:00 2001 From: Yukihiro Shinoda Date: Sat, 5 Sep 2026 12:38:36 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(input):=20=E6=95=B0=E5=AD=97=E3=81=AE?= =?UTF-8?q?=E5=8C=BA=E5=88=87=E3=82=8A=E8=A8=98=E5=8F=B7=E3=81=A8=E4=B8=AD?= =?UTF-8?q?=E9=BB=92=E3=81=AB=E3=83=94=E3=83=AA=E3=82=AA=E3=83=89=E3=83=BB?= =?UTF-8?q?=E3=82=B9=E3=83=A9=E3=83=83=E3=82=B7=E3=83=A5=E5=80=99=E8=A3=9C?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NumericSeparatorCandidates.swift | 39 +++++++++++++++ .../Core/InputUtils/SegmentsManager.swift | 14 +++++- .../NumericSeparatorCandidatesTests.swift | 47 +++++++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 Core/Sources/Core/InputUtils/NumericSeparatorCandidates.swift create mode 100644 Core/Tests/CoreTests/InputUtilsTests/NumericSeparatorCandidatesTests.swift 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..27e908eb 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,18 @@ public final class SegmentsManager { requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled ) ) + let separatorTexts = Set(result.mainResults.map(\.text)) + let separatorCandidates = NumericSeparatorCandidates.variants(for: self.composingText.convertTarget) + .filter { !separatorTexts.contains($0) }.map { text in + Candidate( + text: text, value: -18, + composingCount: .inputCount(self.composingText.input.count), + lastMid: MIDData.一般.mid, + data: [.init(word: text, ruby: self.composingText.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/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) + } + } + } +} From 6588d8ee4d5671350f53ff8ac4c2f895923d2040 Mon Sep 17 00:00:00 2001 From: Yukihiro Shinoda Date: Sat, 5 Sep 2026 13:03:38 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(input):=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E6=8C=87=E6=91=98=E3=81=AB=E5=AF=BE=E5=BF=9C=E3=81=97?= =?UTF-8?q?=E5=A4=89=E6=8F=9B=E7=AF=84=E5=9B=B2=E3=81=A8=E5=80=99=E8=A3=9C?= =?UTF-8?q?=E5=87=A6=E7=90=86=E3=82=92=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Core/InputUtils/SegmentsManager.swift | 15 +++++++----- .../EditedVariantRangeTests.swift | 23 +++++++++++++++++++ 2 files changed, 32 insertions(+), 6 deletions(-) create mode 100644 Core/Tests/CoreTests/InputUtilsTests/EditedVariantRangeTests.swift diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 27e908eb..87061a83 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -574,18 +574,21 @@ public final class SegmentsManager { requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled ) ) - let separatorTexts = Set(result.mainResults.map(\.text)) - let separatorCandidates = NumericSeparatorCandidates.variants(for: self.composingText.convertTarget) - .filter { !separatorTexts.contains($0) }.map { text in + 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(self.composingText.input.count), + composingCount: .inputCount(separatorInput.input.count), lastMid: MIDData.一般.mid, - data: [.init(word: text, ruby: self.composingText.convertTarget.toKatakana(), cid: CIDData.記号.cid, mid: MIDData.一般.mid, value: -18)], + 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)) + 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") +}