diff --git a/Sources/KanaKanjiConverterModule/ConverterAPI/KanaKanjiConverter.swift b/Sources/KanaKanjiConverterModule/ConverterAPI/KanaKanjiConverter.swift index 77e46054..bbcb6c6a 100644 --- a/Sources/KanaKanjiConverterModule/ConverterAPI/KanaKanjiConverter.swift +++ b/Sources/KanaKanjiConverterModule/ConverterAPI/KanaKanjiConverter.swift @@ -13,6 +13,15 @@ import SwiftUtils /// かな漢字変換の管理を受け持つクラス public final class KanaKanjiConverter { + /// 1つのConverterを複数の入力セッションで共有するための識別子。 + public struct ConversionSessionID: Hashable, Sendable { + fileprivate let rawValue: String + } + + public enum ConversionSessionError: Error, Equatable, Sendable { + case unknownSession(ConversionSessionID) + } + private let converter: Kana2Kanji private struct ConversionSessionState { var previousInputData: ComposingText? @@ -23,9 +32,9 @@ public final class KanaKanjiConverter { var ngramCache: NGramCache = .init() var predictiveInputCache: PredictiveInputCacheEntry? var stablePredictionCandidateCache: StablePredictionCandidateCacheEntry? + var lastData: DicdataElement? } - private typealias SessionID = String - private static let defaultSessionID: SessionID = "default" + private static let defaultSessionID = ConversionSessionID(rawValue: "default") public init(dicdataStore: DicdataStore) { self.converter = .init(dicdataStore: dicdataStore) @@ -51,9 +60,10 @@ public final class KanaKanjiConverter { private var checkerInitialized: [KeyboardLanguage: Bool] = [.none: true, .ja_JP: true] // 前回の変換や確定の情報を取っておく部分。 - private var sessions: [SessionID: ConversionSessionState] = ["default": .init()] - private var activeSessionID: SessionID = "default" - private var lastData: DicdataElement? + private var sessions: [ConversionSessionID: ConversionSessionState] = [ + KanaKanjiConverter.defaultSessionID: .init() + ] + private var activeSessionID = KanaKanjiConverter.defaultSessionID /// Zenzaiのためのzenzモデル private var zenz: Zenz? /// 完全な入力・文脈・制約をキーにする純粋なメモ化結果。 @@ -73,8 +83,50 @@ public final class KanaKanjiConverter { self.sessions[self.activeSessionID] = state } + /// 独立した変換状態を持つセッションを作成します。 + /// + /// 辞書、モデル、学習データ、Converter単位のメモ化キャッシュは同じ + /// `KanaKanjiConverter`インスタンス内で共有されます。 + public func createSession() -> ConversionSessionID { + let sessionID = ConversionSessionID(rawValue: UUID().uuidString) + self.sessions[sessionID] = .init() + return sessionID + } + + /// 作成済みの変換セッションを破棄します。 + public func removeSession(_ sessionID: ConversionSessionID) { + guard sessionID != Self.defaultSessionID else { + self.sessions[sessionID] = .init() + return + } + self.sessions[sessionID] = nil + if self.activeSessionID == sessionID { + self.activeSessionID = Self.defaultSessionID + } + } + + /// 指定したセッションを有効にして同期処理を実行します。 + /// + /// `KanaKanjiConverter`はスレッドセーフではありません。複数の実行コンテキストから + /// 利用する場合、呼び出し側でこのメソッドを含むConverterアクセスを直列化してください。 + /// `operation`終了時には、ネストした呼び出しを含めて元のセッションへ戻ります。 + public func withSession( + _ sessionID: ConversionSessionID, + operation: () throws -> Result + ) throws -> Result { + guard self.sessions[sessionID] != nil else { + throw ConversionSessionError.unknownSession(sessionID) + } + let previousSessionID = self.activeSessionID + self.activeSessionID = sessionID + defer { + self.activeSessionID = previousSessionID + } + return try operation() + } + private func withScratchSession(_ body: () -> T) -> T { - let scratchID: SessionID = "scratch-\(UUID().uuidString)" + let scratchID = ConversionSessionID(rawValue: "scratch-\(UUID().uuidString)") self.sessions[scratchID] = self.currentSessionState let previousSessionID = self.activeSessionID let savedPersonalization = self.zenzaiPersonalization @@ -91,9 +143,7 @@ public final class KanaKanjiConverter { public func stopComposition() { self.zenz?.endSession() self.zenzaiPersonalization = nil - self.sessions = [Self.defaultSessionID: .init()] - self.activeSessionID = Self.defaultSessionID - self.lastData = nil + self.sessions[self.activeSessionID] = .init() } /// Zenzaiの純粋なメモ化結果を明示的に破棄します。 @@ -394,8 +444,10 @@ public final class KanaKanjiConverter { /// - Warning: /// `commitUpdateLearningData`を呼び出すまで永続化されません。 public func updateLearningData(_ candidate: Candidate) { - self.dicdataStoreState.updateLearningData(candidate, with: self.lastData) - self.lastData = candidate.data.last + self.dicdataStoreState.updateLearningData(candidate, with: self.currentSessionState.lastData) + self.updateCurrentSessionState { + $0.lastData = candidate.data.last + } } /// 確定操作後、学習メモリをアップデートする関数。 @@ -405,7 +457,9 @@ public final class KanaKanjiConverter { /// `commitUpdateLearningData`を呼び出すまで永続化されません。 public func updateLearningData(_ candidate: Candidate, with predictionCandidate: PostCompositionPredictionCandidate) { self.dicdataStoreState.updateLearningData(candidate, with: predictionCandidate) - self.lastData = predictionCandidate.lastData + self.updateCurrentSessionState { + $0.lastData = predictionCandidate.lastData + } } /// 確定操作後の学習メモリの更新を確定させます。 diff --git a/Sources/KanaKanjiConverterModule/DictionaryManagement/DicdataStoreState.swift b/Sources/KanaKanjiConverterModule/DictionaryManagement/DicdataStoreState.swift index 9ef7a3fa..8a153b02 100644 --- a/Sources/KanaKanjiConverterModule/DictionaryManagement/DicdataStoreState.swift +++ b/Sources/KanaKanjiConverterModule/DictionaryManagement/DicdataStoreState.swift @@ -128,8 +128,9 @@ package final class DicdataStoreState { } func saveMemory() { - self.learningMemoryManager.save() - self.resetMemoryLOUDSCache() + if self.learningMemoryManager.save() { + self.resetMemoryLOUDSCache() + } } func resetMemory() { diff --git a/Sources/KanaKanjiConverterModule/DictionaryManagement/LearningMemory.swift b/Sources/KanaKanjiConverterModule/DictionaryManagement/LearningMemory.swift index 097e5ca1..68bd5b12 100644 --- a/Sources/KanaKanjiConverterModule/DictionaryManagement/LearningMemory.swift +++ b/Sources/KanaKanjiConverterModule/DictionaryManagement/LearningMemory.swift @@ -449,6 +449,10 @@ struct TemporalLearningMemoryTrie { fileprivate var dicdata: [DicdataElement] = [] fileprivate var metadata: [MetadataElement] = [] + var isEmpty: Bool { + self.dicdata.isEmpty + } + /// 同じノードにあることがわかっているデータを一括で追加する場面で利用する関数 /// 主にマージ時の利用を想定 fileprivate mutating func append(dicdata: [DicdataElement], chars: [UInt8], metadata: [MetadataElement]) { @@ -848,11 +852,16 @@ final class LearningManager { self.memoryCollapsed = LongTermLearningMemory.memoryCollapsed(directoryURL: memoryURL) } - func save() { + @discardableResult + func save() -> Bool { guard self.config.learningType.needUpdateMemory, let memoryURL = config.memoryURL else { debug(#function, "config.learningType=\(self.config.learningType as _?)", "skip memory update") - return + return false + } + guard !self.temporaryMemory.isEmpty else { + debug(#function, "skip because there is no pending memory") + return false } do { try LongTermLearningMemory.merge(tempTrie: self.temporaryMemory, directoryURL: memoryURL, maxMemoryCount: self.config.maxMemoryCount, char2UInt8: char2UInt8) @@ -864,6 +873,7 @@ final class LearningManager { } // 状態を更新する self.memoryCollapsed = LongTermLearningMemory.memoryCollapsed(directoryURL: memoryURL) + return true } func resetMemory() { diff --git a/Tests/KanaKanjiConverterModuleTests/DictionaryManagement/LearningMemoryTests.swift b/Tests/KanaKanjiConverterModuleTests/DictionaryManagement/LearningMemoryTests.swift index ab1ecb63..325106b8 100644 --- a/Tests/KanaKanjiConverterModuleTests/DictionaryManagement/LearningMemoryTests.swift +++ b/Tests/KanaKanjiConverterModuleTests/DictionaryManagement/LearningMemoryTests.swift @@ -8,6 +8,22 @@ final class LearningMemoryTests: XCTestCase { .init(learningType: .inputAndOutput, maxMemoryCount: 32, memoryURL: memoryURL) } + func testSaveSkipsWhenNoPendingMemory() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent("LearningMemoryTest-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + + let manager = LearningManager(dictionaryURL: Self.resourceURL) + _ = manager.updateConfig(self.getConfigForMemoryTest(memoryURL: dir)) + + XCTAssertFalse(manager.save()) + + let element = DicdataElement(word: "テスト", ruby: "テスト", cid: CIDData.一般名詞.cid, mid: MIDData.一般.mid, value: -10) + manager.update(data: [element]) + XCTAssertTrue(manager.save()) + XCTAssertFalse(manager.save()) + } + func testPauseFileIsClearedOnInit() throws { let dir = FileManager.default.temporaryDirectory.appendingPathComponent("LearningMemoryTest-\(UUID().uuidString)", isDirectory: true) try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) diff --git a/Tests/KanaKanjiConverterModuleWithDefaultDictionaryTests/ConverterTests/ConverterSessionTests.swift b/Tests/KanaKanjiConverterModuleWithDefaultDictionaryTests/ConverterTests/ConverterSessionTests.swift new file mode 100644 index 00000000..c075ad6a --- /dev/null +++ b/Tests/KanaKanjiConverterModuleWithDefaultDictionaryTests/ConverterTests/ConverterSessionTests.swift @@ -0,0 +1,76 @@ +import Foundation +@testable import KanaKanjiConverterModuleWithDefaultDictionary +import XCTest + +final class ConverterSessionTests: XCTestCase { + func testStopCompositionOnlyResetsActiveSession() throws { + let converter = KanaKanjiConverter.withDefaultDictionary() + let firstSession = converter.createSession() + let secondSession = converter.createSession() + + try converter.withSession(firstSession) { + converter.stopComposition() + } + + XCTAssertNoThrow( + try converter.withSession(secondSession) { + var composingText = ComposingText() + composingText.insertAtCursorPosition("あずーきー", inputStyle: .direct) + _ = converter.requestCandidates(composingText, options: requestOptions()) + } + ) + } + + func testRemovedSessionCannotBeSelected() throws { + let converter = KanaKanjiConverter.withDefaultDictionary() + let removedSession = converter.createSession() + let remainingSession = converter.createSession() + + converter.removeSession(removedSession) + + XCTAssertThrowsError(try converter.withSession(removedSession) {}) { error in + XCTAssertEqual( + error as? KanaKanjiConverter.ConversionSessionError, + .unknownSession(removedSession) + ) + } + XCTAssertNoThrow(try converter.withSession(remainingSession) {}) + } + + func testNestedSessionSelectionRestoresOuterSession() throws { + let converter = KanaKanjiConverter.withDefaultDictionary() + let outerSession = converter.createSession() + let innerSession = converter.createSession() + + try converter.withSession(outerSession) { + try converter.withSession(innerSession) { + converter.stopComposition() + } + converter.stopComposition() + } + + XCTAssertNoThrow(try converter.withSession(outerSession) {}) + XCTAssertNoThrow(try converter.withSession(innerSession) {}) + } + + private func requestOptions() -> ConvertRequestOptions { + ConvertRequestOptions( + N_best: 10, + requireJapanesePrediction: .disabled, + requireEnglishPrediction: .disabled, + keyboardLanguage: .ja_JP, + englishCandidateInRoman2KanaInput: true, + fullWidthRomanCandidate: false, + halfWidthKanaCandidate: false, + learningType: .nothing, + maxMemoryCount: 0, + shouldResetMemory: false, + memoryDirectoryURL: URL(fileURLWithPath: ""), + sharedContainerURL: URL(fileURLWithPath: ""), + textReplacer: .empty, + specialCandidateProviders: [], + typoCorrectionMode: .disabled, + metadata: nil + ) + } +}