From 5bde29123237cad6254a4c2fa234ef6ab49a8556 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):=204=E6=A1=81=E3=81=AE=E6=95=B0?= =?UTF-8?q?=E5=AD=97=E3=81=ABMM/DD=E5=BD=A2=E5=BC=8F=E3=81=AE=E6=97=A5?= =?UTF-8?q?=E4=BB=98=E5=80=99=E8=A3=9C=E3=82=92=E8=BF=BD=E5=8A=A0=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InputUtils/NumericDateShortcuts.swift | 28 ++++++ .../Core/InputUtils/SegmentsManager.swift | 14 ++- .../NumericDateShortcutsTests.swift | 49 ++++++++++ .../SegmentsManagerNumericDateTests.swift | 93 +++++++++++++++++++ README.md | 4 + 5 files changed, 187 insertions(+), 1 deletion(-) create mode 100644 Core/Sources/Core/InputUtils/NumericDateShortcuts.swift create mode 100644 Core/Tests/CoreTests/InputUtilsTests/NumericDateShortcutsTests.swift create mode 100644 Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerNumericDateTests.swift diff --git a/Core/Sources/Core/InputUtils/NumericDateShortcuts.swift b/Core/Sources/Core/InputUtils/NumericDateShortcuts.swift new file mode 100644 index 00000000..7700a839 --- /dev/null +++ b/Core/Sources/Core/InputUtils/NumericDateShortcuts.swift @@ -0,0 +1,28 @@ +enum NumericDateShortcuts { + /// 4桁のMMDDを月日に変換する。年を指定しないため2月29日も受け付ける。 + static func monthDay(matching reading: String) -> String? { + guard reading.unicodeScalars.count == 4 else { + return nil + } + let digits = reading.unicodeScalars.compactMap { scalar -> Int? in + switch scalar.value { + case 0x30...0x39: + return Int(scalar.value - 0x30) + case 0xFF10...0xFF19: + return Int(scalar.value - 0xFF10) + default: + return nil + } + } + guard digits.count == 4 else { + return nil + } + let month = digits[0] * 10 + digits[1] + let day = digits[2] * 10 + digits[3] + let monthLengths = [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + guard (1...12).contains(month), (1...monthLengths[month - 1]).contains(day) else { + return nil + } + return "\(digits[0])\(digits[1])/\(digits[2])\(digits[3])" + } +} diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 3188ca36..25611249 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 ) ) + if let monthDay = NumericDateShortcuts.monthDay(matching: self.composingText.convertTarget), + !result.mainResults.contains(where: { $0.text == monthDay }) { + let candidate = Candidate( + text: monthDay, + value: -18, + composingCount: .surfaceCount(self.composingText.convertTarget.count), + lastMid: MIDData.一般.mid, + data: [.init(word: monthDay, ruby: self.composingText.convertTarget, cid: CIDData.固有名詞.cid, mid: MIDData.一般.mid, value: -18)], + isLearningTarget: false + ) + result.mainResults.insert(candidate, at: min(5, result.mainResults.count)) + } self.rawCandidates = result } diff --git a/Core/Tests/CoreTests/InputUtilsTests/NumericDateShortcutsTests.swift b/Core/Tests/CoreTests/InputUtilsTests/NumericDateShortcutsTests.swift new file mode 100644 index 00000000..d35a45dc --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/NumericDateShortcutsTests.swift @@ -0,0 +1,49 @@ +@testable import Core +import Testing + +@Suite("Numeric date shortcuts") +struct NumericDateShortcutsTests { + @Test(arguments: [ + ("0101", "01/01"), ("0201", "02/01"), ("0905", "09/05"), ("1225", "12/25") + ]) + func convertsValidMonthDay(reading: String, expected: String) { + #expect(NumericDateShortcuts.monthDay(matching: reading) == expected) + } + + @Test(arguments: [ + ("0131", "01/31"), ("0229", "02/29"), ("0331", "03/31"), ("0430", "04/30"), + ("0531", "05/31"), ("0630", "06/30"), ("0731", "07/31"), ("0831", "08/31"), + ("0930", "09/30"), ("1031", "10/31"), ("1130", "11/30"), ("1231", "12/31") + ]) + func acceptsEveryMonthEnd(reading: String, expected: String) { + #expect(NumericDateShortcuts.monthDay(matching: reading) == expected) + } + + @Test func acceptsFebruary29WithoutAYear() { + #expect(NumericDateShortcuts.monthDay(matching: "0228") == "02/28") + #expect(NumericDateShortcuts.monthDay(matching: "0229") == "02/29") + #expect(NumericDateShortcuts.monthDay(matching: "0230") == nil) + } + + @Test(arguments: [ + "0000", "0001", "0100", "1301", "9901", "0199", "0132", "0230", "0231", + "0332", "0431", "0532", "0631", "0732", "0832", "0931", "1032", "1131", "1232" + ]) + func rejectsInvalidMonthDay(_ reading: String) { + #expect(NumericDateShortcuts.monthDay(matching: reading) == nil) + } + + @Test(arguments: ["0905", "0905", "0905", "0905", "0905"]) + func normalizesFullWidthAndMixedDigits(_ reading: String) { + #expect(NumericDateShortcuts.monthDay(matching: reading) == "09/05") + } + + @Test(arguments: [ + "", "905", "00905", "20260905", "09/05", "09-05", " 0905", "0905 ", + "0905\n", "\t0905", "日付0905", "0905です", "a905", "09a5", "٠٩٠٥", "۰۹۰۵", + "०९०५", "𝟘𝟡𝟘𝟝", "⓪⑨⓪⑤", "⁰⁹⁰⁵", "〇九〇五", "0905️", "0931" + ]) + func requiresExactlyFourSupportedDigits(_ reading: String) { + #expect(NumericDateShortcuts.monthDay(matching: reading) == nil) + } +} diff --git a/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerNumericDateTests.swift b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerNumericDateTests.swift new file mode 100644 index 00000000..980bd3c7 --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/SegmentsManagerNumericDateTests.swift @@ -0,0 +1,93 @@ +import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@MainActor +@Suite("Numeric month-day conversion candidates") +struct SegmentsManagerNumericDateTests { + private func withManager(_ body: (SegmentsManager) throws -> Void) 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) + ) + try body(manager) + } + + private func candidates(_ manager: SegmentsManager, rich: Bool = true) -> [Candidate] { + manager.update(requestRichCandidates: rich) + manager.requestSetCandidateWindowState(visible: true) + guard case .selecting(let candidates, _) = manager.getCurrentCandidateWindow(inputState: .selecting) else { + Issue.record("Expected conversion candidate selection") + return [] + } + return candidates + } + + @Test(arguments: [ + ("1111", "11/11"), ("0102", "01/02"), ("0229", "02/29"), + ("1111", "11/11"), ("0102", "01/02"), ("0229", "02/29"), + ("0102", "01/02") + ]) + func validMonthDayPreservesDigitsAndCommitsTheWholeDate(input: String, expected: String) throws { + try withManager { manager in + manager.insertAtCursorPosition(input, inputStyle: .direct) + #expect(manager.convertTarget == input) + #expect(!manager.isEmpty) + let choices = candidates(manager) + #expect(choices.contains { $0.text == input }, "The original numeric candidate must remain available") + #expect(choices.filter { $0.text == expected }.count == 1) + let row = try #require(choices.firstIndex { $0.text == expected }) + manager.requestSelectingRow(row) + let selected = try #require(manager.selectedCandidate) + #expect(!selected.isLearningTarget) + #expect(selected.data.map(\.ruby).joined() == input) + #expect(manager.getCurrentMarkedText(inputState: .selecting).map(\.content).joined() == expected) + manager.prefixCandidateCommited(selected, leftSideContext: "") + #expect(manager.isEmpty) + #expect(manager.convertTarget.isEmpty) + } + } + + @Test(arguments: ["0000", "1301", "0100", "0230", "0431", "0230", "111", "11111", "1111あ"]) + func invalidMonthDayDoesNotAddDateCandidates(input: String) throws { + try withManager { manager in + manager.insertAtCursorPosition(input, inputStyle: .direct) + let choices = candidates(manager) + #expect(!choices.contains { + $0.text.range(of: #"^\d{2}/\d{2}$"#, options: .regularExpression) != nil + }, "Invalid MMDD must not gain a date candidate") + #expect(!manager.isEmpty) + #expect(manager.convertTarget == input) + } + } + + @Test func repeatedUpdatesKeepExactlyOneDateCandidate() throws { + try withManager { manager in + manager.insertAtCursorPosition("1111", inputStyle: .roman2kana) + for rich in [false, true, true, false] { + let choices = candidates(manager, rich: rich) + #expect(choices.filter { $0.text == "11/11" }.count == 1) + #expect(choices.contains { $0.text == "1111" }) + } + } + } + + @Test func editingTheReadingRemovesAndRestoresTheDateCandidate() throws { + try withManager { manager in + manager.insertAtCursorPosition("1111", inputStyle: .direct) + #expect(candidates(manager).contains { $0.text == "11/11" }) + manager.deleteBackwardFromCursorPosition() + #expect(manager.convertTarget == "111") + #expect(!candidates(manager).contains { $0.text == "11/11" }) + manager.insertAtCursorPosition("1", inputStyle: .direct) + #expect(manager.convertTarget == "1111") + #expect(candidates(manager).filter { $0.text == "11/11" }.count == 1) + } + } +} diff --git a/README.md b/README.md index e710c7a7..7bb94dfc 100644 --- a/README.md +++ b/README.md @@ -151,3 +151,7 @@ Thanks to authors!! ## Acknowledgement 本プロジェクトは情報処理推進機構(IPA)による[2024年度未踏IT人材発掘・育成事業](https://www.ipa.go.jp/jinzai/mitou/it/2024/koubokekka.html)の支援を受けて開発を行いました。 + +### 4桁の数字から月日へ変換 + +`1111` → `11/11`、`0102` → `01/02` のように、4桁の数字をMM/DD形式の日付候補へ変換できます。元の数字候補も残ります。全角数字にも対応し、存在しない月日は候補に追加しません。年を指定しないため、`0229` は `02/29` に変換できます。 From e50e841487b228eac7824c8facddc8a46f8acd3c 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 | 3 ++- .../EditedShortcutRangeTests.swift | 22 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 Core/Tests/CoreTests/InputUtilsTests/EditedShortcutRangeTests.swift diff --git a/Core/Sources/Core/InputUtils/SegmentsManager.swift b/Core/Sources/Core/InputUtils/SegmentsManager.swift index 25611249..16c42aa7 100644 --- a/Core/Sources/Core/InputUtils/SegmentsManager.swift +++ b/Core/Sources/Core/InputUtils/SegmentsManager.swift @@ -574,7 +574,8 @@ public final class SegmentsManager { requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled ) ) - if let monthDay = NumericDateShortcuts.monthDay(matching: self.composingText.convertTarget), + if self.composingText.isAtEndIndex, + let monthDay = NumericDateShortcuts.monthDay(matching: self.composingText.convertTarget), !result.mainResults.contains(where: { $0.text == monthDay }) { let candidate = Candidate( text: monthDay, diff --git a/Core/Tests/CoreTests/InputUtilsTests/EditedShortcutRangeTests.swift b/Core/Tests/CoreTests/InputUtilsTests/EditedShortcutRangeTests.swift new file mode 100644 index 00000000..de07dbbb --- /dev/null +++ b/Core/Tests/CoreTests/InputUtilsTests/EditedShortcutRangeTests.swift @@ -0,0 +1,22 @@ +import Core +import Foundation +import KanaKanjiConverterModuleWithDefaultDictionary +import Testing + +@MainActor +@Test func wholeInputShortcutIsSuppressedForEditedSegment() 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("1111", 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 + } + #expect(!choices.contains { $0.text == "11/11" }) + #expect(manager.convertTarget == "1111") +}