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..16c42aa7 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,19 @@ public final class SegmentsManager { requireEnglishPrediction: Config.DebugPredictiveTyping().value ? .manualMix : .disabled ) ) + if self.composingText.isAtEndIndex, + 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/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") +} 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` に変換できます。