Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions Core/Sources/Core/InputUtils/NumericDateShortcuts.swift
Original file line number Diff line number Diff line change
@@ -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])"
}
}
15 changes: 14 additions & 1 deletion Core/Sources/Core/InputUtils/SegmentsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` に変換できます。