From 7d1b293b5cfd42f97fd102544193d6c206882080 Mon Sep 17 00:00:00 2001 From: Rainbow Date: Sat, 12 Sep 2026 13:08:29 +0800 Subject: [PATCH] refactor: isolate input method switching and recovery --- CONTEXT.md | 15 + .../Sources/App/AppFeature+MenuState.swift | 38 +- TypeSwitch/Sources/App/AppFeature.swift | 447 +--- TypeSwitch/Sources/App/SwitchingFeature.swift | 487 ++++ .../Sources/Core/Models/AppRulesStore.swift | 23 + TypeSwitchTests/AppFeatureTestSupport.swift | 151 ++ TypeSwitchTests/AppFeatureTests.swift | 2310 ++++------------- TypeSwitchTests/SwitchingFeatureTests.swift | 1137 ++++++++ 8 files changed, 2425 insertions(+), 2183 deletions(-) create mode 100644 CONTEXT.md create mode 100644 TypeSwitch/Sources/App/SwitchingFeature.swift create mode 100644 TypeSwitchTests/AppFeatureTestSupport.swift create mode 100644 TypeSwitchTests/SwitchingFeatureTests.swift diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..683e63c --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,15 @@ +# TypeSwitch + +TypeSwitch 根据前台应用的规则选择输入法,并在输入法可用性变化后恢复所需的切换。 + +## 领域语言 + +**输入法切换与恢复**:根据当前前台应用及其规则确定目标输入法,并处理切换尝试、输入法目录恢复与前台重试的完整过程。 + +**切换尝试**:针对某个应用和目标输入法发起的一次程序切换。已经选中目标输入法与实际完成一次切换是不同的结果。 + +**输入法目录**:TypeSwitch 获取到的可用输入法集合。尚未获取或获取失败,不等于集合为空,也不等于某个输入法已被删除。 + +**前台重试**:重新确认当前前台应用,再按该应用当时的规则尝试切换;不是无条件重放此前应用的切换。 + +**跟随上次**:记住某个应用上次使用的输入法,并在该应用再次成为前台应用时将其作为切换目标。程序切换自身的目标通知不能被误当作新的手动选择。 diff --git a/TypeSwitch/Sources/App/AppFeature+MenuState.swift b/TypeSwitch/Sources/App/AppFeature+MenuState.swift index 557cb73..4c11d68 100644 --- a/TypeSwitch/Sources/App/AppFeature+MenuState.swift +++ b/TypeSwitch/Sources/App/AppFeature+MenuState.swift @@ -1,6 +1,11 @@ import Foundation extension AppFeature.State { + var currentFrontmostBundleId: String? { switching.currentFrontmostBundleId } + var inputMethodCatalogStatus: SwitchingFeature.State.InputMethodCatalogStatus { switching.inputMethodCatalogStatus } + var inputMethods: [InputMethod] { switching.inputMethods } + var lastSwitchAttempt: SwitchingFeature.State.LastSwitchAttempt? { switching.lastSwitchAttempt } + struct InputMethodDiagnostic: Equatable { enum Kind: Equatable { case catalogEmpty @@ -70,14 +75,7 @@ extension AppFeature.State { } } - var fallbackStrategy: InputMethodStrategy { - switch fallbackRuleStore.strategy { - case .followLast, .ignored: - return .none - case .none, .fixed: - return fallbackRuleStore.strategy - } - } + var fallbackStrategy: InputMethodStrategy { switching.fallbackStrategy } var fallbackSelectedLabel: String? { if fallbackStrategy == .none { @@ -112,7 +110,7 @@ extension AppFeature.State { guard let lastSwitchAttempt, currentFrontmostBundleId == lastSwitchAttempt.bundleId, - isCurrentTarget(lastSwitchAttempt), + switching.isCurrentTarget(lastSwitchAttempt), case .failed(let error) = lastSwitchAttempt.outcome else { return nil @@ -399,28 +397,6 @@ extension AppFeature.State { inputMethods.first(where: { $0.id == inputMethodId })?.name } - func isCurrentTarget(_ attempt: LastSwitchAttempt) -> Bool { - let appStrategy = strategy(for: attempt.bundleId) - switch attempt.ruleSource { - case .app: - return inputMethodId(for: appStrategy) == attempt.inputMethodId - case .fallback: - return appStrategy == .none - && inputMethodId(for: fallbackStrategy) == attempt.inputMethodId - } - } - - private func inputMethodId(for strategy: InputMethodStrategy) -> String? { - switch strategy { - case .fixed(let inputMethodId): - return inputMethodId - case .followLast(let lastInputMethodId): - return lastInputMethodId - case .ignored, .none: - return nil - } - } - private var unavailableInputMethodLabel: String { inputMethodCatalogStatus == .ready ? TypeSwitchStrings.InputMethod.deletedOption diff --git a/TypeSwitch/Sources/App/AppFeature.swift b/TypeSwitch/Sources/App/AppFeature.swift index a194024..a8b8628 100644 --- a/TypeSwitch/Sources/App/AppFeature.swift +++ b/TypeSwitch/Sources/App/AppFeature.swift @@ -5,39 +5,11 @@ import Sharing @Reducer struct AppFeature { @Dependency(\.date.now) var now - @Dependency(\.inputMethodClient) var inputMethodClient @Dependency(\.launchAtLoginClient) var launchAtLoginClient @Dependency(\.workspaceClient) var workspaceClient @ObservableState struct State: Equatable { - enum InputMethodCatalogStatus: Equatable, Sendable { - case loading - case ready - case failed(InputMethodService.InputMethodError) - } - - enum RuleSource: Equatable, Sendable { - case app - case fallback - } - - enum ProgrammaticSwitchOutcome: Equatable, Sendable { - case alreadySelected - case switched - case failed(InputMethodService.InputMethodError) - } - - struct LastSwitchAttempt: Equatable, Sendable { - let appName: String - let bundleId: String - let inputMethodId: String - let inputMethodName: String? - let outcome: ProgrammaticSwitchOutcome - let ruleSource: RuleSource - let timestamp: Date - } - struct AppMenuItem: Equatable, Identifiable { let bundleId: String let name: String @@ -55,34 +27,6 @@ struct AppFeature { } } - struct PendingProgrammaticSwitch: Equatable { - let appName: String - let attemptID: Int - let bundleId: String - var didObserveTargetSelection: Bool - let inputMethodId: String - let inputMethodName: String? - let ruleSource: RuleSource - - init( - appName: String = "", - attemptID: Int = 0, - bundleId: String, - didObserveTargetSelection: Bool = false, - inputMethodId: String, - inputMethodName: String? = nil, - ruleSource: RuleSource = .app - ) { - self.appName = appName - self.attemptID = attemptID - self.bundleId = bundleId - self.didObserveTargetSelection = didObserveTargetSelection - self.inputMethodId = inputMethodId - self.inputMethodName = inputMethodName - self.ruleSource = ruleSource - } - } - struct SwitchStatisticsItem: Equatable, Identifiable { let bundleId: String let name: String @@ -95,22 +39,12 @@ struct AppFeature { @Shared var appRulesStore: AppRulesStore @Shared var appSwitchStatisticsStore: AppSwitchStatisticsStore @Shared var fallbackRuleStore: FallbackRuleStore - var currentFrontmostBundleId: String? - var inputMethodCatalogStatus: InputMethodCatalogStatus = .loading - var inputMethods: [InputMethod] = [] + var switching: SwitchingFeature.State var isMenuPresented = false var isReadmeDemo = false - var lastSwitchAttempt: LastSwitchAttempt? var launchAtLoginStatus: LaunchAtLoginStatus = .disabled var menuStrategiesAtPresentation: [String: InputMethodStrategy] = [:] - var nextFrontmostRetryID = 0 - var nextInputMethodRefreshID = 0 - var nextSwitchAttemptID = 0 - var pendingFrontmostRetryID: Int? - var pendingInputMethodRefreshID: Int? - var pendingProgrammaticSwitch: PendingProgrammaticSwitch? var runningApps: [AppInfo] = [] - var shouldRetryFrontmostAfterInputMethodRefresh = false init( appRulesStore: Shared = Shared( @@ -126,41 +60,32 @@ struct AppFeature { .fileStorage(.fallbackRuleStoreURL) ), currentFrontmostBundleId: String? = nil, - inputMethodCatalogStatus: InputMethodCatalogStatus = .loading, + inputMethodCatalogStatus: SwitchingFeature.State.InputMethodCatalogStatus = .loading, inputMethods: [InputMethod] = [], isMenuPresented: Bool = false, isReadmeDemo: Bool = false, - lastSwitchAttempt: LastSwitchAttempt? = nil, + lastSwitchAttempt: SwitchingFeature.State.LastSwitchAttempt? = nil, launchAtLoginStatus: LaunchAtLoginStatus = .disabled, menuStrategiesAtPresentation: [String: InputMethodStrategy] = [:], - nextFrontmostRetryID: Int = 0, - nextInputMethodRefreshID: Int = 0, - nextSwitchAttemptID: Int = 0, - pendingFrontmostRetryID: Int? = nil, - pendingInputMethodRefreshID: Int? = nil, - pendingProgrammaticSwitch: PendingProgrammaticSwitch? = nil, - runningApps: [AppInfo] = [], - shouldRetryFrontmostAfterInputMethodRefresh: Bool = false + runningApps: [AppInfo] = [] ) { self._appRulesStore = appRulesStore self._appSwitchStatisticsStore = appSwitchStatisticsStore self._fallbackRuleStore = fallbackRuleStore - self.currentFrontmostBundleId = currentFrontmostBundleId - self.inputMethodCatalogStatus = inputMethodCatalogStatus - self.inputMethods = inputMethods + self.switching = SwitchingFeature.State( + appRulesStore: appRulesStore, + appSwitchStatisticsStore: appSwitchStatisticsStore, + fallbackRuleStore: fallbackRuleStore, + currentFrontmostBundleId: currentFrontmostBundleId, + inputMethodCatalogStatus: inputMethodCatalogStatus, + inputMethods: inputMethods, + lastSwitchAttempt: lastSwitchAttempt + ) self.isMenuPresented = isMenuPresented self.isReadmeDemo = isReadmeDemo - self.lastSwitchAttempt = lastSwitchAttempt self.launchAtLoginStatus = launchAtLoginStatus self.menuStrategiesAtPresentation = menuStrategiesAtPresentation - self.nextFrontmostRetryID = nextFrontmostRetryID - self.nextInputMethodRefreshID = nextInputMethodRefreshID - self.nextSwitchAttemptID = nextSwitchAttemptID - self.pendingFrontmostRetryID = pendingFrontmostRetryID - self.pendingInputMethodRefreshID = pendingInputMethodRefreshID - self.pendingProgrammaticSwitch = pendingProgrammaticSwitch self.runningApps = runningApps - self.shouldRetryFrontmostAfterInputMethodRefresh = shouldRetryFrontmostAfterInputMethodRefresh } } @@ -179,20 +104,11 @@ struct AppFeature { } enum ResponseAction: Equatable, Sendable { - case frontmostApplicationLoaded(AppInfo?) - case frontmostApplicationRetried(retryID: Int, appInfo: AppInfo?) - case inputMethodsLoaded( - refreshID: Int, - result: Result<[InputMethod], InputMethodService.InputMethodError> - ) case launchAtLoginLoaded(LaunchAtLoginStatus) - case programmaticSwitchFinished(attemptID: Int, outcome: State.ProgrammaticSwitchOutcome) case runningApps([AppInfo]) } enum SystemAction: Equatable, Sendable { - case inputMethodAvailabilityChanged - case inputMethodSelectedChanged(String) case workspaceEvent(WorkspaceClient.Event) } @@ -203,12 +119,10 @@ struct AppFeature { case view(ViewAction) case response(ResponseAction) case system(SystemAction) + case switching(SwitchingFeature.Action) } private enum CancelID { - case inputMethodAvailability - case inputMethodSelection - case programmaticSwitch case workspaceEvents } @@ -229,16 +143,13 @@ struct AppFeature { case .task: guard !state.isReadmeDemo else { return .none } normalizeFallbackRule(in: &state) - let inputMethodRefreshEffect = beginInputMethodRefresh(in: &state) + let initialSwitchingEffect = reduceSwitching(.loadInitialState, state: &state) return .merge( .concatenate( .run { send in await send(.response(.launchAtLoginLoaded(await launchAtLoginClient.status()))) }, - .run { send in - await send(.response(.frontmostApplicationLoaded(await workspaceClient.frontmostApplication()))) - }, - inputMethodRefreshEffect, + initialSwitchingEffect, refreshRunningAppsEffect() ), .run { send in @@ -248,141 +159,16 @@ struct AppFeature { } } .cancellable(id: CancelID.workspaceEvents, cancelInFlight: true), - .run { send in - let changes = await inputMethodClient.availabilityChanges() - for await _ in changes { - await send(.system(.inputMethodAvailabilityChanged)) - } - } - .cancellable(id: CancelID.inputMethodAvailability, cancelInFlight: true), - .run { send in - let changes = await inputMethodClient.selectionChanges() - for await inputMethodId in changes { - await send(.system(.inputMethodSelectedChanged(inputMethodId))) - } - } - .cancellable(id: CancelID.inputMethodSelection, cancelInFlight: true) + reduceSwitching(.observeInputMethods, state: &state) ) case .view where state.isReadmeDemo: return .none - case .response(.frontmostApplicationLoaded(let appInfo)): - state.currentFrontmostBundleId = appInfo?.bundleId - if let appInfo { - upsertRecord(for: appInfo, in: &state) - } - return .none - - case let .response(.frontmostApplicationRetried(retryID, appInfo)): - guard state.pendingFrontmostRetryID == retryID else { - return .none - } - state.pendingFrontmostRetryID = nil - guard let appInfo else { return .none } - return handleActivatedApplication(appInfo, state: &state) - - case .system(.inputMethodAvailabilityChanged): - return beginInputMethodRefresh(in: &state) - - case .system(.inputMethodSelectedChanged(let inputMethodId)): - if var pendingSwitch = state.pendingProgrammaticSwitch { - if pendingSwitch.inputMethodId == inputMethodId { - pendingSwitch.didObserveTargetSelection = true - state.pendingProgrammaticSwitch = pendingSwitch - return .none - } - if pendingSwitch.didObserveTargetSelection { - pendingSwitch.didObserveTargetSelection = false - state.pendingProgrammaticSwitch = pendingSwitch - } - } - - guard let bundleId = state.currentFrontmostBundleId else { - return .none - } - - if let lastSwitchAttempt = state.lastSwitchAttempt, - lastSwitchAttempt.bundleId == bundleId, - lastSwitchAttempt.inputMethodId == inputMethodId, - case .failed = lastSwitchAttempt.outcome - { - state.lastSwitchAttempt = nil - } - - if case .followLast(let previousInputMethodId) = state.appRules[bundleId]?.strategy { - guard previousInputMethodId != inputMethodId else { - return .none - } - - let updateDate = now - state.$appRulesStore.withLock { store in - guard var rule = store.rules[bundleId] else { return } - rule.strategy = .followLast(lastInputMethodId: inputMethodId) - rule.updatedAt = updateDate - store.rules[bundleId] = rule - } - return .none - } - - return .none - - case let .response(.inputMethodsLoaded(refreshID, .success(inputMethods))): - guard state.pendingInputMethodRefreshID == refreshID else { - return .none - } - state.pendingInputMethodRefreshID = nil - state.inputMethodCatalogStatus = .ready - state.inputMethods = inputMethods - guard state.shouldRetryFrontmostAfterInputMethodRefresh else { - return .none - } - state.shouldRetryFrontmostAfterInputMethodRefresh = false - return retryFrontmostApplicationEffect(in: &state) - - case let .response(.inputMethodsLoaded(refreshID, .failure(error))): - guard state.pendingInputMethodRefreshID == refreshID else { - return .none - } - state.pendingInputMethodRefreshID = nil - state.inputMethodCatalogStatus = .failed(error) - return .none - case .response(.launchAtLoginLoaded(let status)): state.launchAtLoginStatus = status return .none - case let .response(.programmaticSwitchFinished(attemptID, outcome)): - guard let pendingSwitch = state.pendingProgrammaticSwitch, - pendingSwitch.attemptID == attemptID - else { - return .none - } - state.pendingProgrammaticSwitch = nil - let resolvedOutcome: State.ProgrammaticSwitchOutcome - if pendingSwitch.didObserveTargetSelection, - case .failed = outcome - { - resolvedOutcome = .alreadySelected - } else { - resolvedOutcome = outcome - } - state.lastSwitchAttempt = .init( - appName: pendingSwitch.appName, - bundleId: pendingSwitch.bundleId, - inputMethodId: pendingSwitch.inputMethodId, - inputMethodName: pendingSwitch.inputMethodName, - outcome: resolvedOutcome, - ruleSource: pendingSwitch.ruleSource, - timestamp: now - ) - if resolvedOutcome == .switched { - state.$appSwitchStatisticsStore.withLock { store in - store.counts[pendingSwitch.bundleId, default: 0] += 1 - } - } - return .none - case .view(.clearSwitchStatisticsTapped): state.$appSwitchStatisticsStore.withLock { store in store.counts.removeAll() @@ -390,8 +176,6 @@ struct AppFeature { return .none case .view(.ignoreAppTapped(let appInfo)): - let shouldCancelProgrammaticSwitch = state.currentFrontmostBundleId == appInfo.bundleId - || state.pendingProgrammaticSwitch?.bundleId == appInfo.bundleId let updateDate = now state.$appRulesStore.withLock { store in let currentRule = store.rules[appInfo.bundleId] ?? AppRuleRecord( @@ -412,12 +196,10 @@ struct AppFeature { updatedRule.updatedAt = updateDate store.rules[appInfo.bundleId] = updatedRule } - guard shouldCancelProgrammaticSwitch else { return .none } - state.pendingProgrammaticSwitch = nil - return .cancel(id: CancelID.programmaticSwitch) + return reduceSwitching(.applicationIgnored(bundleId: appInfo.bundleId), state: &state) case .view(.reloadInputMethodsTapped): - return beginInputMethodRefresh(in: &state) + return reduceSwitching(.reloadInputMethods, state: &state) case .view(.removeMissingInputMethodRulesTapped): guard state.inputMethodCatalogStatus == .ready else { @@ -475,12 +257,12 @@ struct AppFeature { return .none case .view(.retryCurrentAppTapped): - return retryFrontmostApplicationEffect(in: &state) + return reduceSwitching(.retryCurrentApp, state: &state) case .response(.runningApps(let runningApps)): state.runningApps = runningApps for appInfo in runningApps { - upsertRecord(for: appInfo, in: &state) + state.$appRulesStore.withLock { $0.upsertRecord(for: appInfo, at: now) } } return .none @@ -530,120 +312,23 @@ struct AppFeature { return refreshRunningAppsEffect() case .system(.workspaceEvent(.terminated(let bundleId))): - let wasCurrentApp = state.currentFrontmostBundleId == bundleId - if wasCurrentApp { - state.currentFrontmostBundleId = nil - state.pendingFrontmostRetryID = nil - state.shouldRetryFrontmostAfterInputMethodRefresh = false - } - let shouldCancelProgrammaticSwitch = wasCurrentApp - || state.pendingProgrammaticSwitch?.bundleId == bundleId - guard shouldCancelProgrammaticSwitch else { - return refreshRunningAppsEffect() - } - state.pendingProgrammaticSwitch = nil return .merge( - .cancel(id: CancelID.programmaticSwitch), + reduceSwitching(.applicationTerminated(bundleId: bundleId), state: &state), refreshRunningAppsEffect() ) case .system(.workspaceEvent(.activated(let appInfo))): - state.pendingFrontmostRetryID = nil - return handleActivatedApplication(appInfo, state: &state) - } - } - } + return reduceSwitching(.applicationActivated(appInfo), state: &state) - private func handleActivatedApplication(_ appInfo: AppInfo, state: inout State) -> Effect { - state.currentFrontmostBundleId = appInfo.bundleId - upsertRecord(for: appInfo, in: &state) - - switch resolveSwitchTarget(for: appInfo.bundleId, state: state) { - case .none: - state.pendingProgrammaticSwitch = nil - state.shouldRetryFrontmostAfterInputMethodRefresh = false - return .cancel(id: CancelID.programmaticSwitch) - case .waitingForCatalog: - state.pendingProgrammaticSwitch = nil - state.shouldRetryFrontmostAfterInputMethodRefresh = true - return .cancel(id: CancelID.programmaticSwitch) - case let .unavailable(inputMethodId, ruleSource): - state.pendingProgrammaticSwitch = nil - state.shouldRetryFrontmostAfterInputMethodRefresh = false - state.lastSwitchAttempt = .init( - appName: appInfo.name, - bundleId: appInfo.bundleId, - inputMethodId: inputMethodId, - inputMethodName: nil, - outcome: .failed(.inputMethodNotFound(inputMethodId)), - ruleSource: ruleSource, - timestamp: now - ) - return .cancel(id: CancelID.programmaticSwitch) - case let .target(inputMethod, ruleSource): - state.shouldRetryFrontmostAfterInputMethodRefresh = false - let attemptID = state.nextSwitchAttemptID - state.nextSwitchAttemptID += 1 - state.pendingProgrammaticSwitch = .init( - appName: appInfo.name, - attemptID: attemptID, - bundleId: appInfo.bundleId, - inputMethodId: inputMethod.id, - inputMethodName: inputMethod.name, - ruleSource: ruleSource - ) - - return .run { send in - let outcome: State.ProgrammaticSwitchOutcome - do { - if (try? await inputMethodClient.currentInputMethodId()) == inputMethod.id { - outcome = .alreadySelected - } else { - guard !Task.isCancelled else { return } - try await inputMethodClient.switchToInputMethod(inputMethod.id) - outcome = .switched - } - } catch { - guard !Task.isCancelled else { return } - outcome = .failed(.diagnostic(from: error)) - } - guard !Task.isCancelled else { return } - await send(.response(.programmaticSwitchFinished( - attemptID: attemptID, - outcome: outcome - ))) + case .switching(let action): + return reduceSwitching(action, state: &state) } - .cancellable(id: CancelID.programmaticSwitch, cancelInFlight: true) } } - private func beginInputMethodRefresh(in state: inout State) -> Effect { - let refreshID = state.nextInputMethodRefreshID - state.nextInputMethodRefreshID += 1 - state.pendingInputMethodRefreshID = refreshID - state.inputMethodCatalogStatus = .loading - return refreshInputMethodsEffect(refreshID: refreshID) - } - - private func refreshInputMethodsEffect(refreshID: Int) -> Effect { - .run { send in - do { - let inputMethods = try await inputMethodClient.fetchInputMethods() - guard !Task.isCancelled else { return } - await send(.response(.inputMethodsLoaded( - refreshID: refreshID, - result: .success(inputMethods) - ))) - } catch is CancellationError { - return - } catch { - guard !Task.isCancelled else { return } - await send(.response(.inputMethodsLoaded( - refreshID: refreshID, - result: .failure(.diagnostic(from: error)) - ))) - } - } + // Route synchronously so ignore/termination invalidate pending work in this turn. + private func reduceSwitching(_ action: SwitchingFeature.Action, state: inout State) -> Effect { + SwitchingFeature().reduce(into: &state.switching, action: action).map(Action.switching) } private func refreshRunningAppsEffect() -> Effect { @@ -652,56 +337,6 @@ struct AppFeature { } } - private enum SwitchTargetResolution { - case none - case target(InputMethod, State.RuleSource) - case unavailable(String, State.RuleSource) - case waitingForCatalog - } - - private func resolveSwitchTarget(for bundleId: String, state: State) -> SwitchTargetResolution { - let appStrategy = state.strategy(for: bundleId) - let strategy = appStrategy == .none ? state.fallbackStrategy : appStrategy - let ruleSource: State.RuleSource = appStrategy == .none ? .fallback : .app - let candidateId: String? - - switch strategy { - case .ignored, .none: - return .none - case .fixed(let inputMethodId): - candidateId = inputMethodId - case .followLast(let lastInputMethodId): - candidateId = lastInputMethodId - } - - guard let candidateId else { - return .none - } - if let inputMethod = state.inputMethods.first(where: { $0.id == candidateId }) { - return .target(inputMethod, ruleSource) - } - switch state.inputMethodCatalogStatus { - case .loading: - return .waitingForCatalog - case .ready: - return .unavailable(candidateId, ruleSource) - case .failed: - return .waitingForCatalog - } - } - - private func retryFrontmostApplicationEffect(in state: inout State) -> Effect { - let retryID = state.nextFrontmostRetryID - state.nextFrontmostRetryID += 1 - state.pendingFrontmostRetryID = retryID - return .run { send in - await send(.response(.frontmostApplicationRetried( - retryID: retryID, - appInfo: await workspaceClient.frontmostApplication() - ))) - } - } - private func fallbackSupportedStrategy(_ strategy: InputMethodStrategy) -> InputMethodStrategy { switch strategy { case .followLast, .ignored: @@ -720,30 +355,4 @@ struct AppFeature { store.strategy = supportedStrategy } } - - private func upsertRecord(for appInfo: AppInfo, in state: inout State) { - let updateDate = now - state.$appRulesStore.withLock { store in - guard var existingRule = store.rules[appInfo.bundleId] else { - store.rules[appInfo.bundleId] = AppRuleRecord( - bundleId: appInfo.bundleId, - lastKnownPath: appInfo.path, - lastKnownName: appInfo.name, - strategy: .none, - createdAt: updateDate, - updatedAt: updateDate - ) - return - } - - guard existingRule.lastKnownPath != appInfo.path || existingRule.lastKnownName != appInfo.name else { - return - } - - existingRule.lastKnownPath = appInfo.path - existingRule.lastKnownName = appInfo.name - existingRule.updatedAt = updateDate - store.rules[appInfo.bundleId] = existingRule - } - } } diff --git a/TypeSwitch/Sources/App/SwitchingFeature.swift b/TypeSwitch/Sources/App/SwitchingFeature.swift new file mode 100644 index 0000000..5aab57f --- /dev/null +++ b/TypeSwitch/Sources/App/SwitchingFeature.swift @@ -0,0 +1,487 @@ +import ComposableArchitecture +import Foundation +import Sharing + +/// Owns switching, catalog recovery, and frontmost retries as one event sequence. +@Reducer +struct SwitchingFeature { + @Dependency(\.date.now) var now + @Dependency(\.inputMethodClient) var inputMethodClient + @Dependency(\.workspaceClient) var workspaceClient + + @ObservableState + struct State: Equatable { + enum InputMethodCatalogStatus: Equatable, Sendable { + case loading + case ready + case failed(InputMethodService.InputMethodError) + } + + enum RuleSource: Equatable, Sendable { + case app + case fallback + } + + enum ProgrammaticSwitchOutcome: Equatable, Sendable { + case alreadySelected + case switched + case failed(InputMethodService.InputMethodError) + } + + struct LastSwitchAttempt: Equatable, Sendable { + let appName: String + let bundleId: String + let inputMethodId: String + let inputMethodName: String? + let outcome: ProgrammaticSwitchOutcome + let ruleSource: RuleSource + let timestamp: Date + } + + struct PendingProgrammaticSwitch: Equatable { + let appName: String + let attemptID: Int + let bundleId: String + var didObserveTargetSelection: Bool + let inputMethodId: String + let inputMethodName: String? + let ruleSource: RuleSource + + init( + appName: String = "", + attemptID: Int = 0, + bundleId: String, + didObserveTargetSelection: Bool = false, + inputMethodId: String, + inputMethodName: String? = nil, + ruleSource: RuleSource = .app + ) { + self.appName = appName + self.attemptID = attemptID + self.bundleId = bundleId + self.didObserveTargetSelection = didObserveTargetSelection + self.inputMethodId = inputMethodId + self.inputMethodName = inputMethodName + self.ruleSource = ruleSource + } + } + + @Shared var appRulesStore: AppRulesStore + @Shared var appSwitchStatisticsStore: AppSwitchStatisticsStore + @Shared var fallbackRuleStore: FallbackRuleStore + var currentFrontmostBundleId: String? + var inputMethodCatalogStatus: InputMethodCatalogStatus = .loading + var inputMethods: [InputMethod] = [] + var lastSwitchAttempt: LastSwitchAttempt? + var nextFrontmostRetryID = 0 + var nextInputMethodRefreshID = 0 + var nextSwitchAttemptID = 0 + var pendingFrontmostRetryID: Int? + var pendingInputMethodRefreshID: Int? + var pendingProgrammaticSwitch: PendingProgrammaticSwitch? + var shouldRetryFrontmostAfterInputMethodRefresh = false + + init( + appRulesStore: Shared, + appSwitchStatisticsStore: Shared, + fallbackRuleStore: Shared, + currentFrontmostBundleId: String? = nil, + inputMethodCatalogStatus: InputMethodCatalogStatus = .loading, + inputMethods: [InputMethod] = [], + lastSwitchAttempt: LastSwitchAttempt? = nil + ) { + self._appRulesStore = appRulesStore + self._appSwitchStatisticsStore = appSwitchStatisticsStore + self._fallbackRuleStore = fallbackRuleStore + self.currentFrontmostBundleId = currentFrontmostBundleId + self.inputMethodCatalogStatus = inputMethodCatalogStatus + self.inputMethods = inputMethods + self.lastSwitchAttempt = lastSwitchAttempt + } + + func strategy(for bundleId: String) -> InputMethodStrategy { + appRulesStore.rules[bundleId]?.strategy ?? .none + } + + var fallbackStrategy: InputMethodStrategy { + switch fallbackRuleStore.strategy { + case .followLast, .ignored: + return .none + case .none, .fixed: + return fallbackRuleStore.strategy + } + } + + func isCurrentTarget(_ attempt: LastSwitchAttempt) -> Bool { + let appStrategy = strategy(for: attempt.bundleId) + switch attempt.ruleSource { + case .app: + return inputMethodId(for: appStrategy) == attempt.inputMethodId + case .fallback: + return appStrategy == .none + && inputMethodId(for: fallbackStrategy) == attempt.inputMethodId + } + } + + private func inputMethodId(for strategy: InputMethodStrategy) -> String? { + switch strategy { + case .fixed(let inputMethodId): + return inputMethodId + case .followLast(let lastInputMethodId): + return lastInputMethodId + case .ignored, .none: + return nil + } + } + } + + enum ResponseAction: Equatable, Sendable { + case frontmostApplicationLoaded(AppInfo?) + case frontmostApplicationRetried(retryID: Int, appInfo: AppInfo?) + case inputMethodsLoaded( + refreshID: Int, + result: Result<[InputMethod], InputMethodService.InputMethodError> + ) + case programmaticSwitchFinished(attemptID: Int, outcome: State.ProgrammaticSwitchOutcome) + } + + enum SystemAction: Equatable, Sendable { + case inputMethodAvailabilityChanged + case inputMethodSelectedChanged(String) + } + + enum Action: Equatable, Sendable { + case loadInitialState + case observeInputMethods + case reloadInputMethods + case retryCurrentApp + case applicationActivated(AppInfo) + case applicationTerminated(bundleId: String) + case applicationIgnored(bundleId: String) + case response(ResponseAction) + case system(SystemAction) + } + + private enum CancelID { + case inputMethodAvailability + case inputMethodSelection + case programmaticSwitch + } + + var body: some ReducerOf { + Reduce { state, action in + switch action { + case .loadInitialState: + let refreshEffect = beginInputMethodRefresh(in: &state) + return .concatenate( + .run { send in + await send(.response(.frontmostApplicationLoaded(await workspaceClient.frontmostApplication()))) + }, + refreshEffect + ) + + case .observeInputMethods: + return .merge( + .run { send in + let changes = await inputMethodClient.availabilityChanges() + for await _ in changes { + await send(.system(.inputMethodAvailabilityChanged)) + } + } + .cancellable(id: CancelID.inputMethodAvailability, cancelInFlight: true), + .run { send in + let changes = await inputMethodClient.selectionChanges() + for await inputMethodId in changes { + await send(.system(.inputMethodSelectedChanged(inputMethodId))) + } + } + .cancellable(id: CancelID.inputMethodSelection, cancelInFlight: true) + ) + + case .response(.frontmostApplicationLoaded(let appInfo)): + state.currentFrontmostBundleId = appInfo?.bundleId + if let appInfo { + state.$appRulesStore.withLock { $0.upsertRecord(for: appInfo, at: now) } + } + return .none + + case let .response(.frontmostApplicationRetried(retryID, appInfo)): + guard state.pendingFrontmostRetryID == retryID else { + return .none + } + state.pendingFrontmostRetryID = nil + guard let appInfo else { return .none } + return handleActivatedApplication(appInfo, state: &state) + + case .system(.inputMethodAvailabilityChanged): + return beginInputMethodRefresh(in: &state) + + case .system(.inputMethodSelectedChanged(let inputMethodId)): + if var pendingSwitch = state.pendingProgrammaticSwitch { + if pendingSwitch.inputMethodId == inputMethodId { + pendingSwitch.didObserveTargetSelection = true + state.pendingProgrammaticSwitch = pendingSwitch + return .none + } + if pendingSwitch.didObserveTargetSelection { + pendingSwitch.didObserveTargetSelection = false + state.pendingProgrammaticSwitch = pendingSwitch + } + } + + guard let bundleId = state.currentFrontmostBundleId else { + return .none + } + + if let lastSwitchAttempt = state.lastSwitchAttempt, + lastSwitchAttempt.bundleId == bundleId, + lastSwitchAttempt.inputMethodId == inputMethodId, + case .failed = lastSwitchAttempt.outcome + { + state.lastSwitchAttempt = nil + } + + if case .followLast(let previousInputMethodId) = state.appRulesStore.rules[bundleId]?.strategy { + guard previousInputMethodId != inputMethodId else { + return .none + } + + let updateDate = now + state.$appRulesStore.withLock { store in + guard var rule = store.rules[bundleId] else { return } + rule.strategy = .followLast(lastInputMethodId: inputMethodId) + rule.updatedAt = updateDate + store.rules[bundleId] = rule + } + return .none + } + + return .none + + case let .response(.inputMethodsLoaded(refreshID, .success(inputMethods))): + guard state.pendingInputMethodRefreshID == refreshID else { + return .none + } + state.pendingInputMethodRefreshID = nil + state.inputMethodCatalogStatus = .ready + state.inputMethods = inputMethods + guard state.shouldRetryFrontmostAfterInputMethodRefresh else { + return .none + } + state.shouldRetryFrontmostAfterInputMethodRefresh = false + return retryFrontmostApplicationEffect(in: &state) + + case let .response(.inputMethodsLoaded(refreshID, .failure(error))): + guard state.pendingInputMethodRefreshID == refreshID else { + return .none + } + state.pendingInputMethodRefreshID = nil + state.inputMethodCatalogStatus = .failed(error) + return .none + + case let .response(.programmaticSwitchFinished(attemptID, outcome)): + guard let pendingSwitch = state.pendingProgrammaticSwitch, + pendingSwitch.attemptID == attemptID + else { + return .none + } + state.pendingProgrammaticSwitch = nil + let resolvedOutcome: State.ProgrammaticSwitchOutcome + if pendingSwitch.didObserveTargetSelection, + case .failed = outcome + { + resolvedOutcome = .alreadySelected + } else { + resolvedOutcome = outcome + } + state.lastSwitchAttempt = .init( + appName: pendingSwitch.appName, + bundleId: pendingSwitch.bundleId, + inputMethodId: pendingSwitch.inputMethodId, + inputMethodName: pendingSwitch.inputMethodName, + outcome: resolvedOutcome, + ruleSource: pendingSwitch.ruleSource, + timestamp: now + ) + if resolvedOutcome == .switched { + state.$appSwitchStatisticsStore.withLock { store in + store.counts[pendingSwitch.bundleId, default: 0] += 1 + } + } + return .none + + case .reloadInputMethods: + return beginInputMethodRefresh(in: &state) + + case .retryCurrentApp: + return retryFrontmostApplicationEffect(in: &state) + + case .applicationIgnored(let bundleId): + guard state.currentFrontmostBundleId == bundleId + || state.pendingProgrammaticSwitch?.bundleId == bundleId + else { return .none } + state.pendingProgrammaticSwitch = nil + return .cancel(id: CancelID.programmaticSwitch) + + case .applicationTerminated(let bundleId): + let wasCurrentApp = state.currentFrontmostBundleId == bundleId + if wasCurrentApp { + state.currentFrontmostBundleId = nil + state.pendingFrontmostRetryID = nil + state.shouldRetryFrontmostAfterInputMethodRefresh = false + } + guard wasCurrentApp || state.pendingProgrammaticSwitch?.bundleId == bundleId else { + return .none + } + state.pendingProgrammaticSwitch = nil + return .cancel(id: CancelID.programmaticSwitch) + + case .applicationActivated(let appInfo): + state.pendingFrontmostRetryID = nil + return handleActivatedApplication(appInfo, state: &state) + } + } + } + + private func handleActivatedApplication(_ appInfo: AppInfo, state: inout State) -> Effect { + state.currentFrontmostBundleId = appInfo.bundleId + state.$appRulesStore.withLock { $0.upsertRecord(for: appInfo, at: now) } + + switch resolveSwitchTarget(for: appInfo.bundleId, state: state) { + case .none: + state.pendingProgrammaticSwitch = nil + state.shouldRetryFrontmostAfterInputMethodRefresh = false + return .cancel(id: CancelID.programmaticSwitch) + case .waitingForCatalog: + state.pendingProgrammaticSwitch = nil + state.shouldRetryFrontmostAfterInputMethodRefresh = true + return .cancel(id: CancelID.programmaticSwitch) + case let .unavailable(inputMethodId, ruleSource): + state.pendingProgrammaticSwitch = nil + state.shouldRetryFrontmostAfterInputMethodRefresh = false + state.lastSwitchAttempt = .init( + appName: appInfo.name, + bundleId: appInfo.bundleId, + inputMethodId: inputMethodId, + inputMethodName: nil, + outcome: .failed(.inputMethodNotFound(inputMethodId)), + ruleSource: ruleSource, + timestamp: now + ) + return .cancel(id: CancelID.programmaticSwitch) + case let .target(inputMethod, ruleSource): + state.shouldRetryFrontmostAfterInputMethodRefresh = false + let attemptID = state.nextSwitchAttemptID + state.nextSwitchAttemptID += 1 + state.pendingProgrammaticSwitch = .init( + appName: appInfo.name, + attemptID: attemptID, + bundleId: appInfo.bundleId, + inputMethodId: inputMethod.id, + inputMethodName: inputMethod.name, + ruleSource: ruleSource + ) + + return .run { send in + let outcome: State.ProgrammaticSwitchOutcome + do { + if (try? await inputMethodClient.currentInputMethodId()) == inputMethod.id { + outcome = .alreadySelected + } else { + guard !Task.isCancelled else { return } + try await inputMethodClient.switchToInputMethod(inputMethod.id) + outcome = .switched + } + } catch { + guard !Task.isCancelled else { return } + outcome = .failed(.diagnostic(from: error)) + } + guard !Task.isCancelled else { return } + await send(.response(.programmaticSwitchFinished( + attemptID: attemptID, + outcome: outcome + ))) + } + .cancellable(id: CancelID.programmaticSwitch, cancelInFlight: true) + } + } + + private func beginInputMethodRefresh(in state: inout State) -> Effect { + let refreshID = state.nextInputMethodRefreshID + state.nextInputMethodRefreshID += 1 + state.pendingInputMethodRefreshID = refreshID + state.inputMethodCatalogStatus = .loading + return refreshInputMethodsEffect(refreshID: refreshID) + } + + private func refreshInputMethodsEffect(refreshID: Int) -> Effect { + .run { send in + do { + let inputMethods = try await inputMethodClient.fetchInputMethods() + guard !Task.isCancelled else { return } + await send(.response(.inputMethodsLoaded( + refreshID: refreshID, + result: .success(inputMethods) + ))) + } catch is CancellationError { + return + } catch { + guard !Task.isCancelled else { return } + await send(.response(.inputMethodsLoaded( + refreshID: refreshID, + result: .failure(.diagnostic(from: error)) + ))) + } + } + } + + private enum SwitchTargetResolution { + case none + case target(InputMethod, State.RuleSource) + case unavailable(String, State.RuleSource) + case waitingForCatalog + } + + private func resolveSwitchTarget(for bundleId: String, state: State) -> SwitchTargetResolution { + let appStrategy = state.strategy(for: bundleId) + let strategy = appStrategy == .none ? state.fallbackStrategy : appStrategy + let ruleSource: State.RuleSource = appStrategy == .none ? .fallback : .app + let candidateId: String? + + switch strategy { + case .ignored, .none: + return .none + case .fixed(let inputMethodId): + candidateId = inputMethodId + case .followLast(let lastInputMethodId): + candidateId = lastInputMethodId + } + + guard let candidateId else { + return .none + } + if let inputMethod = state.inputMethods.first(where: { $0.id == candidateId }) { + return .target(inputMethod, ruleSource) + } + switch state.inputMethodCatalogStatus { + case .loading: + return .waitingForCatalog + case .ready: + return .unavailable(candidateId, ruleSource) + case .failed: + return .waitingForCatalog + } + } + + private func retryFrontmostApplicationEffect(in state: inout State) -> Effect { + let retryID = state.nextFrontmostRetryID + state.nextFrontmostRetryID += 1 + state.pendingFrontmostRetryID = retryID + return .run { send in + await send(.response(.frontmostApplicationRetried( + retryID: retryID, + appInfo: await workspaceClient.frontmostApplication() + ))) + } + } +} diff --git a/TypeSwitch/Sources/Core/Models/AppRulesStore.swift b/TypeSwitch/Sources/Core/Models/AppRulesStore.swift index a6302de..68b1113 100644 --- a/TypeSwitch/Sources/Core/Models/AppRulesStore.swift +++ b/TypeSwitch/Sources/Core/Models/AppRulesStore.swift @@ -8,6 +8,29 @@ struct AppRulesStore: Codable, Hashable, Sendable { self.v = v self.rules = rules } + + mutating func upsertRecord(for appInfo: AppInfo, at date: Date) { + guard var existingRule = rules[appInfo.bundleId] else { + rules[appInfo.bundleId] = AppRuleRecord( + bundleId: appInfo.bundleId, + lastKnownPath: appInfo.path, + lastKnownName: appInfo.name, + strategy: .none, + createdAt: date, + updatedAt: date + ) + return + } + + guard existingRule.lastKnownPath != appInfo.path || existingRule.lastKnownName != appInfo.name else { + return + } + + existingRule.lastKnownPath = appInfo.path + existingRule.lastKnownName = appInfo.name + existingRule.updatedAt = date + rules[appInfo.bundleId] = existingRule + } } struct FallbackRuleStore: Codable, Hashable, Sendable { diff --git a/TypeSwitchTests/AppFeatureTestSupport.swift b/TypeSwitchTests/AppFeatureTestSupport.swift new file mode 100644 index 0000000..0ac6e3c --- /dev/null +++ b/TypeSwitchTests/AppFeatureTestSupport.swift @@ -0,0 +1,151 @@ +import Foundation +@testable import TypeSwitch + +actor SwitchRecorder { + private(set) var values: [String] = [] + + func record(_ inputMethodId: String) { + values.append(inputMethodId) + } +} + +actor InputMethodRefreshGate { + private let latestInputMethods: [InputMethod] + private var callCount = 0 + private var firstContinuation: CheckedContinuation< + Result<[InputMethod], InputMethodService.InputMethodError>, + Never + >? + private var firstStartedContinuation: CheckedContinuation? + private var hasStartedFirstCall = false + + init(latestInputMethods: [InputMethod]) { + self.latestInputMethods = latestInputMethods + } + + func value() async throws -> [InputMethod] { + callCount += 1 + guard callCount == 1 else { return latestInputMethods } + + let result = await withCheckedContinuation { continuation in + firstContinuation = continuation + hasStartedFirstCall = true + firstStartedContinuation?.resume() + firstStartedContinuation = nil + } + return try result.get() + } + + func waitUntilFirstStarted() async { + guard !hasStartedFirstCall else { return } + await withCheckedContinuation { continuation in + firstStartedContinuation = continuation + } + } + + func resumeFirst(with result: Result<[InputMethod], InputMethodService.InputMethodError>) { + firstContinuation?.resume(returning: result) + firstContinuation = nil + } +} + +actor InputMethodLookupGate { + private let firstValue: String + private let subsequentValue: String + private var callCount = 0 + private var firstContinuation: CheckedContinuation? + private var firstStartedContinuation: CheckedContinuation? + private var hasStartedFirstCall = false + + init(firstValue: String, subsequentValue: String = "") { + self.firstValue = firstValue + self.subsequentValue = subsequentValue + } + + func value() async -> String { + callCount += 1 + guard callCount == 1 else { return subsequentValue } + + return await withCheckedContinuation { continuation in + firstContinuation = continuation + hasStartedFirstCall = true + firstStartedContinuation?.resume() + firstStartedContinuation = nil + } + } + + func waitForFirstCall() async { + guard !hasStartedFirstCall else { return } + await withCheckedContinuation { continuation in + firstStartedContinuation = continuation + } + } + + func resumeFirst() { + firstContinuation?.resume(returning: firstValue) + firstContinuation = nil + } +} + +actor InputMethodSwitchGate { + private var continuation: CheckedContinuation? + private var startedContinuation: CheckedContinuation? + private var hasStarted = false + + func wait() async { + await withCheckedContinuation { continuation in + self.continuation = continuation + hasStarted = true + startedContinuation?.resume() + startedContinuation = nil + } + } + + func waitUntilStarted() async { + guard !hasStarted else { return } + await withCheckedContinuation { continuation in + startedContinuation = continuation + } + } + + func resume() { + continuation?.resume() + continuation = nil + } +} + +actor FrontmostApplicationGate { + private let appInfo: AppInfo? + private var continuation: CheckedContinuation? + private var startedContinuation: CheckedContinuation? + private var hasStarted = false + + init(appInfo: AppInfo?) { + self.appInfo = appInfo + } + + func value() async -> AppInfo? { + await withCheckedContinuation { continuation in + self.continuation = continuation + hasStarted = true + startedContinuation?.resume() + startedContinuation = nil + } + } + + func waitUntilStarted() async { + guard !hasStarted else { return } + await withCheckedContinuation { continuation in + startedContinuation = continuation + } + } + + func resume() { + continuation?.resume(returning: appInfo) + continuation = nil + } +} + +enum TestError: Error { + case failed +} diff --git a/TypeSwitchTests/AppFeatureTests.swift b/TypeSwitchTests/AppFeatureTests.swift index ef67aba..95500ff 100644 --- a/TypeSwitchTests/AppFeatureTests.swift +++ b/TypeSwitchTests/AppFeatureTests.swift @@ -6,6 +6,106 @@ import XCTest @MainActor final class AppFeatureTests: XCTestCase { + func testStartupLoadsConfiguredFrontmostAppWithoutSwitching() async { + let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") + let inputMethods = [InputMethod(id: "ime.en", name: "English")] + var initialState = AppFeature.State() + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = makeRule(app: app, strategy: .fixed(inputMethodId: "ime.en")) + } + let store = TestStore(initialState: initialState) { AppFeature() } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.workspaceClient.frontmostApplication = { app } + store.dependencies.workspaceClient.runningApplications = { [app] } + store.dependencies.workspaceClient.events = { AsyncStream { $0.finish() } } + store.dependencies.inputMethodClient.fetchInputMethods = { inputMethods } + store.dependencies.inputMethodClient.availabilityChanges = { AsyncStream { $0.finish() } } + store.dependencies.inputMethodClient.selectionChanges = { AsyncStream { $0.finish() } } + store.dependencies.inputMethodClient.currentInputMethodId = { + XCTFail("Startup records the frontmost app without attempting a switch") + return "ime.other" + } + store.dependencies.inputMethodClient.switchToInputMethod = { _ in + XCTFail("Startup must not reapply the configured rule") + } + + await store.send(.task) { + $0.switching.nextInputMethodRefreshID = 1 + $0.switching.pendingInputMethodRefreshID = 0 + } + await store.receive(.response(.launchAtLoginLoaded(.disabled))) + await store.receive(.switching(.response(.frontmostApplicationLoaded(app)))) { + $0.switching.currentFrontmostBundleId = app.bundleId + } + await store.receive(.switching(.response(.inputMethodsLoaded(refreshID: 0, result: .success(inputMethods))))) { + $0.switching.pendingInputMethodRefreshID = nil + $0.switching.inputMethodCatalogStatus = .ready + $0.switching.inputMethods = inputMethods + } + await store.receive(.response(.runningApps([app]))) { + $0.runningApps = [app] + } + await store.finish() + XCTAssertEqual(store.state.currentFrontmostBundleId, app.bundleId) + XCTAssertEqual(store.state.inputMethods, inputMethods) + XCTAssertNil(store.state.lastSwitchAttempt) + XCTAssertEqual(store.state.totalSuccessfulSwitchCount, 0) + } + + func testRuleEditsAndSwitchResultsShareStorageAcrossModules() async { + let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") + let timestamp = Date(timeIntervalSince1970: 10) + let recorder = SwitchRecorder() + var initialState = AppFeature.State( + inputMethodCatalogStatus: .ready, + inputMethods: [InputMethod(id: "ime.en", name: "English")] + ) + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = makeRule(app: app, strategy: .none, timestamp: timestamp) + } + let store = TestStore(initialState: initialState) { AppFeature() } + store.dependencies.date = .constant(timestamp) + store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } + store.dependencies.inputMethodClient.switchToInputMethod = { await recorder.record($0) } + + await store.send(.view(.setStrategy(bundleId: app.bundleId, strategy: .fixed(inputMethodId: "ime.en")))) { + $0.$appRulesStore.withLock { $0.rules[app.bundleId]?.strategy = .fixed(inputMethodId: "ime.en") } + } + await store.send(.system(.workspaceEvent(.activated(app)))) { + $0.switching.currentFrontmostBundleId = app.bundleId + $0.switching.nextSwitchAttemptID = 1 + $0.switching.pendingProgrammaticSwitch = .init( + appName: app.name, + attemptID: 0, + bundleId: app.bundleId, + inputMethodId: "ime.en", + inputMethodName: "English" + ) + } + await store.receive(.switching(.response(.programmaticSwitchFinished(attemptID: 0, outcome: .switched)))) { + $0.switching.pendingProgrammaticSwitch = nil + $0.switching.lastSwitchAttempt = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: "ime.en", + inputMethodName: "English", + outcome: .switched, + ruleSource: .app, + timestamp: timestamp + ) + $0.$appSwitchStatisticsStore.withLock { $0.counts[app.bundleId] = 1 } + } + let switchedIds = await recorder.values + XCTAssertEqual(switchedIds, ["ime.en"]) + XCTAssertEqual(store.state.totalSuccessfulSwitchCount, 1) + XCTAssertEqual(store.state.currentAppMenuItem?.strategy, .fixed(inputMethodId: "ime.en")) + + await store.send(.view(.clearSwitchStatisticsTapped)) { + $0.$appSwitchStatisticsStore.withLock { $0.counts = [:] } + } + XCTAssertTrue(store.state.switching.appSwitchStatisticsStore.counts.isEmpty) + } + func testFrontmostAndRunningAppsCreateDefaultRule() async { let now = Date(timeIntervalSince1970: 1_000) let app = AppInfo(bundleId: "com.test.notes", name: "Notes", path: "/Applications/Notes.app") @@ -15,8 +115,8 @@ final class AppFeatureTests: XCTestCase { } store.dependencies.date = .constant(now) - await store.send(.response(.frontmostApplicationLoaded(app))) { - $0.currentFrontmostBundleId = app.bundleId + await store.send(.switching(.response(.frontmostApplicationLoaded(app)))) { + $0.switching.currentFrontmostBundleId = app.bundleId $0.$appRulesStore.withLock { $0.rules[app.bundleId] = AppRuleRecord( bundleId: app.bundleId, @@ -183,7 +283,7 @@ final class AppFeatureTests: XCTestCase { let recorder = SwitchRecorder() var initialState = AppFeature.State() - initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] + initialState.switching.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] initialState.$appRulesStore.withLock { $0.rules[app.bundleId] = AppRuleRecord( bundleId: app.bundleId, @@ -205,21 +305,21 @@ final class AppFeatureTests: XCTestCase { } await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( + $0.switching.currentFrontmostBundleId = app.bundleId + $0.switching.nextSwitchAttemptID = 1 + $0.switching.pendingProgrammaticSwitch = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: targetInputMethod, inputMethodName: "English" ) } - await store.receive(.response(.programmaticSwitchFinished( + await store.receive(.switching(.response(.programmaticSwitchFinished( attemptID: 0, outcome: .switched - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( + )))) { + $0.switching.pendingProgrammaticSwitch = nil + $0.switching.lastSwitchAttempt = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: targetInputMethod, @@ -244,7 +344,7 @@ final class AppFeatureTests: XCTestCase { let recorder = SwitchRecorder() var initialState = AppFeature.State() - initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] + initialState.switching.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] initialState.$appRulesStore.withLock { $0.rules[app.bundleId] = AppRuleRecord( bundleId: app.bundleId, @@ -267,21 +367,21 @@ final class AppFeatureTests: XCTestCase { } await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( + $0.switching.currentFrontmostBundleId = app.bundleId + $0.switching.nextSwitchAttemptID = 1 + $0.switching.pendingProgrammaticSwitch = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: targetInputMethod, inputMethodName: "English" ) } - await store.receive(.response(.programmaticSwitchFinished( + await store.receive(.switching(.response(.programmaticSwitchFinished( attemptID: 0, outcome: .alreadySelected - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( + )))) { + $0.switching.pendingProgrammaticSwitch = nil + $0.switching.lastSwitchAttempt = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: targetInputMethod, @@ -303,7 +403,7 @@ final class AppFeatureTests: XCTestCase { let targetInputMethod = "ime.en" var initialState = AppFeature.State() - initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] + initialState.switching.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] initialState.$appRulesStore.withLock { $0.rules[app.bundleId] = AppRuleRecord( bundleId: app.bundleId, @@ -325,21 +425,21 @@ final class AppFeatureTests: XCTestCase { } await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( + $0.switching.currentFrontmostBundleId = app.bundleId + $0.switching.nextSwitchAttemptID = 1 + $0.switching.pendingProgrammaticSwitch = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: targetInputMethod, inputMethodName: "English" ) } - await store.receive(.response(.programmaticSwitchFinished( + await store.receive(.switching(.response(.programmaticSwitchFinished( attemptID: 0, outcome: .failed(.diagnostic(from: TestError.failed)) - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( + )))) { + $0.switching.pendingProgrammaticSwitch = nil + $0.switching.lastSwitchAttempt = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: targetInputMethod, @@ -354,71 +454,11 @@ final class AppFeatureTests: XCTestCase { XCTAssertEqual(store.state.totalSuccessfulSwitchCount, 0) } - func testActivatedAppStillSwitchesWhenCurrentInputMethodLookupFails() async { - let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") - let targetInputMethod = "ime.en" - let recorder = SwitchRecorder() - - var initialState = AppFeature.State() - initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] - initialState.$appRulesStore.withLock { - $0.rules[app.bundleId] = AppRuleRecord( - bundleId: app.bundleId, - lastKnownPath: app.path, - lastKnownName: app.name, - strategy: .fixed(inputMethodId: targetInputMethod), - createdAt: Date(timeIntervalSince1970: 10), - updatedAt: Date(timeIntervalSince1970: 10) - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) - store.dependencies.inputMethodClient.currentInputMethodId = { throw TestError.failed } - store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in - await recorder.record(inputMethodId) - } - - await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: targetInputMethod, - inputMethodName: "English" - ) - } - await store.receive(.response(.programmaticSwitchFinished( - attemptID: 0, - outcome: .switched - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: targetInputMethod, - inputMethodName: "English", - outcome: .switched, - ruleSource: .app, - timestamp: Date(timeIntervalSince1970: 10) - ) - $0.$appSwitchStatisticsStore.withLock { - $0.counts[app.bundleId] = 1 - } - } - - let switchedInputMethods = await recorder.values - XCTAssertEqual(switchedInputMethods, [targetInputMethod]) - } - func testInputMethodRefreshFailurePreservesLastSuccessfulList() async { let inputMethods = [InputMethod(id: "ime.en", name: "English")] var initialState = AppFeature.State() - initialState.inputMethodCatalogStatus = .ready - initialState.inputMethods = inputMethods + initialState.switching.inputMethodCatalogStatus = .ready + initialState.switching.inputMethods = inputMethods initialState.$appRulesStore.withLock { $0.rules["com.test.editor"] = AppRuleRecord( bundleId: "com.test.editor", @@ -438,20 +478,20 @@ final class AppFeatureTests: XCTestCase { throw InputMethodService.InputMethodError.failedToFetchInputMethods } - await store.send(.system(.inputMethodAvailabilityChanged)) { - $0.inputMethodCatalogStatus = .loading - $0.nextInputMethodRefreshID = 1 - $0.pendingInputMethodRefreshID = 0 + await store.send(.switching(.system(.inputMethodAvailabilityChanged))) { + $0.switching.inputMethodCatalogStatus = .loading + $0.switching.nextInputMethodRefreshID = 1 + $0.switching.pendingInputMethodRefreshID = 0 } - await store.receive(.response(.inputMethodsLoaded( + await store.receive(.switching(.response(.inputMethodsLoaded( refreshID: 0, result: .failure(.failedToFetchInputMethods) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .failed(.failedToFetchInputMethods) + )))) { + $0.switching.pendingInputMethodRefreshID = nil + $0.switching.inputMethodCatalogStatus = .failed(.failedToFetchInputMethods) } - XCTAssertEqual(store.state.inputMethods, inputMethods) + XCTAssertEqual(store.state.switching.inputMethods, inputMethods) XCTAssertEqual(store.state.inputMethodDiagnostic?.kind, .catalogFailed) XCTAssertFalse(store.state.hasMissingInputMethodRules) @@ -465,7 +505,7 @@ final class AppFeatureTests: XCTestCase { func testReloadInputMethodsRecoversFromFailure() async { let inputMethods = [InputMethod(id: "ime.en", name: "English")] var initialState = AppFeature.State() - initialState.inputMethodCatalogStatus = .failed(.failedToFetchInputMethods) + initialState.switching.inputMethodCatalogStatus = .failed(.failedToFetchInputMethods) let store = TestStore(initialState: initialState) { AppFeature() @@ -473,17 +513,17 @@ final class AppFeatureTests: XCTestCase { store.dependencies.inputMethodClient.fetchInputMethods = { inputMethods } await store.send(.view(.reloadInputMethodsTapped)) { - $0.inputMethodCatalogStatus = .loading - $0.nextInputMethodRefreshID = 1 - $0.pendingInputMethodRefreshID = 0 + $0.switching.inputMethodCatalogStatus = .loading + $0.switching.nextInputMethodRefreshID = 1 + $0.switching.pendingInputMethodRefreshID = 0 } - await store.receive(.response(.inputMethodsLoaded( + await store.receive(.switching(.response(.inputMethodsLoaded( refreshID: 0, result: .success(inputMethods) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .ready - $0.inputMethods = inputMethods + )))) { + $0.switching.pendingInputMethodRefreshID = nil + $0.switching.inputMethodCatalogStatus = .ready + $0.switching.inputMethods = inputMethods } XCTAssertNil(store.state.inputMethodDiagnostic) @@ -493,7 +533,7 @@ final class AppFeatureTests: XCTestCase { let latestInputMethods = [InputMethod(id: "ime.en", name: "English")] let refreshGate = InputMethodRefreshGate(latestInputMethods: latestInputMethods) var initialState = AppFeature.State() - initialState.inputMethodCatalogStatus = .failed(.failedToFetchInputMethods) + initialState.switching.inputMethodCatalogStatus = .failed(.failedToFetchInputMethods) let store = TestStore(initialState: initialState) { AppFeature() @@ -503,111 +543,115 @@ final class AppFeatureTests: XCTestCase { } await store.send(.view(.reloadInputMethodsTapped)) { - $0.inputMethodCatalogStatus = .loading - $0.nextInputMethodRefreshID = 1 - $0.pendingInputMethodRefreshID = 0 + $0.switching.inputMethodCatalogStatus = .loading + $0.switching.nextInputMethodRefreshID = 1 + $0.switching.pendingInputMethodRefreshID = 0 } await refreshGate.waitUntilFirstStarted() await store.send(.view(.reloadInputMethodsTapped)) { - $0.nextInputMethodRefreshID = 2 - $0.pendingInputMethodRefreshID = 1 + $0.switching.nextInputMethodRefreshID = 2 + $0.switching.pendingInputMethodRefreshID = 1 } - await store.receive(.response(.inputMethodsLoaded( + await store.receive(.switching(.response(.inputMethodsLoaded( refreshID: 1, result: .success(latestInputMethods) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .ready - $0.inputMethods = latestInputMethods + )))) { + $0.switching.pendingInputMethodRefreshID = nil + $0.switching.inputMethodCatalogStatus = .ready + $0.switching.inputMethods = latestInputMethods } await refreshGate.resumeFirst(with: .failure(.failedToFetchInputMethods)) - await store.receive(.response(.inputMethodsLoaded( + await store.receive(.switching(.response(.inputMethodsLoaded( refreshID: 0, result: .failure(.failedToFetchInputMethods) - ))) + )))) - XCTAssertEqual(store.state.inputMethodCatalogStatus, .ready) - XCTAssertEqual(store.state.inputMethods, latestInputMethods) + XCTAssertEqual(store.state.switching.inputMethodCatalogStatus, .ready) + XCTAssertEqual(store.state.switching.inputMethods, latestInputMethods) } - func testInputMethodRefreshRetriesActivationSkippedWhileCatalogIsLoading() async { + func testFailedInputMethodRefreshPreservesCatalogRetryAfterReactivationUntilReloadSucceeds() async { let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") let inputMethod = InputMethod(id: "ime.en", name: "English") let timestamp = Date(timeIntervalSince1970: 10) - let recorder = SwitchRecorder() - let switchGate = InputMethodSwitchGate() var initialState = AppFeature.State() - initialState.nextInputMethodRefreshID = 1 - initialState.pendingInputMethodRefreshID = 0 - initialState.$appRulesStore.withLock { - $0.rules[app.bundleId] = makeRule(app: app, strategy: .fixed(inputMethodId: inputMethod.id)) + initialState.switching.nextInputMethodRefreshID = 1 + initialState.switching.pendingInputMethodRefreshID = 0 + initialState.$fallbackRuleStore.withLock { + $0.strategy = .fixed(inputMethodId: inputMethod.id) } let store = TestStore(initialState: initialState) { AppFeature() } store.dependencies.date = .constant(timestamp) + store.dependencies.inputMethodClient.fetchInputMethods = { [inputMethod] } store.dependencies.workspaceClient.frontmostApplication = { app } - store.dependencies.inputMethodClient.currentInputMethodId = { "ime.zh" } - store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in - await recorder.record(inputMethodId) - await switchGate.wait() - } + store.dependencies.inputMethodClient.currentInputMethodId = { inputMethod.id } await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = true + $0.switching.currentFrontmostBundleId = app.bundleId + $0.switching.shouldRetryFrontmostAfterInputMethodRefresh = true + $0.$appRulesStore.withLock { + $0.rules[app.bundleId] = self.makeRule(app: app, strategy: .none) + } } - await store.send(.response(.inputMethodsLoaded( + await store.send(.switching(.response(.inputMethodsLoaded( refreshID: 0, + result: .failure(.failedToFetchInputMethods) + )))) { + $0.switching.pendingInputMethodRefreshID = nil + $0.switching.inputMethodCatalogStatus = .failed(.failedToFetchInputMethods) + } + await store.send(.system(.workspaceEvent(.activated(app)))) + await store.send(.view(.reloadInputMethodsTapped)) { + $0.switching.inputMethodCatalogStatus = .loading + $0.switching.nextInputMethodRefreshID = 2 + $0.switching.pendingInputMethodRefreshID = 1 + } + await store.receive(.switching(.response(.inputMethodsLoaded( + refreshID: 1, result: .success([inputMethod]) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .ready - $0.inputMethods = [inputMethod] - $0.nextFrontmostRetryID = 1 - $0.pendingFrontmostRetryID = 0 - $0.shouldRetryFrontmostAfterInputMethodRefresh = false - } - await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: app))) { - $0.pendingFrontmostRetryID = nil - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( + )))) { + $0.switching.pendingInputMethodRefreshID = nil + $0.switching.inputMethodCatalogStatus = .ready + $0.switching.inputMethods = [inputMethod] + $0.switching.nextFrontmostRetryID = 1 + $0.switching.pendingFrontmostRetryID = 0 + $0.switching.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.receive(.switching(.response(.frontmostApplicationRetried(retryID: 0, appInfo: app)))) { + $0.switching.pendingFrontmostRetryID = nil + $0.switching.nextSwitchAttemptID = 1 + $0.switching.pendingProgrammaticSwitch = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: inputMethod.id, - inputMethodName: inputMethod.name + inputMethodName: inputMethod.name, + ruleSource: .fallback ) } - await switchGate.waitUntilStarted() - await switchGate.resume() - await store.receive(.response(.programmaticSwitchFinished( + await store.receive(.switching(.response(.programmaticSwitchFinished( attemptID: 0, - outcome: .switched - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( + outcome: .alreadySelected + )))) { + $0.switching.pendingProgrammaticSwitch = nil + $0.switching.lastSwitchAttempt = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: inputMethod.id, inputMethodName: inputMethod.name, - outcome: .switched, - ruleSource: .app, + outcome: .alreadySelected, + ruleSource: .fallback, timestamp: timestamp ) - $0.$appSwitchStatisticsStore.withLock { - $0.counts[app.bundleId] = 1 - } } - - let switchedInputMethods = await recorder.values - XCTAssertEqual(switchedInputMethods, [inputMethod.id]) } - func testInputMethodRefreshRetriesOnlyLatestActivationSkippedWhileCatalogIsLoading() async { + func testFailedCatalogRetriesOnlyFinalFrontmostConfiguredAppAfterReloadSucceeds() async { let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") let firstInputMethod = InputMethod(id: "ime.first", name: "First Input Method") @@ -616,9 +660,9 @@ final class AppFeatureTests: XCTestCase { let recorder = SwitchRecorder() let switchGate = InputMethodSwitchGate() - var initialState = AppFeature.State() - initialState.nextInputMethodRefreshID = 1 - initialState.pendingInputMethodRefreshID = 0 + var initialState = AppFeature.State( + inputMethodCatalogStatus: .failed(.failedToFetchInputMethods) + ) initialState.$appRulesStore.withLock { $0.rules[firstApp.bundleId] = makeRule( app: firstApp, @@ -626,7 +670,7 @@ final class AppFeatureTests: XCTestCase { ) $0.rules[secondApp.bundleId] = makeRule( app: secondApp, - strategy: .fixed(inputMethodId: secondInputMethod.id) + strategy: .followLast(lastInputMethodId: secondInputMethod.id) ) } @@ -634,6 +678,9 @@ final class AppFeatureTests: XCTestCase { AppFeature() } store.dependencies.date = .constant(timestamp) + store.dependencies.inputMethodClient.fetchInputMethods = { + [firstInputMethod, secondInputMethod] + } store.dependencies.workspaceClient.frontmostApplication = { secondApp } store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in @@ -642,27 +689,32 @@ final class AppFeatureTests: XCTestCase { } await store.send(.system(.workspaceEvent(.activated(firstApp)))) { - $0.currentFrontmostBundleId = firstApp.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = true + $0.switching.currentFrontmostBundleId = firstApp.bundleId + $0.switching.shouldRetryFrontmostAfterInputMethodRefresh = true } await store.send(.system(.workspaceEvent(.activated(secondApp)))) { - $0.currentFrontmostBundleId = secondApp.bundleId + $0.switching.currentFrontmostBundleId = secondApp.bundleId + } + await store.send(.view(.reloadInputMethodsTapped)) { + $0.switching.inputMethodCatalogStatus = .loading + $0.switching.nextInputMethodRefreshID = 1 + $0.switching.pendingInputMethodRefreshID = 0 } - await store.send(.response(.inputMethodsLoaded( + await store.receive(.switching(.response(.inputMethodsLoaded( refreshID: 0, result: .success([firstInputMethod, secondInputMethod]) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .ready - $0.inputMethods = [firstInputMethod, secondInputMethod] - $0.nextFrontmostRetryID = 1 - $0.pendingFrontmostRetryID = 0 - $0.shouldRetryFrontmostAfterInputMethodRefresh = false - } - await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: secondApp))) { - $0.pendingFrontmostRetryID = nil - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( + )))) { + $0.switching.pendingInputMethodRefreshID = nil + $0.switching.inputMethodCatalogStatus = .ready + $0.switching.inputMethods = [firstInputMethod, secondInputMethod] + $0.switching.nextFrontmostRetryID = 1 + $0.switching.pendingFrontmostRetryID = 0 + $0.switching.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.receive(.switching(.response(.frontmostApplicationRetried(retryID: 0, appInfo: secondApp)))) { + $0.switching.pendingFrontmostRetryID = nil + $0.switching.nextSwitchAttemptID = 1 + $0.switching.pendingProgrammaticSwitch = .init( appName: secondApp.name, bundleId: secondApp.bundleId, inputMethodId: secondInputMethod.id, @@ -671,12 +723,12 @@ final class AppFeatureTests: XCTestCase { } await switchGate.waitUntilStarted() await switchGate.resume() - await store.receive(.response(.programmaticSwitchFinished( + await store.receive(.switching(.response(.programmaticSwitchFinished( attemptID: 0, outcome: .switched - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( + )))) { + $0.switching.pendingProgrammaticSwitch = nil + $0.switching.lastSwitchAttempt = .init( appName: secondApp.name, bundleId: secondApp.bundleId, inputMethodId: secondInputMethod.id, @@ -694,7 +746,7 @@ final class AppFeatureTests: XCTestCase { XCTAssertEqual(switchedInputMethods, [secondInputMethod.id]) } - func testActivationWithoutSwitchTargetClearsPendingCatalogRetry() async { + func testFailedCatalogClearsRetryForAppsWithoutExplicitTarget() async { let configuredApp = AppInfo( bundleId: "com.test.configured", name: "Configured", @@ -705,262 +757,169 @@ final class AppFeatureTests: XCTestCase { name: "Unconfigured", path: "/Applications/Unconfigured.app" ) + let ignoredApp = AppInfo( + bundleId: "com.test.ignored", + name: "Ignored", + path: "/Applications/Ignored.app" + ) + let followLastApp = AppInfo( + bundleId: "com.test.follow-last", + name: "Follow Last", + path: "/Applications/Follow Last.app" + ) let inputMethod = InputMethod(id: "ime.en", name: "English") let timestamp = Date(timeIntervalSince1970: 10) - var initialState = AppFeature.State() - initialState.nextInputMethodRefreshID = 1 - initialState.pendingInputMethodRefreshID = 0 + var initialState = AppFeature.State( + inputMethodCatalogStatus: .failed(.failedToFetchInputMethods) + ) initialState.$appRulesStore.withLock { $0.rules[configuredApp.bundleId] = makeRule( app: configuredApp, strategy: .fixed(inputMethodId: inputMethod.id) ) + $0.rules[ignoredApp.bundleId] = makeRule(app: ignoredApp, strategy: .ignored) + $0.rules[followLastApp.bundleId] = makeRule( + app: followLastApp, + strategy: .followLast(lastInputMethodId: nil) + ) } let store = TestStore(initialState: initialState) { AppFeature() } store.dependencies.date = .constant(timestamp) + store.dependencies.inputMethodClient.fetchInputMethods = { [inputMethod] } store.dependencies.workspaceClient.frontmostApplication = { XCTFail("A cleared catalog retry must not query the frontmost application") - return unconfiguredApp + return followLastApp } store.dependencies.inputMethodClient.switchToInputMethod = { _ in - XCTFail("An unconfigured app must not trigger a compensated switch") + XCTFail("An app without an explicit target must not trigger a compensated switch") } await store.send(.system(.workspaceEvent(.activated(configuredApp)))) { - $0.currentFrontmostBundleId = configuredApp.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = true + $0.switching.currentFrontmostBundleId = configuredApp.bundleId + $0.switching.shouldRetryFrontmostAfterInputMethodRefresh = true } await store.send(.system(.workspaceEvent(.activated(unconfiguredApp)))) { - $0.currentFrontmostBundleId = unconfiguredApp.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = false + $0.switching.currentFrontmostBundleId = unconfiguredApp.bundleId + $0.switching.shouldRetryFrontmostAfterInputMethodRefresh = false $0.$appRulesStore.withLock { $0.rules[unconfiguredApp.bundleId] = self.makeRule(app: unconfiguredApp, strategy: .none) } } - await store.send(.response(.inputMethodsLoaded( + await store.send(.system(.workspaceEvent(.activated(configuredApp)))) { + $0.switching.currentFrontmostBundleId = configuredApp.bundleId + $0.switching.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.system(.workspaceEvent(.activated(ignoredApp)))) { + $0.switching.currentFrontmostBundleId = ignoredApp.bundleId + $0.switching.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.send(.system(.workspaceEvent(.activated(configuredApp)))) { + $0.switching.currentFrontmostBundleId = configuredApp.bundleId + $0.switching.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.system(.workspaceEvent(.activated(followLastApp)))) { + $0.switching.currentFrontmostBundleId = followLastApp.bundleId + $0.switching.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.send(.view(.reloadInputMethodsTapped)) { + $0.switching.inputMethodCatalogStatus = .loading + $0.switching.nextInputMethodRefreshID = 1 + $0.switching.pendingInputMethodRefreshID = 0 + } + await store.receive(.switching(.response(.inputMethodsLoaded( refreshID: 0, result: .success([inputMethod]) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .ready - $0.inputMethods = [inputMethod] + )))) { + $0.switching.pendingInputMethodRefreshID = nil + $0.switching.inputMethodCatalogStatus = .ready + $0.switching.inputMethods = [inputMethod] } } - func testFailedInputMethodRefreshPreservesCatalogRetryAfterReactivationUntilReloadSucceeds() async { + func testRetryCurrentAppUsesFreshFrontmostApplication() async { let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") let inputMethod = InputMethod(id: "ime.en", name: "English") let timestamp = Date(timeIntervalSince1970: 10) - var initialState = AppFeature.State() - initialState.nextInputMethodRefreshID = 1 - initialState.pendingInputMethodRefreshID = 0 - initialState.$fallbackRuleStore.withLock { - $0.strategy = .fixed(inputMethodId: inputMethod.id) + initialState.switching.inputMethods = [inputMethod] + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .fixed(inputMethodId: inputMethod.id), + createdAt: timestamp, + updatedAt: timestamp + ) } let store = TestStore(initialState: initialState) { AppFeature() } store.dependencies.date = .constant(timestamp) - store.dependencies.inputMethodClient.fetchInputMethods = { [inputMethod] } store.dependencies.workspaceClient.frontmostApplication = { app } store.dependencies.inputMethodClient.currentInputMethodId = { inputMethod.id } - await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = true - $0.$appRulesStore.withLock { - $0.rules[app.bundleId] = self.makeRule(app: app, strategy: .none) - } - } - await store.send(.response(.inputMethodsLoaded( - refreshID: 0, - result: .failure(.failedToFetchInputMethods) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .failed(.failedToFetchInputMethods) - } - await store.send(.system(.workspaceEvent(.activated(app)))) - await store.send(.view(.reloadInputMethodsTapped)) { - $0.inputMethodCatalogStatus = .loading - $0.nextInputMethodRefreshID = 2 - $0.pendingInputMethodRefreshID = 1 - } - await store.receive(.response(.inputMethodsLoaded( - refreshID: 1, - result: .success([inputMethod]) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .ready - $0.inputMethods = [inputMethod] - $0.nextFrontmostRetryID = 1 - $0.pendingFrontmostRetryID = 0 - $0.shouldRetryFrontmostAfterInputMethodRefresh = false - } - await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: app))) { - $0.pendingFrontmostRetryID = nil - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( + await store.send(.view(.retryCurrentAppTapped)) { + $0.switching.nextFrontmostRetryID = 1 + $0.switching.pendingFrontmostRetryID = 0 + } + await store.receive(.switching(.response(.frontmostApplicationRetried(retryID: 0, appInfo: app)))) { + $0.switching.pendingFrontmostRetryID = nil + $0.switching.currentFrontmostBundleId = app.bundleId + $0.switching.nextSwitchAttemptID = 1 + $0.switching.pendingProgrammaticSwitch = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: inputMethod.id, - inputMethodName: inputMethod.name, - ruleSource: .fallback + inputMethodName: inputMethod.name ) } - await store.receive(.response(.programmaticSwitchFinished( + await store.receive(.switching(.response(.programmaticSwitchFinished( attemptID: 0, outcome: .alreadySelected - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( + )))) { + $0.switching.pendingProgrammaticSwitch = nil + $0.switching.lastSwitchAttempt = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: inputMethod.id, inputMethodName: inputMethod.name, outcome: .alreadySelected, - ruleSource: .fallback, + ruleSource: .app, timestamp: timestamp ) } } - func testFailedCatalogRetriesOnlyFinalFrontmostConfiguredAppAfterReloadSucceeds() async { + func testRetryCurrentAppIgnoresSnapshotAfterNewActivation() async { let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") - let firstInputMethod = InputMethod(id: "ime.first", name: "First Input Method") - let secondInputMethod = InputMethod(id: "ime.second", name: "Second Input Method") + let frontmostGate = FrontmostApplicationGate(appInfo: firstApp) let timestamp = Date(timeIntervalSince1970: 10) - let recorder = SwitchRecorder() - let switchGate = InputMethodSwitchGate() - var initialState = AppFeature.State( - inputMethodCatalogStatus: .failed(.failedToFetchInputMethods) - ) + var initialState = AppFeature.State() + initialState.switching.currentFrontmostBundleId = firstApp.bundleId initialState.$appRulesStore.withLock { - $0.rules[firstApp.bundleId] = makeRule( - app: firstApp, - strategy: .fixed(inputMethodId: firstInputMethod.id) - ) - $0.rules[secondApp.bundleId] = makeRule( - app: secondApp, - strategy: .followLast(lastInputMethodId: secondInputMethod.id) - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(timestamp) - store.dependencies.inputMethodClient.fetchInputMethods = { - [firstInputMethod, secondInputMethod] - } - store.dependencies.workspaceClient.frontmostApplication = { secondApp } - store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } - store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in - await recorder.record(inputMethodId) - await switchGate.wait() - } - - await store.send(.system(.workspaceEvent(.activated(firstApp)))) { - $0.currentFrontmostBundleId = firstApp.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = true - } - await store.send(.system(.workspaceEvent(.activated(secondApp)))) { - $0.currentFrontmostBundleId = secondApp.bundleId - } - await store.send(.view(.reloadInputMethodsTapped)) { - $0.inputMethodCatalogStatus = .loading - $0.nextInputMethodRefreshID = 1 - $0.pendingInputMethodRefreshID = 0 - } - await store.receive(.response(.inputMethodsLoaded( - refreshID: 0, - result: .success([firstInputMethod, secondInputMethod]) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .ready - $0.inputMethods = [firstInputMethod, secondInputMethod] - $0.nextFrontmostRetryID = 1 - $0.pendingFrontmostRetryID = 0 - $0.shouldRetryFrontmostAfterInputMethodRefresh = false - } - await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: secondApp))) { - $0.pendingFrontmostRetryID = nil - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( - appName: secondApp.name, - bundleId: secondApp.bundleId, - inputMethodId: secondInputMethod.id, - inputMethodName: secondInputMethod.name + $0.rules[firstApp.bundleId] = AppRuleRecord( + bundleId: firstApp.bundleId, + lastKnownPath: firstApp.path, + lastKnownName: firstApp.name, + strategy: .fixed(inputMethodId: "ime.en"), + createdAt: timestamp, + updatedAt: timestamp ) - } - await switchGate.waitUntilStarted() - await switchGate.resume() - await store.receive(.response(.programmaticSwitchFinished( - attemptID: 0, - outcome: .switched - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( - appName: secondApp.name, + $0.rules[secondApp.bundleId] = AppRuleRecord( bundleId: secondApp.bundleId, - inputMethodId: secondInputMethod.id, - inputMethodName: secondInputMethod.name, - outcome: .switched, - ruleSource: .app, - timestamp: timestamp - ) - $0.$appSwitchStatisticsStore.withLock { - $0.counts[secondApp.bundleId] = 1 - } - } - - let switchedInputMethods = await recorder.values - XCTAssertEqual(switchedInputMethods, [secondInputMethod.id]) - } - - func testFailedCatalogClearsRetryForAppsWithoutExplicitTarget() async { - let configuredApp = AppInfo( - bundleId: "com.test.configured", - name: "Configured", - path: "/Applications/Configured.app" - ) - let unconfiguredApp = AppInfo( - bundleId: "com.test.unconfigured", - name: "Unconfigured", - path: "/Applications/Unconfigured.app" - ) - let ignoredApp = AppInfo( - bundleId: "com.test.ignored", - name: "Ignored", - path: "/Applications/Ignored.app" - ) - let followLastApp = AppInfo( - bundleId: "com.test.follow-last", - name: "Follow Last", - path: "/Applications/Follow Last.app" - ) - let inputMethod = InputMethod(id: "ime.en", name: "English") - let timestamp = Date(timeIntervalSince1970: 10) - - var initialState = AppFeature.State( - inputMethodCatalogStatus: .failed(.failedToFetchInputMethods) - ) - initialState.$appRulesStore.withLock { - $0.rules[configuredApp.bundleId] = makeRule( - app: configuredApp, - strategy: .fixed(inputMethodId: inputMethod.id) - ) - $0.rules[ignoredApp.bundleId] = makeRule(app: ignoredApp, strategy: .ignored) - $0.rules[followLastApp.bundleId] = makeRule( - app: followLastApp, - strategy: .followLast(lastInputMethodId: nil) + lastKnownPath: secondApp.path, + lastKnownName: secondApp.name, + strategy: .none, + createdAt: timestamp, + updatedAt: timestamp ) } @@ -968,321 +927,25 @@ final class AppFeatureTests: XCTestCase { AppFeature() } store.dependencies.date = .constant(timestamp) - store.dependencies.inputMethodClient.fetchInputMethods = { [inputMethod] } - store.dependencies.workspaceClient.frontmostApplication = { - XCTFail("A cleared catalog retry must not query the frontmost application") - return followLastApp - } - store.dependencies.inputMethodClient.switchToInputMethod = { _ in - XCTFail("An app without an explicit target must not trigger a compensated switch") - } - - await store.send(.system(.workspaceEvent(.activated(configuredApp)))) { - $0.currentFrontmostBundleId = configuredApp.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = true - } - await store.send(.system(.workspaceEvent(.activated(unconfiguredApp)))) { - $0.currentFrontmostBundleId = unconfiguredApp.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = false - $0.$appRulesStore.withLock { - $0.rules[unconfiguredApp.bundleId] = self.makeRule(app: unconfiguredApp, strategy: .none) - } - } - await store.send(.system(.workspaceEvent(.activated(configuredApp)))) { - $0.currentFrontmostBundleId = configuredApp.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = true - } - await store.send(.system(.workspaceEvent(.activated(ignoredApp)))) { - $0.currentFrontmostBundleId = ignoredApp.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = false - } - await store.send(.system(.workspaceEvent(.activated(configuredApp)))) { - $0.currentFrontmostBundleId = configuredApp.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = true - } - await store.send(.system(.workspaceEvent(.activated(followLastApp)))) { - $0.currentFrontmostBundleId = followLastApp.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = false - } - await store.send(.view(.reloadInputMethodsTapped)) { - $0.inputMethodCatalogStatus = .loading - $0.nextInputMethodRefreshID = 1 - $0.pendingInputMethodRefreshID = 0 - } - await store.receive(.response(.inputMethodsLoaded( - refreshID: 0, - result: .success([inputMethod]) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .ready - $0.inputMethods = [inputMethod] - } - } - - func testStaleInputMethodRefreshDoesNotConsumePendingCatalogRetry() async { - let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") - let inputMethod = InputMethod(id: "ime.en", name: "English") - let timestamp = Date(timeIntervalSince1970: 10) - - var initialState = AppFeature.State() - initialState.nextInputMethodRefreshID = 2 - initialState.pendingInputMethodRefreshID = 1 - initialState.$appRulesStore.withLock { - $0.rules[app.bundleId] = makeRule(app: app, strategy: .fixed(inputMethodId: inputMethod.id)) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(timestamp) - store.dependencies.workspaceClient.frontmostApplication = { app } - store.dependencies.inputMethodClient.currentInputMethodId = { inputMethod.id } - - await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = true - } - await store.send(.response(.inputMethodsLoaded( - refreshID: 0, - result: .success([inputMethod]) - ))) - XCTAssertTrue(store.state.shouldRetryFrontmostAfterInputMethodRefresh) - - await store.send(.response(.inputMethodsLoaded( - refreshID: 1, - result: .success([inputMethod]) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .ready - $0.inputMethods = [inputMethod] - $0.nextFrontmostRetryID = 1 - $0.pendingFrontmostRetryID = 0 - $0.shouldRetryFrontmostAfterInputMethodRefresh = false - } - await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: app))) { - $0.pendingFrontmostRetryID = nil - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: inputMethod.id, - inputMethodName: inputMethod.name - ) - } - await store.receive(.response(.programmaticSwitchFinished( - attemptID: 0, - outcome: .alreadySelected - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: inputMethod.id, - inputMethodName: inputMethod.name, - outcome: .alreadySelected, - ruleSource: .app, - timestamp: timestamp - ) - } - } - - func testCatalogRetryIgnoresFrontmostSnapshotAfterNewActivation() async { - let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") - let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") - let inputMethod = InputMethod(id: "ime.en", name: "English") - let frontmostGate = FrontmostApplicationGate(appInfo: firstApp) - - var initialState = AppFeature.State() - initialState.nextInputMethodRefreshID = 1 - initialState.pendingInputMethodRefreshID = 0 - initialState.$appRulesStore.withLock { - $0.rules[firstApp.bundleId] = makeRule( - app: firstApp, - strategy: .fixed(inputMethodId: inputMethod.id) - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) store.dependencies.workspaceClient.frontmostApplication = { await frontmostGate.value() } - store.dependencies.inputMethodClient.switchToInputMethod = { _ in - XCTFail("A stale frontmost snapshot must not switch input methods") - } - await store.send(.system(.workspaceEvent(.activated(firstApp)))) { - $0.currentFrontmostBundleId = firstApp.bundleId - $0.shouldRetryFrontmostAfterInputMethodRefresh = true - } - await store.send(.response(.inputMethodsLoaded( - refreshID: 0, - result: .success([inputMethod]) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .ready - $0.inputMethods = [inputMethod] - $0.nextFrontmostRetryID = 1 - $0.pendingFrontmostRetryID = 0 - $0.shouldRetryFrontmostAfterInputMethodRefresh = false + await store.send(.view(.retryCurrentAppTapped)) { + $0.switching.nextFrontmostRetryID = 1 + $0.switching.pendingFrontmostRetryID = 0 } await frontmostGate.waitUntilStarted() await store.send(.system(.workspaceEvent(.activated(secondApp)))) { - $0.currentFrontmostBundleId = secondApp.bundleId - $0.pendingFrontmostRetryID = nil - $0.$appRulesStore.withLock { - $0.rules[secondApp.bundleId] = self.makeRule(app: secondApp, strategy: .none) - } + $0.switching.currentFrontmostBundleId = secondApp.bundleId + $0.switching.pendingFrontmostRetryID = nil } await frontmostGate.resume() - await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: firstApp))) - - XCTAssertEqual(store.state.currentFrontmostBundleId, secondApp.bundleId) - } + await store.receive(.switching(.response(.frontmostApplicationRetried(retryID: 0, appInfo: firstApp)))) - func testSuccessfulInputMethodRefreshWithoutPendingCatalogRetryDoesNotReapplyCurrentRule() async { - let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") - let inputMethod = InputMethod(id: "ime.en", name: "English") - - var initialState = AppFeature.State() - initialState.currentFrontmostBundleId = app.bundleId - initialState.nextInputMethodRefreshID = 1 - initialState.pendingInputMethodRefreshID = 0 - initialState.$appRulesStore.withLock { - $0.rules[app.bundleId] = makeRule(app: app, strategy: .fixed(inputMethodId: inputMethod.id)) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.workspaceClient.frontmostApplication = { - XCTFail("A normal catalog refresh must not query the frontmost application") - return app - } - store.dependencies.inputMethodClient.switchToInputMethod = { _ in - XCTFail("A normal catalog refresh must not reapply the current rule") - } - - await store.send(.response(.inputMethodsLoaded( - refreshID: 0, - result: .success([inputMethod]) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .ready - $0.inputMethods = [inputMethod] - } - } - - func testRetryCurrentAppUsesFreshFrontmostApplication() async { - let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") - let inputMethod = InputMethod(id: "ime.en", name: "English") - let timestamp = Date(timeIntervalSince1970: 10) - var initialState = AppFeature.State() - initialState.inputMethods = [inputMethod] - initialState.$appRulesStore.withLock { - $0.rules[app.bundleId] = AppRuleRecord( - bundleId: app.bundleId, - lastKnownPath: app.path, - lastKnownName: app.name, - strategy: .fixed(inputMethodId: inputMethod.id), - createdAt: timestamp, - updatedAt: timestamp - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(timestamp) - store.dependencies.workspaceClient.frontmostApplication = { app } - store.dependencies.inputMethodClient.currentInputMethodId = { inputMethod.id } - - await store.send(.view(.retryCurrentAppTapped)) { - $0.nextFrontmostRetryID = 1 - $0.pendingFrontmostRetryID = 0 - } - await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: app))) { - $0.pendingFrontmostRetryID = nil - $0.currentFrontmostBundleId = app.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: inputMethod.id, - inputMethodName: inputMethod.name - ) - } - await store.receive(.response(.programmaticSwitchFinished( - attemptID: 0, - outcome: .alreadySelected - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: inputMethod.id, - inputMethodName: inputMethod.name, - outcome: .alreadySelected, - ruleSource: .app, - timestamp: timestamp - ) - } - } - - func testRetryCurrentAppIgnoresSnapshotAfterNewActivation() async { - let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") - let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") - let frontmostGate = FrontmostApplicationGate(appInfo: firstApp) - let timestamp = Date(timeIntervalSince1970: 10) - - var initialState = AppFeature.State() - initialState.currentFrontmostBundleId = firstApp.bundleId - initialState.$appRulesStore.withLock { - $0.rules[firstApp.bundleId] = AppRuleRecord( - bundleId: firstApp.bundleId, - lastKnownPath: firstApp.path, - lastKnownName: firstApp.name, - strategy: .fixed(inputMethodId: "ime.en"), - createdAt: timestamp, - updatedAt: timestamp - ) - $0.rules[secondApp.bundleId] = AppRuleRecord( - bundleId: secondApp.bundleId, - lastKnownPath: secondApp.path, - lastKnownName: secondApp.name, - strategy: .none, - createdAt: timestamp, - updatedAt: timestamp - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(timestamp) - store.dependencies.workspaceClient.frontmostApplication = { - await frontmostGate.value() - } - - await store.send(.view(.retryCurrentAppTapped)) { - $0.nextFrontmostRetryID = 1 - $0.pendingFrontmostRetryID = 0 - } - await frontmostGate.waitUntilStarted() - - await store.send(.system(.workspaceEvent(.activated(secondApp)))) { - $0.currentFrontmostBundleId = secondApp.bundleId - $0.pendingFrontmostRetryID = nil - } - - await frontmostGate.resume() - await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: firstApp))) - - XCTAssertEqual(store.state.currentFrontmostBundleId, secondApp.bundleId) + XCTAssertEqual(store.state.switching.currentFrontmostBundleId, secondApp.bundleId) } func testRetryCurrentAppContinuesAfterUnrelatedAppTerminates() async { @@ -1290,649 +953,219 @@ final class AppFeatureTests: XCTestCase { let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") let unrelatedApp = AppInfo(bundleId: "com.test.other", name: "Other", path: "/Applications/Other.app") let frontmostGate = FrontmostApplicationGate(appInfo: secondApp) - let runningApps = [firstApp, secondApp, unrelatedApp] - let timestamp = Date(timeIntervalSince1970: 10) - - var initialState = AppFeature.State() - initialState.currentFrontmostBundleId = firstApp.bundleId - initialState.runningApps = runningApps - initialState.$appRulesStore.withLock { store in - for app in runningApps { - store.rules[app.bundleId] = AppRuleRecord( - bundleId: app.bundleId, - lastKnownPath: app.path, - lastKnownName: app.name, - strategy: .none, - createdAt: timestamp, - updatedAt: timestamp - ) - } - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(timestamp) - store.dependencies.workspaceClient.frontmostApplication = { - await frontmostGate.value() - } - store.dependencies.workspaceClient.runningApplications = { [firstApp, secondApp] } - - await store.send(.view(.retryCurrentAppTapped)) { - $0.nextFrontmostRetryID = 1 - $0.pendingFrontmostRetryID = 0 - } - await frontmostGate.waitUntilStarted() - - await store.send(.system(.workspaceEvent(.terminated(bundleId: unrelatedApp.bundleId)))) - await store.receive(.response(.runningApps([firstApp, secondApp]))) { - $0.runningApps = [firstApp, secondApp] - } - - await frontmostGate.resume() - await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: secondApp))) { - $0.currentFrontmostBundleId = secondApp.bundleId - $0.pendingFrontmostRetryID = nil - } - - XCTAssertEqual(store.state.currentFrontmostBundleId, secondApp.bundleId) - } - - func testStaleProgrammaticSwitchResultIsIgnored() async { - var initialState = AppFeature.State() - initialState.pendingProgrammaticSwitch = .init( - appName: "Editor", - attemptID: 1, - bundleId: "com.test.editor", - inputMethodId: "ime.en", - inputMethodName: "English" - ) - - let store = TestStore(initialState: initialState) { - AppFeature() - } - - await store.send(.response(.programmaticSwitchFinished( - attemptID: 0, - outcome: .switched - ))) - - XCTAssertNotNil(store.state.pendingProgrammaticSwitch) - XCTAssertNil(store.state.lastSwitchAttempt) - XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) - } - - func testSelectionNotificationDoesNotDiscardSuccessfulSwitchResult() async { - let timestamp = Date(timeIntervalSince1970: 10) - let bundleId = "com.test.editor" - var initialState = AppFeature.State() - initialState.pendingProgrammaticSwitch = .init( - appName: "Editor", - bundleId: bundleId, - inputMethodId: "ime.en", - inputMethodName: "English" - ) - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(timestamp) - - await store.send(.system(.inputMethodSelectedChanged("ime.en"))) { - $0.pendingProgrammaticSwitch?.didObserveTargetSelection = true - } - await store.send(.response(.programmaticSwitchFinished( - attemptID: 0, - outcome: .switched - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( - appName: "Editor", - bundleId: bundleId, - inputMethodId: "ime.en", - inputMethodName: "English", - outcome: .switched, - ruleSource: .app, - timestamp: timestamp - ) - $0.$appSwitchStatisticsStore.withLock { - $0.counts[bundleId] = 1 - } - } - } - - func testSelectionNotificationPreventsFailureFromOverwritingConfirmedSuccess() async { - let timestamp = Date(timeIntervalSince1970: 10) - let bundleId = "com.test.editor" - let inputMethodId = "ime.en" - var initialState = AppFeature.State() - initialState.currentFrontmostBundleId = bundleId - initialState.pendingProgrammaticSwitch = .init( - appName: "Editor", - bundleId: bundleId, - inputMethodId: inputMethodId, - inputMethodName: "English" - ) - initialState.$appRulesStore.withLock { - $0.rules[bundleId] = AppRuleRecord( - bundleId: bundleId, - lastKnownPath: "/Applications/Editor.app", - lastKnownName: "Editor", - strategy: .fixed(inputMethodId: inputMethodId), - createdAt: timestamp, - updatedAt: timestamp - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(timestamp) - - await store.send(.system(.inputMethodSelectedChanged(inputMethodId))) { - $0.pendingProgrammaticSwitch?.didObserveTargetSelection = true - } - await store.send(.response(.programmaticSwitchFinished( - attemptID: 0, - outcome: .failed(.failedToVerifyInputMethod(inputMethodId)) - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( - appName: "Editor", - bundleId: bundleId, - inputMethodId: inputMethodId, - inputMethodName: "English", - outcome: .alreadySelected, - ruleSource: .app, - timestamp: timestamp - ) - } - - XCTAssertNil(store.state.inputMethodDiagnostic) - XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) - } - - func testNonTargetSelectionRevokesConfirmedProgrammaticSwitch() async { - let timestamp = Date(timeIntervalSince1970: 10) - let bundleId = "com.test.editor" - let targetInputMethodId = "ime.en" - var initialState = AppFeature.State() - initialState.currentFrontmostBundleId = bundleId - initialState.pendingProgrammaticSwitch = .init( - appName: "Editor", - bundleId: bundleId, - inputMethodId: targetInputMethodId, - inputMethodName: "English" - ) - initialState.$appRulesStore.withLock { - $0.rules[bundleId] = AppRuleRecord( - bundleId: bundleId, - lastKnownPath: "/Applications/Editor.app", - lastKnownName: "Editor", - strategy: .fixed(inputMethodId: targetInputMethodId), - createdAt: timestamp, - updatedAt: timestamp - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(timestamp) - - await store.send(.system(.inputMethodSelectedChanged(targetInputMethodId))) { - $0.pendingProgrammaticSwitch?.didObserveTargetSelection = true - } - await store.send(.system(.inputMethodSelectedChanged("ime.jp"))) { - $0.pendingProgrammaticSwitch?.didObserveTargetSelection = false - } - await store.send(.response(.programmaticSwitchFinished( - attemptID: 0, - outcome: .failed(.failedToVerifyInputMethod(targetInputMethodId)) - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( - appName: "Editor", - bundleId: bundleId, - inputMethodId: targetInputMethodId, - inputMethodName: "English", - outcome: .failed(.failedToVerifyInputMethod(targetInputMethodId)), - ruleSource: .app, - timestamp: timestamp - ) - } - - XCTAssertEqual( - store.state.lastSwitchAttempt?.outcome, - .failed(.failedToVerifyInputMethod(targetInputMethodId)) - ) - XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) - } - - func testVerificationFailureProducesSwitchDiagnostic() async { - let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") - let targetInputMethod = "ime.en" - let timestamp = Date(timeIntervalSince1970: 10) - - var initialState = AppFeature.State() - initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] - initialState.$appRulesStore.withLock { - $0.rules[app.bundleId] = AppRuleRecord( - bundleId: app.bundleId, - lastKnownPath: app.path, - lastKnownName: app.name, - strategy: .fixed(inputMethodId: targetInputMethod), - createdAt: timestamp, - updatedAt: timestamp - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(timestamp) - store.dependencies.inputMethodClient.currentInputMethodId = { "ime.zh" } - store.dependencies.inputMethodClient.switchToInputMethod = { _ in - throw InputMethodService.InputMethodError.failedToVerifyInputMethod(targetInputMethod) - } - - await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: targetInputMethod, - inputMethodName: "English" - ) - } - await store.receive(.response(.programmaticSwitchFinished( - attemptID: 0, - outcome: .failed(.failedToVerifyInputMethod(targetInputMethod)) - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: targetInputMethod, - inputMethodName: "English", - outcome: .failed(.failedToVerifyInputMethod(targetInputMethod)), - ruleSource: .app, - timestamp: timestamp - ) - } - - XCTAssertEqual(store.state.inputMethodDiagnostic?.kind, .switchFailed) - XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) - } - - func testSwitchDiagnosticDisappearsWhenRuleNoLongerTargetsFailedInputMethod() { - let bundleId = "com.test.browser" - var state = AppFeature.State() - state.currentFrontmostBundleId = bundleId - state.lastSwitchAttempt = .init( - appName: "Browser", - bundleId: bundleId, - inputMethodId: "ime.en", - inputMethodName: "English", - outcome: .failed(.failedToSwitchInputMethod("ime.en")), - ruleSource: .app, - timestamp: Date(timeIntervalSince1970: 10) - ) - state.$appRulesStore.withLock { - $0.rules[bundleId] = AppRuleRecord( - bundleId: bundleId, - lastKnownPath: "/Applications/Browser.app", - lastKnownName: "Browser", - strategy: .fixed(inputMethodId: "ime.en"), - createdAt: Date(timeIntervalSince1970: 10), - updatedAt: Date(timeIntervalSince1970: 10) - ) - } - - XCTAssertEqual(state.inputMethodDiagnostic?.kind, .switchFailed) - - state.$appRulesStore.withLock { - $0.rules[bundleId]?.strategy = .none - } - XCTAssertNil(state.inputMethodDiagnostic) - - state.$appRulesStore.withLock { - $0.rules[bundleId]?.strategy = .fixed(inputMethodId: "ime.zh") - } - XCTAssertNil(state.inputMethodDiagnostic) - - state.$appRulesStore.withLock { - $0.rules[bundleId]?.strategy = .ignored - } - XCTAssertNil(state.inputMethodDiagnostic) - } - - func testActivatedAppUsesAppRuleBeforeFallbackRule() async { - let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") - let appInputMethod = "ime.app" - let fallbackInputMethod = "ime.fallback" - let recorder = SwitchRecorder() - - var initialState = AppFeature.State() - initialState.inputMethods = [ - InputMethod(id: appInputMethod, name: "App"), - InputMethod(id: fallbackInputMethod, name: "Fallback"), - ] - initialState.$fallbackRuleStore.withLock { - $0.strategy = .fixed(inputMethodId: fallbackInputMethod) - } - initialState.$appRulesStore.withLock { - $0.rules[app.bundleId] = AppRuleRecord( - bundleId: app.bundleId, - lastKnownPath: app.path, - lastKnownName: app.name, - strategy: .fixed(inputMethodId: appInputMethod), - createdAt: Date(timeIntervalSince1970: 10), - updatedAt: Date(timeIntervalSince1970: 10) - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) - store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } - store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in - await recorder.record(inputMethodId) - } - - await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: appInputMethod, - inputMethodName: "App" - ) - } - await store.receive(.response(.programmaticSwitchFinished( - attemptID: 0, - outcome: .switched - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: appInputMethod, - inputMethodName: "App", - outcome: .switched, - ruleSource: .app, - timestamp: Date(timeIntervalSince1970: 10) - ) - $0.$appSwitchStatisticsStore.withLock { - $0.counts[app.bundleId] = 1 - } - } - - let switchedInputMethods = await recorder.values - XCTAssertEqual(switchedInputMethods, [appInputMethod]) - } - - func testActivatedAppUsesFallbackRuleWhenAppRuleIsNone() async { - let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") - let fallbackInputMethod = "ime.fallback" - let recorder = SwitchRecorder() - - var initialState = AppFeature.State() - initialState.inputMethods = [InputMethod(id: fallbackInputMethod, name: "Fallback")] - initialState.$fallbackRuleStore.withLock { - $0.strategy = .fixed(inputMethodId: fallbackInputMethod) - } - initialState.$appRulesStore.withLock { - $0.rules[app.bundleId] = AppRuleRecord( - bundleId: app.bundleId, - lastKnownPath: app.path, - lastKnownName: app.name, - strategy: .none, - createdAt: Date(timeIntervalSince1970: 10), - updatedAt: Date(timeIntervalSince1970: 10) - ) + let runningApps = [firstApp, secondApp, unrelatedApp] + let timestamp = Date(timeIntervalSince1970: 10) + + var initialState = AppFeature.State() + initialState.switching.currentFrontmostBundleId = firstApp.bundleId + initialState.runningApps = runningApps + initialState.$appRulesStore.withLock { store in + for app in runningApps { + store.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .none, + createdAt: timestamp, + updatedAt: timestamp + ) + } } let store = TestStore(initialState: initialState) { AppFeature() } - store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) - store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } - store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in - await recorder.record(inputMethodId) + store.dependencies.date = .constant(timestamp) + store.dependencies.workspaceClient.frontmostApplication = { + await frontmostGate.value() } + store.dependencies.workspaceClient.runningApplications = { [firstApp, secondApp] } - await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: fallbackInputMethod, - inputMethodName: "Fallback", - ruleSource: .fallback - ) + await store.send(.view(.retryCurrentAppTapped)) { + $0.switching.nextFrontmostRetryID = 1 + $0.switching.pendingFrontmostRetryID = 0 } - await store.receive(.response(.programmaticSwitchFinished( - attemptID: 0, - outcome: .switched - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: fallbackInputMethod, - inputMethodName: "Fallback", - outcome: .switched, - ruleSource: .fallback, - timestamp: Date(timeIntervalSince1970: 10) - ) - $0.$appSwitchStatisticsStore.withLock { - $0.counts[app.bundleId] = 1 - } + await frontmostGate.waitUntilStarted() + + await store.send(.system(.workspaceEvent(.terminated(bundleId: unrelatedApp.bundleId)))) + await store.receive(.response(.runningApps([firstApp, secondApp]))) { + $0.runningApps = [firstApp, secondApp] } - let switchedInputMethods = await recorder.values - XCTAssertEqual(switchedInputMethods, [fallbackInputMethod]) - } + await frontmostGate.resume() + await store.receive(.switching(.response(.frontmostApplicationRetried(retryID: 0, appInfo: secondApp)))) { + $0.switching.currentFrontmostBundleId = secondApp.bundleId + $0.switching.pendingFrontmostRetryID = nil + } - func testActivatedIgnoredAppOverridesFixedFallback() async { - let app = AppInfo(bundleId: "com.test.passwords", name: "Passwords", path: "/Applications/Passwords.app") + XCTAssertEqual(store.state.switching.currentFrontmostBundleId, secondApp.bundleId) + } + func testSelectionNotificationPreventsFailureFromOverwritingConfirmedSuccess() async { + let timestamp = Date(timeIntervalSince1970: 10) + let bundleId = "com.test.editor" + let inputMethodId = "ime.en" var initialState = AppFeature.State() - initialState.inputMethods = [InputMethod(id: "ime.fallback", name: "Fallback")] - initialState.$fallbackRuleStore.withLock { - $0.strategy = .fixed(inputMethodId: "ime.fallback") - } + initialState.switching.currentFrontmostBundleId = bundleId + initialState.switching.pendingProgrammaticSwitch = .init( + appName: "Editor", + bundleId: bundleId, + inputMethodId: inputMethodId, + inputMethodName: "English" + ) initialState.$appRulesStore.withLock { - $0.rules[app.bundleId] = AppRuleRecord( - bundleId: app.bundleId, - lastKnownPath: app.path, - lastKnownName: app.name, - strategy: .ignored, - createdAt: Date(timeIntervalSince1970: 10), - updatedAt: Date(timeIntervalSince1970: 10) + $0.rules[bundleId] = AppRuleRecord( + bundleId: bundleId, + lastKnownPath: "/Applications/Editor.app", + lastKnownName: "Editor", + strategy: .fixed(inputMethodId: inputMethodId), + createdAt: timestamp, + updatedAt: timestamp ) } let store = TestStore(initialState: initialState) { AppFeature() } - store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) - store.dependencies.inputMethodClient.currentInputMethodId = { - XCTFail("Ignored apps must not query the current input method") - return "ime.other" - } - store.dependencies.inputMethodClient.switchToInputMethod = { _ in - XCTFail("Ignored apps must not switch input methods") - } + store.dependencies.date = .constant(timestamp) - await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId + await store.send(.switching(.system(.inputMethodSelectedChanged(inputMethodId)))) { + $0.switching.pendingProgrammaticSwitch?.didObserveTargetSelection = true + } + await store.send(.switching(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .failed(.failedToVerifyInputMethod(inputMethodId)) + )))) { + $0.switching.pendingProgrammaticSwitch = nil + $0.switching.lastSwitchAttempt = .init( + appName: "Editor", + bundleId: bundleId, + inputMethodId: inputMethodId, + inputMethodName: "English", + outcome: .alreadySelected, + ruleSource: .app, + timestamp: timestamp + ) } + XCTAssertNil(store.state.inputMethodDiagnostic) XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) } - func testIgnoringCurrentAppCancelsPendingProgrammaticSwitch() async { + func testVerificationFailureProducesSwitchDiagnostic() async { let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") let targetInputMethod = "ime.en" - let updateDate = Date(timeIntervalSince1970: 20) - let lookupGate = InputMethodLookupGate(firstValue: "ime.other") - let recorder = SwitchRecorder() + let timestamp = Date(timeIntervalSince1970: 10) var initialState = AppFeature.State() - initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] + initialState.switching.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] initialState.$appRulesStore.withLock { $0.rules[app.bundleId] = AppRuleRecord( bundleId: app.bundleId, lastKnownPath: app.path, lastKnownName: app.name, strategy: .fixed(inputMethodId: targetInputMethod), - createdAt: Date(timeIntervalSince1970: 10), - updatedAt: Date(timeIntervalSince1970: 10) + createdAt: timestamp, + updatedAt: timestamp ) } let store = TestStore(initialState: initialState) { AppFeature() } - store.dependencies.date = .constant(updateDate) - store.dependencies.inputMethodClient.currentInputMethodId = { - await lookupGate.value() - } - store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in - await recorder.record(inputMethodId) + store.dependencies.date = .constant(timestamp) + store.dependencies.inputMethodClient.currentInputMethodId = { "ime.zh" } + store.dependencies.inputMethodClient.switchToInputMethod = { _ in + throw InputMethodService.InputMethodError.failedToVerifyInputMethod(targetInputMethod) } await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( + $0.switching.currentFrontmostBundleId = app.bundleId + $0.switching.nextSwitchAttemptID = 1 + $0.switching.pendingProgrammaticSwitch = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: targetInputMethod, inputMethodName: "English" ) } - await lookupGate.waitForFirstCall() - - await store.send(.view(.ignoreAppTapped(app))) { - $0.pendingProgrammaticSwitch = nil - $0.$appRulesStore.withLock { - guard var rule = $0.rules[app.bundleId] else { return } - rule.strategyBeforeIgnoring = .fixed(inputMethodId: targetInputMethod) - rule.strategy = .ignored - rule.updatedAt = updateDate - $0.rules[app.bundleId] = rule - } + await store.receive(.switching(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .failed(.failedToVerifyInputMethod(targetInputMethod)) + )))) { + $0.switching.pendingProgrammaticSwitch = nil + $0.switching.lastSwitchAttempt = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: targetInputMethod, + inputMethodName: "English", + outcome: .failed(.failedToVerifyInputMethod(targetInputMethod)), + ruleSource: .app, + timestamp: timestamp + ) } - await lookupGate.resumeFirst() - await store.finish() - - let switchedInputMethods = await recorder.values - XCTAssertTrue(switchedInputMethods.isEmpty) + XCTAssertEqual(store.state.inputMethodDiagnostic?.kind, .switchFailed) XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) } - func testActivatingIgnoredAppCancelsPreviousProgrammaticSwitch() async { - let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") - let ignoredApp = AppInfo(bundleId: "com.test.ignored", name: "Ignored", path: "/Applications/Ignored.app") - let targetInputMethod = "ime.en" - let lookupGate = InputMethodLookupGate(firstValue: "ime.other") - let recorder = SwitchRecorder() - - var initialState = AppFeature.State() - initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] - initialState.$appRulesStore.withLock { - $0.rules[firstApp.bundleId] = AppRuleRecord( - bundleId: firstApp.bundleId, - lastKnownPath: firstApp.path, - lastKnownName: firstApp.name, - strategy: .fixed(inputMethodId: targetInputMethod), - createdAt: Date(timeIntervalSince1970: 10), - updatedAt: Date(timeIntervalSince1970: 10) - ) - $0.rules[ignoredApp.bundleId] = AppRuleRecord( - bundleId: ignoredApp.bundleId, - lastKnownPath: ignoredApp.path, - lastKnownName: ignoredApp.name, - strategy: .ignored, + func testSwitchDiagnosticDisappearsWhenRuleNoLongerTargetsFailedInputMethod() { + let bundleId = "com.test.browser" + var state = AppFeature.State() + state.switching.currentFrontmostBundleId = bundleId + state.switching.lastSwitchAttempt = .init( + appName: "Browser", + bundleId: bundleId, + inputMethodId: "ime.en", + inputMethodName: "English", + outcome: .failed(.failedToSwitchInputMethod("ime.en")), + ruleSource: .app, + timestamp: Date(timeIntervalSince1970: 10) + ) + state.$appRulesStore.withLock { + $0.rules[bundleId] = AppRuleRecord( + bundleId: bundleId, + lastKnownPath: "/Applications/Browser.app", + lastKnownName: "Browser", + strategy: .fixed(inputMethodId: "ime.en"), createdAt: Date(timeIntervalSince1970: 10), updatedAt: Date(timeIntervalSince1970: 10) ) } - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) - store.dependencies.inputMethodClient.currentInputMethodId = { - await lookupGate.value() - } - store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in - await recorder.record(inputMethodId) - } + XCTAssertEqual(state.inputMethodDiagnostic?.kind, .switchFailed) - await store.send(.system(.workspaceEvent(.activated(firstApp)))) { - $0.currentFrontmostBundleId = firstApp.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( - appName: firstApp.name, - bundleId: firstApp.bundleId, - inputMethodId: targetInputMethod, - inputMethodName: "English" - ) + state.$appRulesStore.withLock { + $0.rules[bundleId]?.strategy = .none } - await lookupGate.waitForFirstCall() + XCTAssertNil(state.inputMethodDiagnostic) - await store.send(.system(.workspaceEvent(.activated(ignoredApp)))) { - $0.currentFrontmostBundleId = ignoredApp.bundleId - $0.pendingProgrammaticSwitch = nil + state.$appRulesStore.withLock { + $0.rules[bundleId]?.strategy = .fixed(inputMethodId: "ime.zh") } + XCTAssertNil(state.inputMethodDiagnostic) - await lookupGate.resumeFirst() - await store.finish() - - let switchedInputMethods = await recorder.values - XCTAssertTrue(switchedInputMethods.isEmpty) - XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) + state.$appRulesStore.withLock { + $0.rules[bundleId]?.strategy = .ignored + } + XCTAssertNil(state.inputMethodDiagnostic) } - func testConsecutiveActivationsOnlyCompleteLatestProgrammaticSwitch() async { - let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") - let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") - let firstInputMethod = "ime.first" - let secondInputMethod = "ime.second" - let lookupGate = InputMethodLookupGate( - firstValue: "ime.other", - subsequentValue: "ime.other" - ) + func testIgnoringCurrentAppCancelsPendingProgrammaticSwitch() async { + let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") + let targetInputMethod = "ime.en" + let updateDate = Date(timeIntervalSince1970: 20) + let lookupGate = InputMethodLookupGate(firstValue: "ime.other") let recorder = SwitchRecorder() var initialState = AppFeature.State() - initialState.inputMethods = [ - InputMethod(id: firstInputMethod, name: "First"), - InputMethod(id: secondInputMethod, name: "Second"), - ] + initialState.switching.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] initialState.$appRulesStore.withLock { - $0.rules[firstApp.bundleId] = AppRuleRecord( - bundleId: firstApp.bundleId, - lastKnownPath: firstApp.path, - lastKnownName: firstApp.name, - strategy: .fixed(inputMethodId: firstInputMethod), - createdAt: Date(timeIntervalSince1970: 10), - updatedAt: Date(timeIntervalSince1970: 10) - ) - $0.rules[secondApp.bundleId] = AppRuleRecord( - bundleId: secondApp.bundleId, - lastKnownPath: secondApp.path, - lastKnownName: secondApp.name, - strategy: .fixed(inputMethodId: secondInputMethod), + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .fixed(inputMethodId: targetInputMethod), createdAt: Date(timeIntervalSince1970: 10), updatedAt: Date(timeIntervalSince1970: 10) ) @@ -1941,7 +1174,7 @@ final class AppFeatureTests: XCTestCase { let store = TestStore(initialState: initialState) { AppFeature() } - store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.date = .constant(updateDate) store.dependencies.inputMethodClient.currentInputMethodId = { await lookupGate.value() } @@ -1949,45 +1182,26 @@ final class AppFeatureTests: XCTestCase { await recorder.record(inputMethodId) } - await store.send(.system(.workspaceEvent(.activated(firstApp)))) { - $0.currentFrontmostBundleId = firstApp.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( - appName: firstApp.name, - bundleId: firstApp.bundleId, - inputMethodId: firstInputMethod, - inputMethodName: "First" - ) - } - await lookupGate.waitForFirstCall() - - await store.send(.system(.workspaceEvent(.activated(secondApp)))) { - $0.currentFrontmostBundleId = secondApp.bundleId - $0.nextSwitchAttemptID = 2 - $0.pendingProgrammaticSwitch = .init( - appName: secondApp.name, - attemptID: 1, - bundleId: secondApp.bundleId, - inputMethodId: secondInputMethod, - inputMethodName: "Second" - ) - } - await store.receive(.response(.programmaticSwitchFinished( - attemptID: 1, - outcome: .switched - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( - appName: secondApp.name, - bundleId: secondApp.bundleId, - inputMethodId: secondInputMethod, - inputMethodName: "Second", - outcome: .switched, - ruleSource: .app, - timestamp: Date(timeIntervalSince1970: 10) + await store.send(.system(.workspaceEvent(.activated(app)))) { + $0.switching.currentFrontmostBundleId = app.bundleId + $0.switching.nextSwitchAttemptID = 1 + $0.switching.pendingProgrammaticSwitch = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: targetInputMethod, + inputMethodName: "English" ) - $0.$appSwitchStatisticsStore.withLock { - $0.counts[secondApp.bundleId] = 1 + } + await lookupGate.waitForFirstCall() + + await store.send(.view(.ignoreAppTapped(app))) { + $0.switching.pendingProgrammaticSwitch = nil + $0.$appRulesStore.withLock { + guard var rule = $0.rules[app.bundleId] else { return } + rule.strategyBeforeIgnoring = .fixed(inputMethodId: targetInputMethod) + rule.strategy = .ignored + rule.updatedAt = updateDate + $0.rules[app.bundleId] = rule } } @@ -1995,9 +1209,8 @@ final class AppFeatureTests: XCTestCase { await store.finish() let switchedInputMethods = await recorder.values - XCTAssertEqual(switchedInputMethods, [secondInputMethod]) - XCTAssertNil(store.state.appSwitchStatisticsStore.counts[firstApp.bundleId]) - XCTAssertEqual(store.state.appSwitchStatisticsStore.counts[secondApp.bundleId], 1) + XCTAssertTrue(switchedInputMethods.isEmpty) + XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) } func testTerminatingCurrentAppCancelsSwitchAfterSelectionNotification() async { @@ -2007,7 +1220,7 @@ final class AppFeatureTests: XCTestCase { let recorder = SwitchRecorder() var initialState = AppFeature.State() - initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] + initialState.switching.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] initialState.runningApps = [app] initialState.$appRulesStore.withLock { $0.rules[app.bundleId] = AppRuleRecord( @@ -2032,9 +1245,9 @@ final class AppFeatureTests: XCTestCase { store.dependencies.workspaceClient.runningApplications = { [] } await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( + $0.switching.currentFrontmostBundleId = app.bundleId + $0.switching.nextSwitchAttemptID = 1 + $0.switching.pendingProgrammaticSwitch = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: targetInputMethod, @@ -2043,12 +1256,12 @@ final class AppFeatureTests: XCTestCase { } await switchGate.waitUntilStarted() - await store.send(.system(.inputMethodSelectedChanged(targetInputMethod))) { - $0.pendingProgrammaticSwitch?.didObserveTargetSelection = true + await store.send(.switching(.system(.inputMethodSelectedChanged(targetInputMethod)))) { + $0.switching.pendingProgrammaticSwitch?.didObserveTargetSelection = true } await store.send(.system(.workspaceEvent(.terminated(bundleId: app.bundleId)))) { - $0.currentFrontmostBundleId = nil - $0.pendingProgrammaticSwitch = nil + $0.switching.currentFrontmostBundleId = nil + $0.switching.pendingProgrammaticSwitch = nil } await store.receive(.response(.runningApps([]))) { $0.runningApps = [] @@ -2062,116 +1275,12 @@ final class AppFeatureTests: XCTestCase { XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) } - func testActivatedAppUsesFallbackRuleWhenAppRuleIsMissing() async { - let now = Date(timeIntervalSince1970: 10) - let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") - let fallbackInputMethod = "ime.fallback" - let recorder = SwitchRecorder() - - var initialState = AppFeature.State() - initialState.inputMethods = [InputMethod(id: fallbackInputMethod, name: "Fallback")] - initialState.$fallbackRuleStore.withLock { - $0.strategy = .fixed(inputMethodId: fallbackInputMethod) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(now) - store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } - store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in - await recorder.record(inputMethodId) - } - - await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.nextSwitchAttemptID = 1 - $0.pendingProgrammaticSwitch = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: fallbackInputMethod, - inputMethodName: "Fallback", - ruleSource: .fallback - ) - $0.$appRulesStore.withLock { - $0.rules[app.bundleId] = AppRuleRecord( - bundleId: app.bundleId, - lastKnownPath: app.path, - lastKnownName: app.name, - strategy: .none, - createdAt: now, - updatedAt: now - ) - } - } - await store.receive(.response(.programmaticSwitchFinished( - attemptID: 0, - outcome: .switched - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: fallbackInputMethod, - inputMethodName: "Fallback", - outcome: .switched, - ruleSource: .fallback, - timestamp: now - ) - $0.$appSwitchStatisticsStore.withLock { - $0.counts[app.bundleId] = 1 - } - } - - let switchedInputMethods = await recorder.values - XCTAssertEqual(switchedInputMethods, [fallbackInputMethod]) - } - - func testActivatedAppSkipsSwitchWhenFallbackRuleIsNone() async { - let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") - let recorder = SwitchRecorder() - - var initialState = AppFeature.State() - initialState.$fallbackRuleStore.withLock { - $0.strategy = .none - } - initialState.$appRulesStore.withLock { - $0.rules[app.bundleId] = AppRuleRecord( - bundleId: app.bundleId, - lastKnownPath: app.path, - lastKnownName: app.name, - strategy: .none, - createdAt: Date(timeIntervalSince1970: 10), - updatedAt: Date(timeIntervalSince1970: 10) - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) - store.dependencies.inputMethodClient.currentInputMethodId = { - XCTFail("Fallback .none should not trigger current input method lookup") - return "ime.en" - } - store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in - await recorder.record(inputMethodId) - } - - await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - } - - let switchedInputMethods = await recorder.values - XCTAssertTrue(switchedInputMethods.isEmpty) - } - func testActivatedAppSkipsSwitchWhenFallbackRuleIsLegacyFollowLast() async { let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") let recorder = SwitchRecorder() var initialState = AppFeature.State() - initialState.inputMethods = [InputMethod(id: "ime.jp", name: "Japanese")] + initialState.switching.inputMethods = [InputMethod(id: "ime.jp", name: "Japanese")] initialState.$fallbackRuleStore.withLock { $0.strategy = .followLast(lastInputMethodId: "ime.jp") } @@ -2199,7 +1308,7 @@ final class AppFeatureTests: XCTestCase { } await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId + $0.switching.currentFrontmostBundleId = app.bundleId } let switchedInputMethods = await recorder.values @@ -2212,8 +1321,8 @@ final class AppFeatureTests: XCTestCase { let recorder = SwitchRecorder() var initialState = AppFeature.State() - initialState.inputMethodCatalogStatus = .ready - initialState.inputMethods = [InputMethod(id: "ime.en", name: "English")] + initialState.switching.inputMethodCatalogStatus = .ready + initialState.switching.inputMethods = [InputMethod(id: "ime.en", name: "English")] initialState.$fallbackRuleStore.withLock { $0.strategy = .fixed(inputMethodId: "ime.deleted") } @@ -2241,8 +1350,8 @@ final class AppFeatureTests: XCTestCase { } await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.lastSwitchAttempt = .init( + $0.switching.currentFrontmostBundleId = app.bundleId + $0.switching.lastSwitchAttempt = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: "ime.deleted", @@ -2262,8 +1371,8 @@ final class AppFeatureTests: XCTestCase { let missingPath = "/tmp/\(UUID().uuidString)" var initialState = AppFeature.State() - initialState.inputMethodCatalogStatus = .ready - initialState.inputMethods = [InputMethod(id: "ime.en", name: "English")] + initialState.switching.inputMethodCatalogStatus = .ready + initialState.switching.inputMethods = [InputMethod(id: "ime.en", name: "English")] initialState.$appRulesStore.withLock { $0.rules["com.test.missing"] = AppRuleRecord( bundleId: "com.test.missing", @@ -2279,63 +1388,11 @@ final class AppFeatureTests: XCTestCase { XCTAssertEqual(initialState.unavailableApps.map(\.bundleId), ["com.test.missing"]) } - func testActivatedAppSkipsMissingFixedInputMethod() async { - let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") - let missingInputMethod = "ime.deleted" - let recorder = SwitchRecorder() - - var initialState = AppFeature.State() - initialState.inputMethodCatalogStatus = .ready - initialState.inputMethods = [InputMethod(id: "ime.en", name: "English")] - initialState.$appRulesStore.withLock { - $0.rules[app.bundleId] = AppRuleRecord( - bundleId: app.bundleId, - lastKnownPath: app.path, - lastKnownName: app.name, - strategy: .fixed(inputMethodId: missingInputMethod), - createdAt: Date(timeIntervalSince1970: 10), - updatedAt: Date(timeIntervalSince1970: 10) - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) - store.dependencies.inputMethodClient.currentInputMethodId = { - XCTFail("Missing input methods should not trigger current input method lookup") - return "ime.en" - } - store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in - await recorder.record(inputMethodId) - } - - await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.lastSwitchAttempt = .init( - appName: app.name, - bundleId: app.bundleId, - inputMethodId: missingInputMethod, - inputMethodName: nil, - outcome: .failed(.inputMethodNotFound(missingInputMethod)), - ruleSource: .app, - timestamp: Date(timeIntervalSince1970: 10) - ) - } - - let switchedInputMethods = await recorder.values - XCTAssertTrue(switchedInputMethods.isEmpty) - XCTAssertEqual( - store.state.appRules[app.bundleId]?.strategy, - .fixed(inputMethodId: missingInputMethod) - ) - } - func testFollowLastAvailableInputMethodShowsCurrentInputMethodInMenuOption() { let app = AppInfo(bundleId: "com.test.chat", name: "Chat", path: "/Applications/Chat.app") var state = AppFeature.State() - state.inputMethods = [InputMethod(id: "ime.zh", name: "Pinyin")] + state.switching.inputMethods = [InputMethod(id: "ime.zh", name: "Pinyin")] state.runningApps = [app] state.$appRulesStore.withLock { $0.rules[app.bundleId] = AppRuleRecord( @@ -2379,7 +1436,7 @@ final class AppFeatureTests: XCTestCase { let app = AppInfo(bundleId: "com.test.chat", name: "Chat", path: "/Applications/Chat.app") var state = AppFeature.State() - state.inputMethods = [InputMethod(id: "ime.zh", name: "Pinyin")] + state.switching.inputMethods = [InputMethod(id: "ime.zh", name: "Pinyin")] state.runningApps = [app] state.$fallbackRuleStore.withLock { $0.strategy = .fixed(inputMethodId: "ime.zh") @@ -2440,7 +1497,7 @@ final class AppFeatureTests: XCTestCase { let app = AppInfo(bundleId: "com.test.passwords", name: "Passwords", path: "/Applications/Passwords.app") var state = AppFeature.State() - state.currentFrontmostBundleId = app.bundleId + state.switching.currentFrontmostBundleId = app.bundleId state.runningApps = [app] state.$appSwitchStatisticsStore.withLock { $0.counts[app.bundleId] = 4 @@ -2740,7 +1797,7 @@ final class AppFeatureTests: XCTestCase { let notes = AppInfo(bundleId: "com.test.notes", name: "Notes", path: "/Applications/Notes.app") var state = AppFeature.State() - state.currentFrontmostBundleId = chat.bundleId + state.switching.currentFrontmostBundleId = chat.bundleId state.runningApps = [chat, notes] XCTAssertEqual(state.currentAppMenuItem?.bundleId, chat.bundleId) @@ -2757,7 +1814,7 @@ final class AppFeatureTests: XCTestCase { func testMenuBarIconUsesUnconfiguredIconForFrontmostAppWithoutRule() { var state = AppFeature.State() - state.currentFrontmostBundleId = "com.test.chat" + state.switching.currentFrontmostBundleId = "com.test.chat" XCTAssertEqual(state.menuBarIconSystemName, "keyboard.badge.ellipsis") XCTAssertEqual(state.menuBarAccessibilityLabel, TypeSwitchStrings.Menu.accessibilityUnconfigured) @@ -2767,7 +1824,7 @@ final class AppFeatureTests: XCTestCase { let app = AppInfo(bundleId: "com.test.chat", name: "Chat", path: "/Applications/Chat.app") var state = AppFeature.State() - state.currentFrontmostBundleId = app.bundleId + state.switching.currentFrontmostBundleId = app.bundleId state.$appRulesStore.withLock { $0.rules[app.bundleId] = AppRuleRecord( bundleId: app.bundleId, @@ -2787,7 +1844,7 @@ final class AppFeatureTests: XCTestCase { let app = AppInfo(bundleId: "com.test.chat", name: "Chat", path: "/Applications/Chat.app") var state = AppFeature.State() - state.currentFrontmostBundleId = app.bundleId + state.switching.currentFrontmostBundleId = app.bundleId state.$appRulesStore.withLock { $0.rules[app.bundleId] = AppRuleRecord( bundleId: app.bundleId, @@ -2807,7 +1864,7 @@ final class AppFeatureTests: XCTestCase { let app = AppInfo(bundleId: "com.test.chat", name: "Chat", path: "/Applications/Chat.app") var state = AppFeature.State() - state.currentFrontmostBundleId = app.bundleId + state.switching.currentFrontmostBundleId = app.bundleId state.$appRulesStore.withLock { $0.rules[app.bundleId] = AppRuleRecord( bundleId: app.bundleId, @@ -2827,7 +1884,7 @@ final class AppFeatureTests: XCTestCase { let app = AppInfo(bundleId: "com.test.chat", name: "Chat", path: "/Applications/Chat.app") var state = AppFeature.State() - state.currentFrontmostBundleId = app.bundleId + state.switching.currentFrontmostBundleId = app.bundleId state.$appRulesStore.withLock { $0.rules[app.bundleId] = AppRuleRecord( bundleId: app.bundleId, @@ -2845,10 +1902,10 @@ final class AppFeatureTests: XCTestCase { func testMenuBarAccessibilityLabelUsesWarningWithoutFrontmostApp() { var catalogEmptyState = AppFeature.State() - catalogEmptyState.inputMethodCatalogStatus = .ready + catalogEmptyState.switching.inputMethodCatalogStatus = .ready var catalogFailedState = AppFeature.State() - catalogFailedState.inputMethodCatalogStatus = .failed(.failedToFetchInputMethods) + catalogFailedState.switching.inputMethodCatalogStatus = .failed(.failedToFetchInputMethods) for state in [catalogEmptyState, catalogFailedState] { XCTAssertNotNil(state.inputMethodDiagnostic) @@ -2944,8 +2001,8 @@ final class AppFeatureTests: XCTestCase { let recorder = SwitchRecorder() var initialState = AppFeature.State() - initialState.inputMethodCatalogStatus = .ready - initialState.inputMethods = [InputMethod(id: "ime.en", name: "English")] + initialState.switching.inputMethodCatalogStatus = .ready + initialState.switching.inputMethods = [InputMethod(id: "ime.en", name: "English")] initialState.$appRulesStore.withLock { $0.rules[app.bundleId] = AppRuleRecord( bundleId: app.bundleId, @@ -2980,8 +2037,8 @@ final class AppFeatureTests: XCTestCase { } await store.send(.system(.workspaceEvent(.activated(app)))) { - $0.currentFrontmostBundleId = app.bundleId - $0.lastSwitchAttempt = .init( + $0.switching.currentFrontmostBundleId = app.bundleId + $0.switching.lastSwitchAttempt = .init( appName: app.name, bundleId: app.bundleId, inputMethodId: missingInputMethod, @@ -3000,47 +2057,15 @@ final class AppFeatureTests: XCTestCase { ) } - func testManualSelectionUpdatesFollowLastStrategy() async { - let bundleId = "com.test.chat" - let updateDate = Date(timeIntervalSince1970: 888) - - var initialState = AppFeature.State() - initialState.currentFrontmostBundleId = bundleId - initialState.$appRulesStore.withLock { - $0.rules[bundleId] = AppRuleRecord( - bundleId: bundleId, - lastKnownPath: "/Applications/Chat.app", - lastKnownName: "Chat", - strategy: .followLast(lastInputMethodId: nil), - createdAt: Date(timeIntervalSince1970: 100), - updatedAt: Date(timeIntervalSince1970: 100) - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - store.dependencies.date = .constant(updateDate) - - await store.send(.system(.inputMethodSelectedChanged("ime.jp"))) { - $0.$appRulesStore.withLock { - guard var rule = $0.rules[bundleId] else { return } - rule.strategy = .followLast(lastInputMethodId: "ime.jp") - rule.updatedAt = updateDate - $0.rules[bundleId] = rule - } - } - } - func testManualSelectionOfFailedTargetClearsDiagnosticWithoutIncrementingStatistics() async { let bundleId = "com.test.chat" let targetInputMethod = "ime.zh" var initialState = AppFeature.State() - initialState.currentFrontmostBundleId = bundleId - initialState.inputMethodCatalogStatus = .ready - initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "Pinyin")] - initialState.lastSwitchAttempt = .init( + initialState.switching.currentFrontmostBundleId = bundleId + initialState.switching.inputMethodCatalogStatus = .ready + initialState.switching.inputMethods = [InputMethod(id: targetInputMethod, name: "Pinyin")] + initialState.switching.lastSwitchAttempt = .init( appName: "Chat", bundleId: bundleId, inputMethodId: targetInputMethod, @@ -3069,8 +2094,8 @@ final class AppFeatureTests: XCTestCase { XCTAssertEqual(store.state.inputMethodDiagnostic?.kind, .switchFailed) - await store.send(.system(.inputMethodSelectedChanged(targetInputMethod))) { - $0.lastSwitchAttempt = nil + await store.send(.switching(.system(.inputMethodSelectedChanged(targetInputMethod)))) { + $0.switching.lastSwitchAttempt = nil } XCTAssertNil(store.state.inputMethodDiagnostic) @@ -3084,13 +2109,13 @@ final class AppFeatureTests: XCTestCase { let updateDate = Date(timeIntervalSince1970: 888) var initialState = AppFeature.State() - initialState.currentFrontmostBundleId = bundleId - initialState.inputMethodCatalogStatus = .ready - initialState.inputMethods = [ + initialState.switching.currentFrontmostBundleId = bundleId + initialState.switching.inputMethodCatalogStatus = .ready + initialState.switching.inputMethods = [ InputMethod(id: failedInputMethod, name: "Pinyin"), InputMethod(id: otherInputMethod, name: "Japanese"), ] - initialState.lastSwitchAttempt = .init( + initialState.switching.lastSwitchAttempt = .init( appName: "Chat", bundleId: bundleId, inputMethodId: failedInputMethod, @@ -3120,7 +2145,7 @@ final class AppFeatureTests: XCTestCase { XCTAssertEqual(store.state.inputMethodDiagnostic?.kind, .switchFailed) - await store.send(.system(.inputMethodSelectedChanged(otherInputMethod))) { + await store.send(.switching(.system(.inputMethodSelectedChanged(otherInputMethod)))) { $0.$appRulesStore.withLock { guard var rule = $0.rules[bundleId] else { return } rule.strategy = .followLast(lastInputMethodId: otherInputMethod) @@ -3130,10 +2155,10 @@ final class AppFeatureTests: XCTestCase { } XCTAssertNil(store.state.inputMethodDiagnostic) - XCTAssertNotNil(store.state.lastSwitchAttempt) + XCTAssertNotNil(store.state.switching.lastSwitchAttempt) - await store.send(.system(.inputMethodSelectedChanged(failedInputMethod))) { - $0.lastSwitchAttempt = nil + await store.send(.switching(.system(.inputMethodSelectedChanged(failedInputMethod)))) { + $0.switching.lastSwitchAttempt = nil $0.$appRulesStore.withLock { guard var rule = $0.rules[bundleId] else { return } rule.strategy = .followLast(lastInputMethodId: failedInputMethod) @@ -3154,7 +2179,7 @@ final class AppFeatureTests: XCTestCase { let bundleId = "com.test.chat" var initialState = AppFeature.State() - initialState.currentFrontmostBundleId = bundleId + initialState.switching.currentFrontmostBundleId = bundleId initialState.$fallbackRuleStore.withLock { $0.strategy = .followLast(lastInputMethodId: nil) } @@ -3173,7 +2198,7 @@ final class AppFeatureTests: XCTestCase { AppFeature() } - await store.send(.system(.inputMethodSelectedChanged("ime.jp"))) + await store.send(.switching(.system(.inputMethodSelectedChanged("ime.jp")))) XCTAssertEqual(store.state.appRules[bundleId]?.strategy, InputMethodStrategy.none) XCTAssertEqual( @@ -3187,7 +2212,7 @@ final class AppFeatureTests: XCTestCase { let bundleId = "com.test.chat" var initialState = AppFeature.State() - initialState.currentFrontmostBundleId = bundleId + initialState.switching.currentFrontmostBundleId = bundleId initialState.$fallbackRuleStore.withLock { $0.strategy = .followLast(lastInputMethodId: nil) } @@ -3196,7 +2221,7 @@ final class AppFeatureTests: XCTestCase { AppFeature() } - await store.send(.system(.inputMethodSelectedChanged("ime.jp"))) + await store.send(.switching(.system(.inputMethodSelectedChanged("ime.jp")))) XCTAssertTrue(store.state.appRules.isEmpty) XCTAssertEqual( @@ -3206,45 +2231,13 @@ final class AppFeatureTests: XCTestCase { XCTAssertEqual(store.state.fallbackStrategy, .none) } - func testProgrammaticSelectionDoesNotOverwriteFollowLastStrategy() async { - let bundleId = "com.test.terminal" - let targetInputMethod = "ime.en" - - var initialState = AppFeature.State() - initialState.currentFrontmostBundleId = bundleId - initialState.pendingProgrammaticSwitch = .init(bundleId: bundleId, inputMethodId: targetInputMethod) - initialState.$appRulesStore.withLock { - $0.rules[bundleId] = AppRuleRecord( - bundleId: bundleId, - lastKnownPath: "/Applications/Terminal.app", - lastKnownName: "Terminal", - strategy: .followLast(lastInputMethodId: nil), - createdAt: Date(timeIntervalSince1970: 100), - updatedAt: Date(timeIntervalSince1970: 100) - ) - } - - let store = TestStore(initialState: initialState) { - AppFeature() - } - - await store.send(.system(.inputMethodSelectedChanged(targetInputMethod))) { - $0.pendingProgrammaticSwitch?.didObserveTargetSelection = true - } - - XCTAssertEqual( - store.state.appRules[bundleId]?.strategy, - .followLast(lastInputMethodId: nil) - ) - } - func testProgrammaticSelectionDoesNotOverwriteFallbackFollowLastStrategy() async { let bundleId = "com.test.terminal" let targetInputMethod = "ime.en" var initialState = AppFeature.State() - initialState.currentFrontmostBundleId = bundleId - initialState.pendingProgrammaticSwitch = .init(bundleId: bundleId, inputMethodId: targetInputMethod) + initialState.switching.currentFrontmostBundleId = bundleId + initialState.switching.pendingProgrammaticSwitch = .init(bundleId: bundleId, inputMethodId: targetInputMethod) initialState.$fallbackRuleStore.withLock { $0.strategy = .followLast(lastInputMethodId: nil) } @@ -3253,8 +2246,8 @@ final class AppFeatureTests: XCTestCase { AppFeature() } - await store.send(.system(.inputMethodSelectedChanged(targetInputMethod))) { - $0.pendingProgrammaticSwitch?.didObserveTargetSelection = true + await store.send(.switching(.system(.inputMethodSelectedChanged(targetInputMethod)))) { + $0.switching.pendingProgrammaticSwitch?.didObserveTargetSelection = true } XCTAssertEqual( @@ -3271,8 +2264,8 @@ final class AppFeatureTests: XCTestCase { let newUpdatedAt = Date(timeIntervalSince1970: 300) var initialState = AppFeature.State() - initialState.inputMethodCatalogStatus = .ready - initialState.inputMethods = [InputMethod(id: "ime.en", name: "English")] + initialState.switching.inputMethodCatalogStatus = .ready + initialState.switching.inputMethods = [InputMethod(id: "ime.en", name: "English")] initialState.$appRulesStore.withLock { $0.rules["missing-fixed"] = AppRuleRecord( bundleId: "missing-fixed", @@ -3379,7 +2372,7 @@ final class AppFeatureTests: XCTestCase { initialState.$appSwitchStatisticsStore.withLock { $0.counts[bundleId] = 1 } - initialState.pendingProgrammaticSwitch = .init( + initialState.switching.pendingProgrammaticSwitch = .init( appName: "Browser", bundleId: bundleId, inputMethodId: "ime.en" @@ -3390,12 +2383,12 @@ final class AppFeatureTests: XCTestCase { } store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) - await store.send(.response(.programmaticSwitchFinished( + await store.send(.switching(.response(.programmaticSwitchFinished( attemptID: 0, outcome: .switched - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( + )))) { + $0.switching.pendingProgrammaticSwitch = nil + $0.switching.lastSwitchAttempt = .init( appName: "Browser", bundleId: bundleId, inputMethodId: "ime.en", @@ -3417,7 +2410,7 @@ final class AppFeatureTests: XCTestCase { initialState.$appSwitchStatisticsStore.withLock { $0.counts["com.test.browser"] = 2 } - initialState.pendingProgrammaticSwitch = .init( + initialState.switching.pendingProgrammaticSwitch = .init( appName: "Editor", bundleId: "com.test.editor", inputMethodId: "ime.en" @@ -3428,12 +2421,12 @@ final class AppFeatureTests: XCTestCase { } store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) - await store.send(.response(.programmaticSwitchFinished( + await store.send(.switching(.response(.programmaticSwitchFinished( attemptID: 0, outcome: .switched - ))) { - $0.pendingProgrammaticSwitch = nil - $0.lastSwitchAttempt = .init( + )))) { + $0.switching.pendingProgrammaticSwitch = nil + $0.switching.lastSwitchAttempt = .init( appName: "Editor", bundleId: "com.test.editor", inputMethodId: "ime.en", @@ -3547,14 +2540,14 @@ final class AppFeatureTests: XCTestCase { private func receiveStartupResponses(from store: TestStoreOf) async { await store.receive(.response(.launchAtLoginLoaded(.disabled))) - await store.receive(.response(.frontmostApplicationLoaded(nil))) - let refreshID = store.state.pendingInputMethodRefreshID - await store.receive(.response(.inputMethodsLoaded( + await store.receive(.switching(.response(.frontmostApplicationLoaded(nil)))) + let refreshID = store.state.switching.pendingInputMethodRefreshID + await store.receive(.switching(.response(.inputMethodsLoaded( refreshID: refreshID ?? -1, result: .success([]) - ))) { - $0.pendingInputMethodRefreshID = nil - $0.inputMethodCatalogStatus = .ready + )))) { + $0.switching.pendingInputMethodRefreshID = nil + $0.switching.inputMethodCatalogStatus = .ready } await store.receive(.response(.runningApps([]))) } @@ -3565,152 +2558,3 @@ final class AppFeatureTests: XCTestCase { } } } - -private actor SwitchRecorder { - private(set) var values: [String] = [] - - func record(_ inputMethodId: String) { - values.append(inputMethodId) - } -} - -private actor InputMethodRefreshGate { - private let latestInputMethods: [InputMethod] - private var callCount = 0 - private var firstContinuation: CheckedContinuation< - Result<[InputMethod], InputMethodService.InputMethodError>, - Never - >? - private var firstStartedContinuation: CheckedContinuation? - private var hasStartedFirstCall = false - - init(latestInputMethods: [InputMethod]) { - self.latestInputMethods = latestInputMethods - } - - func value() async throws -> [InputMethod] { - callCount += 1 - guard callCount == 1 else { return latestInputMethods } - - let result = await withCheckedContinuation { continuation in - firstContinuation = continuation - hasStartedFirstCall = true - firstStartedContinuation?.resume() - firstStartedContinuation = nil - } - return try result.get() - } - - func waitUntilFirstStarted() async { - guard !hasStartedFirstCall else { return } - await withCheckedContinuation { continuation in - firstStartedContinuation = continuation - } - } - - func resumeFirst(with result: Result<[InputMethod], InputMethodService.InputMethodError>) { - firstContinuation?.resume(returning: result) - firstContinuation = nil - } -} - -private actor InputMethodLookupGate { - private let firstValue: String - private let subsequentValue: String - private var callCount = 0 - private var firstContinuation: CheckedContinuation? - private var firstStartedContinuation: CheckedContinuation? - private var hasStartedFirstCall = false - - init(firstValue: String, subsequentValue: String = "") { - self.firstValue = firstValue - self.subsequentValue = subsequentValue - } - - func value() async -> String { - callCount += 1 - guard callCount == 1 else { return subsequentValue } - - return await withCheckedContinuation { continuation in - firstContinuation = continuation - hasStartedFirstCall = true - firstStartedContinuation?.resume() - firstStartedContinuation = nil - } - } - - func waitForFirstCall() async { - guard !hasStartedFirstCall else { return } - await withCheckedContinuation { continuation in - firstStartedContinuation = continuation - } - } - - func resumeFirst() { - firstContinuation?.resume(returning: firstValue) - firstContinuation = nil - } -} - -private actor InputMethodSwitchGate { - private var continuation: CheckedContinuation? - private var startedContinuation: CheckedContinuation? - private var hasStarted = false - - func wait() async { - await withCheckedContinuation { continuation in - self.continuation = continuation - hasStarted = true - startedContinuation?.resume() - startedContinuation = nil - } - } - - func waitUntilStarted() async { - guard !hasStarted else { return } - await withCheckedContinuation { continuation in - startedContinuation = continuation - } - } - - func resume() { - continuation?.resume() - continuation = nil - } -} - -private actor FrontmostApplicationGate { - private let appInfo: AppInfo? - private var continuation: CheckedContinuation? - private var startedContinuation: CheckedContinuation? - private var hasStarted = false - - init(appInfo: AppInfo?) { - self.appInfo = appInfo - } - - func value() async -> AppInfo? { - await withCheckedContinuation { continuation in - self.continuation = continuation - hasStarted = true - startedContinuation?.resume() - startedContinuation = nil - } - } - - func waitUntilStarted() async { - guard !hasStarted else { return } - await withCheckedContinuation { continuation in - startedContinuation = continuation - } - } - - func resume() { - continuation?.resume(returning: appInfo) - continuation = nil - } -} - -private enum TestError: Error { - case failed -} diff --git a/TypeSwitchTests/SwitchingFeatureTests.swift b/TypeSwitchTests/SwitchingFeatureTests.swift new file mode 100644 index 0000000..a423b72 --- /dev/null +++ b/TypeSwitchTests/SwitchingFeatureTests.swift @@ -0,0 +1,1137 @@ +import ComposableArchitecture +import Sharing +@testable import TypeSwitch +import XCTest + +@MainActor +final class SwitchingFeatureTests: XCTestCase { + func testActivatedAppStillSwitchesWhenCurrentInputMethodLookupFails() async { + let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") + let targetInputMethod = "ime.en" + let recorder = SwitchRecorder() + + var initialState = makeState() + initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .fixed(inputMethodId: targetInputMethod), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.inputMethodClient.currentInputMethodId = { throw TestError.failed } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + } + + await store.send(.applicationActivated(app)) { + $0.currentFrontmostBundleId = app.bundleId + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: targetInputMethod, + inputMethodName: "English" + ) + } + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .switched + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: targetInputMethod, + inputMethodName: "English", + outcome: .switched, + ruleSource: .app, + timestamp: Date(timeIntervalSince1970: 10) + ) + $0.$appSwitchStatisticsStore.withLock { + $0.counts[app.bundleId] = 1 + } + } + + let switchedInputMethods = await recorder.values + XCTAssertEqual(switchedInputMethods, [targetInputMethod]) + } + + func testInputMethodRefreshRetriesActivationSkippedWhileCatalogIsLoading() async { + let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") + let inputMethod = InputMethod(id: "ime.en", name: "English") + let timestamp = Date(timeIntervalSince1970: 10) + let recorder = SwitchRecorder() + let switchGate = InputMethodSwitchGate() + + var initialState = makeState() + initialState.nextInputMethodRefreshID = 1 + initialState.pendingInputMethodRefreshID = 0 + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = makeRule(app: app, strategy: .fixed(inputMethodId: inputMethod.id)) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(timestamp) + store.dependencies.workspaceClient.frontmostApplication = { app } + store.dependencies.inputMethodClient.currentInputMethodId = { "ime.zh" } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + await switchGate.wait() + } + + await store.send(.applicationActivated(app)) { + $0.currentFrontmostBundleId = app.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([inputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [inputMethod] + $0.nextFrontmostRetryID = 1 + $0.pendingFrontmostRetryID = 0 + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: app))) { + $0.pendingFrontmostRetryID = nil + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: inputMethod.id, + inputMethodName: inputMethod.name + ) + } + await switchGate.waitUntilStarted() + await switchGate.resume() + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .switched + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: inputMethod.id, + inputMethodName: inputMethod.name, + outcome: .switched, + ruleSource: .app, + timestamp: timestamp + ) + $0.$appSwitchStatisticsStore.withLock { + $0.counts[app.bundleId] = 1 + } + } + + let switchedInputMethods = await recorder.values + XCTAssertEqual(switchedInputMethods, [inputMethod.id]) + } + + func testInputMethodRefreshRetriesOnlyLatestActivationSkippedWhileCatalogIsLoading() async { + let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") + let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") + let firstInputMethod = InputMethod(id: "ime.first", name: "First Input Method") + let secondInputMethod = InputMethod(id: "ime.second", name: "Second Input Method") + let timestamp = Date(timeIntervalSince1970: 10) + let recorder = SwitchRecorder() + let switchGate = InputMethodSwitchGate() + + var initialState = makeState() + initialState.nextInputMethodRefreshID = 1 + initialState.pendingInputMethodRefreshID = 0 + initialState.$appRulesStore.withLock { + $0.rules[firstApp.bundleId] = makeRule( + app: firstApp, + strategy: .fixed(inputMethodId: firstInputMethod.id) + ) + $0.rules[secondApp.bundleId] = makeRule( + app: secondApp, + strategy: .fixed(inputMethodId: secondInputMethod.id) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(timestamp) + store.dependencies.workspaceClient.frontmostApplication = { secondApp } + store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + await switchGate.wait() + } + + await store.send(.applicationActivated(firstApp)) { + $0.currentFrontmostBundleId = firstApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.applicationActivated(secondApp)) { + $0.currentFrontmostBundleId = secondApp.bundleId + } + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([firstInputMethod, secondInputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [firstInputMethod, secondInputMethod] + $0.nextFrontmostRetryID = 1 + $0.pendingFrontmostRetryID = 0 + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: secondApp))) { + $0.pendingFrontmostRetryID = nil + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: secondApp.name, + bundleId: secondApp.bundleId, + inputMethodId: secondInputMethod.id, + inputMethodName: secondInputMethod.name + ) + } + await switchGate.waitUntilStarted() + await switchGate.resume() + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .switched + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: secondApp.name, + bundleId: secondApp.bundleId, + inputMethodId: secondInputMethod.id, + inputMethodName: secondInputMethod.name, + outcome: .switched, + ruleSource: .app, + timestamp: timestamp + ) + $0.$appSwitchStatisticsStore.withLock { + $0.counts[secondApp.bundleId] = 1 + } + } + + let switchedInputMethods = await recorder.values + XCTAssertEqual(switchedInputMethods, [secondInputMethod.id]) + } + + func testActivationWithoutSwitchTargetClearsPendingCatalogRetry() async { + let configuredApp = AppInfo( + bundleId: "com.test.configured", + name: "Configured", + path: "/Applications/Configured.app" + ) + let unconfiguredApp = AppInfo( + bundleId: "com.test.unconfigured", + name: "Unconfigured", + path: "/Applications/Unconfigured.app" + ) + let inputMethod = InputMethod(id: "ime.en", name: "English") + let timestamp = Date(timeIntervalSince1970: 10) + + var initialState = makeState() + initialState.nextInputMethodRefreshID = 1 + initialState.pendingInputMethodRefreshID = 0 + initialState.$appRulesStore.withLock { + $0.rules[configuredApp.bundleId] = makeRule( + app: configuredApp, + strategy: .fixed(inputMethodId: inputMethod.id) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(timestamp) + store.dependencies.workspaceClient.frontmostApplication = { + XCTFail("A cleared catalog retry must not query the frontmost application") + return unconfiguredApp + } + store.dependencies.inputMethodClient.switchToInputMethod = { _ in + XCTFail("An unconfigured app must not trigger a compensated switch") + } + + await store.send(.applicationActivated(configuredApp)) { + $0.currentFrontmostBundleId = configuredApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.applicationActivated(unconfiguredApp)) { + $0.currentFrontmostBundleId = unconfiguredApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + $0.$appRulesStore.withLock { + $0.rules[unconfiguredApp.bundleId] = self.makeRule(app: unconfiguredApp, strategy: .none) + } + } + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([inputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [inputMethod] + } + } + + func testStaleInputMethodRefreshDoesNotConsumePendingCatalogRetry() async { + let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") + let inputMethod = InputMethod(id: "ime.en", name: "English") + let timestamp = Date(timeIntervalSince1970: 10) + + var initialState = makeState() + initialState.nextInputMethodRefreshID = 2 + initialState.pendingInputMethodRefreshID = 1 + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = makeRule(app: app, strategy: .fixed(inputMethodId: inputMethod.id)) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(timestamp) + store.dependencies.workspaceClient.frontmostApplication = { app } + store.dependencies.inputMethodClient.currentInputMethodId = { inputMethod.id } + + await store.send(.applicationActivated(app)) { + $0.currentFrontmostBundleId = app.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([inputMethod]) + ))) + XCTAssertTrue(store.state.shouldRetryFrontmostAfterInputMethodRefresh) + + await store.send(.response(.inputMethodsLoaded( + refreshID: 1, + result: .success([inputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [inputMethod] + $0.nextFrontmostRetryID = 1 + $0.pendingFrontmostRetryID = 0 + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: app))) { + $0.pendingFrontmostRetryID = nil + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: inputMethod.id, + inputMethodName: inputMethod.name + ) + } + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .alreadySelected + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: inputMethod.id, + inputMethodName: inputMethod.name, + outcome: .alreadySelected, + ruleSource: .app, + timestamp: timestamp + ) + } + } + + func testCatalogRetryIgnoresFrontmostSnapshotAfterNewActivation() async { + let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") + let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") + let inputMethod = InputMethod(id: "ime.en", name: "English") + let frontmostGate = FrontmostApplicationGate(appInfo: firstApp) + + var initialState = makeState() + initialState.nextInputMethodRefreshID = 1 + initialState.pendingInputMethodRefreshID = 0 + initialState.$appRulesStore.withLock { + $0.rules[firstApp.bundleId] = makeRule( + app: firstApp, + strategy: .fixed(inputMethodId: inputMethod.id) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.workspaceClient.frontmostApplication = { + await frontmostGate.value() + } + store.dependencies.inputMethodClient.switchToInputMethod = { _ in + XCTFail("A stale frontmost snapshot must not switch input methods") + } + + await store.send(.applicationActivated(firstApp)) { + $0.currentFrontmostBundleId = firstApp.bundleId + $0.shouldRetryFrontmostAfterInputMethodRefresh = true + } + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([inputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [inputMethod] + $0.nextFrontmostRetryID = 1 + $0.pendingFrontmostRetryID = 0 + $0.shouldRetryFrontmostAfterInputMethodRefresh = false + } + await frontmostGate.waitUntilStarted() + + await store.send(.applicationActivated(secondApp)) { + $0.currentFrontmostBundleId = secondApp.bundleId + $0.pendingFrontmostRetryID = nil + $0.$appRulesStore.withLock { + $0.rules[secondApp.bundleId] = self.makeRule(app: secondApp, strategy: .none) + } + } + + await frontmostGate.resume() + await store.receive(.response(.frontmostApplicationRetried(retryID: 0, appInfo: firstApp))) + + XCTAssertEqual(store.state.currentFrontmostBundleId, secondApp.bundleId) + } + + func testSuccessfulInputMethodRefreshWithoutPendingCatalogRetryDoesNotReapplyCurrentRule() async { + let app = AppInfo(bundleId: "com.test.editor", name: "Editor", path: "/Applications/Editor.app") + let inputMethod = InputMethod(id: "ime.en", name: "English") + + var initialState = makeState() + initialState.currentFrontmostBundleId = app.bundleId + initialState.nextInputMethodRefreshID = 1 + initialState.pendingInputMethodRefreshID = 0 + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = makeRule(app: app, strategy: .fixed(inputMethodId: inputMethod.id)) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.workspaceClient.frontmostApplication = { + XCTFail("A normal catalog refresh must not query the frontmost application") + return app + } + store.dependencies.inputMethodClient.switchToInputMethod = { _ in + XCTFail("A normal catalog refresh must not reapply the current rule") + } + + await store.send(.response(.inputMethodsLoaded( + refreshID: 0, + result: .success([inputMethod]) + ))) { + $0.pendingInputMethodRefreshID = nil + $0.inputMethodCatalogStatus = .ready + $0.inputMethods = [inputMethod] + } + } + + func testStaleProgrammaticSwitchResultIsIgnored() async { + var initialState = makeState() + initialState.pendingProgrammaticSwitch = .init( + appName: "Editor", + attemptID: 1, + bundleId: "com.test.editor", + inputMethodId: "ime.en", + inputMethodName: "English" + ) + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + + await store.send(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .switched + ))) + + XCTAssertNotNil(store.state.pendingProgrammaticSwitch) + XCTAssertNil(store.state.lastSwitchAttempt) + XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) + } + + func testSelectionNotificationDoesNotDiscardSuccessfulSwitchResult() async { + let timestamp = Date(timeIntervalSince1970: 10) + let bundleId = "com.test.editor" + var initialState = makeState() + initialState.pendingProgrammaticSwitch = .init( + appName: "Editor", + bundleId: bundleId, + inputMethodId: "ime.en", + inputMethodName: "English" + ) + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(timestamp) + + await store.send(.system(.inputMethodSelectedChanged("ime.en"))) { + $0.pendingProgrammaticSwitch?.didObserveTargetSelection = true + } + await store.send(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .switched + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: "Editor", + bundleId: bundleId, + inputMethodId: "ime.en", + inputMethodName: "English", + outcome: .switched, + ruleSource: .app, + timestamp: timestamp + ) + $0.$appSwitchStatisticsStore.withLock { + $0.counts[bundleId] = 1 + } + } + } + + func testNonTargetSelectionRevokesConfirmedProgrammaticSwitch() async { + let timestamp = Date(timeIntervalSince1970: 10) + let bundleId = "com.test.editor" + let targetInputMethodId = "ime.en" + var initialState = makeState() + initialState.currentFrontmostBundleId = bundleId + initialState.pendingProgrammaticSwitch = .init( + appName: "Editor", + bundleId: bundleId, + inputMethodId: targetInputMethodId, + inputMethodName: "English" + ) + initialState.$appRulesStore.withLock { + $0.rules[bundleId] = AppRuleRecord( + bundleId: bundleId, + lastKnownPath: "/Applications/Editor.app", + lastKnownName: "Editor", + strategy: .fixed(inputMethodId: targetInputMethodId), + createdAt: timestamp, + updatedAt: timestamp + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(timestamp) + + await store.send(.system(.inputMethodSelectedChanged(targetInputMethodId))) { + $0.pendingProgrammaticSwitch?.didObserveTargetSelection = true + } + await store.send(.system(.inputMethodSelectedChanged("ime.jp"))) { + $0.pendingProgrammaticSwitch?.didObserveTargetSelection = false + } + await store.send(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .failed(.failedToVerifyInputMethod(targetInputMethodId)) + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: "Editor", + bundleId: bundleId, + inputMethodId: targetInputMethodId, + inputMethodName: "English", + outcome: .failed(.failedToVerifyInputMethod(targetInputMethodId)), + ruleSource: .app, + timestamp: timestamp + ) + } + + XCTAssertEqual( + store.state.lastSwitchAttempt?.outcome, + .failed(.failedToVerifyInputMethod(targetInputMethodId)) + ) + XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) + } + + func testActivatedAppUsesAppRuleBeforeFallbackRule() async { + let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") + let appInputMethod = "ime.app" + let fallbackInputMethod = "ime.fallback" + let recorder = SwitchRecorder() + + var initialState = makeState() + initialState.inputMethods = [ + InputMethod(id: appInputMethod, name: "App"), + InputMethod(id: fallbackInputMethod, name: "Fallback"), + ] + initialState.$fallbackRuleStore.withLock { + $0.strategy = .fixed(inputMethodId: fallbackInputMethod) + } + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .fixed(inputMethodId: appInputMethod), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + } + + await store.send(.applicationActivated(app)) { + $0.currentFrontmostBundleId = app.bundleId + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: appInputMethod, + inputMethodName: "App" + ) + } + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .switched + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: appInputMethod, + inputMethodName: "App", + outcome: .switched, + ruleSource: .app, + timestamp: Date(timeIntervalSince1970: 10) + ) + $0.$appSwitchStatisticsStore.withLock { + $0.counts[app.bundleId] = 1 + } + } + + let switchedInputMethods = await recorder.values + XCTAssertEqual(switchedInputMethods, [appInputMethod]) + } + + func testActivatedAppUsesFallbackRuleWhenAppRuleIsNone() async { + let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") + let fallbackInputMethod = "ime.fallback" + let recorder = SwitchRecorder() + + var initialState = makeState() + initialState.inputMethods = [InputMethod(id: fallbackInputMethod, name: "Fallback")] + initialState.$fallbackRuleStore.withLock { + $0.strategy = .fixed(inputMethodId: fallbackInputMethod) + } + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .none, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + } + + await store.send(.applicationActivated(app)) { + $0.currentFrontmostBundleId = app.bundleId + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: fallbackInputMethod, + inputMethodName: "Fallback", + ruleSource: .fallback + ) + } + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .switched + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: fallbackInputMethod, + inputMethodName: "Fallback", + outcome: .switched, + ruleSource: .fallback, + timestamp: Date(timeIntervalSince1970: 10) + ) + $0.$appSwitchStatisticsStore.withLock { + $0.counts[app.bundleId] = 1 + } + } + + let switchedInputMethods = await recorder.values + XCTAssertEqual(switchedInputMethods, [fallbackInputMethod]) + } + + func testActivatedIgnoredAppOverridesFixedFallback() async { + let app = AppInfo(bundleId: "com.test.passwords", name: "Passwords", path: "/Applications/Passwords.app") + + var initialState = makeState() + initialState.inputMethods = [InputMethod(id: "ime.fallback", name: "Fallback")] + initialState.$fallbackRuleStore.withLock { + $0.strategy = .fixed(inputMethodId: "ime.fallback") + } + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .ignored, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.inputMethodClient.currentInputMethodId = { + XCTFail("Ignored apps must not query the current input method") + return "ime.other" + } + store.dependencies.inputMethodClient.switchToInputMethod = { _ in + XCTFail("Ignored apps must not switch input methods") + } + + await store.send(.applicationActivated(app)) { + $0.currentFrontmostBundleId = app.bundleId + } + + XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) + } + + func testActivatingIgnoredAppCancelsPreviousProgrammaticSwitch() async { + let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") + let ignoredApp = AppInfo(bundleId: "com.test.ignored", name: "Ignored", path: "/Applications/Ignored.app") + let targetInputMethod = "ime.en" + let lookupGate = InputMethodLookupGate(firstValue: "ime.other") + let recorder = SwitchRecorder() + + var initialState = makeState() + initialState.inputMethods = [InputMethod(id: targetInputMethod, name: "English")] + initialState.$appRulesStore.withLock { + $0.rules[firstApp.bundleId] = AppRuleRecord( + bundleId: firstApp.bundleId, + lastKnownPath: firstApp.path, + lastKnownName: firstApp.name, + strategy: .fixed(inputMethodId: targetInputMethod), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + $0.rules[ignoredApp.bundleId] = AppRuleRecord( + bundleId: ignoredApp.bundleId, + lastKnownPath: ignoredApp.path, + lastKnownName: ignoredApp.name, + strategy: .ignored, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.inputMethodClient.currentInputMethodId = { + await lookupGate.value() + } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + } + + await store.send(.applicationActivated(firstApp)) { + $0.currentFrontmostBundleId = firstApp.bundleId + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: firstApp.name, + bundleId: firstApp.bundleId, + inputMethodId: targetInputMethod, + inputMethodName: "English" + ) + } + await lookupGate.waitForFirstCall() + + await store.send(.applicationActivated(ignoredApp)) { + $0.currentFrontmostBundleId = ignoredApp.bundleId + $0.pendingProgrammaticSwitch = nil + } + + await lookupGate.resumeFirst() + await store.finish() + + let switchedInputMethods = await recorder.values + XCTAssertTrue(switchedInputMethods.isEmpty) + XCTAssertTrue(store.state.appSwitchStatisticsStore.counts.isEmpty) + } + + func testConsecutiveActivationsOnlyCompleteLatestProgrammaticSwitch() async { + let firstApp = AppInfo(bundleId: "com.test.first", name: "First", path: "/Applications/First.app") + let secondApp = AppInfo(bundleId: "com.test.second", name: "Second", path: "/Applications/Second.app") + let firstInputMethod = "ime.first" + let secondInputMethod = "ime.second" + let lookupGate = InputMethodLookupGate( + firstValue: "ime.other", + subsequentValue: "ime.other" + ) + let recorder = SwitchRecorder() + + var initialState = makeState() + initialState.inputMethods = [ + InputMethod(id: firstInputMethod, name: "First"), + InputMethod(id: secondInputMethod, name: "Second"), + ] + initialState.$appRulesStore.withLock { + $0.rules[firstApp.bundleId] = AppRuleRecord( + bundleId: firstApp.bundleId, + lastKnownPath: firstApp.path, + lastKnownName: firstApp.name, + strategy: .fixed(inputMethodId: firstInputMethod), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + $0.rules[secondApp.bundleId] = AppRuleRecord( + bundleId: secondApp.bundleId, + lastKnownPath: secondApp.path, + lastKnownName: secondApp.name, + strategy: .fixed(inputMethodId: secondInputMethod), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.inputMethodClient.currentInputMethodId = { + await lookupGate.value() + } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + } + + await store.send(.applicationActivated(firstApp)) { + $0.currentFrontmostBundleId = firstApp.bundleId + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: firstApp.name, + bundleId: firstApp.bundleId, + inputMethodId: firstInputMethod, + inputMethodName: "First" + ) + } + await lookupGate.waitForFirstCall() + + await store.send(.applicationActivated(secondApp)) { + $0.currentFrontmostBundleId = secondApp.bundleId + $0.nextSwitchAttemptID = 2 + $0.pendingProgrammaticSwitch = .init( + appName: secondApp.name, + attemptID: 1, + bundleId: secondApp.bundleId, + inputMethodId: secondInputMethod, + inputMethodName: "Second" + ) + } + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 1, + outcome: .switched + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: secondApp.name, + bundleId: secondApp.bundleId, + inputMethodId: secondInputMethod, + inputMethodName: "Second", + outcome: .switched, + ruleSource: .app, + timestamp: Date(timeIntervalSince1970: 10) + ) + $0.$appSwitchStatisticsStore.withLock { + $0.counts[secondApp.bundleId] = 1 + } + } + + await lookupGate.resumeFirst() + await store.finish() + + let switchedInputMethods = await recorder.values + XCTAssertEqual(switchedInputMethods, [secondInputMethod]) + XCTAssertNil(store.state.appSwitchStatisticsStore.counts[firstApp.bundleId]) + XCTAssertEqual(store.state.appSwitchStatisticsStore.counts[secondApp.bundleId], 1) + } + + func testActivatedAppUsesFallbackRuleWhenAppRuleIsMissing() async { + let now = Date(timeIntervalSince1970: 10) + let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") + let fallbackInputMethod = "ime.fallback" + let recorder = SwitchRecorder() + + var initialState = makeState() + initialState.inputMethods = [InputMethod(id: fallbackInputMethod, name: "Fallback")] + initialState.$fallbackRuleStore.withLock { + $0.strategy = .fixed(inputMethodId: fallbackInputMethod) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(now) + store.dependencies.inputMethodClient.currentInputMethodId = { "ime.other" } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + } + + await store.send(.applicationActivated(app)) { + $0.currentFrontmostBundleId = app.bundleId + $0.nextSwitchAttemptID = 1 + $0.pendingProgrammaticSwitch = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: fallbackInputMethod, + inputMethodName: "Fallback", + ruleSource: .fallback + ) + $0.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .none, + createdAt: now, + updatedAt: now + ) + } + } + await store.receive(.response(.programmaticSwitchFinished( + attemptID: 0, + outcome: .switched + ))) { + $0.pendingProgrammaticSwitch = nil + $0.lastSwitchAttempt = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: fallbackInputMethod, + inputMethodName: "Fallback", + outcome: .switched, + ruleSource: .fallback, + timestamp: now + ) + $0.$appSwitchStatisticsStore.withLock { + $0.counts[app.bundleId] = 1 + } + } + + let switchedInputMethods = await recorder.values + XCTAssertEqual(switchedInputMethods, [fallbackInputMethod]) + } + + func testActivatedAppSkipsSwitchWhenFallbackRuleIsNone() async { + let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") + let recorder = SwitchRecorder() + + var initialState = makeState() + initialState.$fallbackRuleStore.withLock { + $0.strategy = .none + } + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .none, + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.inputMethodClient.currentInputMethodId = { + XCTFail("Fallback .none should not trigger current input method lookup") + return "ime.en" + } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + } + + await store.send(.applicationActivated(app)) { + $0.currentFrontmostBundleId = app.bundleId + } + + let switchedInputMethods = await recorder.values + XCTAssertTrue(switchedInputMethods.isEmpty) + } + + func testActivatedAppSkipsMissingFixedInputMethod() async { + let app = AppInfo(bundleId: "com.test.browser", name: "Browser", path: "/Applications/Browser.app") + let missingInputMethod = "ime.deleted" + let recorder = SwitchRecorder() + + var initialState = makeState() + initialState.inputMethodCatalogStatus = .ready + initialState.inputMethods = [InputMethod(id: "ime.en", name: "English")] + initialState.$appRulesStore.withLock { + $0.rules[app.bundleId] = AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: .fixed(inputMethodId: missingInputMethod), + createdAt: Date(timeIntervalSince1970: 10), + updatedAt: Date(timeIntervalSince1970: 10) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(Date(timeIntervalSince1970: 10)) + store.dependencies.inputMethodClient.currentInputMethodId = { + XCTFail("Missing input methods should not trigger current input method lookup") + return "ime.en" + } + store.dependencies.inputMethodClient.switchToInputMethod = { inputMethodId in + await recorder.record(inputMethodId) + } + + await store.send(.applicationActivated(app)) { + $0.currentFrontmostBundleId = app.bundleId + $0.lastSwitchAttempt = .init( + appName: app.name, + bundleId: app.bundleId, + inputMethodId: missingInputMethod, + inputMethodName: nil, + outcome: .failed(.inputMethodNotFound(missingInputMethod)), + ruleSource: .app, + timestamp: Date(timeIntervalSince1970: 10) + ) + } + + let switchedInputMethods = await recorder.values + XCTAssertTrue(switchedInputMethods.isEmpty) + XCTAssertEqual( + store.state.appRulesStore.rules[app.bundleId]?.strategy, + .fixed(inputMethodId: missingInputMethod) + ) + } + + func testManualSelectionUpdatesFollowLastStrategy() async { + let bundleId = "com.test.chat" + let updateDate = Date(timeIntervalSince1970: 888) + + var initialState = makeState() + initialState.currentFrontmostBundleId = bundleId + initialState.$appRulesStore.withLock { + $0.rules[bundleId] = AppRuleRecord( + bundleId: bundleId, + lastKnownPath: "/Applications/Chat.app", + lastKnownName: "Chat", + strategy: .followLast(lastInputMethodId: nil), + createdAt: Date(timeIntervalSince1970: 100), + updatedAt: Date(timeIntervalSince1970: 100) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + store.dependencies.date = .constant(updateDate) + + await store.send(.system(.inputMethodSelectedChanged("ime.jp"))) { + $0.$appRulesStore.withLock { + guard var rule = $0.rules[bundleId] else { return } + rule.strategy = .followLast(lastInputMethodId: "ime.jp") + rule.updatedAt = updateDate + $0.rules[bundleId] = rule + } + } + } + + func testProgrammaticSelectionDoesNotOverwriteFollowLastStrategy() async { + let bundleId = "com.test.terminal" + let targetInputMethod = "ime.en" + + var initialState = makeState() + initialState.currentFrontmostBundleId = bundleId + initialState.pendingProgrammaticSwitch = .init(bundleId: bundleId, inputMethodId: targetInputMethod) + initialState.$appRulesStore.withLock { + $0.rules[bundleId] = AppRuleRecord( + bundleId: bundleId, + lastKnownPath: "/Applications/Terminal.app", + lastKnownName: "Terminal", + strategy: .followLast(lastInputMethodId: nil), + createdAt: Date(timeIntervalSince1970: 100), + updatedAt: Date(timeIntervalSince1970: 100) + ) + } + + let store = TestStore(initialState: initialState) { + SwitchingFeature() + } + + await store.send(.system(.inputMethodSelectedChanged(targetInputMethod))) { + $0.pendingProgrammaticSwitch?.didObserveTargetSelection = true + } + + XCTAssertEqual( + store.state.appRulesStore.rules[bundleId]?.strategy, + .followLast(lastInputMethodId: nil) + ) + } + + private func makeRule( + app: AppInfo, + strategy: InputMethodStrategy, + timestamp: Date = Date(timeIntervalSince1970: 10) + ) -> AppRuleRecord { + AppRuleRecord( + bundleId: app.bundleId, + lastKnownPath: app.path, + lastKnownName: app.name, + strategy: strategy, + createdAt: timestamp, + updatedAt: timestamp + ) + } + + private func makeState() -> SwitchingFeature.State { + SwitchingFeature.State( + appRulesStore: Shared(value: AppRulesStore()), + appSwitchStatisticsStore: Shared(value: AppSwitchStatisticsStore()), + fallbackRuleStore: Shared(value: FallbackRuleStore()) + ) + } +}