diff --git a/TopManager.xcodeproj/project.pbxproj b/TopManager.xcodeproj/project.pbxproj index e0ebb1c..24dbc64 100644 --- a/TopManager.xcodeproj/project.pbxproj +++ b/TopManager.xcodeproj/project.pbxproj @@ -15,6 +15,7 @@ 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; }; 266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */; }; 2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 332290DABD5326C87258723C /* ProcessDataTests.swift */; }; + 2DE14B65EA1D4E9B4D9DA5A6 /* BatteryMathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */; }; 341C12CED58388D32ED6C5BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F1ACD0492CAB741595DEAA /* ContentView.swift */; }; 35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; }; 4427B8406B25B8FEDF27CDBA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 334573CEB47D60570A40B55D /* Assets.xcassets */; }; @@ -28,11 +29,13 @@ 9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */; }; A0649565A902BA48DB10F592 /* CPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */; }; 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 */; }; 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 */; }; D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A404EBD588B7ACD36079A95D /* TopManagerApp.swift */; }; + D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */; }; DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 279B972C08A878053821285D /* SystemMonitor.swift */; }; DE50ECC69FCE60B10364802C /* ByteFormatters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */; }; E379C349DC9617D6C505B74B /* GPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */; }; @@ -72,7 +75,9 @@ 352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskInfo.swift; sourceTree = ""; }; 44281079654C7E1C9E16806C /* CPUInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUInfo.swift; sourceTree = ""; }; 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessInfo.swift; sourceTree = ""; }; + 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerInfo.swift; sourceTree = ""; }; 5E1D2EA8E1AD11B42FF225EB /* TopManager.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TopManager.entitlements; sourceTree = ""; }; + 70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatteryMathTests.swift; sourceTree = ""; }; 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryLineChart.swift; sourceTree = ""; }; 78C9B469F6A45F3CC21A7594 /* ByteFormatters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ByteFormatters.swift; sourceTree = ""; }; 7B6F97E6EF65AA17A469F037 /* ProcessView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessView.swift; sourceTree = ""; }; @@ -81,6 +86,7 @@ 8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryDonutView.swift; sourceTree = ""; }; 94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUChartView.swift; sourceTree = ""; }; 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskUsageView.swift; sourceTree = ""; }; + 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PowerMonitor.swift; sourceTree = ""; }; 9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendChartView.swift; sourceTree = ""; }; 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; }; @@ -104,6 +110,7 @@ 27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */, 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */, 347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */, + 95F7CCD4F64BE7478DFAA17F /* PowerMonitor.swift */, 295232B4C746511757DCF8FE /* ProcessMonitor.swift */, 279B972C08A878053821285D /* SystemMonitor.swift */, ); @@ -127,6 +134,7 @@ F481045B4FC6017F50849FF1 /* GPUInfo.swift */, BD3EDCDEDF4A0CE1921A3B25 /* MemoryInfo.swift */, 3010F605712E09175D9D8878 /* NetworkInfo.swift */, + 51C512B13DC0C1024CF39A5E /* PowerInfo.swift */, 4CF69D4888FDBB3669619A1A /* ProcessInfo.swift */, ); path = Models; @@ -210,6 +218,7 @@ D078BA97D4F498E5282AC5E0 /* TopManagerTests */ = { isa = PBXGroup; children = ( + 70CCC4ED4F2BDC35ABD4F4F6 /* BatteryMathTests.swift */, 301352EB5B362C732CE1A246 /* FormattingTests.swift */, 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */, 332290DABD5326C87258723C /* ProcessDataTests.swift */, @@ -330,6 +339,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 2DE14B65EA1D4E9B4D9DA5A6 /* BatteryMathTests.swift in Sources */, 49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */, 24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */, 2B74C177F49B4358D9642712 /* ProcessDataTests.swift in Sources */, @@ -361,6 +371,8 @@ E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */, EB0D84848F42134927F9C9B1 /* NetworkMonitor.swift in Sources */, 00EC5A90B7B8C6C5015D90D1 /* PerformanceView.swift in Sources */, + B0165358B483B0ECC28DB4DE /* PowerInfo.swift in Sources */, + D9E882888E23C26B748E22CF /* PowerMonitor.swift in Sources */, 501C13D57386C179C0B3B14B /* PowerStorageView.swift in Sources */, C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */, 744F52D036D1F3E081923382 /* ProcessInfo.swift in Sources */, diff --git a/TopManager/Models/PowerInfo.swift b/TopManager/Models/PowerInfo.swift new file mode 100644 index 0000000..a8f42c3 --- /dev/null +++ b/TopManager/Models/PowerInfo.swift @@ -0,0 +1,83 @@ +import Foundation + +/// Battery / power state. Mirrors what Activity Monitor's Energy tab and the +/// macOS battery menu surface, which TopManager currently lacks entirely. +struct PowerInfo { + let hasBattery: Bool + let currentCharge: Int // 0–100 % + let isCharging: Bool + let isPluggedIn: Bool // external power connected + let fullyCharged: Bool + let cycleCount: Int? + let designCapacity: Int? // mAh + let maxCapacity: Int? // mAh (current full-charge capacity) + let temperature: Double? // °C + let voltage: Double? // volts + let amperage: Int? // mA (negative while discharging) + let timeToEmpty: Int? // minutes (nil if unknown/charging) + let timeToFull: Int? // minutes (nil if unknown/discharging) + let adapterWatts: Int? // power adapter rating + + /// Battery health as current-full-charge ÷ design capacity. + var healthPercent: Double? { + guard let maxCapacity, let designCapacity, designCapacity > 0 else { return nil } + return BatteryMath.healthPercent(maxCapacity: maxCapacity, designCapacity: designCapacity) + } + + var condition: BatteryCondition { + BatteryMath.condition(healthPercent: healthPercent, cycleCount: cycleCount) + } + + /// Instantaneous power flow in watts (positive = charging, negative = draining). + var powerWatts: Double? { + guard let voltage, let amperage else { return nil } + return BatteryMath.watts(volts: voltage, milliAmps: amperage) + } + + var powerSourceLabel: String { + isPluggedIn ? "AC Power" : "Battery Power" + } +} + +enum BatteryCondition: String { + case normal = "Normal" + case serviceRecommended = "Service Recommended" + case unknown = "Unknown" + + var color: String { + switch self { + case .normal: return "green" + case .serviceRecommended: return "orange" + case .unknown: return "gray" + } + } +} + +/// Pure, testable battery math (no IOKit, no clock). +enum BatteryMath { + static func healthPercent(maxCapacity: Int, designCapacity: Int) -> Double { + guard designCapacity > 0 else { return 0 } + return Double(maxCapacity) / Double(designCapacity) * 100 + } + + /// Apple flags "Service Recommended" as health degrades or cycles climb. + /// This approximates that classification from the raw numbers. + static func condition(healthPercent: Double?, cycleCount: Int?) -> BatteryCondition { + guard let healthPercent else { return .unknown } + if healthPercent < 80 { return .serviceRecommended } + if let cycleCount, cycleCount > 1000 { return .serviceRecommended } + return .normal + } + + static func watts(volts: Double, milliAmps: Int) -> Double { + volts * Double(milliAmps) / 1000.0 + } + + /// Format a minutes duration as "2h 15m" / "45m"; nil-safe caller. + static func formatMinutes(_ minutes: Int) -> String { + guard minutes > 0 else { return "—" } + let h = minutes / 60 + let m = minutes % 60 + return h > 0 ? "\(h)h \(m)m" : "\(m)m" + } +} diff --git a/TopManager/Services/PowerMonitor.swift b/TopManager/Services/PowerMonitor.swift new file mode 100644 index 0000000..0091c5a --- /dev/null +++ b/TopManager/Services/PowerMonitor.swift @@ -0,0 +1,77 @@ +import Foundation +import IOKit + +/// Reads battery / power state from the AppleSmartBattery IORegistry entry. +/// No entitlements or root required. On a desktop Mac (no battery) it reports +/// `hasBattery == false` and AC power. +final class PowerMonitor { + + func fetchPowerInfo() -> PowerInfo { + let service = IOServiceGetMatchingService(kIOMainPortDefault, IOServiceMatching("AppleSmartBattery")) + guard service != 0 else { + // No battery (e.g. Mac mini / Studio / Pro): assume AC power. + return PowerInfo( + hasBattery: false, currentCharge: 100, isCharging: false, isPluggedIn: true, + fullyCharged: true, cycleCount: nil, designCapacity: nil, maxCapacity: nil, + temperature: nil, voltage: nil, amperage: nil, timeToEmpty: nil, + timeToFull: nil, adapterWatts: nil + ) + } + defer { IOObjectRelease(service) } + + var unmanagedProps: Unmanaged? + guard IORegistryEntryCreateCFProperties(service, &unmanagedProps, kCFAllocatorDefault, 0) == KERN_SUCCESS, + let props = unmanagedProps?.takeRetainedValue() as? [String: Any] else { + return PowerInfo( + hasBattery: false, currentCharge: 100, isCharging: false, isPluggedIn: true, + fullyCharged: true, cycleCount: nil, designCapacity: nil, maxCapacity: nil, + temperature: nil, voltage: nil, amperage: nil, timeToEmpty: nil, + timeToFull: nil, adapterWatts: nil + ) + } + + let int = { (key: String) -> Int? in props[key] as? Int } + let bool = { (key: String) -> Bool? in props[key] as? Bool } + + // Percent charge: CurrentCapacity is 0–100 on Apple Silicon. + let currentCharge = int("CurrentCapacity") ?? 0 + let isCharging = bool("IsCharging") ?? false + let isPluggedIn = bool("ExternalConnected") ?? false + let fullyCharged = bool("FullyCharged") ?? false + let cycleCount = int("CycleCount") + let designCapacity = int("DesignCapacity") + // Raw (mAh) capacity is the meaningful "full charge capacity" on AS. + let maxCapacity = int("AppleRawMaxCapacity") ?? int("MaxCapacity") + let temperature = int("Temperature").map { Double($0) / 100.0 } // centi-°C + let voltage = int("Voltage").map { Double($0) / 1000.0 } // mV → V + let amperage = int("Amperage") + + let timeToEmpty = normalizedMinutes(int("AvgTimeToEmpty"), valid: !isPluggedIn) + let timeToFull = normalizedMinutes(int("AvgTimeToFull"), valid: isCharging) + + let adapterWatts = (props["AdapterDetails"] as? [String: Any])?["Watts"] as? Int + + return PowerInfo( + hasBattery: true, + currentCharge: currentCharge, + isCharging: isCharging, + isPluggedIn: isPluggedIn, + fullyCharged: fullyCharged, + cycleCount: cycleCount, + designCapacity: designCapacity, + maxCapacity: maxCapacity, + temperature: temperature, + voltage: voltage, + amperage: amperage, + timeToEmpty: timeToEmpty, + timeToFull: timeToFull, + adapterWatts: adapterWatts + ) + } + + /// AppleSmartBattery reports 65535 (or 0) for "still calculating"; treat as unknown. + private func normalizedMinutes(_ value: Int?, valid: Bool) -> Int? { + guard valid, let value, value > 0, value != 65535 else { return nil } + return value + } +} diff --git a/TopManager/Services/SystemMonitor.swift b/TopManager/Services/SystemMonitor.swift index aa0fc50..0bd7f3b 100644 --- a/TopManager/Services/SystemMonitor.swift +++ b/TopManager/Services/SystemMonitor.swift @@ -13,6 +13,7 @@ final class SystemMonitor: ObservableObject { @MainActor @Published var diskInfo: DiskInfo? @MainActor @Published var networkInfo: NetworkInfo? @MainActor @Published var gpuInfo: GPUInfo? + @MainActor @Published var powerInfo: PowerInfo? @MainActor @Published var lastError: String? // History for charts @@ -28,6 +29,7 @@ final class SystemMonitor: ObservableObject { private let diskMonitor = DiskMonitor() private let networkMonitor = NetworkMonitor() private let gpuMonitor = GPUMonitor() + private let powerMonitor = PowerMonitor() // Timer private var timer: Timer? @@ -63,10 +65,11 @@ final class SystemMonitor: ObservableObject { let processData = self.processMonitor.fetchProcesses() let diskData = self.diskMonitor.fetchDiskInfo() let gpuData = self.gpuMonitor.fetchGPUInfo() + let powerData = self.powerMonitor.fetchPowerInfo() DispatchQueue.main.async { self.updateUI(cpu: cpuData, memory: memData, network: netData, - processes: processData, disk: diskData, gpu: gpuData) + processes: processData, disk: diskData, gpu: gpuData, power: powerData) } } @@ -99,23 +102,26 @@ final class SystemMonitor: ObservableObject { processData = processMonitor.fetchProcesses() } - // Fetch disk and GPU every 3 cycles + // Fetch disk, GPU and power every 3 cycles var diskData: DiskInfo? = nil var gpuData: GPUInfo? = nil + var powerData: PowerInfo? = nil if currentCount % 3 == 0 { diskData = diskMonitor.fetchDiskInfo() gpuData = gpuMonitor.fetchGPUInfo() + powerData = powerMonitor.fetchPowerInfo() } // Update UI on main thread DispatchQueue.main.async { [weak self] in self?.updateUI(cpu: cpuData, memory: memData, network: netData, - processes: processData, disk: diskData, gpu: gpuData) + processes: processData, disk: diskData, gpu: gpuData, power: powerData) } } @MainActor private func updateUI(cpu: CPUInfo?, memory: MemoryInfo?, network: NetworkInfo?, - processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?) { + processes: [ProcessItem]?, disk: DiskInfo?, gpu: GPUInfo?, + power: PowerInfo?) { if let info = cpu { cpuInfo = info let historyPoint = CPUHistoryPoint( @@ -179,6 +185,10 @@ final class SystemMonitor: ObservableObject { gpuInfo = info } + if let info = power { + powerInfo = info + } + // Persist a system-wide sample so history survives restarts and long ranges. if let c = cpuInfo, let m = memoryInfo { MetricsStore.shared.record(MetricsSample( diff --git a/TopManager/Views/MenuBarView.swift b/TopManager/Views/MenuBarView.swift index 369b2a8..9ba7948 100644 --- a/TopManager/Views/MenuBarView.swift +++ b/TopManager/Views/MenuBarView.swift @@ -58,6 +58,21 @@ struct MenuBarView: View { } } + if let power = monitor.powerInfo, power.hasBattery { + HStack { + Image(systemName: power.isCharging ? "battery.100.bolt" : "battery.100") + .frame(width: 16) + Text("Battery:") + Spacer() + Text("\(power.currentCharge)%") + .monospacedDigit() + if let toEmpty = power.timeToEmpty { + Text("(\(BatteryMath.formatMinutes(toEmpty)))") + .foregroundColor(.secondary) + } + } + } + Divider() Button("Open TopManager") { diff --git a/TopManager/Views/PowerStorage/PowerStorageView.swift b/TopManager/Views/PowerStorage/PowerStorageView.swift index 49e8857..825e347 100644 --- a/TopManager/Views/PowerStorage/PowerStorageView.swift +++ b/TopManager/Views/PowerStorage/PowerStorageView.swift @@ -141,6 +141,11 @@ struct PowerStorageView: View { } .fixedSize(horizontal: false, vertical: true) + // Battery & Power + if let power = monitor.powerInfo { + BatteryStatusView(power: power) + } + // Storage GroupBox { VStack(alignment: .leading, spacing: 12) { @@ -184,6 +189,109 @@ struct PowerStorageView: View { } } +struct BatteryStatusView: View { + let power: PowerInfo + + var body: some View { + GroupBox { + VStack(alignment: .leading, spacing: 10) { + HStack { + Label("Battery & Power", systemImage: batteryIcon) + .font(.headline) + Spacer() + HStack(spacing: 4) { + Image(systemName: power.isPluggedIn ? "powerplug.fill" : "battery.100") + .foregroundColor(.secondary) + Text(power.powerSourceLabel) + .foregroundColor(.secondary) + } + } + + if power.hasBattery { + HStack { + Text("Charge:") + Spacer() + if power.isCharging { + Image(systemName: "bolt.fill").foregroundColor(.yellow) + } + Text("\(power.currentCharge)%").monospacedDigit() + } + ProgressView(value: Double(power.currentCharge), total: 100) + .tint(chargeColor) + + row("Condition") { + Text(power.condition.rawValue) + .foregroundColor(conditionColor) + } + + if let health = power.healthPercent { + row("Health (max/design)") { + Text(String(format: "%.0f%%", health)).monospacedDigit() + } + } + if let cycles = power.cycleCount { + row("Cycle Count") { Text("\(cycles)").monospacedDigit() } + } + if let temp = power.temperature { + row("Temperature") { Text(String(format: "%.1f °C", temp)).monospacedDigit() } + } + if let watts = power.powerWatts { + row(power.isCharging ? "Charge Power" : "Power Draw") { + Text(String(format: "%.1f W", abs(watts))).monospacedDigit() + } + } + if let toEmpty = power.timeToEmpty { + row("Time Remaining") { Text(BatteryMath.formatMinutes(toEmpty)).monospacedDigit() } + } + if let toFull = power.timeToFull { + row("Time to Full") { Text(BatteryMath.formatMinutes(toFull)).monospacedDigit() } + } + if let adapter = power.adapterWatts, adapter > 0 { + row("Power Adapter") { Text("\(adapter) W").monospacedDigit() } + } + } else { + Text("No battery detected — running on AC power.") + .foregroundColor(.secondary) + } + } + .padding(.vertical, 8) + } + } + + private func row(_ label: String, @ViewBuilder value: () -> Content) -> some View { + HStack { + Text(label + ":") + Spacer() + value() + } + } + + private var batteryIcon: String { + if !power.hasBattery { return "powerplug" } + if power.isCharging { return "battery.100.bolt" } + switch power.currentCharge { + case ..<15: return "battery.25" + case ..<50: return "battery.50" + case ..<85: return "battery.75" + default: return "battery.100" + } + } + + private var chargeColor: Color { + if power.currentCharge < 15 { return .red } + if power.currentCharge < 30 { return .orange } + return .green + } + + private var conditionColor: Color { + switch power.condition { + case .normal: return .green + case .serviceRecommended: return .orange + case .unknown: return .secondary + } + } +} + struct ThermalStateView: View { let state: ProcessInfo.ThermalState diff --git a/TopManagerTests/BatteryMathTests.swift b/TopManagerTests/BatteryMathTests.swift new file mode 100644 index 0000000..12d15ea --- /dev/null +++ b/TopManagerTests/BatteryMathTests.swift @@ -0,0 +1,58 @@ +import XCTest +@testable import TopManager + +final class BatteryMathTests: XCTestCase { + + func testHealthPercent() { + // 4000 mAh current full-charge vs 5000 mAh design = 80% + XCTAssertEqual(BatteryMath.healthPercent(maxCapacity: 4000, designCapacity: 5000), 80, accuracy: 0.001) + } + + func testHealthPercentZeroDesignIsSafe() { + XCTAssertEqual(BatteryMath.healthPercent(maxCapacity: 100, designCapacity: 0), 0) + } + + func testConditionNormalWhenHealthy() { + XCTAssertEqual(BatteryMath.condition(healthPercent: 92, cycleCount: 120), .normal) + } + + func testConditionServiceWhenHealthLow() { + XCTAssertEqual(BatteryMath.condition(healthPercent: 74, cycleCount: 120), .serviceRecommended) + } + + func testConditionServiceWhenCyclesHigh() { + XCTAssertEqual(BatteryMath.condition(healthPercent: 95, cycleCount: 1200), .serviceRecommended) + } + + func testConditionUnknownWhenNoHealth() { + XCTAssertEqual(BatteryMath.condition(healthPercent: nil, cycleCount: 100), .unknown) + } + + func testWattsPositiveWhenCharging() { + // 12 V * 2000 mA = 24 W + XCTAssertEqual(BatteryMath.watts(volts: 12, milliAmps: 2000), 24, accuracy: 0.001) + } + + func testWattsNegativeWhenDischarging() { + XCTAssertEqual(BatteryMath.watts(volts: 12, milliAmps: -1500), -18, accuracy: 0.001) + } + + func testFormatMinutes() { + XCTAssertEqual(BatteryMath.formatMinutes(135), "2h 15m") + XCTAssertEqual(BatteryMath.formatMinutes(45), "45m") + XCTAssertEqual(BatteryMath.formatMinutes(0), "—") + } + + func testPowerInfoDerivedValues() { + let p = PowerInfo( + hasBattery: true, currentCharge: 66, isCharging: false, isPluggedIn: false, + fullyCharged: false, cycleCount: 150, designCapacity: 5000, maxCapacity: 4600, + temperature: 30.5, voltage: 12.0, amperage: -1500, timeToEmpty: 200, + timeToFull: nil, adapterWatts: nil + ) + XCTAssertEqual(p.healthPercent ?? 0, 92, accuracy: 0.001) + XCTAssertEqual(p.condition, .normal) + XCTAssertEqual(p.powerWatts ?? 0, -18, accuracy: 0.001) + XCTAssertEqual(p.powerSourceLabel, "Battery Power") + } +}