384 lines
14 KiB
Swift
384 lines
14 KiB
Swift
import XCTest
|
|
import MacToolsPluginKit
|
|
@testable import MacTools
|
|
@testable import BatteryChargeLimitPlugin
|
|
|
|
// MARK: - Mocks
|
|
|
|
@MainActor
|
|
private final class MockBatteryReader: BatteryChargeLimitReading {
|
|
var snapshot: BatterySnapshot
|
|
private(set) var readCount = 0
|
|
|
|
init(snapshot: BatterySnapshot = .empty) {
|
|
self.snapshot = snapshot
|
|
}
|
|
|
|
func readSnapshot() -> BatterySnapshot {
|
|
readCount += 1
|
|
return snapshot
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class MockBatteryWriter: BatteryChargeLimitWriting {
|
|
var isHelperAvailable: Bool
|
|
var isInstalledHelperAvailable: Bool
|
|
var capabilities: BatterySMCCapabilities
|
|
var inhibitCalls: [Int] = []
|
|
var resumeCalls: Int = 0
|
|
var dischargeCalls: [Bool] = []
|
|
var nextError: BatteryChargeWriteError?
|
|
|
|
init(
|
|
isHelperAvailable: Bool = true,
|
|
isInstalledHelperAvailable: Bool = true,
|
|
capabilities: BatterySMCCapabilities = BatterySMCCapabilities(
|
|
hasCHTE: false, hasCH0BC: true, hasBCLM: false, hasCH0I: true
|
|
)
|
|
) {
|
|
self.isHelperAvailable = isHelperAvailable
|
|
self.isInstalledHelperAvailable = isInstalledHelperAvailable
|
|
self.capabilities = capabilities
|
|
}
|
|
|
|
func probeCapabilities() -> BatterySMCCapabilities { capabilities }
|
|
|
|
@discardableResult
|
|
func inhibitCharging(limitPercent: Int) -> BatteryChargeWriteError? {
|
|
inhibitCalls.append(limitPercent)
|
|
return nextError
|
|
}
|
|
|
|
@discardableResult
|
|
func resumeCharging() -> BatteryChargeWriteError? {
|
|
resumeCalls += 1
|
|
return nextError
|
|
}
|
|
|
|
@discardableResult
|
|
func setForceDischarge(_ on: Bool) -> BatteryChargeWriteError? {
|
|
dischargeCalls.append(on)
|
|
return nextError
|
|
}
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@MainActor
|
|
final class BatteryChargeLimitPluginTests: XCTestCase {
|
|
|
|
// MARK: Metadata
|
|
|
|
func testMetadataIdentifiesBatteryChargeLimitPlugin() {
|
|
let plugin = makePlugin()
|
|
|
|
XCTAssertEqual(plugin.metadata.id, "battery-charge-limit")
|
|
XCTAssertEqual(plugin.metadata.title, "电池充电上限")
|
|
}
|
|
|
|
func testControlStyleIsDisclosure() {
|
|
let plugin = makePlugin()
|
|
|
|
XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .disclosure)
|
|
}
|
|
|
|
// MARK: Visibility
|
|
|
|
func testPanelHiddenWhenNoBattery() {
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: .empty))
|
|
|
|
XCTAssertFalse(plugin.primaryPanelState.isVisible)
|
|
}
|
|
|
|
func testPanelVisibleWhenBatteryPresent() {
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 65)))
|
|
plugin.refresh()
|
|
|
|
XCTAssertTrue(plugin.primaryPanelState.isVisible)
|
|
}
|
|
|
|
// MARK: Enable / Disable
|
|
|
|
func testEnableTogglePersistsAndInhibitsCharging() {
|
|
let writer = MockBatteryWriter()
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer)
|
|
plugin.refresh()
|
|
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
|
|
XCTAssertTrue(plugin.store.isEnabled)
|
|
XCTAssertEqual(writer.inhibitCalls, [BatteryChargeLimits.defaultPercent])
|
|
}
|
|
|
|
func testDisableTogglePersistsAndResumesCharging() {
|
|
let writer = MockBatteryWriter()
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer)
|
|
plugin.refresh()
|
|
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
|
|
XCTAssertFalse(plugin.store.isEnabled)
|
|
XCTAssertGreaterThanOrEqual(writer.resumeCalls, 1)
|
|
}
|
|
|
|
func testEnableWithUnsupportedHardwareSurfacesError() {
|
|
let writer = MockBatteryWriter(capabilities: .none)
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer)
|
|
plugin.refresh()
|
|
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
|
|
XCTAssertFalse(plugin.store.isEnabled)
|
|
XCTAssertNotNil(plugin.primaryPanelState.errorMessage)
|
|
}
|
|
|
|
func testActivateWhenDisabledReadsOnceWithoutStartingMonitoring() async {
|
|
let reader = MockBatteryReader(snapshot: makeSnapshot(level: 60))
|
|
let plugin = makePlugin(reader: reader)
|
|
|
|
plugin.activate(context: PluginRuntimeContext(pluginID: "battery-charge-limit"))
|
|
await Task.yield()
|
|
try? await Task.sleep(for: .milliseconds(20))
|
|
|
|
XCTAssertEqual(reader.readCount, 1)
|
|
}
|
|
|
|
func testActivateWhenEnabledStartsMonitoring() async {
|
|
let reader = MockBatteryReader(snapshot: makeSnapshot(level: 60))
|
|
let plugin = makePlugin(reader: reader)
|
|
plugin.store.setEnabled(true)
|
|
|
|
plugin.activate(context: PluginRuntimeContext(pluginID: "battery-charge-limit"))
|
|
await Task.yield()
|
|
try? await Task.sleep(for: .milliseconds(20))
|
|
|
|
XCTAssertGreaterThanOrEqual(reader.readCount, 2)
|
|
plugin.deactivate(reason: .updating)
|
|
}
|
|
|
|
func testDeactivateWithoutEnablingDoesNotWriteSMC() {
|
|
let writer = MockBatteryWriter()
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer)
|
|
|
|
plugin.deactivate(reason: .hostShutdown)
|
|
|
|
XCTAssertEqual(writer.resumeCalls, 0)
|
|
XCTAssertTrue(writer.dischargeCalls.isEmpty)
|
|
}
|
|
|
|
func testDeactivateWhenEnabledRestoresUnrestrictedCharging() {
|
|
let writer = MockBatteryWriter()
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer)
|
|
plugin.refresh()
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
writer.resumeCalls = 0
|
|
writer.dischargeCalls = []
|
|
|
|
plugin.deactivate(reason: .hostShutdown)
|
|
|
|
XCTAssertEqual(writer.resumeCalls, 1)
|
|
XCTAssertEqual(writer.dischargeCalls, [false])
|
|
}
|
|
|
|
func testDeactivateWhenEnabledButHelperNotInstalledSkipsCleanup() {
|
|
let writer = MockBatteryWriter()
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer)
|
|
plugin.refresh()
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
writer.resumeCalls = 0
|
|
writer.dischargeCalls = []
|
|
writer.isInstalledHelperAvailable = false
|
|
|
|
plugin.deactivate(reason: .hostShutdown)
|
|
|
|
XCTAssertEqual(writer.resumeCalls, 0)
|
|
XCTAssertTrue(writer.dischargeCalls.isEmpty)
|
|
}
|
|
|
|
func testDeactivateWhileChargingDoesNotRepeatCleanup() {
|
|
let writer = MockBatteryWriter()
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer)
|
|
plugin.refresh()
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
plugin.handleAction(.invokeAction(controlID: "battery-charge-action"))
|
|
writer.resumeCalls = 0
|
|
writer.dischargeCalls = []
|
|
|
|
plugin.deactivate(reason: .hostShutdown)
|
|
|
|
XCTAssertEqual(writer.resumeCalls, 0)
|
|
XCTAssertTrue(writer.dischargeCalls.isEmpty)
|
|
}
|
|
|
|
func testDeactivateAfterFailedEnableDoesNotRetryCleanup() {
|
|
let writer = MockBatteryWriter(capabilities: .none)
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer)
|
|
plugin.refresh()
|
|
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
plugin.deactivate(reason: .hostShutdown)
|
|
|
|
XCTAssertEqual(writer.resumeCalls, 0)
|
|
XCTAssertTrue(writer.dischargeCalls.isEmpty)
|
|
}
|
|
|
|
// MARK: Limit Changes
|
|
|
|
func testLimitSliderUpdatesPersistedLimitOnEnd() {
|
|
let writer = MockBatteryWriter()
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer)
|
|
plugin.refresh()
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
|
|
plugin.handleAction(.setSlider(controlID: "battery-limit-slider", value: 70, phase: .ended))
|
|
|
|
XCTAssertEqual(plugin.store.limitPercent, 70)
|
|
// Limit change re-applies holdAtLimit using the new value.
|
|
XCTAssertTrue(writer.inhibitCalls.contains(70))
|
|
}
|
|
|
|
func testLimitSliderChangedPhaseDoesNotPersist() {
|
|
let writer = MockBatteryWriter()
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer)
|
|
plugin.refresh()
|
|
|
|
plugin.handleAction(.setSlider(controlID: "battery-limit-slider", value: 70, phase: .changed))
|
|
|
|
XCTAssertEqual(plugin.store.limitPercent, BatteryChargeLimits.defaultPercent)
|
|
}
|
|
|
|
// MARK: Mode Transitions
|
|
|
|
func testStartChargingResumesAndTransitionsToCharging() {
|
|
let writer = MockBatteryWriter()
|
|
let plugin = makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)), writer: writer)
|
|
plugin.refresh()
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
writer.resumeCalls = 0
|
|
|
|
plugin.handleAction(.invokeAction(controlID: "battery-charge-action"))
|
|
|
|
XCTAssertEqual(plugin.store.mode, .charging)
|
|
XCTAssertGreaterThanOrEqual(writer.resumeCalls, 1)
|
|
}
|
|
|
|
func testReachingLimitWhileChargingTransitionsBackToHold() {
|
|
let writer = MockBatteryWriter()
|
|
let reader = MockBatteryReader(snapshot: makeSnapshot(level: 60))
|
|
let plugin = makePlugin(reader: reader, writer: writer)
|
|
plugin.refresh()
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
plugin.handleAction(.invokeAction(controlID: "battery-charge-action"))
|
|
XCTAssertEqual(plugin.store.mode, .charging)
|
|
|
|
// Simulate the battery reaching the configured limit.
|
|
reader.snapshot = makeSnapshot(level: BatteryChargeLimits.defaultPercent)
|
|
plugin.refresh()
|
|
|
|
XCTAssertEqual(plugin.store.mode, .holdAtLimit)
|
|
}
|
|
|
|
func testHoldAtLimitDoesNotAutoResumeBelowLimit() {
|
|
let writer = MockBatteryWriter()
|
|
let reader = MockBatteryReader(snapshot: makeSnapshot(level: 60))
|
|
let plugin = makePlugin(reader: reader, writer: writer)
|
|
plugin.refresh()
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
writer.resumeCalls = 0
|
|
|
|
// Battery drops further while in holdAtLimit — mode must NOT transition
|
|
// back to .charging on its own. This is the core behavior contract.
|
|
reader.snapshot = makeSnapshot(level: 50)
|
|
plugin.refresh()
|
|
reader.snapshot = makeSnapshot(level: 40)
|
|
plugin.refresh()
|
|
|
|
XCTAssertEqual(plugin.store.mode, .holdAtLimit)
|
|
XCTAssertEqual(writer.resumeCalls, 0, "Plugin must not call resumeCharging() while in holdAtLimit")
|
|
}
|
|
|
|
func testForceDischargeStartsWhenSupportedAndAboveLimit() {
|
|
let writer = MockBatteryWriter()
|
|
let reader = MockBatteryReader(snapshot: makeSnapshot(level: 90))
|
|
let plugin = makePlugin(reader: reader, writer: writer)
|
|
plugin.refresh()
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
|
|
plugin.handleAction(.invokeAction(controlID: "battery-discharge-action"))
|
|
|
|
XCTAssertEqual(plugin.store.mode, .discharging)
|
|
XCTAssertTrue(writer.dischargeCalls.contains(true))
|
|
}
|
|
|
|
func testForceDischargeStopsWhenReachingLimit() {
|
|
let writer = MockBatteryWriter()
|
|
let reader = MockBatteryReader(snapshot: makeSnapshot(level: 90))
|
|
let plugin = makePlugin(reader: reader, writer: writer)
|
|
plugin.refresh()
|
|
plugin.handleAction(.invokeAction(controlID: "battery-enable-action"))
|
|
plugin.handleAction(.invokeAction(controlID: "battery-discharge-action"))
|
|
|
|
reader.snapshot = makeSnapshot(level: BatteryChargeLimits.defaultPercent)
|
|
plugin.refresh()
|
|
|
|
XCTAssertEqual(plugin.store.mode, .holdAtLimit)
|
|
}
|
|
|
|
// MARK: Permissions
|
|
|
|
func testPermissionRequirementsIsEmpty() {
|
|
let plugin = makePlugin()
|
|
|
|
XCTAssertTrue(plugin.permissionRequirements.isEmpty)
|
|
}
|
|
|
|
func testSettingsSectionsIsEmpty() {
|
|
let plugin = makePlugin()
|
|
|
|
XCTAssertTrue(plugin.settingsSections.isEmpty)
|
|
}
|
|
|
|
func testShortcutDefinitionsIsEmpty() {
|
|
let plugin = makePlugin()
|
|
|
|
XCTAssertTrue(plugin.shortcutDefinitions.isEmpty)
|
|
}
|
|
|
|
// MARK: Host Integration
|
|
|
|
func testPluginHostIncludesBatteryChargeLimitPlugin() {
|
|
let host = makePluginHostForTests(plugins: [makePlugin(reader: MockBatteryReader(snapshot: makeSnapshot(level: 60)))])
|
|
|
|
XCTAssertTrue(host.featureManagementItems.contains { $0.id == "battery-charge-limit" })
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
private func makePlugin(
|
|
reader: MockBatteryReader? = nil,
|
|
writer: MockBatteryWriter? = nil
|
|
) -> BatteryChargeLimitPlugin {
|
|
let suiteName = "BatteryChargeLimitPluginTests-\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suiteName)!
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
let storage = UserDefaultsPluginStorage(pluginID: "battery-charge-limit", userDefaults: defaults)
|
|
let context = PluginRuntimeContext(pluginID: "battery-charge-limit", storage: storage)
|
|
return BatteryChargeLimitPlugin(
|
|
context: context,
|
|
reader: reader ?? MockBatteryReader(),
|
|
writer: writer ?? MockBatteryWriter()
|
|
)
|
|
}
|
|
|
|
private func makeSnapshot(level: Int, state: BatteryPowerState = .acPower, isOnAdapter: Bool = true) -> BatterySnapshot {
|
|
BatterySnapshot(
|
|
isAvailable: true,
|
|
levelPercent: level,
|
|
state: state,
|
|
isOnAdapter: isOnAdapter
|
|
)
|
|
}
|
|
}
|