287 lines
10 KiB
Swift
287 lines
10 KiB
Swift
import XCTest
|
|
import MacToolsPluginKit
|
|
@testable import FanControlPlugin
|
|
|
|
@MainActor
|
|
final class FanControlPluginTests: XCTestCase {
|
|
func testMetadataAndInitialPanelState() {
|
|
let plugin = makePlugin()
|
|
|
|
XCTAssertEqual(plugin.metadata.id, "fan-control")
|
|
XCTAssertEqual(plugin.metadata.title, "风扇控制")
|
|
XCTAssertEqual(plugin.primaryPanelDescriptor.controlStyle, .disclosure)
|
|
XCTAssertFalse(plugin.primaryPanelState.isExpanded)
|
|
XCTAssertTrue(plugin.primaryPanelState.subtitle.contains("自动"))
|
|
}
|
|
|
|
func testRefreshShowsFanSpeedInSubtitle() {
|
|
let plugin = makePlugin(reader: MockSMCReader(snapshot: FanSnapshot(
|
|
fanCount: 1,
|
|
fanSpeeds: [3600],
|
|
fanMinSpeeds: [1200],
|
|
fanMaxSpeeds: [5200],
|
|
cpuTemperature: 45
|
|
)))
|
|
|
|
plugin.refresh()
|
|
|
|
XCTAssertTrue(plugin.primaryPanelState.subtitle.contains("3600 RPM"))
|
|
}
|
|
|
|
func testSelectingBuiltInPresetAppliesStrategy() {
|
|
let writer = MockSMCWriter()
|
|
let plugin = makePlugin(writer: writer)
|
|
|
|
plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.fullSpeed))
|
|
|
|
XCTAssertEqual(writer.appliedStrategy, .fullSpeed)
|
|
}
|
|
|
|
func testSliderEndedUpdatesCustomPresetRPM() {
|
|
let writer = MockSMCWriter()
|
|
let plugin = makePlugin(writer: writer)
|
|
let preset = plugin.presetStore.addCustomPreset()
|
|
plugin.presetStore.setActivePreset(id: preset.id)
|
|
|
|
plugin.handleAction(.setSlider(controlID: "fan-custom-rpm", value: 4000, phase: .ended))
|
|
|
|
XCTAssertEqual(writer.appliedStrategy, .fixed(rpm: 4000))
|
|
}
|
|
|
|
func testWriteErrorAppearsAndCollapseClearsIt() {
|
|
let writer = MockSMCWriter()
|
|
writer.writeError = .writeFailed("硬件写入失败")
|
|
let plugin = makePlugin(writer: writer)
|
|
|
|
plugin.handleAction(.setDisclosureExpanded(true))
|
|
plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.fullSpeed))
|
|
XCTAssertNotNil(plugin.primaryPanelState.errorMessage)
|
|
|
|
plugin.handleAction(.setDisclosureExpanded(false))
|
|
XCTAssertNil(plugin.primaryPanelState.errorMessage)
|
|
}
|
|
|
|
func testDisclosureNoOpDoesNotNotifyStateChange() {
|
|
let plugin = makePlugin()
|
|
var stateChangeCount = 0
|
|
plugin.onStateChange = {
|
|
stateChangeCount += 1
|
|
}
|
|
|
|
plugin.handleAction(.setDisclosureExpanded(false))
|
|
|
|
XCTAssertEqual(stateChangeCount, 0)
|
|
}
|
|
|
|
func testDeletingActiveCustomPresetResetsToAuto() {
|
|
let writer = MockSMCWriter()
|
|
let plugin = makePlugin(writer: writer)
|
|
let preset = plugin.presetStore.addCustomPreset()
|
|
plugin.presetStore.setActivePreset(id: preset.id)
|
|
|
|
plugin.handleAction(.invokeAction(controlID: "fan-delete-preset"))
|
|
|
|
XCTAssertEqual(writer.appliedStrategy, .auto)
|
|
}
|
|
|
|
func testDeactivateWithoutSuccessfulManualPresetDoesNotRestoreAuto() {
|
|
let writer = MockSMCWriter()
|
|
let plugin = makePlugin(writer: writer)
|
|
|
|
plugin.deactivate(reason: .hostShutdown)
|
|
|
|
XCTAssertTrue(writer.appliedStrategies.isEmpty)
|
|
}
|
|
|
|
func testDeactivateAfterSuccessfulManualPresetRestoresAuto() {
|
|
let writer = MockSMCWriter()
|
|
let plugin = makePlugin(writer: writer)
|
|
|
|
plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.fullSpeed))
|
|
plugin.deactivate(reason: .hostShutdown)
|
|
|
|
XCTAssertEqual(writer.appliedStrategies, [.fullSpeed, .auto])
|
|
}
|
|
|
|
func testDeactivateAfterHelperInstallFailureDoesNotRestoreAuto() {
|
|
let writer = MockSMCWriter()
|
|
writer.writeError = .helperInstallFailed("用户取消了授权")
|
|
let plugin = makePlugin(writer: writer)
|
|
|
|
plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.fullSpeed))
|
|
plugin.deactivate(reason: .hostShutdown)
|
|
|
|
XCTAssertEqual(writer.appliedStrategies, [.fullSpeed])
|
|
}
|
|
|
|
func testDeactivateAfterManualPresetSkipsRestoreWhenInstalledHelperIsUnavailable() {
|
|
let writer = MockSMCWriter()
|
|
let plugin = makePlugin(writer: writer)
|
|
|
|
plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.fullSpeed))
|
|
writer.isInstalledHelperAvailable = false
|
|
plugin.deactivate(reason: .hostShutdown)
|
|
|
|
XCTAssertEqual(writer.appliedStrategies, [.fullSpeed])
|
|
}
|
|
|
|
func testSelectingAutoClearsDeactivateRestoreRequirement() {
|
|
let writer = MockSMCWriter()
|
|
let plugin = makePlugin(writer: writer)
|
|
|
|
plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.fullSpeed))
|
|
plugin.handleAction(.setSelection(controlID: "fan-preset-list", optionID: FanPresetBuiltInID.auto))
|
|
plugin.deactivate(reason: .hostShutdown)
|
|
|
|
XCTAssertEqual(writer.appliedStrategies, [.fullSpeed, .auto])
|
|
}
|
|
|
|
func testMonitoringOnlyPublishesMeaningfulSnapshotChanges() async throws {
|
|
let firstSnapshot = FanSnapshot(
|
|
fanCount: 1,
|
|
fanSpeeds: [3600],
|
|
fanMinSpeeds: [1200],
|
|
fanMaxSpeeds: [5200],
|
|
cpuTemperature: 45
|
|
)
|
|
let equivalentSnapshot = FanSnapshot(
|
|
fanCount: 1,
|
|
fanSpeeds: [3605],
|
|
fanMinSpeeds: [1200],
|
|
fanMaxSpeeds: [5200],
|
|
cpuTemperature: 45.2
|
|
)
|
|
let changedSnapshot = FanSnapshot(
|
|
fanCount: 1,
|
|
fanSpeeds: [3900],
|
|
fanMinSpeeds: [1200],
|
|
fanMaxSpeeds: [5200],
|
|
cpuTemperature: 49
|
|
)
|
|
let reader = MockSMCReader(
|
|
snapshot: changedSnapshot,
|
|
snapshots: [firstSnapshot, equivalentSnapshot, changedSnapshot]
|
|
)
|
|
let plugin = makePlugin(
|
|
reader: reader,
|
|
monitoringActiveInterval: .milliseconds(10),
|
|
monitoringIdleInterval: .milliseconds(10)
|
|
)
|
|
var stateChangeCount = 0
|
|
plugin.onStateChange = {
|
|
stateChangeCount += 1
|
|
}
|
|
|
|
plugin.activate(context: PluginRuntimeContext(pluginID: "fan-control"))
|
|
try await Task.sleep(for: .milliseconds(45))
|
|
plugin.deactivate(reason: .disabled)
|
|
|
|
XCTAssertGreaterThanOrEqual(reader.readCount, 3)
|
|
XCTAssertEqual(stateChangeCount, 2)
|
|
}
|
|
|
|
func testFeatureVisibilityAndDisclosureControlActiveMonitoring() async throws {
|
|
let snapshot = FanSnapshot(
|
|
fanCount: 1,
|
|
fanSpeeds: [3600],
|
|
fanMinSpeeds: [1200],
|
|
fanMaxSpeeds: [5200],
|
|
cpuTemperature: 45
|
|
)
|
|
let reader = MockSMCReader(snapshot: snapshot)
|
|
let plugin = makePlugin(
|
|
reader: reader,
|
|
monitoringActiveInterval: .milliseconds(10),
|
|
monitoringIdleInterval: .milliseconds(200)
|
|
)
|
|
|
|
plugin.activate(context: PluginRuntimeContext(pluginID: "fan-control"))
|
|
try await Task.sleep(for: .milliseconds(40))
|
|
let idleReadCount = reader.readCount
|
|
|
|
plugin.featureVisibilityDidChange(true)
|
|
plugin.handleAction(.setDisclosureExpanded(true))
|
|
try await Task.sleep(for: .milliseconds(45))
|
|
let activeReadCount = reader.readCount
|
|
|
|
plugin.featureVisibilityDidChange(false)
|
|
try await Task.sleep(for: .milliseconds(45))
|
|
let closedReadCount = reader.readCount
|
|
|
|
plugin.deactivate(reason: .disabled)
|
|
|
|
XCTAssertLessThanOrEqual(idleReadCount, 2)
|
|
XCTAssertGreaterThanOrEqual(activeReadCount - idleReadCount, 3)
|
|
XCTAssertLessThanOrEqual(closedReadCount - activeReadCount, 2)
|
|
}
|
|
|
|
private func makePlugin(
|
|
reader: MockSMCReader? = nil,
|
|
writer: MockSMCWriter? = nil,
|
|
monitoringActiveInterval: Duration = .seconds(2),
|
|
monitoringIdleInterval: Duration = .seconds(10)
|
|
) -> FanControlPlugin {
|
|
FanControlPlugin(
|
|
context: PluginRuntimeContext(pluginID: "fan-control", storage: FanControlMemoryStorage()),
|
|
smcReader: reader ?? MockSMCReader(),
|
|
smcWriter: writer ?? MockSMCWriter(),
|
|
monitoringActiveInterval: monitoringActiveInterval,
|
|
monitoringIdleInterval: monitoringIdleInterval
|
|
)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class MockSMCReader: FanControlSMCReading {
|
|
var snapshot: FanSnapshot
|
|
var snapshots: [FanSnapshot]
|
|
private(set) var readCount = 0
|
|
|
|
init(snapshot: FanSnapshot = .empty, snapshots: [FanSnapshot] = []) {
|
|
self.snapshot = snapshot
|
|
self.snapshots = snapshots
|
|
}
|
|
|
|
func readSnapshot() -> FanSnapshot {
|
|
readCount += 1
|
|
guard !snapshots.isEmpty else {
|
|
return snapshot
|
|
}
|
|
return snapshots.removeFirst()
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class MockSMCWriter: FanControlSMCWriting {
|
|
var isHelperAvailable = true
|
|
var isInstalledHelperAvailable = true
|
|
var appliedStrategy: FanControlStrategy?
|
|
var appliedStrategies: [FanControlStrategy] = []
|
|
var writeError: FanWriteError?
|
|
|
|
func apply(strategy: FanControlStrategy, snapshot _: FanSnapshot) -> FanWriteError? {
|
|
appliedStrategy = strategy
|
|
appliedStrategies.append(strategy)
|
|
return writeError
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class FanControlMemoryStorage: 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)
|
|
}
|
|
}
|