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 ) } }