Files
wehub-resource-sync 1d1286fadb
Build / Build and test (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:18:38 +08:00

664 lines
25 KiB
Swift

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: <BCBatteryDevice: 0x804941b80; vendor = Apple; productIdentifier = 8212; parts = (null); identifier = 49443244; matchIdentifier = (null); name = ggbond AirPods; groupName =ggbond AirPods; percentCharge = 24; lowBattery = NO; lowPowerModeActive = NO; connected = YES; charging = YES; paused = NO; internal = NO; powerSource = NO; poweredSoureState = AC Power; transportType = Bluetooth; accessoryIdentifier = 2C7600E3-8F61-4CAA-A1F0-BADBEEF12345; accessoryCategory = Headphones; modelNumber = AirPods Pro 2; >
"""
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
}
}