diff --git a/AGENTS.md b/AGENTS.md index 4c63715..d8dd12b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,5 +3,5 @@ 只记录本项目专属约束。通用规则由 ~/.agents/AGENTS.md 提供。 - 只 import `SMCtlClient` 与 `SMCtlProtocol`。禁止 import `SMCtlDaemonCore`。 -- 策略留在 `smctld`。菜单栏只读 ping 与状态。 +- 策略留在 `smctld`。菜单栏通过 `DaemonClient` 读写 ping、电池状态、maintain/charging,以及风扇 profile(`auto` / `quiet` / `full`,回 auto 走 `setFanProfile` / `setFanAuto`)。不提供手动 RPM。 - `SMCtlClient` 在 smctl#21 合并前通过 `Vendor/SMCtlClient` 与 `Vendor/SMCtlProtocol` 符号链接钉住 extract worktree,不要改成整个 smctl package。 diff --git a/README.md b/README.md index a87c80a..8c1d79f 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ SwiftUI `MenuBarExtra` companion for [smctl](https://github.com/leaperone/smctl). -Depends on `SMCtlClient` and `SMCtlProtocol` from smctl. Policy stays in `smctld`. +Depends on `SMCtlClient` and `SMCtlProtocol` from smctl. Policy stays in `smctld`. The extra shows charge percent, the configured limit, and fan RPM (or the SMC no-fans message). It sends maintain (`80`, `70-80`, `stop`), charging on/off, and fan profiles (`auto`, `quiet`, `full`, plus return-to-auto) through `DaemonClient`. Manual RPM entry is not in this menu. Thermal guard stays active on the daemon. ## Path pin (stack) diff --git a/SMCtlMenuBar.xcodeproj/project.pbxproj b/SMCtlMenuBar.xcodeproj/project.pbxproj index 009dcac..18cc67d 100644 --- a/SMCtlMenuBar.xcodeproj/project.pbxproj +++ b/SMCtlMenuBar.xcodeproj/project.pbxproj @@ -15,6 +15,12 @@ A10000000000000000000035 /* DaemonClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000015 /* DaemonClient.swift */; }; A10000000000000000000036 /* SMCtlClientError.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000016 /* SMCtlClientError.swift */; }; A10000000000000000000037 /* SMCtlProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000017 /* SMCtlProtocol.swift */; }; + A10000000000000000000038 /* BatteryMenuSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000019 /* BatteryMenuSection.swift */; }; + A10000000000000000000039 /* BatteryActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000001C /* BatteryActions.swift */; }; + A1000000000000000000003A /* BatteryActionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000001E /* BatteryActionsTests.swift */; }; + A1000000000000000000003B /* FanMenuSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000001D /* FanMenuSection.swift */; }; + A1000000000000000000003C /* FanActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1000000000000000000001F /* FanActions.swift */; }; + A1000000000000000000003D /* FanActionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A10000000000000000000024 /* FanActionsTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -39,6 +45,12 @@ A10000000000000000000015 /* DaemonClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DaemonClient.swift; sourceTree = ""; }; A10000000000000000000016 /* SMCtlClientError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMCtlClientError.swift; sourceTree = ""; }; A10000000000000000000017 /* SMCtlProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMCtlProtocol.swift; sourceTree = ""; }; + A10000000000000000000019 /* BatteryMenuSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMenuSection.swift; sourceTree = ""; }; + A1000000000000000000001C /* BatteryActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActions.swift; sourceTree = ""; }; + A1000000000000000000001E /* BatteryActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryActionsTests.swift; sourceTree = ""; }; + A1000000000000000000001D /* FanMenuSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanMenuSection.swift; sourceTree = ""; }; + A1000000000000000000001F /* FanActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanActions.swift; sourceTree = ""; }; + A10000000000000000000024 /* FanActionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanActionsTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -63,6 +75,10 @@ isa = PBXGroup; children = ( A10000000000000000000011 /* MenuBarRootView.swift */, + A10000000000000000000019 /* BatteryMenuSection.swift */, + A1000000000000000000001C /* BatteryActions.swift */, + A1000000000000000000001D /* FanMenuSection.swift */, + A1000000000000000000001F /* FanActions.swift */, A10000000000000000000012 /* DaemonSession.swift */, A10000000000000000000013 /* MenuSnapshot.swift */, ); @@ -74,6 +90,8 @@ isa = PBXGroup; children = ( A10000000000000000000014 /* MenuSnapshotTests.swift */, + A1000000000000000000001E /* BatteryActionsTests.swift */, + A10000000000000000000024 /* FanActionsTests.swift */, ); name = SMCtlMenuBarTests; path = Tests/SMCtlMenuBarTests; @@ -213,6 +231,10 @@ A10000000000000000000031 /* MenuBarRootView.swift in Sources */, A10000000000000000000032 /* DaemonSession.swift in Sources */, A10000000000000000000033 /* MenuSnapshot.swift in Sources */, + A10000000000000000000038 /* BatteryMenuSection.swift in Sources */, + A10000000000000000000039 /* BatteryActions.swift in Sources */, + A1000000000000000000003B /* FanMenuSection.swift in Sources */, + A1000000000000000000003C /* FanActions.swift in Sources */, A10000000000000000000035 /* DaemonClient.swift in Sources */, A10000000000000000000036 /* SMCtlClientError.swift in Sources */, A10000000000000000000037 /* SMCtlProtocol.swift in Sources */, @@ -224,6 +246,8 @@ buildActionMask = 2147483647; files = ( A10000000000000000000034 /* MenuSnapshotTests.swift in Sources */, + A1000000000000000000003A /* BatteryActionsTests.swift in Sources */, + A1000000000000000000003D /* FanActionsTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Sources/SMCtlMenuBar/BatteryActions.swift b/Sources/SMCtlMenuBar/BatteryActions.swift new file mode 100644 index 0000000..d070780 --- /dev/null +++ b/Sources/SMCtlMenuBar/BatteryActions.swift @@ -0,0 +1,66 @@ +#if canImport(SMCtlClient) +import SMCtlClient +#endif + +public enum MaintainPreset: String, CaseIterable, Equatable, Hashable, Sendable { + case cap80 = "80" + case band7080 = "70-80" + case stop = "stop" + + public var wireLimit: String { + switch self { + case .cap80: + return "80" + case .band7080: + return "70-80" + case .stop: + return "100" + } + } + + public var menuTitle: String { + switch self { + case .cap80: + return "Maintain 80" + case .band7080: + return "Maintain 70-80" + case .stop: + return "Stop maintain" + } + } + + public static func matching(configuredLimit: String) -> MaintainPreset? { + let trimmed = configuredLimit.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if ["100", "stop", "off", "disabled"].contains(trimmed) { + return .stop + } + if trimmed == "80" { + return .cap80 + } + if trimmed == "70-80" { + return .band7080 + } + return nil + } +} + +public enum BatteryCommand: Sendable, Equatable { + case maintain(MaintainPreset) + case setCharging(Bool) +} + +public enum BatteryActions { + public static func perform(_ command: BatteryCommand) throws { + let client = DaemonClient() + switch command { + case .maintain(let preset): + try client.setChargeLimit(preset.wireLimit) + if preset == .stop { + _ = try? client.setChargingEnabled(true) + _ = try? client.setAdapterEnabled(true) + } + case .setCharging(let enabled): + try client.setChargingEnabled(enabled) + } + } +} diff --git a/Sources/SMCtlMenuBar/BatteryMenuSection.swift b/Sources/SMCtlMenuBar/BatteryMenuSection.swift new file mode 100644 index 0000000..9f3c75c --- /dev/null +++ b/Sources/SMCtlMenuBar/BatteryMenuSection.swift @@ -0,0 +1,50 @@ +import SwiftUI + +public struct BatteryMenuSection: View { + public var battery: BatterySnapshot + public var perform: (BatteryCommand) -> Void + + public init(battery: BatterySnapshot, perform: @escaping (BatteryCommand) -> Void) { + self.battery = battery + self.perform = perform + } + + public var body: some View { + Text(battery.chargeLine) + .accessibilityIdentifier("battery-charge") + Text(battery.limitLine) + .accessibilityIdentifier("battery-limit") + if let daemonMessage = battery.daemonMessage, !daemonMessage.isEmpty { + Text(daemonMessage) + } + if let lastWriteError = battery.lastWriteError, !lastWriteError.isEmpty { + Text(lastWriteError) + .accessibilityIdentifier("battery-write-error") + } + + if battery.chargingControlSupported { + Divider() + ForEach(MaintainPreset.allCases, id: \.self) { preset in + Button(title(for: preset)) { + perform(.maintain(preset)) + } + .accessibilityIdentifier("maintain-\(preset.rawValue)") + } + Button("Charging on") { + perform(.setCharging(true)) + } + .accessibilityIdentifier("charging-on") + Button("Charging off") { + perform(.setCharging(false)) + } + .accessibilityIdentifier("charging-off") + } + } + + private func title(for preset: MaintainPreset) -> String { + if battery.selectedMaintain == preset { + return "✓ \(preset.menuTitle)" + } + return preset.menuTitle + } +} diff --git a/Sources/SMCtlMenuBar/DaemonSession.swift b/Sources/SMCtlMenuBar/DaemonSession.swift index 54ed0ee..c109a94 100644 --- a/Sources/SMCtlMenuBar/DaemonSession.swift +++ b/Sources/SMCtlMenuBar/DaemonSession.swift @@ -23,15 +23,64 @@ public final class DaemonSession { } } + public func perform(_ command: BatteryCommand) { + Task { + let next = await Task.detached { + Self.writeThenFetch( + { try BatteryActions.perform(command) }, + attachError: { $0.attachingBatteryWriteError($1) } + ) + }.value + self.snapshot = next + } + } + + public func perform(_ command: FanCommand) { + Task { + let next = await Task.detached { + Self.writeThenFetch( + { try FanActions.perform(command) }, + attachError: { $0.attachingFanWriteError($1) } + ) + }.value + self.snapshot = next + } + } + // XPC calls block on a semaphore inside DaemonClient. Never run them on the main actor. nonisolated private static func fetchLive() -> MenuSnapshot { do { let client = DaemonClient() let ping = try client.ping() let battery = try client.getBatteryStatus() - return .from(ping: ping, battery: battery) + let fans = try client.getFans() + return .from(ping: ping, battery: battery, fans: fans) } catch { return .from(error: error) } } + + nonisolated private static func writeThenFetch( + _ write: () throws -> Void, + attachError: (MenuSnapshot, String) -> MenuSnapshot + ) -> MenuSnapshot { + do { + try write() + return fetchLive() + } catch { + return snapshotAfterFailedWrite(error, attachError: attachError) + } + } + + nonisolated private static func snapshotAfterFailedWrite( + _ error: Error, + attachError: (MenuSnapshot, String) -> MenuSnapshot + ) -> MenuSnapshot { + let live = fetchLive() + let message = MenuSnapshot.errorMessage(error) + if case .connected = live { + return attachError(live, message.isEmpty ? "Write failed" : message) + } + return .from(error: error) + } } diff --git a/Sources/SMCtlMenuBar/FanActions.swift b/Sources/SMCtlMenuBar/FanActions.swift new file mode 100644 index 0000000..eb3ee60 --- /dev/null +++ b/Sources/SMCtlMenuBar/FanActions.swift @@ -0,0 +1,49 @@ +#if canImport(SMCtlClient) +import SMCtlClient +#endif + +public enum FanProfileChoice: String, CaseIterable, Equatable, Hashable, Sendable { + case auto + case quiet + case full + + public var wireName: String { + rawValue + } + + public var menuTitle: String { + switch self { + case .auto: + return "Auto" + case .quiet: + return "Quiet" + case .full: + return "Full" + } + } + + public static func matching(profile: String) -> FanProfileChoice? { + FanProfileChoice(rawValue: profile.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()) + } + + public var command: FanCommand { + .setProfile(self) + } +} + +public enum FanCommand: Sendable, Equatable { + case setProfile(FanProfileChoice) + case returnToAuto +} + +public enum FanActions { + public static func perform(_ command: FanCommand) throws { + let client = DaemonClient() + switch command { + case .returnToAuto: + try client.setFanAuto(index: nil) + case .setProfile(let choice): + try client.setFanProfile(choice.wireName) + } + } +} diff --git a/Sources/SMCtlMenuBar/FanMenuSection.swift b/Sources/SMCtlMenuBar/FanMenuSection.swift new file mode 100644 index 0000000..069a288 --- /dev/null +++ b/Sources/SMCtlMenuBar/FanMenuSection.swift @@ -0,0 +1,53 @@ +import SwiftUI + +public struct FanMenuSection: View { + public var fans: FanSnapshot + public var perform: (FanCommand) -> Void + + public init(fans: FanSnapshot, perform: @escaping (FanCommand) -> Void) { + self.fans = fans + self.perform = perform + } + + public var body: some View { + Text(fans.profileLine) + .accessibilityIdentifier("fan-profile") + if fans.rows.isEmpty { + Text(fans.emptyLine) + .accessibilityIdentifier("fan-empty") + } else { + ForEach(fans.rows, id: \.index) { row in + Text(row.line) + .accessibilityIdentifier("fan-\(row.index)") + } + if let daemonMessage = fans.daemonMessage, !daemonMessage.isEmpty { + Text(daemonMessage) + } + } + if let lastWriteError = fans.lastWriteError, !lastWriteError.isEmpty { + Text(lastWriteError) + .accessibilityIdentifier("fan-write-error") + } + Text(FanSnapshot.thermalGuardLine) + .accessibilityIdentifier("fan-thermal-guard") + + Divider() + ForEach(FanProfileChoice.allCases, id: \.self) { choice in + Button(title(for: choice)) { + perform(choice.command) + } + .accessibilityIdentifier("fan-profile-\(choice.rawValue)") + } + Button("Return to auto") { + perform(.returnToAuto) + } + .accessibilityIdentifier("fan-return-to-auto") + } + + private func title(for choice: FanProfileChoice) -> String { + if fans.selectedProfile == choice { + return "✓ \(choice.menuTitle)" + } + return choice.menuTitle + } +} diff --git a/Sources/SMCtlMenuBar/MenuBarRootView.swift b/Sources/SMCtlMenuBar/MenuBarRootView.swift index 596bd5c..d299a74 100644 --- a/Sources/SMCtlMenuBar/MenuBarRootView.swift +++ b/Sources/SMCtlMenuBar/MenuBarRootView.swift @@ -13,9 +13,12 @@ public struct MenuBarRootView: View { switch session.snapshot { case .idle, .loading: Text("Connecting…") - case .connected(let pingLine, let statusLine): + case .connected(let pingLine, let battery, let fans): Text(pingLine) - Text(statusLine) + .accessibilityIdentifier("ping-line") + BatteryMenuSection(battery: battery, perform: session.perform) + Divider() + FanMenuSection(fans: fans, perform: session.perform) case .disconnected(let message): Text("Not connected") Text(message) diff --git a/Sources/SMCtlMenuBar/MenuSnapshot.swift b/Sources/SMCtlMenuBar/MenuSnapshot.swift index 5ca1452..d7c41a1 100644 --- a/Sources/SMCtlMenuBar/MenuSnapshot.swift +++ b/Sources/SMCtlMenuBar/MenuSnapshot.swift @@ -6,39 +6,214 @@ import SMCtlProtocol public enum MenuSnapshot: Equatable, Sendable { case idle case loading - case connected(pingLine: String, statusLine: String) + case connected(pingLine: String, battery: BatterySnapshot, fans: FanSnapshot) case disconnected(message: String) } +public struct BatterySnapshot: Equatable, Sendable { + public var chargePercent: Int? + public var configuredLimit: String + public var isCharging: Bool? + public var pluggedIn: Bool? + public var chargingControlSupported: Bool + public var adapterControlSupported: Bool + public var daemonMessage: String? + public var lastWriteError: String? + + public init( + chargePercent: Int?, + configuredLimit: String, + isCharging: Bool?, + pluggedIn: Bool?, + chargingControlSupported: Bool, + adapterControlSupported: Bool, + daemonMessage: String? = nil, + lastWriteError: String? = nil + ) { + self.chargePercent = chargePercent + self.configuredLimit = configuredLimit + self.isCharging = isCharging + self.pluggedIn = pluggedIn + self.chargingControlSupported = chargingControlSupported + self.adapterControlSupported = adapterControlSupported + self.daemonMessage = daemonMessage + self.lastWriteError = lastWriteError + } + + public static func from(_ battery: BatteryStatusDTO, lastWriteError: String? = nil) -> BatterySnapshot { + BatterySnapshot( + chargePercent: battery.chargePercent, + configuredLimit: battery.configuredLimit, + isCharging: battery.isCharging, + pluggedIn: battery.pluggedIn, + chargingControlSupported: battery.chargingControlSupported, + adapterControlSupported: battery.adapterControlSupported, + daemonMessage: battery.message, + lastWriteError: lastWriteError + ) + } + + public var chargeLine: String { + guard let percent = chargePercent else { + return "Battery unavailable" + } + if isCharging == true { + return "Battery \(percent)% · charging" + } + if pluggedIn == true { + return "Battery \(percent)% · plugged in" + } + return "Battery \(percent)% · on battery" + } + + public var limitLine: String { + "Limit \(displayLimit)" + } + + public var displayLimit: String { + if selectedMaintain == .stop { + return "off" + } + return configuredLimit + } + + public var selectedMaintain: MaintainPreset? { + MaintainPreset.matching(configuredLimit: configuredLimit) + } + + public func attachingWriteError(_ message: String) -> BatterySnapshot { + var copy = self + copy.lastWriteError = message + return copy + } +} + +public struct FanRPMRow: Equatable, Sendable { + public var index: Int + public var actualRPM: Double? + public var targetRPM: Double? + public var mode: String + + public init(index: Int, actualRPM: Double?, targetRPM: Double?, mode: String) { + self.index = index + self.actualRPM = actualRPM + self.targetRPM = targetRPM + self.mode = mode + } + + public var line: String { + "Fan \(index) · \(Self.formatRPM(actualRPM))" + } + + public static func formatRPM(_ value: Double?) -> String { + guard let value else { + return "—" + } + return "\(String(format: "%.0f", value)) RPM" + } +} + +public struct FanSnapshot: Equatable, Sendable { + public var profile: String + public var rows: [FanRPMRow] + public var daemonMessage: String? + public var lastWriteError: String? + + public static let thermalGuardLine = "Thermal guard remains active." + + public init( + profile: String, + rows: [FanRPMRow], + daemonMessage: String? = nil, + lastWriteError: String? = nil + ) { + self.profile = profile + self.rows = rows + self.daemonMessage = daemonMessage + self.lastWriteError = lastWriteError + } + + public static func from(_ fans: FansStatusDTO, lastWriteError: String? = nil) -> FanSnapshot { + FanSnapshot( + profile: fans.profile, + rows: fans.fans.map { fan in + FanRPMRow( + index: fan.index, + actualRPM: fan.actualRPM, + targetRPM: fan.targetRPM, + mode: fan.mode + ) + }, + daemonMessage: fans.message, + lastWriteError: lastWriteError + ) + } + + public var profileLine: String { + "Profile \(profile)" + } + + public var emptyLine: String { + if let daemonMessage, !daemonMessage.isEmpty { + return daemonMessage + } + return "No fans were reported by SMC." + } + + public var selectedProfile: FanProfileChoice? { + FanProfileChoice.matching(profile: profile) + } + + public func attachingWriteError(_ message: String) -> FanSnapshot { + var copy = self + copy.lastWriteError = message + return copy + } +} + extension MenuSnapshot { - public static func from(ping: PingDTO, battery: BatteryStatusDTO) -> MenuSnapshot { + public static func from(ping: PingDTO, battery: BatteryStatusDTO, fans: FansStatusDTO) -> MenuSnapshot { guard ping.ok else { return .disconnected(message: "Daemon ping returned not ok") } return .connected( pingLine: "smctld ok \(ping.version)", - statusLine: batteryStatusLine(battery) + battery: BatterySnapshot.from(battery), + fans: FanSnapshot.from(fans) ) } public static func from(error: Error) -> MenuSnapshot { - let message = (error as? LocalizedError)?.errorDescription ?? error.localizedDescription + let message = errorMessage(error) if message.isEmpty { return .disconnected(message: "Not connected") } return .disconnected(message: message) } -} -private func batteryStatusLine(_ battery: BatteryStatusDTO) -> String { - guard let percent = battery.chargePercent else { - return "Battery unavailable" + public func attachingWriteError(_ message: String) -> MenuSnapshot { + attachingBatteryWriteError(message) } - if battery.isCharging == true { - return "Battery \(percent)% · charging" + + public func attachingBatteryWriteError(_ message: String) -> MenuSnapshot { + switch self { + case .connected(let pingLine, let battery, let fans): + return .connected(pingLine: pingLine, battery: battery.attachingWriteError(message), fans: fans) + case .idle, .loading, .disconnected: + return .disconnected(message: message) + } + } + + public func attachingFanWriteError(_ message: String) -> MenuSnapshot { + switch self { + case .connected(let pingLine, let battery, let fans): + return .connected(pingLine: pingLine, battery: battery, fans: fans.attachingWriteError(message)) + case .idle, .loading, .disconnected: + return .disconnected(message: message) + } } - if battery.pluggedIn == true { - return "Battery \(percent)% · plugged in" + + public static func errorMessage(_ error: Error) -> String { + (error as? LocalizedError)?.errorDescription ?? error.localizedDescription } - return "Battery \(percent)% · on battery" } diff --git a/Tests/SMCtlMenuBarTests/BatteryActionsTests.swift b/Tests/SMCtlMenuBarTests/BatteryActionsTests.swift new file mode 100644 index 0000000..db8a2c4 --- /dev/null +++ b/Tests/SMCtlMenuBarTests/BatteryActionsTests.swift @@ -0,0 +1,23 @@ +import Testing +@testable import SMCtlMenuBar + +@Test(arguments: [ + (MaintainPreset.cap80, "80"), + (MaintainPreset.band7080, "70-80"), + (MaintainPreset.stop, "100"), +]) +func maintainPresetMapsToExactLimitString(preset: MaintainPreset, expected: String) { + #expect(preset.wireLimit == expected) +} + +@Test(arguments: [ + ("100", MaintainPreset.stop), + ("stop", MaintainPreset.stop), + ("off", MaintainPreset.stop), + ("disabled", MaintainPreset.stop), + ("80", MaintainPreset.cap80), + ("70-80", MaintainPreset.band7080), +]) +func maintainPresetMatchesConfiguredLimit(configured: String, expected: MaintainPreset) { + #expect(MaintainPreset.matching(configuredLimit: configured) == expected) +} diff --git a/Tests/SMCtlMenuBarTests/FanActionsTests.swift b/Tests/SMCtlMenuBarTests/FanActionsTests.swift new file mode 100644 index 0000000..45ab265 --- /dev/null +++ b/Tests/SMCtlMenuBarTests/FanActionsTests.swift @@ -0,0 +1,39 @@ +import Testing +@testable import SMCtlMenuBar + +@Test(arguments: [ + (FanProfileChoice.auto, "auto"), + (FanProfileChoice.quiet, "quiet"), + (FanProfileChoice.full, "full"), +]) +func fanProfileMapsToExactSetFanProfileName(choice: FanProfileChoice, expected: String) { + #expect(choice.wireName == expected) +} + +@Test(arguments: [ + ("auto", FanProfileChoice.auto), + ("quiet", FanProfileChoice.quiet), + ("full", FanProfileChoice.full), + (" Quiet ", FanProfileChoice.quiet), +]) +func fanProfileMatchesStatusName(profile: String, expected: FanProfileChoice) { + #expect(FanProfileChoice.matching(profile: profile) == expected) +} + +@Test +func returnToAutoIsNotAProfileWrite() { + #expect(FanCommand.returnToAuto != FanCommand.setProfile(.auto)) +} + +@Test +func menuChoicesMapToFanCommands() { + #expect(FanProfileChoice.auto.command == .setProfile(.auto)) + #expect(FanProfileChoice.quiet.command == .setProfile(.quiet)) + #expect(FanProfileChoice.full.command == .setProfile(.full)) +} + +@Test +func unknownFanProfileDoesNotMatchMenuChoice() { + #expect(FanProfileChoice.matching(profile: "manual") == nil) + #expect(FanProfileChoice.matching(profile: "custom-curve") == nil) +} diff --git a/Tests/SMCtlMenuBarTests/MenuSnapshotTests.swift b/Tests/SMCtlMenuBarTests/MenuSnapshotTests.swift index a8dd1c4..80fe14d 100644 --- a/Tests/SMCtlMenuBarTests/MenuSnapshotTests.swift +++ b/Tests/SMCtlMenuBarTests/MenuSnapshotTests.swift @@ -12,32 +12,38 @@ func connectedSnapshotFromFakePingDTO() { version: "0.2.3", timestamp: Date(timeIntervalSince1970: 1_700_000_000) ) - let battery = sampleBattery(percent: 80, charging: true, pluggedIn: true) + let battery = sampleBattery(percent: 80, charging: true, pluggedIn: true, limit: "80") + + let fans = sampleFans() #expect( - MenuSnapshot.from(ping: ping, battery: battery) - == .connected(pingLine: "smctld ok 0.2.3", statusLine: "Battery 80% · charging") + MenuSnapshot.from(ping: ping, battery: battery, fans: fans) + == .connected( + pingLine: "smctld ok 0.2.3", + battery: BatterySnapshot.from(battery), + fans: FanSnapshot.from(fans) + ) ) } @Test func pluggedInIdleBatteryLine() { - let ping = PingDTO(ok: true, version: "0.1.8", timestamp: Date(timeIntervalSince1970: 0)) - let battery = sampleBattery(percent: 64, charging: false, pluggedIn: true) - - #expect( - MenuSnapshot.from(ping: ping, battery: battery) - == .connected(pingLine: "smctld ok 0.1.8", statusLine: "Battery 64% · plugged in") + let battery = BatterySnapshot.from( + sampleBattery(percent: 64, charging: false, pluggedIn: true, limit: "80") ) + + #expect(battery.chargeLine == "Battery 64% · plugged in") + #expect(battery.limitLine == "Limit 80") + #expect(battery.selectedMaintain == .cap80) } @Test func failedPingIsDisconnected() { let ping = PingDTO(ok: false, version: "0.2.3", timestamp: Date(timeIntervalSince1970: 0)) - let battery = sampleBattery(percent: 50, charging: false, pluggedIn: false) + let battery = sampleBattery(percent: 50, charging: false, pluggedIn: false, limit: "80") #expect( - MenuSnapshot.from(ping: ping, battery: battery) + MenuSnapshot.from(ping: ping, battery: battery, fans: sampleFans()) == .disconnected(message: "Daemon ping returned not ok") ) } @@ -57,15 +63,115 @@ func errorSnapshotIsDisconnectedWithoutLiveDaemon() { @Test func missingChargePercent() { let ping = PingDTO(ok: true, version: "0.2.3", timestamp: Date(timeIntervalSince1970: 0)) - let battery = sampleBattery(percent: nil, charging: nil, pluggedIn: nil) + let battery = sampleBattery(percent: nil, charging: nil, pluggedIn: nil, limit: "80") + let snapshot = MenuSnapshot.from(ping: ping, battery: battery, fans: sampleFans()) - #expect( - MenuSnapshot.from(ping: ping, battery: battery) - == .connected(pingLine: "smctld ok 0.2.3", statusLine: "Battery unavailable") + guard case .connected(_, let live, _) = snapshot else { + Issue.record("expected connected snapshot") + return + } + #expect(live.chargeLine == "Battery unavailable") +} + +@Test +func maintainBandAndStopDisplay() { + let band = BatterySnapshot.from( + sampleBattery(percent: 75, charging: false, pluggedIn: true, limit: "70-80") + ) + let stopped = BatterySnapshot.from( + sampleBattery(percent: 90, charging: true, pluggedIn: true, limit: "100") + ) + let unknown = BatterySnapshot.from( + sampleBattery(percent: 50, charging: false, pluggedIn: false, limit: "60") + ) + + #expect(band.limitLine == "Limit 70-80") + #expect(band.selectedMaintain == .band7080) + #expect(stopped.limitLine == "Limit off") + #expect(stopped.selectedMaintain == .stop) + #expect(unknown.selectedMaintain == nil) +} + +@Test +func writeErrorStaysOnConnectedSnapshot() { + let ping = PingDTO(ok: true, version: "0.2.3", timestamp: Date(timeIntervalSince1970: 0)) + let battery = sampleBattery(percent: 80, charging: false, pluggedIn: true, limit: "80") + let connected = MenuSnapshot.from(ping: ping, battery: battery, fans: sampleFans()) + let failed = connected.attachingWriteError("Write requests require root or an admin user.") + + guard case .connected(_, let live, _) = failed else { + Issue.record("expected connected snapshot with write error") + return + } + #expect(live.lastWriteError == "Write requests require root or an admin user.") + #expect(live.chargeLine == "Battery 80% · plugged in") +} + +@Test +func emptyFansUsesDaemonMessage() { + let fans = FanSnapshot.from(sampleFans()) + + #expect(fans.profileLine == "Profile auto") + #expect(fans.emptyLine == "No fans were reported by SMC.") + #expect(fans.rows.isEmpty) + #expect(fans.selectedProfile == .auto) + #expect(FanSnapshot.thermalGuardLine == "Thermal guard remains active.") +} + +@Test +func fanRPMRowsAndSelectedProfile() { + let fans = FanSnapshot.from( + sampleFans( + profile: "quiet", + fans: [ + FanStatusDTO( + index: 0, + actualRPM: 1843, + targetRPM: 2000, + minimumRPM: 800, + maximumRPM: 6000, + mode: "manual" + ) + ], + message: nil + ) ) + + #expect(fans.profileLine == "Profile quiet") + #expect(fans.rows.map(\.line) == ["Fan 0 · 1843 RPM"]) + #expect(fans.selectedProfile == .quiet) + #expect(fans.emptyLine == "No fans were reported by SMC.") + #expect(FanSnapshot.thermalGuardLine == "Thermal guard remains active.") +} + +@Test +func unknownFanProfileHasNoMenuSelection() { + let fans = FanSnapshot.from(sampleFans(profile: "manual", message: nil)) + #expect(fans.selectedProfile == nil) + #expect(fans.profileLine == "Profile manual") } -private func sampleBattery(percent: Int?, charging: Bool?, pluggedIn: Bool?) -> BatteryStatusDTO { +@Test +func fanWriteErrorStaysOnConnectedSnapshot() { + let ping = PingDTO(ok: true, version: "0.2.3", timestamp: Date(timeIntervalSince1970: 0)) + let battery = sampleBattery(percent: 80, charging: false, pluggedIn: true, limit: "80") + let connected = MenuSnapshot.from(ping: ping, battery: battery, fans: sampleFans()) + let failed = connected.attachingFanWriteError("No writable fan mode keys were detected on this Mac/system.") + + guard case .connected(_, _, let live) = failed else { + Issue.record("expected connected snapshot with fan write error") + return + } + #expect(live.lastWriteError == "No writable fan mode keys were detected on this Mac/system.") + #expect(live.profileLine == "Profile auto") +} + +private func sampleBattery( + percent: Int?, + charging: Bool?, + pluggedIn: Bool?, + limit: String +) -> BatteryStatusDTO { BatteryStatusDTO( timestamp: Date(timeIntervalSince1970: 0), chargePercent: percent, @@ -75,10 +181,23 @@ private func sampleBattery(percent: Int?, charging: Bool?, pluggedIn: Bool?) -> adapterControlSupported: true, chargingControlGroup: nil, adapterControlGroup: nil, - configuredLimit: "80", + configuredLimit: limit, lowerBound: 70, upperBound: 80, sleepPolicy: "ignore", message: nil ) } + +private func sampleFans( + profile: String = "auto", + fans: [FanStatusDTO] = [], + message: String? = "No fans were reported by SMC." +) -> FansStatusDTO { + FansStatusDTO( + timestamp: Date(timeIntervalSince1970: 0), + profile: profile, + fans: fans, + message: message + ) +}