import XCTest import MacToolsPluginKit @testable import DeviceBatteryPlugin @MainActor final class DeviceBatteryPluginTests: XCTestCase { func testPluginDescriptorUsesExpandedFullWidthSpan() { let plugin = DeviceBatteryPlugin( context: makeContext(), viewModel: DeviceBatteryViewModel( sampler: StubDeviceBatterySampler(items: []), rapooMonitor: StubRapooBatteryMonitor() ), inputMonitoringAuthorizationStatus: { .unknown } ) XCTAssertEqual(plugin.metadata.id, "device-battery") XCTAssertEqual(plugin.metadata.title, "设备电量") XCTAssertEqual(plugin.descriptor.span, PluginComponentSpan(width: 4, height: 15)!) } func testLayoutSpanAccountsForVisibleDeviceCount() { XCTAssertEqual(DeviceBatteryComponentLayout.spanHeight(mode: .list, visibleItemCount: 1), 14) XCTAssertEqual(DeviceBatteryComponentLayout.spanHeight(mode: .list, visibleItemCount: 9), 31) } func testStorePersistsLayoutAndSources() { let storage = DeviceBatteryMemoryStorage() let store = DeviceBatteryStore(storage: storage) store.setLayoutMode(.list) store.setShowBluetoothDevices(false) store.setShowAppleMobileDevices(false) store.setShowRapooDevices(false) let reloaded = DeviceBatteryStore(storage: storage) XCTAssertEqual(reloaded.layoutMode, .list) XCTAssertTrue(reloaded.showInternalBattery) XCTAssertFalse(reloaded.showBluetoothDevices) XCTAssertFalse(reloaded.showAppleMobileDevices) XCTAssertFalse(reloaded.showRapooDevices) } func testStorePersistsLowBatteryNotificationSettings() { let storage = DeviceBatteryMemoryStorage() let store = DeviceBatteryStore(storage: storage) XCTAssertFalse(store.lowBatteryNotificationEnabled) XCTAssertEqual(store.lowBatteryNotificationThreshold, 20) store.setLowBatteryNotificationEnabled(true) store.setLowBatteryNotificationThreshold(15) let reloaded = DeviceBatteryStore(storage: storage) XCTAssertTrue(reloaded.lowBatteryNotificationEnabled) XCTAssertEqual(reloaded.lowBatteryNotificationThreshold, 15) } func testAppleMobileRefreshIntervalTracksComponentVisibility() { let viewModel = DeviceBatteryViewModel( sampler: StubDeviceBatterySampler(items: []), rapooMonitor: StubRapooBatteryMonitor() ) let plugin = DeviceBatteryPlugin( context: makeContext(), viewModel: viewModel, inputMonitoringAuthorizationStatus: { .unknown } ) XCTAssertEqual(viewModel.appleMobileRefreshInterval, 5 * 60) plugin.panelSurfaceDidBecomeVisible(.primary) XCTAssertEqual(viewModel.appleMobileRefreshInterval, 5 * 60) plugin.panelSurfaceDidBecomeVisible(.component) XCTAssertEqual(viewModel.appleMobileRefreshInterval, 90) viewModel.stop() XCTAssertEqual(viewModel.appleMobileRefreshInterval, 5 * 60) plugin.panelSurfaceDidBecomeVisible(.component) XCTAssertEqual(viewModel.appleMobileRefreshInterval, 90) plugin.panelSurfaceDidBecomeHidden(.component) XCTAssertEqual(viewModel.appleMobileRefreshInterval, 5 * 60) } func testStoreClampsLowBatteryNotificationThreshold() { let store = DeviceBatteryStore(storage: DeviceBatteryMemoryStorage()) store.setLowBatteryNotificationThreshold(0) XCTAssertEqual(store.lowBatteryNotificationThreshold, 1) store.setLowBatteryNotificationThreshold(120) XCTAssertEqual(store.lowBatteryNotificationThreshold, 99) } func testBluetoothPowerLogParserReadsConnectedMouseBattery() { let line = """ 2026-06-02 14:05:52.648 Df bluetoothd[616:f85de1] [com.apple.bluetooth:CBPowerSource] Power source updated CBPowerSource Nm 'MX Anywhere 3S', SID 49354549, AcCa Mouse, AcID 0532E370-EA18-11C7-44F9-6D7E86E35891, PID 0xB037 (?), VID 0x046D (?), VIDSrc USB, Type 'Accessory Source', TPT Bluetooth LE, CF 0x1 < Attributes >, IF 0x2 < IOKit >, Present yes, MaxC 100%, Battery -80% """ let reading = DeviceBatteryBluetoothPowerLogParser.reading(from: line) XCTAssertEqual(reading?.name, "MX Anywhere 3S") XCTAssertEqual(reading?.vendorID, "0x046D") XCTAssertEqual(reading?.productID, "0xB037") XCTAssertEqual(reading?.deviceType, "Mouse") XCTAssertEqual(reading?.level, 80) XCTAssertEqual(reading?.chargeState, .normal) } func testBluetoothPowerLogParserReadsAirPodsComponents() { let line = """ 2026-06-02 16:43:44.978 Df bluetoothd[616:ff000b] [com.apple.bluetooth:CBPowerSource] Power source updated CBPowerSource Nm 'ggbond AirPods 4', SID 70391692, AcCa Headphone, PID 0x201B (Device1,8219), VID 0x004C (Apple), Battery 68% (Unknown), Components (Y): Left +100%, CF 0x1 < Attributes >, Right +100%, CF 0x1 < Attributes >, Case +83%, CF 0x1 < Attributes > """ let readings = DeviceBatteryBluetoothPowerLogParser.readings(fromLine: line) XCTAssertEqual(readings.first { $0.component == nil }?.level, 68) XCTAssertEqual(readings.first { $0.component == .left }?.chargeState, .charging) XCTAssertEqual(readings.first { $0.component == .chargingCase }?.level, 83) } func testBatteryCenterLogParserReadsChargingState() { let line = """ 2026-06-12 21:43:32.313 Df NotificationCenter[1199:36289f6] [com.apple.BatteryCenter:PowerSourceController] Found device: """ let reading = DeviceBatteryBatteryCenterLogParser.reading(fromLine: line) XCTAssertEqual(reading?.name, "ggbond AirPods") XCTAssertEqual(reading?.model, "AirPods Pro 2") XCTAssertEqual(reading?.level, 24) XCTAssertEqual(reading?.chargeState, .charging) XCTAssertEqual(reading?.isConnected, true) } func testAppleHeadphoneAdvertisementParserReadsChargingParts() { var data = [UInt8](repeating: 0, count: 25) data[0] = 0x4C data[1] = 0x00 data[2] = 0x12 data[12] = 0x80 | 24 data[13] = 0x80 | 100 data[14] = 100 let readings = DeviceBatteryAppleHeadphoneAdvertisementParser.readings(from: Data(data)) XCTAssertEqual(readings.first { $0.component == .chargingCase }?.chargeState, .charging) XCTAssertEqual(readings.first { $0.component == .left }?.level, 100) XCTAssertEqual(readings.first { $0.component == .right }?.chargeState, .normal) } func testRapooParserReadsProtocolOneBatteryReport() { let report = [UInt8](repeating: 0, count: 16).setting(1, at: 6).setting(83, at: 7) XCTAssertEqual( RapooBatteryParser.parseInputReport(reportID: 7, bytes: report), RapooBatteryReading(level: 83, chargeState: .normal, statusCode: 1) ) } func testMobileBatteryParserReadsChargingIPhone() throws { let record = try XCTUnwrap(DeviceBatteryMobileBatteryParser.record( identifier: "phone-id", name: "测试 iPhone", productType: "iPhone18,1", deviceClass: "iPhone", connectionType: "Wi-Fi", battery: [ "BatteryCurrentCapacity": 67, "BatteryIsCharging": true, "ExternalConnected": true ] )) XCTAssertEqual(record.category, .phone) XCTAssertEqual(record.level, 67) XCTAssertEqual(record.chargeState, .charging) XCTAssertEqual(record.batteryItem(referenceDate: Date()).kind, .phone) } func testMobileBatteryParserDistinguishesPluggedAndFullyCharged() throws { let plugged = try XCTUnwrap(DeviceBatteryMobileBatteryParser.record( identifier: "tablet-id", name: "iPad", productType: "iPad17,2", deviceClass: "iPad", connectionType: "USB", battery: [ "BatteryCurrentCapacity": 80, "BatteryIsCharging": false, "ExternalConnected": true ] )) let charged = try XCTUnwrap(DeviceBatteryMobileBatteryParser.record( identifier: "watch-id", name: "Apple Watch", productType: "Watch7,5", deviceClass: "Watch", connectionType: "Wi-Fi", battery: [ "BatteryCurrentCapacity": 100, "FullyCharged": true, "ExternalConnected": true ] )) XCTAssertEqual(plugged.category, .tablet) XCTAssertEqual(plugged.chargeState, .plugged) XCTAssertEqual(charged.category, .watch) XCTAssertEqual(charged.chargeState, .charged) } func testMobileBatteryParserNormalizesIORegistryCapacity() throws { let record = try XCTUnwrap(DeviceBatteryMobileBatteryParser.record( identifier: "vision-id", name: "Vision Pro", productType: "RealityDevice15,1", deviceClass: "RealityDevice", connectionType: "Wi-Fi", battery: [ "AppleRawCurrentCapacity": 3_200, "AppleRawMaxCapacity": 4_000, "IsCharging": false ] )) XCTAssertEqual(record.category, .spatialComputer) XCTAssertEqual(record.level, 80) XCTAssertEqual(record.chargeState, .normal) } func testMobileBatteryParserRecognizesIPodTouch() throws { let record = try XCTUnwrap(DeviceBatteryMobileBatteryParser.record( identifier: "ipod-id", name: "iPod touch", productType: "iPod9,1", deviceClass: "iPod", connectionType: "USB", battery: ["BatteryCurrentCapacity": 42] )) XCTAssertEqual(record.category, .mediaPlayer) XCTAssertEqual(record.batteryItem(referenceDate: Date()).kind, .mediaPlayer) } func testMobileDeviceReadingWinsOverStaleChargingLog() throws { let now = Date() let mobile = try XCTUnwrap(DeviceBatteryMobileBatteryParser.record( identifier: "phone-id", name: "测试 iPhone", productType: "iPhone18,1", deviceClass: "iPhone", connectionType: "Wi-Fi", battery: [ "BatteryCurrentCapacity": 66, "BatteryIsCharging": false, "ExternalConnected": false ] )).batteryItem(referenceDate: now) let staleLog = DeviceBatteryItem( id: "batterycenter-phone-id", name: "测试 iPhone", model: "iPhone18,1", kind: .phone, level: 65, chargeState: .charging, parentName: nil, source: "BatteryCenter", lastUpdated: now, isConnected: true, detail: nil ) let item = try XCTUnwrap(DeviceBatterySampler.deduplicated([staleLog, mobile]).first) XCTAssertEqual(item.source, "MobileDevice") XCTAssertEqual(item.level, 66) XCTAssertEqual(item.chargeState, .normal) } func testItemNormalizerDropsAirPodsAggregateWhenComponentsExist() { let aggregate = makeAirPodsItem(id: "main", role: .aggregate) let caseItem = makeAirPodsItem(id: "case", role: .chargingCase) XCTAssertEqual( DeviceBatteryItemNormalizer.removingRedundantComponentAggregates([aggregate, caseItem]).map(\.id), ["case"] ) } func testLowBatteryNotificationMergesMultipleDevices() { let notifier = RecordingLowBatteryNotifier() let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) let snapshot = makeSnapshot(items: [ makeBatteryItem(id: "mouse", name: "Mouse", level: 12), makeBatteryItem(id: "keyboard", name: "Keyboard", level: 18), makeBatteryItem(id: "trackpad", name: "Trackpad", level: 38) ]) controller.evaluate( snapshot: snapshot, isEnabled: true, threshold: 20, localization: PluginLocalization(bundle: .main) ) XCTAssertEqual(notifier.notifications.count, 1) XCTAssertEqual(notifier.notifications[0].deviceIDs, ["mouse", "keyboard"]) XCTAssertEqual(notifier.notifications[0].title, "2 台设备电量偏低") XCTAssertTrue(notifier.notifications[0].body.contains("Mouse 12%")) XCTAssertTrue(notifier.notifications[0].body.contains("Keyboard 18%")) XCTAssertFalse(notifier.notifications[0].body.contains("Trackpad")) } func testLowBatteryNotificationDoesNotRepeatUntilDeviceRecovers() { let notifier = RecordingLowBatteryNotifier() let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) let lowSnapshot = makeSnapshot(items: [ makeBatteryItem(id: "mouse", name: "Mouse", level: 12) ]) controller.evaluate( snapshot: lowSnapshot, isEnabled: true, threshold: 20, localization: PluginLocalization(bundle: .main) ) controller.evaluate( snapshot: lowSnapshot, isEnabled: true, threshold: 20, localization: PluginLocalization(bundle: .main) ) XCTAssertEqual(notifier.notifications.count, 1) controller.evaluate( snapshot: makeSnapshot(items: [ makeBatteryItem(id: "mouse", name: "Mouse", level: 35) ]), isEnabled: true, threshold: 20, localization: PluginLocalization(bundle: .main) ) controller.evaluate( snapshot: lowSnapshot, isEnabled: true, threshold: 20, localization: PluginLocalization(bundle: .main) ) XCTAssertEqual(notifier.notifications.count, 2) } func testLowBatteryNotificationDoesNotRepeatWhenSourceIDChanges() { let notifier = RecordingLowBatteryNotifier() let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) controller.evaluate( snapshot: makeSnapshot(items: [ makeBatteryItem( id: "bluetooth-powerlog-airpods-left", name: "AirPods 左耳", level: 7, kind: .airPodsPart, parentName: "AirPods 充电盒", componentIdentity: DeviceBatteryComponentIdentity(groupID: "airpods-address", role: .left) ) ]), isEnabled: true, threshold: 10, localization: PluginLocalization(bundle: .main) ) controller.evaluate( snapshot: makeSnapshot(items: [ makeBatteryItem( id: "apple-headphone-advertisement-airpods-left", name: "AirPods 左耳", level: 6, kind: .airPodsPart, parentName: "AirPods 充电盒", componentIdentity: DeviceBatteryComponentIdentity(groupID: "airpods-advertisement", role: .left) ) ]), isEnabled: true, threshold: 10, localization: PluginLocalization(bundle: .main) ) XCTAssertEqual(notifier.notifications.count, 1) } func testLowBatteryNotificationDoesNotRepeatWhenDeviceKindChanges() { let notifier = RecordingLowBatteryNotifier() let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) controller.evaluate( snapshot: makeSnapshot(items: [ makeBatteryItem( id: "bluetooth-mx-anywhere", name: "MX Anywhere 3S", level: 9, kind: .bluetooth ) ]), isEnabled: true, threshold: 10, localization: PluginLocalization(bundle: .main) ) controller.evaluate( snapshot: makeSnapshot(items: [ makeBatteryItem( id: "batterycenter-mx-anywhere", name: "MX Anywhere 3S", level: 8, kind: .magicAccessory ) ]), isEnabled: true, threshold: 10, localization: PluginLocalization(bundle: .main) ) XCTAssertEqual(notifier.notifications.count, 1) } func testLowBatteryNotificationDoesNotRepeatWhenAggregateRoleChanges() { let notifier = RecordingLowBatteryNotifier() let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) controller.evaluate( snapshot: makeSnapshot(items: [ makeBatteryItem( id: "corebluetooth-airpods", name: "AirPods", level: 9, kind: .airPodsPart, componentIdentity: DeviceBatteryComponentIdentity(groupID: "airpods", role: .aggregate) ) ]), isEnabled: true, threshold: 10, localization: PluginLocalization(bundle: .main) ) controller.evaluate( snapshot: makeSnapshot(items: [ makeBatteryItem( id: "batterycenter-airpods", name: "AirPods", level: 8, kind: .airPodsPart ) ]), isEnabled: true, threshold: 10, localization: PluginLocalization(bundle: .main) ) XCTAssertEqual(notifier.notifications.count, 1) } func testLowBatteryNotificationDoesNotResetWhenDeviceTemporarilyMissing() { let notifier = RecordingLowBatteryNotifier() let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) let lowSnapshot = makeSnapshot(items: [ makeBatteryItem(id: "mouse", name: "Mouse", level: 8) ]) controller.evaluate( snapshot: lowSnapshot, isEnabled: true, threshold: 10, localization: PluginLocalization(bundle: .main) ) controller.evaluate( snapshot: makeSnapshot(items: []), isEnabled: true, threshold: 10, localization: PluginLocalization(bundle: .main) ) controller.evaluate( snapshot: lowSnapshot, isEnabled: true, threshold: 10, localization: PluginLocalization(bundle: .main) ) XCTAssertEqual(notifier.notifications.count, 1) } func testLowBatteryNotificationResetsAfterDeviceIsCharged() { let notifier = RecordingLowBatteryNotifier() let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) let lowSnapshot = makeSnapshot(items: [ makeBatteryItem(id: "mouse", name: "Mouse", level: 12) ]) controller.evaluate( snapshot: lowSnapshot, isEnabled: true, threshold: 20, localization: PluginLocalization(bundle: .main) ) controller.evaluate( snapshot: makeSnapshot(items: [ makeBatteryItem(id: "mouse", name: "Mouse", level: 100, chargeState: .charged) ]), isEnabled: true, threshold: 20, localization: PluginLocalization(bundle: .main) ) controller.evaluate( snapshot: lowSnapshot, isEnabled: true, threshold: 20, localization: PluginLocalization(bundle: .main) ) XCTAssertEqual(notifier.notifications.count, 2) } func testLowBatteryNotificationIgnoresChargingDevicesAndBoundaryValue() { let notifier = RecordingLowBatteryNotifier() let controller = DeviceBatteryLowBatteryNotificationController(notifier: notifier) controller.evaluate( snapshot: makeSnapshot(items: [ makeBatteryItem(id: "mouse", name: "Mouse", level: 20), makeBatteryItem(id: "keyboard", name: "Keyboard", level: 12, chargeState: .charging), makeBatteryItem(id: "trackpad", name: "Trackpad", level: 12, isConnected: false) ]), isEnabled: true, threshold: 20, localization: PluginLocalization(bundle: .main) ) XCTAssertTrue(notifier.notifications.isEmpty) } private func makeContext() -> PluginRuntimeContext { PluginRuntimeContext(pluginID: "device-battery", storage: DeviceBatteryMemoryStorage()) } private func makeSnapshot(items: [DeviceBatteryItem]) -> DeviceBatterySnapshot { DeviceBatterySnapshot( accessState: .ready, items: items, lastUpdated: Date(), rapooState: .idle ) } private func makeAirPodsItem(id: String, role: DeviceBatteryComponentRole) -> DeviceBatteryItem { DeviceBatteryItem( id: id, name: id, model: "AirPods 4", kind: .airPodsPart, level: 80, chargeState: .normal, parentName: nil, source: "test", lastUpdated: Date(), isConnected: true, detail: "Headphones", componentIdentity: DeviceBatteryComponentIdentity(groupID: "airpods", role: role) ) } private func makeBatteryItem( id: String, name: String, level: Int, kind: DeviceBatteryKind = .bluetooth, chargeState: DeviceBatteryChargeState = .normal, isConnected: Bool = true, parentName: String? = nil, componentIdentity: DeviceBatteryComponentIdentity? = nil ) -> DeviceBatteryItem { DeviceBatteryItem( id: id, name: name, model: nil, kind: kind, level: level, chargeState: chargeState, parentName: parentName, source: "test", lastUpdated: Date(), isConnected: isConnected, detail: nil, componentIdentity: componentIdentity ) } } @MainActor private final class DeviceBatteryMemoryStorage: PluginStorage { private var values: [String: Any] = [:] func object(forKey key: String) -> Any? { values[key] } func data(forKey key: String) -> Data? { values[key] as? Data } func string(forKey key: String) -> String? { values[key] as? String } func stringArray(forKey key: String) -> [String]? { values[key] as? [String] } func integer(forKey key: String) -> Int { values[key] as? Int ?? 0 } func bool(forKey key: String) -> Bool { values[key] as? Bool ?? false } func set(_ value: Any?, forKey key: String) { values[key] = value } func removeObject(forKey key: String) { values.removeValue(forKey: key) } func migrateValueIfNeeded(fromLegacyKey legacyKey: String, to key: String) { guard values[key] == nil, let value = values[legacyKey] else { return } values[key] = value values.removeValue(forKey: legacyKey) } } private struct StubDeviceBatterySampler: DeviceBatterySampling { let items: [DeviceBatteryItem] func collectSystemDevices( referenceDate: Date, options: DeviceBatterySamplingOptions ) async -> [DeviceBatteryItem] { items } } @MainActor private final class RecordingLowBatteryNotifier: DeviceBatteryLowBatteryNotifying { private(set) var notifications: [DeviceBatteryLowBatteryNotification] = [] func notifyLowBatteryDevices( _ items: [DeviceBatteryItem], threshold: Int, localization: PluginLocalization ) { notifications.append( DeviceBatteryLowBatteryNotificationContent.make( items: items, threshold: threshold, localization: localization ) ) } } @MainActor private final class StubRapooBatteryMonitor: RapooBatteryMonitoring { var snapshot = RapooMouseBatterySnapshot.idle var onSnapshotChange: ((RapooMouseBatterySnapshot) -> Void)? func start() {} func stop() {} func refresh() {} } private extension Array where Element == UInt8 { func setting(_ value: UInt8, at index: Int) -> [UInt8] { var copy = self copy[index] = value return copy } }