From f20b126af7b4f2967c914b7c2e4e7457e5e178e5 Mon Sep 17 00:00:00 2001 From: HashaamKhan19 Date: Thu, 16 Jul 2026 02:27:56 +0500 Subject: [PATCH] Add Reset to Defaults button to General settings Adds a "Reset to Defaults" button that clears known UserDefaults keys (runner choice, appearance toggles, update interval, metrics visibility/configuration) and re-seeds the affected stores, without touching custom runners or custom metrics sources. A confirmation dialog guards the action. Runner/Metrics settings tabs also resync their cached state from UserDefaults whenever they appear, since macOS only keeps the active Settings tab mounted. Signed-off-by: HashaamKhan19 Co-authored-by: Cursor --- .../DataSource/Entities/AppState.swift | 1 + .../Repositories/UserDefaultsRepository.swift | 10 + .../Model/Stores/GeneralSettings.swift | 32 ++ .../Sources/Model/Stores/MetricsBar.swift | 6 + .../Model/Stores/MetricsBarSettings.swift | 16 +- .../Model/Stores/MetricsSettings.swift | 23 +- .../Sources/Model/Stores/RunnerSettings.swift | 20 ++ .../Resources/Localizable.xcstrings | 330 ++++++++++++++++++ .../Views/Settings/GeneralSettingsView.swift | 39 +++ .../UserDefaultsRepositoryTests.swift | 27 ++ .../StoreTests/GeneralSettingsTests.swift | 49 +++ .../StoreTests/MetricsBarSettingsTests.swift | 19 + .../StoreTests/MetricsBarTests.swift | 28 ++ .../StoreTests/MetricsSettingsTests.swift | 58 +++ .../StoreTests/RunnerSettingsTests.swift | 60 ++++ 15 files changed, 712 insertions(+), 6 deletions(-) create mode 100644 LocalPackage/Tests/DataSourceTests/RepositoryTests/UserDefaultsRepositoryTests.swift diff --git a/LocalPackage/Sources/DataSource/Entities/AppState.swift b/LocalPackage/Sources/DataSource/Entities/AppState.swift index bb1bf8f..804249f 100644 --- a/LocalPackage/Sources/DataSource/Entities/AppState.swift +++ b/LocalPackage/Sources/DataSource/Entities/AppState.swift @@ -33,6 +33,7 @@ public struct AppState: Sendable { public var runnerBundleLists = AsyncStreamBundle<[RunnerBundle]>() public var runnerBundles = AsyncStreamBundle() public var runnerSpeeds = AsyncStreamBundle() + public var settingsResets = AsyncStreamBundle() init( name: String = "", diff --git a/LocalPackage/Sources/DataSource/Repositories/UserDefaultsRepository.swift b/LocalPackage/Sources/DataSource/Repositories/UserDefaultsRepository.swift index 380c37b..347514e 100644 --- a/LocalPackage/Sources/DataSource/Repositories/UserDefaultsRepository.swift +++ b/LocalPackage/Sources/DataSource/Repositories/UserDefaultsRepository.swift @@ -115,6 +115,16 @@ public struct UserDefaultsRepository: Sendable { } } + public func resetToDefaults() { + userDefaultsClient.removeObject(.runnerID) + userDefaultsClient.removeObject(.speedDecreasesUnderLoad) + userDefaultsClient.removeObject(.isFlippedHorizontally) + userDefaultsClient.removeObject(.updateInterval) + userDefaultsClient.removeObject(.systemMetricsConfiguration) + userDefaultsClient.removeObject(.showsMetricsBar) + userDefaultsClient.removeObject(.metricsBarConfiguration) + } + private func showAllData() { guard let dict = userDefaultsClient.persistentDomain(Bundle.main.bundleIdentifier!) else { return diff --git a/LocalPackage/Sources/Model/Stores/GeneralSettings.swift b/LocalPackage/Sources/Model/Stores/GeneralSettings.swift index 665a9fb..587bd4c 100644 --- a/LocalPackage/Sources/Model/Stores/GeneralSettings.swift +++ b/LocalPackage/Sources/Model/Stores/GeneralSettings.swift @@ -23,27 +23,34 @@ import Observation @MainActor @Observable public final class GeneralSettings: Composable { + private let appStateClient: AppStateClient private let launchAtLoginRepository: LaunchAtLoginRepository private let userDefaultsRepository: UserDefaultsRepository private let logService: LogService private let systemMetricsService: SystemMetricsService + private let runnerService: RunnerService public var updateInterval: UpdateInterval public var launchesAtLogin: Bool + public var showingResetConfirmationDialog: Bool public let action: (Action) async -> Void public init( _ appDependencies: AppDependencies, updateInterval: UpdateInterval? = nil, launchesAtLogin: Bool? = nil, + showingResetConfirmationDialog: Bool = false, action: @escaping (Action) async -> Void = { _ in } ) { + self.appStateClient = appDependencies.appStateClient self.launchAtLoginRepository = .init(appDependencies.smAppServiceClient) self.userDefaultsRepository = .init(appDependencies.userDefaultsClient) self.logService = .init(appDependencies) self.systemMetricsService = .init(appDependencies) + self.runnerService = .init(appDependencies) self.updateInterval = updateInterval ?? userDefaultsRepository.updateInterval self.launchesAtLogin = launchesAtLogin ?? launchAtLoginRepository.isEnabled + self.showingResetConfirmationDialog = showingResetConfirmationDialog self.action = action } @@ -65,6 +72,28 @@ public final class GeneralSettings: Composable { case let .failure(.switchFailed(value)): launchesAtLogin = value } + + case .resetToDefaultsButtonTapped: + showingResetConfirmationDialog = true + + case .resetToDefaultsCancelled: + showingResetConfirmationDialog = false + + case .resetToDefaultsConfirmed: + showingResetConfirmationDialog = false + userDefaultsRepository.resetToDefaults() + updateInterval = userDefaultsRepository.updateInterval + systemMetricsService.stopMonitoring() + systemMetricsService.startMonitoring() + do { + try runnerService.update(runner: .default) + } catch { + logService.critical(.unknown(error)) + } + let cpuInfo = systemMetricsService.currentSystemInfoBundle.cpuInfo + runnerService.updateRunnerSpeed(from: cpuInfo) + systemMetricsService.emitConfigurationChange() + appStateClient.send(\.settingsResets, ()) } } @@ -72,5 +101,8 @@ public final class GeneralSettings: Composable { case task(String) case updateIntervalChanged(UpdateInterval) case launchAtLoginToggleSwitched(Bool) + case resetToDefaultsButtonTapped + case resetToDefaultsCancelled + case resetToDefaultsConfirmed } } diff --git a/LocalPackage/Sources/Model/Stores/MetricsBar.swift b/LocalPackage/Sources/Model/Stores/MetricsBar.swift index 81bb935..7a43733 100644 --- a/LocalPackage/Sources/Model/Stores/MetricsBar.swift +++ b/LocalPackage/Sources/Model/Stores/MetricsBar.swift @@ -83,6 +83,12 @@ public final class MetricsBar: Composable { self?.updateMetricsBarConfiguration() } } + group.addImmediateTask { + let stream = appStateClient.withLock(\.settingsResets.stream) + for await _ in stream { + self?.updateMetricsBarConfiguration() + } + } } } diff --git a/LocalPackage/Sources/Model/Stores/MetricsBarSettings.swift b/LocalPackage/Sources/Model/Stores/MetricsBarSettings.swift index c7d55ac..b673939 100644 --- a/LocalPackage/Sources/Model/Stores/MetricsBarSettings.swift +++ b/LocalPackage/Sources/Model/Stores/MetricsBarSettings.swift @@ -59,9 +59,19 @@ public final class MetricsBarSettings: Composable { customMetricsSources = userDefaultsRepository.customMetricsConfiguration.sources task?.cancel() task = Task.immediate { [weak self, appStateClient] in - let stream = appStateClient.withLock(\.customMetricsConfigurationChanges.stream) - for await _ in stream { - self?.updateCustomMetricsConfiguration() + await withTaskGroup { group in + group.addImmediateTask { + let stream = appStateClient.withLock(\.customMetricsConfigurationChanges.stream) + for await _ in stream { + self?.updateCustomMetricsConfiguration() + } + } + group.addImmediateTask { + let stream = appStateClient.withLock(\.settingsResets.stream) + for await _ in stream { + self?.updateCustomMetricsConfiguration() + } + } } } diff --git a/LocalPackage/Sources/Model/Stores/MetricsSettings.swift b/LocalPackage/Sources/Model/Stores/MetricsSettings.swift index f08f018..973ddac 100644 --- a/LocalPackage/Sources/Model/Stores/MetricsSettings.swift +++ b/LocalPackage/Sources/Model/Stores/MetricsSettings.swift @@ -74,11 +74,23 @@ public final class MetricsSettings: Composable { switch action { case let .task(screenName): logService.notice(.screenView(name: screenName)) + showsMetricsBar = userDefaultsRepository.showsMetricsBar + refreshSystemMetricsConfiguration() task?.cancel() task = Task.immediate { [weak self, appStateClient] in - let stream = appStateClient.withLock(\.systemMetricsConfigurationChanges.stream) - for await _ in stream { - self?.refreshSystemMetricsConfiguration() + await withTaskGroup { group in + group.addImmediateTask { + let stream = appStateClient.withLock(\.systemMetricsConfigurationChanges.stream) + for await _ in stream { + self?.refreshSystemMetricsConfiguration() + } + } + group.addImmediateTask { + let stream = appStateClient.withLock(\.settingsResets.stream) + for await _ in stream { + self?.resetToDefaults() + } + } } } @@ -141,6 +153,11 @@ public final class MetricsSettings: Composable { systemMetricsConfiguration = userDefaultsRepository.systemMetricsConfiguration } + private func resetToDefaults() { + showsMetricsBar = userDefaultsRepository.showsMetricsBar + refreshSystemMetricsConfiguration() + } + public enum Action: Sendable { case task(String) case onDisappear diff --git a/LocalPackage/Sources/Model/Stores/RunnerSettings.swift b/LocalPackage/Sources/Model/Stores/RunnerSettings.swift index 126fd8c..b1c89d9 100644 --- a/LocalPackage/Sources/Model/Stores/RunnerSettings.swift +++ b/LocalPackage/Sources/Model/Stores/RunnerSettings.swift @@ -73,6 +73,8 @@ public final class RunnerSettings: Composable { switch action { case let .task(screenName): logService.notice(.screenView(name: screenName)) + speedDecreasesUnderLoad = userDefaultsRepository.speedDecreasesUnderLoad + isFlippedHorizontally = userDefaultsRepository.isFlippedHorizontally if let runnerBundle = appStateClient.withLock(\.runnerBundles.latestValue) { currentRunner = runnerBundle.runner } @@ -91,6 +93,12 @@ public final class RunnerSettings: Composable { self?.update(runnerBundleList: value) } } + group.addImmediateTask { + let stream = appStateClient.withLock(\.settingsResets.stream) + for await _ in stream { + self?.resetToDefaults() + } + } } } @@ -136,6 +144,18 @@ public final class RunnerSettings: Composable { self.runnerBundleList = runnerBundleList } + private func resetToDefaults() { + speedDecreasesUnderLoad = userDefaultsRepository.speedDecreasesUnderLoad + isFlippedHorizontally = userDefaultsRepository.isFlippedHorizontally + do { + try runnerService.update(runner: .default) + } catch { + logService.critical(.unknown(error)) + } + let cpuInfo = systemMetricsService.currentSystemInfoBundle.cpuInfo + runnerService.updateRunnerSpeed(from: cpuInfo) + } + public enum Action: Sendable { case task(String) case onDisappear diff --git a/LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings b/LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings index fd4aee3..bd1bdc1 100644 --- a/LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings +++ b/LocalPackage/Sources/UserInterface/Resources/Localizable.xcstrings @@ -4093,6 +4093,336 @@ } } }, + "reset" : { + "comment" : "A heading for the reset-to-defaults settings section, and a button to reset settings to their defaults.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zurücksetzen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réinitialiser" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "リセット" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "재설정" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt lại" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "重設" + } + } + } + }, + "resetToDefaults" : { + "comment" : "A button to reset all settings to their default values.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf Standard zurücksetzen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset to Defaults" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablecer valores predeterminados" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rétablir les valeurs par défaut" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デフォルトに戻す" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본값으로 재설정" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить настройки" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt lại về mặc định" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复默认设置" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "還原預設設定" + } + } + } + }, + "resetToDefaultsConfirmationMessage" : { + "comment" : "A confirmation message for resetting all settings to their default values.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Die Einstellungen für Allgemein, Runner und Metriken werden auf ihre Standardwerte zurückgesetzt. Eigene Runner und eigene Metrikquellen sind davon nicht betroffen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "General, Runner, and Metrics settings return to their defaults. Custom runners and custom metrics sources are not affected." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Los ajustes de General, Runner y Métricas vuelven a sus valores predeterminados. Los runners personalizados y las fuentes de métricas personalizadas no se ven afectados." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Les réglages Général, Runner et Métriques reviennent à leurs valeurs par défaut. Les runners personnalisés et les sources de métriques personnalisées ne sont pas concernés." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "一般、ランナー、メトリクスの設定がデフォルト値に戻ります。カスタムランナーとカスタムメトリクスのソースには影響しません。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "일반, 러너, 메트릭 설정이 기본값으로 재설정됩니다. 사용자 설정 러너와 사용자 설정 메트릭 소스는 영향을 받지 않습니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Настройки Основных, Раннера и Метрик вернутся к значениям по умолчанию. Пользовательские раннеры и источники пользовательских метрик не будут затронуты." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thiết lập Chung, Runner và Số liệu sẽ trở về giá trị mặc định. Runner tùy chỉnh và nguồn số liệu tùy chỉnh không bị ảnh hưởng." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "「通用」「跑者」「指标」设置将恢复为默认值。自定义跑者和自定义指标来源不受影响。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "「一般」「跑者」「指標」設定將還原為預設值。自訂跑者與自訂指標來源不受影響。" + } + } + } + }, + "resetToDefaultsConfirmationTitle" : { + "comment" : "A confirmation dialog title for resetting all settings to their default values.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Auf Standard zurücksetzen?" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reset to Defaults?" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¿Restablecer los valores predeterminados?" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rétablir les valeurs par défaut ?" + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "デフォルトに戻しますか?" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기본값으로 재설정하겠습니까?" + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Сбросить настройки?" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đặt lại về mặc định?" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "恢复默认设置?" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "還原預設設定?" + } + } + } + }, + "resetToDefaultsDescription" : { + "comment" : "A description explaining what the reset-to-defaults button does.", + "isCommentAutoGenerated" : true, + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Alle Einstellungen auf ihre Standardwerte zurücksetzen." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restore all settings to their default values." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Restablece todos los ajustes a sus valores predeterminados." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rétablit tous les réglages à leurs valeurs par défaut." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "すべての設定をデフォルト値に戻します。" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "모든 설정을 기본값으로 복원합니다." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Восстанавливает все настройки до значений по умолчанию." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khôi phục tất cả thiết lập về giá trị mặc định." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将所有设置恢复为默认值。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "將所有設定還原為預設值。" + } + } + } + }, "restoreSubscription" : { "comment" : "A button to restore a previous purchase.", "isCommentAutoGenerated" : true, diff --git a/LocalPackage/Sources/UserInterface/Views/Settings/GeneralSettingsView.swift b/LocalPackage/Sources/UserInterface/Views/Settings/GeneralSettingsView.swift index a562e74..ad84e38 100644 --- a/LocalPackage/Sources/UserInterface/Views/Settings/GeneralSettingsView.swift +++ b/LocalPackage/Sources/UserInterface/Views/Settings/GeneralSettingsView.swift @@ -52,8 +52,47 @@ struct GeneralSettingsView: View { } header: { Text("monitoring", bundle: .module) } + Section { + LabeledContent { + Button { + Task { + await store.send(.resetToDefaultsButtonTapped) + } + } label: { + Text("resetToDefaults", bundle: .module) + } + .buttonStyle(.borderless) + } label: { + Text("resetToDefaultsDescription", bundle: .module) + } + } header: { + Text("reset", bundle: .module) + } } .formStyle(.grouped) + .confirmationDialog( + Text("resetToDefaultsConfirmationTitle", bundle: .module), + isPresented: $store.showingResetConfirmationDialog, + actions: { + Button(role: .destructive) { + Task { + await store.send(.resetToDefaultsConfirmed) + } + } label: { + Text("resetToDefaults", bundle: .module) + } + Button(role: .cancel) { + Task { + await store.send(.resetToDefaultsCancelled) + } + } label: { + Text("cancel", bundle: .module) + } + }, + message: { + Text("resetToDefaultsConfirmationMessage", bundle: .module) + } + ) .task { await store.send(.task(String(describing: Self.self))) } diff --git a/LocalPackage/Tests/DataSourceTests/RepositoryTests/UserDefaultsRepositoryTests.swift b/LocalPackage/Tests/DataSourceTests/RepositoryTests/UserDefaultsRepositoryTests.swift new file mode 100644 index 0000000..04ff5fd --- /dev/null +++ b/LocalPackage/Tests/DataSourceTests/RepositoryTests/UserDefaultsRepositoryTests.swift @@ -0,0 +1,27 @@ +import AllocatedUnfairLock +import Testing + +@testable import DataSource + +struct UserDefaultsRepositoryTests { + @Test + func resetToDefaults_removes_all_known_keys() async { + let removedKeys = AllocatedUnfairLock<[String]>(initialState: []) + let userDefaultsClient = testDependency(of: UserDefaultsClient.self) { + $0.removeObject = { key in + removedKeys.withLock { $0.append(key) } + } + } + let sut = UserDefaultsRepository(userDefaultsClient) + sut.resetToDefaults() + #expect(removedKeys.withLock(\.self) == [ + .runnerID, + .speedDecreasesUnderLoad, + .isFlippedHorizontally, + .updateInterval, + .systemMetricsConfiguration, + .showsMetricsBar, + .metricsBarConfiguration, + ]) + } +} diff --git a/LocalPackage/Tests/ModelTests/StoreTests/GeneralSettingsTests.swift b/LocalPackage/Tests/ModelTests/StoreTests/GeneralSettingsTests.swift index aba9cf9..982317f 100644 --- a/LocalPackage/Tests/ModelTests/StoreTests/GeneralSettingsTests.swift +++ b/LocalPackage/Tests/ModelTests/StoreTests/GeneralSettingsTests.swift @@ -57,4 +57,53 @@ struct GeneralSettingsTests { await sut.send(.launchAtLoginToggleSwitched(true)) #expect(sut.launchesAtLogin == false) } + + @MainActor @Test + func send_resetToDefaultsButtonTapped_shows_confirmation_dialog() async { + let sut = GeneralSettings(.testDependencies()) + await sut.send(.resetToDefaultsButtonTapped) + #expect(sut.showingResetConfirmationDialog == true) + } + + @MainActor @Test + func send_resetToDefaultsCancelled_hides_confirmation_dialog() async { + let sut = GeneralSettings(.testDependencies(), showingResetConfirmationDialog: true) + await sut.send(.resetToDefaultsCancelled) + #expect(sut.showingResetConfirmationDialog == false) + } + + @MainActor @Test + func send_resetToDefaultsConfirmed_clears_defaults_restarts_monitoring_and_notifies() async { + let appState = AllocatedUnfairLock(initialState: .init()) + appState.withLock { + $0.runnerBundles.send(RunnerBundle(runner: Runner(kind: .dog), frame: .preset("dog-frame-0"))) + } + let removedKeys = AllocatedUnfairLock<[String]>(initialState: []) + let monitoringEvents = AllocatedUnfairLock<[String]>(initialState: []) + let sut = GeneralSettings(.testDependencies( + appStateClient: .testDependency(appState), + systemInfoObserverClient: testDependency(of: SystemInfoObserverClient.self) { + $0.stopMonitoring = { + monitoringEvents.withLock { $0.append("stop") } + } + $0.startMonitoring = { interval in + monitoringEvents.withLock { $0.append("start: \(interval)") } + } + }, + userDefaultsClient: testDependency(of: UserDefaultsClient.self) { + $0.removeObject = { key in + removedKeys.withLock { $0.append(key) } + } + } + ), showingResetConfirmationDialog: true) + await sut.send(.resetToDefaultsConfirmed) + #expect(sut.showingResetConfirmationDialog == false) + #expect(sut.updateInterval == .default) + #expect(removedKeys.withLock(\.self).contains(.runnerID)) + #expect(monitoringEvents.withLock(\.self) == ["stop", "start: 5.0"]) + #expect(appState.withLock(\.settingsResets.latestValue) != nil) + #expect(appState.withLock(\.runnerBundles.latestValue)?.runner == Runner.default) + #expect(appState.withLock(\.runnerSpeeds.latestValue) != nil) + #expect(appState.withLock(\.systemMetricsConfigurationChanges.latestValue) != nil) + } } diff --git a/LocalPackage/Tests/ModelTests/StoreTests/MetricsBarSettingsTests.swift b/LocalPackage/Tests/ModelTests/StoreTests/MetricsBarSettingsTests.swift index 9743f31..f89482a 100644 --- a/LocalPackage/Tests/ModelTests/StoreTests/MetricsBarSettingsTests.swift +++ b/LocalPackage/Tests/ModelTests/StoreTests/MetricsBarSettingsTests.swift @@ -111,6 +111,25 @@ struct MetricsBarSettingsTests { #expect(appState.withLock(\.systemMetricsConfigurationChanges.latestValue) != nil) } + @MainActor @Test + func send_task_refreshes_configuration_when_settingsResets_is_emitted() async { + let appState = AllocatedUnfairLock(initialState: .init()) + let storage = UserDefaultsClient.storage(initialSources: [makeSource(id: UUID(1))]) + let sut = MetricsBarSettings(.testDependencies( + appStateClient: .testDependency(appState), + userDefaultsClient: storage.client + )) + await sut.send(.task("MetricsBarSettingsTests")) + #expect(sut.customMetricsSources == [makeSource(id: UUID(1))]) + storage.lock.withLock { $0[.customMetricsConfiguration] = nil } + storage.lock.withLock { $0[.metricsBarConfiguration] = nil } + appState.withLock { $0.settingsResets.send() } + await waitUntil { sut.customMetricsSources.isEmpty } + #expect(sut.customMetricsSources.isEmpty) + #expect(sut.metricsBarConfiguration == .default) + await sut.send(.onDisappear) + } + @MainActor @Test func send_showsSystemMetricsToggleSwitched_cpu_does_not_toggle_activation() async { let activationCount = AllocatedUnfairLock(initialState: 0) diff --git a/LocalPackage/Tests/ModelTests/StoreTests/MetricsBarTests.swift b/LocalPackage/Tests/ModelTests/StoreTests/MetricsBarTests.swift index 410f870..56c58f4 100644 --- a/LocalPackage/Tests/ModelTests/StoreTests/MetricsBarTests.swift +++ b/LocalPackage/Tests/ModelTests/StoreTests/MetricsBarTests.swift @@ -82,6 +82,34 @@ struct MetricsBarTests { await sut.send(.onDisappear) } + @MainActor @Test + func send_task_refreshes_configuration_when_settingsResets_is_emitted() async throws { + let appState = AllocatedUnfairLock(initialState: .init()) + let configurationData = AllocatedUnfairLock(initialState: nil) + let sut = MetricsBar(.testDependencies( + appStateClient: .testDependency(appState), + userDefaultsClient: testDependency(of: UserDefaultsClient.self) { + $0.data = { _ in configurationData.withLock(\.self) } + } + )) + await sut.send(.task("MetricsBarTests")) + #expect(sut.metricsBarConfiguration == .default) + let updatedConfiguration = MetricsBarConfiguration( + showsCPU: true, + showsMemory: true, + showsStorage: true, + showsBattery: false, + showsNetwork: false, + visibleCustomMetricsSourceIDs: [] + ) + let encodedConfiguration = try JSONEncoder().encode(updatedConfiguration) + configurationData.withLock { $0 = encodedConfiguration } + appState.withLock { $0.settingsResets.send() } + await waitUntil { sut.metricsBarConfiguration == updatedConfiguration } + #expect(sut.metricsBarConfiguration == updatedConfiguration) + await sut.send(.onDisappear) + } + @MainActor @Test func send_onDisappear_stops_observing_streams() async { let appState = AllocatedUnfairLock(initialState: .init()) diff --git a/LocalPackage/Tests/ModelTests/StoreTests/MetricsSettingsTests.swift b/LocalPackage/Tests/ModelTests/StoreTests/MetricsSettingsTests.swift index 0a505d6..4a570a3 100644 --- a/LocalPackage/Tests/ModelTests/StoreTests/MetricsSettingsTests.swift +++ b/LocalPackage/Tests/ModelTests/StoreTests/MetricsSettingsTests.swift @@ -185,4 +185,62 @@ struct MetricsSettingsTests { #expect(sut.error == .customMetrics(.fileUnreadable)) #expect(sut.showingAlert == true) } + + @MainActor @Test + func send_task_refreshes_showsMetricsBar_and_configuration_when_settingsResets_is_emitted() async throws { + let appState = AllocatedUnfairLock(initialState: .init()) + let storage = UserDefaultsClient.storage() + let sut = MetricsSettings(.testDependencies( + appStateClient: .testDependency(appState), + userDefaultsClient: storage.client + ), showsMetricsBar: true) + await sut.send(.task("MetricsSettingsTests")) + let updatedConfiguration = SystemMetricsConfiguration( + monitorsMemory: false, + monitorsStorage: false, + monitorsBattery: false, + monitorsNetwork: false + ) + let encodedConfiguration = try JSONEncoder().encode(updatedConfiguration) + storage.lock.withLock { $0[.systemMetricsConfiguration] = encodedConfiguration } + appState.withLock { $0.settingsResets.send() } + await waitUntil { sut.showsMetricsBar == false } + #expect(sut.showsMetricsBar == false) + #expect(sut.systemMetricsConfiguration == updatedConfiguration) + await sut.send(.onDisappear) + } + + @MainActor @Test + func send_task_resyncs_showsMetricsBar_and_configuration_when_revisited_after_external_change() async throws { + let appState = AllocatedUnfairLock(initialState: .init()) + let configurationData = AllocatedUnfairLock(initialState: nil) + let showsMetricsBarFlag = AllocatedUnfairLock(initialState: true) + let userDefaultsClient = testDependency(of: UserDefaultsClient.self) { + $0.bool = { _ in showsMetricsBarFlag.withLock(\.self) } + $0.data = { _ in configurationData.withLock(\.self) } + } + let sut = MetricsSettings(.testDependencies( + appStateClient: .testDependency(appState), + userDefaultsClient: userDefaultsClient + )) + await sut.send(.task("MetricsSettingsTests")) + #expect(sut.showsMetricsBar == true) + #expect(sut.systemMetricsConfiguration == .default) + await sut.send(.onDisappear) + + // Simulate a reset performed elsewhere while this tab was not visible. + showsMetricsBarFlag.withLock { $0 = false } + let updatedConfiguration = SystemMetricsConfiguration( + monitorsMemory: false, + monitorsStorage: false, + monitorsBattery: false, + monitorsNetwork: false + ) + configurationData.withLock { $0 = try? JSONEncoder().encode(updatedConfiguration) } + + await sut.send(.task("MetricsSettingsTests")) + #expect(sut.showsMetricsBar == false) + #expect(sut.systemMetricsConfiguration == updatedConfiguration) + await sut.send(.onDisappear) + } } diff --git a/LocalPackage/Tests/ModelTests/StoreTests/RunnerSettingsTests.swift b/LocalPackage/Tests/ModelTests/StoreTests/RunnerSettingsTests.swift index 59a5bab..bc327e3 100644 --- a/LocalPackage/Tests/ModelTests/StoreTests/RunnerSettingsTests.swift +++ b/LocalPackage/Tests/ModelTests/StoreTests/RunnerSettingsTests.swift @@ -109,4 +109,64 @@ struct RunnerSettingsTests { #expect(sut.error == .customRunner(.loadingFailed)) #expect(sut.showingAlert == true) } + + @MainActor @Test + func send_task_resets_appearance_and_runner_when_settingsResets_is_emitted() async { + let appState = AllocatedUnfairLock(initialState: .init()) + appState.withLock { + $0.runnerBundles.send(RunnerBundle(runner: Runner(kind: .dog), frame: .preset("dog-frame-0"))) + } + let recorder = makeSetRecorder() + let sut = RunnerSettings(.testDependencies( + appStateClient: .testDependency(appState), + userDefaultsClient: recorder.client + ), speedDecreasesUnderLoad: true, isFlippedHorizontally: true) + await sut.send(.task("RunnerSettingsTests")) + appState.withLock { $0.settingsResets.send() } + await waitUntil { sut.currentRunner == Runner.default } + #expect(sut.speedDecreasesUnderLoad == false) + #expect(sut.isFlippedHorizontally == false) + #expect(sut.currentRunner == Runner.default) + #expect(appState.withLock(\.runnerBundles.latestValue)?.runner == Runner.default) + await sut.send(.onDisappear) + } + + @MainActor @Test + func send_task_resyncs_toggles_and_runner_when_revisited_after_external_change() async { + let appState = AllocatedUnfairLock(initialState: .init()) + appState.withLock { + $0.runnerBundles.send(RunnerBundle(runner: Runner(kind: .dog), frame: .preset("dog-frame-0"))) + } + let flags = AllocatedUnfairLock<[String: Bool]>(initialState: [ + .speedDecreasesUnderLoad: true, + .isFlippedHorizontally: true, + ]) + let userDefaultsClient = testDependency(of: UserDefaultsClient.self) { + $0.bool = { key in flags.withLock { $0[key] ?? false } } + } + let sut = RunnerSettings(.testDependencies( + appStateClient: .testDependency(appState), + userDefaultsClient: userDefaultsClient + )) + await sut.send(.task("RunnerSettingsTests")) + #expect(sut.speedDecreasesUnderLoad == true) + #expect(sut.isFlippedHorizontally == true) + #expect(sut.currentRunner == Runner(kind: .dog)) + await sut.send(.onDisappear) + + // Simulate a reset performed elsewhere while this tab was not visible. + flags.withLock { + $0[.speedDecreasesUnderLoad] = false + $0[.isFlippedHorizontally] = false + } + appState.withLock { + $0.runnerBundles.send(RunnerBundle(runner: .default, frame: .preset("cat-frame-0"))) + } + + await sut.send(.task("RunnerSettingsTests")) + #expect(sut.speedDecreasesUnderLoad == false) + #expect(sut.isFlippedHorizontally == false) + #expect(sut.currentRunner == Runner.default) + await sut.send(.onDisappear) + } }