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

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