450 lines
17 KiB
Swift
450 lines
17 KiB
Swift
import SwiftUI
|
|
import XCTest
|
|
import MacToolsPluginKit
|
|
@testable import MacTools
|
|
@testable import SystemStatusPlugin
|
|
|
|
@MainActor
|
|
final class SystemStatusPluginTests: XCTestCase {
|
|
private let suiteName = "SystemStatusPluginTests"
|
|
|
|
override func tearDown() {
|
|
UserDefaults(suiteName: suiteName)?.removePersistentDomain(forName: suiteName)
|
|
super.tearDown()
|
|
}
|
|
|
|
func testPluginDescriptorUsesExpandedFullWidthSpan() {
|
|
let plugin = SystemStatusPlugin(storage: SystemStatusMemoryPluginStorage())
|
|
let expectedHeight = PluginComponentPanelLayoutMetrics.default.heightSpan(
|
|
fittingContentHeight: SystemStatusComponentLayout.dashboardContentHeight
|
|
)
|
|
|
|
XCTAssertEqual(plugin.metadata.id, "system-status")
|
|
XCTAssertEqual(plugin.metadata.title, "系统状态")
|
|
XCTAssertEqual(plugin.descriptor.span, PluginComponentSpan(width: 4, height: expectedHeight)!)
|
|
}
|
|
|
|
func testPluginHostIncludesSystemStatusComponentOnlyWhenProvided() {
|
|
let host = makePluginHostForTests(
|
|
plugins: [SystemStatusPlugin(storage: SystemStatusMemoryPluginStorage())],
|
|
suiteName: suiteName
|
|
)
|
|
|
|
XCTAssertTrue(host.componentItems.contains { $0.id == "system-status" })
|
|
XCTAssertFalse(host.panelItems.contains { $0.id == "system-status" })
|
|
|
|
let managementItem = host.featureManagementItems.first { $0.id == "system-status" }
|
|
XCTAssertEqual(managementItem?.presentation, .componentPanel)
|
|
}
|
|
|
|
func testPluginHostCanPresentSystemStatusSettings() {
|
|
let host = makePluginHostForTests(
|
|
plugins: [SystemStatusPlugin(storage: SystemStatusMemoryPluginStorage())],
|
|
suiteName: suiteName
|
|
)
|
|
|
|
host.presentPluginConfiguration(pluginID: "system-status")
|
|
|
|
XCTAssertEqual(host.selectedSettingsDestination, .pluginConfiguration)
|
|
XCTAssertEqual(host.selectedFeatureSettingsPane, .configuration("system-status"))
|
|
}
|
|
|
|
func testSystemStatusLayoutUsesTwoColumnCoreMetricGridOrder() {
|
|
XCTAssertEqual(SystemStatusComponentLayout.columns, 2)
|
|
XCTAssertEqual(SystemStatusComponentLayout.metricRows, 3)
|
|
XCTAssertEqual(SystemStatusComponentLayout.cardSpacing, 6)
|
|
XCTAssertEqual(SystemStatusComponentLayout.cardContentPadding, 8)
|
|
XCTAssertEqual(SystemStatusComponentLayout.dashboardContentHeight, 411)
|
|
XCTAssertEqual(
|
|
SystemStatusComponentLayout.defaultPanelMetricKinds,
|
|
[.cpu, .gpu, .network, .disk, .memory, .battery, .topProcesses]
|
|
)
|
|
XCTAssertEqual(SystemStatusComponentLayout.defaultMenuBarMetricKinds, [.cpu, .gpu, .network, .disk, .memory, .battery])
|
|
XCTAssertEqual(SystemStatusComponentLayout.position(for: .cpu), SystemStatusGridPosition(row: 0, column: 0))
|
|
XCTAssertEqual(SystemStatusComponentLayout.position(for: .gpu), SystemStatusGridPosition(row: 0, column: 1))
|
|
XCTAssertEqual(SystemStatusComponentLayout.position(for: .network), SystemStatusGridPosition(row: 1, column: 0))
|
|
XCTAssertEqual(SystemStatusComponentLayout.position(for: .disk), SystemStatusGridPosition(row: 1, column: 1))
|
|
XCTAssertEqual(SystemStatusComponentLayout.position(for: .memory), SystemStatusGridPosition(row: 2, column: 0))
|
|
XCTAssertEqual(SystemStatusComponentLayout.position(for: .battery), SystemStatusGridPosition(row: 2, column: 1))
|
|
XCTAssertNil(SystemStatusComponentLayout.position(for: .topProcesses))
|
|
}
|
|
|
|
func testSystemStatusLayoutHeightFollowsVisibleMetricRows() {
|
|
XCTAssertEqual(SystemStatusComponentLayout.contentHeight(for: []), 96)
|
|
XCTAssertEqual(SystemStatusComponentLayout.contentHeight(for: [.cpu]), 99)
|
|
XCTAssertEqual(SystemStatusComponentLayout.contentHeight(for: [.cpu, .gpu, .topProcesses]), 201)
|
|
}
|
|
|
|
func testConfigurationDefaultsShowPanelMetricsAndHideMenuBarMetrics() {
|
|
let controller = SystemStatusSettingsController(
|
|
store: SystemStatusPluginStorageConfigurationStore(storage: SystemStatusMemoryPluginStorage())
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
controller.configuration.visiblePanelMetricKinds,
|
|
SystemStatusComponentLayout.defaultPanelMetricKinds
|
|
)
|
|
XCTAssertTrue(controller.configuration.visibleMenuBarMetricKinds.isEmpty)
|
|
}
|
|
|
|
func testConfigurationPersistsVisibilityAndOrder() {
|
|
let storage = SystemStatusMemoryPluginStorage()
|
|
let controller = SystemStatusSettingsController(
|
|
store: SystemStatusPluginStorageConfigurationStore(storage: storage)
|
|
)
|
|
|
|
controller.setPanelMetric(.gpu, visible: false)
|
|
controller.movePanelMetric(.battery, toOffset: 0)
|
|
controller.setMenuBarMetric(.cpu, visible: true)
|
|
controller.setMenuBarMetric(.memory, visible: true)
|
|
controller.moveMenuBarMetric(.memory, toOffset: 0)
|
|
|
|
let restoredController = SystemStatusSettingsController(
|
|
store: SystemStatusPluginStorageConfigurationStore(storage: storage)
|
|
)
|
|
|
|
XCTAssertEqual(restoredController.configuration.panelItems.map(\.kind).first, .battery)
|
|
XCTAssertFalse(restoredController.configuration.panelItems.first { $0.kind == .gpu }?.isVisible ?? true)
|
|
XCTAssertEqual(restoredController.configuration.visibleMenuBarMetricKinds, [.memory, .cpu])
|
|
}
|
|
|
|
func testPluginDescriptorShrinksWhenPanelMetricsAreHidden() {
|
|
let controller = SystemStatusSettingsController(
|
|
store: SystemStatusPluginStorageConfigurationStore(storage: SystemStatusMemoryPluginStorage())
|
|
)
|
|
controller.setPanelMetric(.gpu, visible: false)
|
|
controller.setPanelMetric(.network, visible: false)
|
|
controller.setPanelMetric(.disk, visible: false)
|
|
controller.setPanelMetric(.memory, visible: false)
|
|
controller.setPanelMetric(.battery, visible: false)
|
|
controller.setPanelMetric(.topProcesses, visible: false)
|
|
|
|
let plugin = SystemStatusPlugin(
|
|
settingsController: controller,
|
|
storage: SystemStatusMemoryPluginStorage()
|
|
)
|
|
|
|
let expectedHeight = PluginComponentPanelLayoutMetrics.default.heightSpan(
|
|
fittingContentHeight: SystemStatusComponentLayout.contentHeight(for: [.cpu])
|
|
)
|
|
XCTAssertEqual(plugin.descriptor.span, PluginComponentSpan(width: 4, height: expectedHeight)!)
|
|
}
|
|
|
|
func testMenuBarFormatterUsesSelectedOrder() {
|
|
var snapshot = SystemStatusSnapshot.empty
|
|
snapshot.cpu = SystemStatusCPUSnapshot(
|
|
usage: 0.125,
|
|
loadAverage1Minute: nil,
|
|
temperatureCelsius: 42.4,
|
|
systemPowerWatts: nil,
|
|
isCollecting: false
|
|
)
|
|
snapshot.memory = SystemStatusMemorySnapshot(
|
|
usedBytes: 6_000,
|
|
totalBytes: 10_000,
|
|
swapUsedBytes: nil,
|
|
swapTotalBytes: nil
|
|
)
|
|
snapshot.network = SystemStatusNetworkSnapshot(
|
|
interfaceName: "en0",
|
|
ipAddress: nil,
|
|
publicIPAddress: nil,
|
|
downloadBytesPerSecond: 1_024,
|
|
uploadBytesPerSecond: 2_048,
|
|
isConnected: true,
|
|
isCollecting: false
|
|
)
|
|
|
|
XCTAssertEqual(
|
|
SystemStatusMenuBarMetricsFormatter.text(snapshot: snapshot, kinds: [.memory, .cpu, .network]),
|
|
"RAM 60% | CPU 13% 42° | NET ↓1K ↑2K"
|
|
)
|
|
}
|
|
|
|
func testProductionSamplingScheduleBalancesForegroundDetailAndBackgroundCost() {
|
|
let schedule = SystemStatusSamplingSchedule.production
|
|
|
|
XCTAssertEqual(schedule.backgroundFastInterval, .seconds(30))
|
|
XCTAssertEqual(schedule.menuBarFastInterval, .seconds(3))
|
|
XCTAssertEqual(schedule.foregroundFastInterval, .seconds(3))
|
|
XCTAssertEqual(schedule.backgroundSlowInterval, 300)
|
|
XCTAssertEqual(schedule.menuBarSlowInterval, 3)
|
|
XCTAssertEqual(schedule.foregroundSlowInterval, 15)
|
|
XCTAssertEqual(schedule.backgroundProcessInterval, 300)
|
|
XCTAssertEqual(schedule.foregroundProcessInterval, 15)
|
|
XCTAssertEqual(schedule.backgroundHistoryInterval, 300)
|
|
XCTAssertEqual(schedule.foregroundHistoryInterval, 60)
|
|
}
|
|
|
|
func testViewModelMergesDiskCapacityAndActivitySamples() async throws {
|
|
let viewModel = SystemStatusViewModel(
|
|
sampler: StubSystemStatusSampler(),
|
|
historyStore: StubSystemStatusHistoryStore(),
|
|
schedule: .test
|
|
)
|
|
|
|
await viewModel.refreshSnapshotNow(referenceDate: Date(timeIntervalSince1970: 2_000))
|
|
|
|
XCTAssertEqual(viewModel.snapshot.disk.usedBytes, 50)
|
|
XCTAssertEqual(viewModel.snapshot.disk.totalBytes, 100)
|
|
XCTAssertEqual(viewModel.snapshot.disk.readBytesPerSecond, 2_048)
|
|
XCTAssertEqual(viewModel.snapshot.disk.writeBytesPerSecond, 1_024)
|
|
XCTAssertEqual(viewModel.snapshot.history.last?.diskReadBytesPerSecond, 2_048)
|
|
XCTAssertEqual(viewModel.snapshot.history.last?.diskWriteBytesPerSecond, 1_024)
|
|
}
|
|
|
|
func testDisplayHistoryFastPruneKeepsRecentSortedSamples() {
|
|
let points = (0..<1_905).map { index in
|
|
SystemStatusHistoryPoint(
|
|
timestamp: TimeInterval(index),
|
|
cpuUsage: Double(index) / 2_000
|
|
)
|
|
}
|
|
|
|
let pruned = SystemStatusViewModel.prunedSortedDisplayHistory(
|
|
points,
|
|
referenceDate: Date(timeIntervalSince1970: 1_904)
|
|
)
|
|
|
|
XCTAssertEqual(pruned.count, 1_800)
|
|
XCTAssertEqual(pruned.first?.timestamp, 105)
|
|
XCTAssertEqual(pruned.last?.timestamp, 1_904)
|
|
}
|
|
|
|
func testDisplayHistoryFallbackPruneSortsOutOfOrderSamples() {
|
|
let points = [
|
|
SystemStatusHistoryPoint(timestamp: 12),
|
|
SystemStatusHistoryPoint(timestamp: 10),
|
|
SystemStatusHistoryPoint(timestamp: 11),
|
|
SystemStatusHistoryPoint(timestamp: -2_000),
|
|
SystemStatusHistoryPoint(timestamp: 80)
|
|
]
|
|
|
|
let pruned = SystemStatusViewModel.prunedDisplayHistory(
|
|
points,
|
|
referenceDate: Date(timeIntervalSince1970: 20)
|
|
)
|
|
|
|
XCTAssertEqual(pruned.map(\.timestamp), [10, 11, 12, 80])
|
|
}
|
|
|
|
func testPluginReusesViewModelAcrossComponentViews() {
|
|
let viewModel = SystemStatusViewModel(sampler: StubSystemStatusSampler())
|
|
let plugin = SystemStatusPlugin(viewModel: viewModel, storage: SystemStatusMemoryPluginStorage())
|
|
|
|
let first = plugin.makeView(
|
|
context: PluginComponentContext(
|
|
pluginID: "system-status",
|
|
dismiss: {},
|
|
isPanelVisible: true
|
|
)
|
|
)
|
|
let second = plugin.makeView(
|
|
context: PluginComponentContext(
|
|
pluginID: "system-status",
|
|
dismiss: {},
|
|
isPanelVisible: true
|
|
)
|
|
)
|
|
|
|
XCTAssertFalse(String(describing: first).isEmpty)
|
|
XCTAssertFalse(String(describing: second).isEmpty)
|
|
}
|
|
|
|
}
|
|
|
|
private actor StubSystemStatusSampler: SystemStatusSampling {
|
|
private(set) var fastCallCount = 0
|
|
private(set) var slowCallCount = 0
|
|
private(set) var processCallCount = 0
|
|
private(set) var publicIPCallCount = 0
|
|
|
|
var callCounts: (fast: Int, slow: Int, processes: Int, publicIP: Int) {
|
|
(fastCallCount, slowCallCount, processCallCount, publicIPCallCount)
|
|
}
|
|
|
|
func collectFast(referenceDate: Date) async -> SystemStatusFastSample {
|
|
fastCallCount += 1
|
|
return SystemStatusFastSample(
|
|
cpu: SystemStatusCPUSnapshot(
|
|
usage: min(0.95, 0.20 + Double(fastCallCount) * 0.01),
|
|
loadAverage1Minute: 1.42,
|
|
temperatureCelsius: 42,
|
|
systemPowerWatts: 8.5,
|
|
isCollecting: false
|
|
),
|
|
memory: SystemStatusMemorySnapshot(
|
|
usedBytes: 4_000,
|
|
totalBytes: 8_000,
|
|
swapUsedBytes: 512,
|
|
swapTotalBytes: 2_048
|
|
),
|
|
network: SystemStatusNetworkSnapshot(
|
|
interfaceName: "en0",
|
|
ipAddress: "192.168.1.2",
|
|
publicIPAddress: nil,
|
|
downloadBytesPerSecond: 1_024,
|
|
uploadBytesPerSecond: 512,
|
|
isConnected: true,
|
|
isCollecting: false
|
|
),
|
|
disk: SystemStatusDiskSnapshot(
|
|
usedBytes: nil,
|
|
totalBytes: nil,
|
|
readBytesPerSecond: 2_048,
|
|
writeBytesPerSecond: 1_024
|
|
)
|
|
)
|
|
}
|
|
|
|
func collectSlow() async -> SystemStatusSlowSample {
|
|
slowCallCount += 1
|
|
return SystemStatusSlowSample(
|
|
disk: SystemStatusDiskSnapshot(
|
|
usedBytes: 50,
|
|
totalBytes: 100,
|
|
readBytesPerSecond: nil,
|
|
writeBytesPerSecond: nil
|
|
),
|
|
battery: SystemStatusBatterySnapshot(
|
|
isAvailable: true,
|
|
level: 0.8,
|
|
state: .acPower,
|
|
timeRemainingMinutes: nil,
|
|
adapterWatts: 70,
|
|
batteryPowerWatts: -18.5,
|
|
temperatureCelsius: 31,
|
|
healthPercent: 96,
|
|
cycleCount: 120
|
|
),
|
|
gpu: SystemStatusGPUSnapshot(
|
|
usage: 0.4,
|
|
name: "M1 Pro",
|
|
temperatureCelsius: 43,
|
|
isAvailable: true,
|
|
isCollecting: false
|
|
),
|
|
hardware: SystemStatusHardwareSnapshot(
|
|
modelName: "MacBookPro18,3",
|
|
chipName: "Apple M1 Pro",
|
|
macOSVersion: "macOS 15.0",
|
|
uptimeSeconds: 3_600,
|
|
totalMemoryBytes: 16_000
|
|
)
|
|
)
|
|
}
|
|
|
|
func collectTopProcesses(limit: Int) async -> [SystemStatusTopProcess] {
|
|
processCallCount += 1
|
|
return [
|
|
SystemStatusTopProcess(
|
|
pid: 1,
|
|
displayName: "launchd",
|
|
command: "/sbin/launchd",
|
|
cpuPercent: 1,
|
|
memoryPercent: 0.1,
|
|
memoryBytes: 12_582_912
|
|
)
|
|
]
|
|
}
|
|
|
|
func collectPublicIPAddress() async -> String? {
|
|
publicIPCallCount += 1
|
|
return "203.0.113.1"
|
|
}
|
|
}
|
|
|
|
private actor StubSystemStatusHistoryStore: SystemStatusHistoryStoring {
|
|
private(set) var appendedCount = 0
|
|
private var points: [SystemStatusHistoryPoint] = []
|
|
|
|
func load(referenceDate: Date) async -> [SystemStatusHistoryPoint] {
|
|
points
|
|
}
|
|
|
|
func append(_ point: SystemStatusHistoryPoint, referenceDate: Date) async -> [SystemStatusHistoryPoint] {
|
|
appendedCount += 1
|
|
points.append(point)
|
|
return points
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class SystemStatusMemoryPluginStorage: 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) {
|
|
guard let value else {
|
|
removeObject(forKey: key)
|
|
return
|
|
}
|
|
|
|
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 extension SystemStatusSamplingSchedule {
|
|
static let test = SystemStatusSamplingSchedule(
|
|
backgroundFastInterval: .milliseconds(20),
|
|
menuBarFastInterval: .milliseconds(20),
|
|
foregroundFastInterval: .milliseconds(20),
|
|
backgroundSlowInterval: 0,
|
|
menuBarSlowInterval: 0,
|
|
foregroundSlowInterval: 0,
|
|
backgroundProcessInterval: 0,
|
|
foregroundProcessInterval: 0,
|
|
backgroundHistoryInterval: 0,
|
|
foregroundHistoryInterval: 0
|
|
)
|
|
|
|
static let foregroundRestart = SystemStatusSamplingSchedule(
|
|
backgroundFastInterval: .seconds(30),
|
|
menuBarFastInterval: .milliseconds(20),
|
|
foregroundFastInterval: .milliseconds(20),
|
|
backgroundSlowInterval: 30,
|
|
menuBarSlowInterval: 30,
|
|
foregroundSlowInterval: 30,
|
|
backgroundProcessInterval: 30,
|
|
foregroundProcessInterval: 30,
|
|
backgroundHistoryInterval: 30,
|
|
foregroundHistoryInterval: 30
|
|
)
|
|
}
|