From 2e3574f6e3b6aa162a53e8c8c672ab1d87619b81 Mon Sep 17 00:00:00 2001 From: hariel1985 Date: Thu, 2 Jul 2026 20:25:43 +0200 Subject: [PATCH] Increment 5: Settings, launch-at-login & rich menu-bar HUD MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AppSettings (@MainActor, UserDefaults-persisted): refresh interval, menu-bar metric, notifications toggle, CPU/disk/low-battery alert thresholds, launch-at-login; pushes changes into live services; nonisolated clampInterval - LoginItem via SMAppService (macOS 13+), register/unregister with error catch - SystemMonitor: runtime-configurable refresh cadence (was hardcoded 3s) - SettingsView (⌘,): General (login, interval, menu-bar metric) + Alerts (notifications + thresholds) tabs - MenuBarLabel: user-selectable menu-bar metric (CPU/Memory/Health/Download) - Menu-bar dropdown: Top CPU Consumers with one-click quit - 3 new tests (55 total); smoke: runs clean, Settings… menu registered Co-Authored-By: Claude Opus 4.8 --- TopManager.xcodeproj/project.pbxproj | 12 +++ TopManager/Services/AppSettings.swift | 108 ++++++++++++++++++++++++ TopManager/Services/SystemMonitor.swift | 15 +++- TopManager/TopManagerApp.swift | 18 ++-- TopManager/Views/MenuBarView.swift | 53 ++++++++++++ TopManager/Views/SettingsView.swift | 87 +++++++++++++++++++ TopManagerTests/SettingsTests.swift | 27 ++++++ 7 files changed, 311 insertions(+), 9 deletions(-) create mode 100644 TopManager/Services/AppSettings.swift create mode 100644 TopManager/Views/SettingsView.swift create mode 100644 TopManagerTests/SettingsTests.swift diff --git a/TopManager.xcodeproj/project.pbxproj b/TopManager.xcodeproj/project.pbxproj index ac425d7..69f7cb4 100644 --- a/TopManager.xcodeproj/project.pbxproj +++ b/TopManager.xcodeproj/project.pbxproj @@ -27,7 +27,9 @@ 744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */; }; 79E837AE662021C521F7E74F /* HealthModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C70E54E350F7C8C22D342594 /* HealthModel.swift */; }; 7A1144E43159D96564C13164 /* MemoryInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */; }; + 7A6088469A2643D360B35B89 /* AppSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 892247B0F17363DFC146EDF8 /* AppSettings.swift */; }; 833976FD8DC4CCC45078CF9A /* DiskMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */; }; + 8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 01C19ABF49697A97F0C40378 /* SettingsTests.swift */; }; 8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; }; 9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 295232B4C746511757DCF8FE /* ProcessMonitor.swift */; }; 9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */; }; @@ -35,6 +37,7 @@ A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813543165B39EBD213EB3BC7 /* AppsView.swift */; }; B0165358B483B0ECC28DB4DE /* PowerInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */; }; B62B18DF32A8734D5908B16B /* DiskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */; }; + B89695749D32F394DB459C15 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AACBF14FFC5EC68C9E438040 /* SettingsView.swift */; }; BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; }; C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; }; CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; }; @@ -62,6 +65,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 01C19ABF49697A97F0C40378 /* SettingsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsTests.swift; sourceTree = ""; }; 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStore.swift; sourceTree = ""; }; 0D5B918C9462144E9EE7880A /* Alert.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Alert.swift; sourceTree = ""; }; 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStoreTests.swift; sourceTree = ""; }; @@ -89,6 +93,7 @@ 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatters.swift; sourceTree = ""; }; 7B6F97E6EF65AA17A469F037 /* ProcessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessView.swift; sourceTree = ""; }; 813543165B39EBD213EB3BC7 /* AppsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppsView.swift; sourceTree = ""; }; + 892247B0F17363DFC146EDF8 /* AppSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSettings.swift; sourceTree = ""; }; 8E4A6E8E2305CEDC119AC6BC /* TopManager_icon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; path = TopManager_icon.png; sourceTree = ""; }; 8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryDonutView.swift; sourceTree = ""; }; 94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUChartView.swift; sourceTree = ""; }; @@ -98,6 +103,7 @@ 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; A3A3D0B1EB522C9975F71DA8 /* TopManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TopManager.app; sourceTree = BUILT_PRODUCTS_DIR; }; A404EBD588B7ACD36079A95D /* TopManagerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopManagerApp.swift; sourceTree = ""; }; + AACBF14FFC5EC68C9E438040 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; B6038CCDF3BDF0F8BC93C9D4 /* PowerStorageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerStorageView.swift; sourceTree = ""; }; BBFADE83965AA863B775A910 /* NetworkChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkChartView.swift; sourceTree = ""; }; BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryInfo.swift; sourceTree = ""; }; @@ -115,6 +121,7 @@ isa = PBXGroup; children = ( C2C96471F109FF1C20ABF59E /* AlertCenter.swift */, + 892247B0F17363DFC146EDF8 /* AppSettings.swift */, 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */, 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */, BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */, @@ -211,6 +218,7 @@ children = ( C4F1ACD0492CAB741595DEAA /* ContentView.swift */, 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */, + AACBF14FFC5EC68C9E438040 /* SettingsView.swift */, 6901D0C7B3DA57547BCFAAFC /* Apps */, CD1E8360C279FD4EFAD85DF6 /* Health */, 4935484C55FD4C912A30D623 /* Performance */, @@ -245,6 +253,7 @@ E539A84E57291B69D4EE0314 /* HealthAlertTests.swift */, 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */, 332290DABD5326C87258723C /* ProcessDataTests.swift */, + 01C19ABF49697A97F0C40378 /* SettingsTests.swift */, ); path = TopManagerTests; sourceTree = ""; @@ -367,6 +376,7 @@ 489E29F78A4093D3548673E6 /* HealthAlertTests.swift in Sources */, 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */, 2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */, + 8789C5F4A1AF4B1E8E434D71 /* SettingsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -376,6 +386,7 @@ files = ( EB7EAB13C0DC7865BB1A99D5 /* Alert.swift in Sources */, 67F66E7EC7C01A6309DC060E /* AlertCenter.swift in Sources */, + 7A6088469A2643D360B35B89 /* AppSettings.swift in Sources */, A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */, DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */, FA252F9182ADDA26AD1E54DE /* CPUChartView.swift in Sources */, @@ -406,6 +417,7 @@ 744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */, 9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */, 0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */, + B89695749D32F394DB459C15 /* SettingsView.swift in Sources */, DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */, D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */, 9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */, diff --git a/TopManager/Services/AppSettings.swift b/TopManager/Services/AppSettings.swift new file mode 100644 index 0000000..2763390 --- /dev/null +++ b/TopManager/Services/AppSettings.swift @@ -0,0 +1,108 @@ +import Foundation +import ServiceManagement + +/// What the menu-bar title shows at a glance. +enum MenuBarMetric: String, CaseIterable, Identifiable { + case cpu = "CPU %" + case memory = "Memory %" + case health = "Health" + case download = "Download" + var id: String { rawValue } +} + +/// Wraps the macOS login-item API (SMAppService, macOS 13+). +enum LoginItem { + static var isEnabled: Bool { + SMAppService.mainApp.status == .enabled + } + static func setEnabled(_ enabled: Bool) { + do { + if enabled { + if SMAppService.mainApp.status != .enabled { try SMAppService.mainApp.register() } + } else { + if SMAppService.mainApp.status == .enabled { try SMAppService.mainApp.unregister() } + } + } catch { + NSLog("TopManager: login item toggle failed: \(error.localizedDescription)") + } + } +} + +/// Persisted user preferences. Changes are written to `UserDefaults` and pushed +/// into the running services (monitor cadence, alert thresholds, notifications). +@MainActor +final class AppSettings: ObservableObject { + static let shared = AppSettings() + + private let d = UserDefaults.standard + + @Published var refreshInterval: Double { + didSet { + d.set(refreshInterval, forKey: Keys.refreshInterval) + SystemMonitor.shared.updateRefreshInterval(refreshInterval) + } + } + @Published var menuBarMetric: MenuBarMetric { + didSet { d.set(menuBarMetric.rawValue, forKey: Keys.menuBarMetric) } + } + @Published var notificationsEnabled: Bool { + didSet { + d.set(notificationsEnabled, forKey: Keys.notificationsEnabled) + AlertCenter.shared.notificationsEnabled = notificationsEnabled + } + } + @Published var cpuAlertThreshold: Double { + didSet { + d.set(cpuAlertThreshold, forKey: Keys.cpuAlertThreshold) + AlertCenter.shared.thresholds.cpuPercent = cpuAlertThreshold + } + } + @Published var diskAlertPercent: Double { + didSet { + d.set(diskAlertPercent, forKey: Keys.diskAlertPercent) + AlertCenter.shared.thresholds.diskUsedFraction = diskAlertPercent / 100 + } + } + @Published var lowBatteryPercent: Int { + didSet { + d.set(lowBatteryPercent, forKey: Keys.lowBatteryPercent) + AlertCenter.shared.thresholds.lowBatteryPercent = lowBatteryPercent + } + } + @Published var launchAtLogin: Bool { + didSet { LoginItem.setEnabled(launchAtLogin) } + } + + private init() { + refreshInterval = AppSettings.clampInterval(d.object(forKey: Keys.refreshInterval) as? Double ?? 3.0) + menuBarMetric = MenuBarMetric(rawValue: d.string(forKey: Keys.menuBarMetric) ?? "") ?? .cpu + notificationsEnabled = d.object(forKey: Keys.notificationsEnabled) as? Bool ?? true + cpuAlertThreshold = d.object(forKey: Keys.cpuAlertThreshold) as? Double ?? 90 + diskAlertPercent = d.object(forKey: Keys.diskAlertPercent) as? Double ?? 95 + lowBatteryPercent = d.object(forKey: Keys.lowBatteryPercent) as? Int ?? 15 + launchAtLogin = LoginItem.isEnabled + } + + /// Push all persisted values into the live services (call once on launch). + func applyAll() { + SystemMonitor.shared.updateRefreshInterval(refreshInterval) + AlertCenter.shared.notificationsEnabled = notificationsEnabled + AlertCenter.shared.thresholds.cpuPercent = cpuAlertThreshold + AlertCenter.shared.thresholds.diskUsedFraction = diskAlertPercent / 100 + AlertCenter.shared.thresholds.lowBatteryPercent = lowBatteryPercent + } + + /// Refresh cadence must stay in a sane range (1–30s). + nonisolated static func clampInterval(_ value: Double) -> Double { + min(30, max(1, value)) + } + + private enum Keys { + static let refreshInterval = "refreshInterval" + static let menuBarMetric = "menuBarMetric" + static let notificationsEnabled = "notificationsEnabled" + static let cpuAlertThreshold = "cpuAlertThreshold" + static let diskAlertPercent = "diskAlertPercent" + static let lowBatteryPercent = "lowBatteryPercent" + } +} diff --git a/TopManager/Services/SystemMonitor.swift b/TopManager/Services/SystemMonitor.swift index 2d0755d..8fee94e 100644 --- a/TopManager/Services/SystemMonitor.swift +++ b/TopManager/Services/SystemMonitor.swift @@ -73,14 +73,25 @@ final class SystemMonitor: ObservableObject { } } - // Start periodic refresh (3 second interval) - timer = Timer.scheduledTimer(withTimeInterval: 3.0, repeats: true) { [weak self] _ in + // Start periodic refresh at the user-configured cadence + scheduleTimer(interval: AppSettings.shared.refreshInterval) + } + + private func scheduleTimer(interval: Double) { + timer?.invalidate() + timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in self?.backgroundQueue.async { self?.refreshAllBackground() } } } + /// Change the refresh cadence at runtime (from Settings). No-op if not monitoring. + @MainActor func updateRefreshInterval(_ interval: Double) { + guard timer != nil else { return } + scheduleTimer(interval: AppSettings.clampInterval(interval)) + } + @MainActor func stopMonitoring() { timer?.invalidate() timer = nil diff --git a/TopManager/TopManagerApp.swift b/TopManager/TopManagerApp.swift index c7b5e71..69d9bbf 100644 --- a/TopManager/TopManagerApp.swift +++ b/TopManager/TopManagerApp.swift @@ -11,6 +11,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { @main struct TopManagerApp: App { @StateObject private var monitor = SystemMonitor.shared + @StateObject private var settings = AppSettings.shared @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @Environment(\.scenePhase) private var scenePhase @@ -19,8 +20,10 @@ struct TopManagerApp: App { ContentView() .environmentObject(monitor) .environmentObject(AlertCenter.shared) + .environmentObject(settings) .onAppear { monitor.startMonitoring() + settings.applyAll() AlertCenter.shared.requestNotificationAuthorization() } } @@ -35,18 +38,19 @@ struct TopManagerApp: App { CommandGroup(replacing: .newItem) { } } - // Menu Bar Extra showing CPU % + Settings { + SettingsView() + .environmentObject(settings) + } + + // Menu Bar Extra with configurable metric MenuBarExtra { MenuBarView() .environmentObject(monitor) .environmentObject(AlertCenter.shared) + .environmentObject(settings) } label: { - if let cpu = monitor.cpuInfo { - Text(String(format: "%.0f%%", cpu.globalUsage)) - .monospacedDigit() - } else { - Image(systemName: "cpu") - } + MenuBarLabel(monitor: monitor, settings: settings, alerts: AlertCenter.shared) } .menuBarExtraStyle(.window) } diff --git a/TopManager/Views/MenuBarView.swift b/TopManager/Views/MenuBarView.swift index 222f4f0..1c5be4c 100644 --- a/TopManager/Views/MenuBarView.swift +++ b/TopManager/Views/MenuBarView.swift @@ -1,9 +1,37 @@ import SwiftUI +/// The compact menu-bar title. Renders whichever metric the user picked. +struct MenuBarLabel: View { + @ObservedObject var monitor: SystemMonitor + @ObservedObject var settings: AppSettings + @ObservedObject var alerts: AlertCenter + + var body: some View { + switch settings.menuBarMetric { + case .cpu: + textOrIcon(monitor.cpuInfo.map { String(format: "%.0f%%", $0.globalUsage) }, icon: "cpu") + case .memory: + textOrIcon(monitor.memoryInfo.map { String(format: "%.0f%%", $0.usagePercentage) }, icon: "memorychip") + case .health: + Text("♥ \(alerts.healthScore)").monospacedDigit() + case .download: + textOrIcon(monitor.networkInfo.map { formatBytesPerSecond($0.totalDownloadRate) }, icon: "arrow.down") + } + } + + @ViewBuilder private func textOrIcon(_ text: String?, icon: String) -> some View { + if let text { Text(text).monospacedDigit() } else { Image(systemName: icon) } + } +} + struct MenuBarView: View { @EnvironmentObject var monitor: SystemMonitor @EnvironmentObject var alertCenter: AlertCenter + private var topCPU: [ProcessItem] { + monitor.processes.sorted { $0.cpuUsage > $1.cpuUsage }.prefix(3).map { $0 } + } + var body: some View { VStack(alignment: .leading, spacing: 8) { HStack { @@ -90,6 +118,31 @@ struct MenuBarView: View { } } + if !topCPU.isEmpty { + Divider() + Text("Top CPU Consumers") + .font(.caption) + .foregroundColor(.secondary) + ForEach(topCPU) { process in + HStack(spacing: 6) { + Text(process.name) + .lineLimit(1) + .truncationMode(.middle) + Spacer() + Text(String(format: "%.0f%%", process.cpuUsage)) + .monospacedDigit() + .foregroundColor(.secondary) + Button { + _ = monitor.terminateProcess(process.pid, expectedStartTime: process.startTime) + } label: { + Image(systemName: "xmark.circle") + } + .buttonStyle(.borderless) + .help("Quit \(process.name)") + } + } + } + Divider() Button("Open TopManager") { diff --git a/TopManager/Views/SettingsView.swift b/TopManager/Views/SettingsView.swift new file mode 100644 index 0000000..918369b --- /dev/null +++ b/TopManager/Views/SettingsView.swift @@ -0,0 +1,87 @@ +import SwiftUI + +struct SettingsView: View { + @EnvironmentObject var settings: AppSettings + + var body: some View { + TabView { + generalTab + .tabItem { Label("General", systemImage: "gearshape") } + alertsTab + .tabItem { Label("Alerts", systemImage: "bell") } + } + .frame(width: 440, height: 340) + .padding() + } + + private var generalTab: some View { + Form { + Section { + Toggle("Launch at login", isOn: $settings.launchAtLogin) + + VStack(alignment: .leading) { + HStack { + Text("Refresh interval") + Spacer() + Text(String(format: "%.0f s", settings.refreshInterval)) + .monospacedDigit() + .foregroundColor(.secondary) + } + Slider(value: $settings.refreshInterval, in: 1...30, step: 1) + } + } + + Section("Menu Bar") { + Picker("Show in menu bar", selection: $settings.menuBarMetric) { + ForEach(MenuBarMetric.allCases) { metric in + Text(metric.rawValue).tag(metric) + } + } + } + } + .formStyle(.grouped) + } + + private var alertsTab: some View { + Form { + Section { + Toggle("Enable notifications", isOn: $settings.notificationsEnabled) + } footer: { + Text("TopManager posts a macOS notification when a threshold is first crossed.") + .font(.caption).foregroundColor(.secondary) + } + + Section("Thresholds") { + sliderRow(title: "High CPU", value: $settings.cpuAlertThreshold, + range: 50...100, unit: "%") + sliderRow(title: "Disk almost full", value: $settings.diskAlertPercent, + range: 80...99, unit: "%") + VStack(alignment: .leading) { + HStack { + Text("Low battery") + Spacer() + Text("\(settings.lowBatteryPercent)%").monospacedDigit().foregroundColor(.secondary) + } + Slider(value: Binding( + get: { Double(settings.lowBatteryPercent) }, + set: { settings.lowBatteryPercent = Int($0) } + ), in: 5...50, step: 5) + } + } + } + .formStyle(.grouped) + } + + private func sliderRow(title: String, value: Binding, range: ClosedRange, unit: String) -> some View { + VStack(alignment: .leading) { + HStack { + Text(title) + Spacer() + Text(String(format: "%.0f%@", value.wrappedValue, unit)) + .monospacedDigit() + .foregroundColor(.secondary) + } + Slider(value: value, in: range, step: 1) + } + } +} diff --git a/TopManagerTests/SettingsTests.swift b/TopManagerTests/SettingsTests.swift new file mode 100644 index 0000000..c2b1b92 --- /dev/null +++ b/TopManagerTests/SettingsTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import TopManager + +final class SettingsTests: XCTestCase { + + func testClampIntervalBounds() { + XCTAssertEqual(AppSettings.clampInterval(0.2), 1) + XCTAssertEqual(AppSettings.clampInterval(100), 30) + XCTAssertEqual(AppSettings.clampInterval(5), 5) + XCTAssertEqual(AppSettings.clampInterval(1), 1) + XCTAssertEqual(AppSettings.clampInterval(30), 30) + } + + func testMenuBarMetricRoundTrip() { + for metric in MenuBarMetric.allCases { + XCTAssertEqual(MenuBarMetric(rawValue: metric.rawValue), metric) + } + XCTAssertNil(MenuBarMetric(rawValue: "nonexistent")) + } + + func testMenuBarMetricCoversKeyMetrics() { + XCTAssertTrue(MenuBarMetric.allCases.contains(.cpu)) + XCTAssertTrue(MenuBarMetric.allCases.contains(.memory)) + XCTAssertTrue(MenuBarMetric.allCases.contains(.health)) + XCTAssertTrue(MenuBarMetric.allCases.contains(.download)) + } +}