Read-only mounts (DMG images like /Volumes/Ghostty, update snapshots) with ~0% free produced false-positive low-storage alerts. StorageVolumeContext now carries isReadOnly and totalBytes; the rule skips read-only volumes and volumes under 1 GB where thresholds are meaningless. Regression tests cover both exclusions plus the normal trigger path. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
260 lines
9.7 KiB
Swift
260 lines
9.7 KiB
Swift
import XCTest
|
|
import UserNotifications
|
|
@testable import MacMonitor
|
|
|
|
// MARK: - Mock Dispatcher
|
|
|
|
final class MockNotificationDispatcher: AlertNotificationDispatching {
|
|
private(set) var dispatchedAlerts: [SystemAlert] = []
|
|
private(set) var authorizationRequests = 0
|
|
var stubbedStatus: UNAuthorizationStatus = .authorized
|
|
|
|
func requestAuthorization() {
|
|
authorizationRequests += 1
|
|
}
|
|
|
|
func dispatch(_ alert: SystemAlert) {
|
|
dispatchedAlerts.append(alert)
|
|
}
|
|
|
|
func authorizationStatus() async -> UNAuthorizationStatus {
|
|
stubbedStatus
|
|
}
|
|
}
|
|
|
|
// MARK: - Tests
|
|
|
|
@MainActor
|
|
final class MMAlertsTests: XCTestCase {
|
|
private var mock: MockNotificationDispatcher!
|
|
private var engine: AlertEngine!
|
|
private var defaults: UserDefaults!
|
|
|
|
override func setUp() async throws {
|
|
mock = MockNotificationDispatcher()
|
|
defaults = UserDefaults(suiteName: "MMAlertsTests-\(UUID().uuidString)")!
|
|
engine = AlertEngine(dispatcher: mock, defaults: defaults)
|
|
}
|
|
|
|
// MARK: Helpers
|
|
|
|
private func context(
|
|
peakTemp: Double = 50,
|
|
packageTemp: Double = 50,
|
|
fans: [Int: Double] = [0: 2500],
|
|
freeMemMB: Double = 8192,
|
|
pressure: String = "Normal",
|
|
volumes: [StorageVolumeContext] = [],
|
|
battery: Double = 80,
|
|
hasBattery: Bool = false,
|
|
onAC: Bool = true,
|
|
charging: Bool = false,
|
|
at date: Date = Date()
|
|
) -> AlertEvaluationContext {
|
|
AlertEvaluationContext(
|
|
peakCoreTemperature: peakTemp,
|
|
packageTemperature: packageTemp,
|
|
fanRPMs: fans,
|
|
memoryFreeBytes: UInt64(freeMemMB * 1024 * 1024),
|
|
memoryPressureLevel: pressure,
|
|
volumes: volumes,
|
|
batteryLevel: battery,
|
|
hasBattery: hasBattery,
|
|
onExternalPower: onAC,
|
|
isCharging: charging,
|
|
timestamp: date
|
|
)
|
|
}
|
|
|
|
// MARK: CPU Temperature
|
|
|
|
func testCPUTemperatureRuleTriggersAboveThreshold() {
|
|
engine.evaluate(context(peakTemp: 96))
|
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
|
XCTAssertEqual(engine.activeAlerts.first?.kind, .cpuTemperature)
|
|
XCTAssertEqual(engine.activeAlerts.first?.severity, .critical)
|
|
XCTAssertEqual(mock.dispatchedAlerts.count, 1)
|
|
}
|
|
|
|
func testCPUTemperatureRuleUsesMaxOfPeakAndPackage() {
|
|
engine.evaluate(context(peakTemp: 40, packageTemp: 96))
|
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
|
}
|
|
|
|
func testCPUTemperatureRuleDoesNotFireBelowThreshold() {
|
|
engine.evaluate(context(peakTemp: 94.9, packageTemp: 50))
|
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
|
XCTAssertTrue(mock.dispatchedAlerts.isEmpty)
|
|
}
|
|
|
|
// MARK: Fan Stall
|
|
|
|
func testFanStallTriggersWhenZeroRPMWhileHot() {
|
|
engine.evaluate(context(packageTemp: 80, fans: [0: 0, 1: 2200]))
|
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
|
XCTAssertEqual(engine.activeAlerts.first?.id, "fanStall:0")
|
|
}
|
|
|
|
func testFanStallDoesNotTriggerWhileCPUIsCool() {
|
|
engine.evaluate(context(packageTemp: 40, fans: [0: 0]))
|
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
|
}
|
|
|
|
func testFanStallSkippedWhenNoFanTelemetry() {
|
|
engine.evaluate(context(packageTemp: 90, fans: [:]))
|
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
|
}
|
|
|
|
// MARK: Memory
|
|
|
|
func testMemoryRuleTriggersOnLowFreeBytes() {
|
|
engine.evaluate(context(freeMemMB: 400))
|
|
XCTAssertEqual(engine.activeAlerts.first?.kind, .memoryPressure)
|
|
XCTAssertEqual(engine.activeAlerts.first?.severity, .warning)
|
|
}
|
|
|
|
func testMemoryRuleTriggersOnCriticalPressure() {
|
|
engine.evaluate(context(freeMemMB: 8192, pressure: "Critical"))
|
|
XCTAssertEqual(engine.activeAlerts.first?.kind, .memoryPressure)
|
|
XCTAssertEqual(engine.activeAlerts.first?.severity, .critical)
|
|
}
|
|
|
|
func testMemoryRuleDoesNotFireWhenHealthy() {
|
|
engine.evaluate(context(freeMemMB: 8192, pressure: "Normal"))
|
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
|
}
|
|
|
|
// MARK: Storage
|
|
|
|
func testStorageRuleTriggersPerVolume() {
|
|
let volumes = [
|
|
StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824,
|
|
freePercent: 5, totalBytes: 500 * 1_073_741_824),
|
|
StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824,
|
|
freePercent: 50, totalBytes: 1_000_000_000_000)
|
|
]
|
|
engine.evaluate(context(volumes: volumes))
|
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
|
XCTAssertEqual(engine.activeAlerts.first?.id, "lowStorage:/")
|
|
}
|
|
|
|
func testStorageRuleTriggersOnLowPercent() {
|
|
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big",
|
|
freeBytes: 200 * 1_073_741_824, freePercent: 4,
|
|
totalBytes: 2_000_000_000_000)]
|
|
engine.evaluate(context(volumes: volumes))
|
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
|
}
|
|
|
|
func testStorageRuleSkipsReadOnlyVolumes() {
|
|
// A read-only DMG mount (e.g. an app running from a mounted image)
|
|
// with ~0% free must not alert — issue #33 false positive.
|
|
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Ghostty", volumeName: "Ghostty",
|
|
freeBytes: 1_048_576, freePercent: 0.1,
|
|
totalBytes: 500_000_000_000, isReadOnly: true)]
|
|
engine.evaluate(context(volumes: volumes))
|
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
|
XCTAssertTrue(engine.history.isEmpty)
|
|
}
|
|
|
|
func testStorageRuleSkipsTinyVolumes() {
|
|
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Small", volumeName: "Small",
|
|
freeBytes: 1_048_576, freePercent: 0.5,
|
|
totalBytes: 200_000_000, isReadOnly: false)]
|
|
engine.evaluate(context(volumes: volumes))
|
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
|
}
|
|
|
|
// MARK: Battery
|
|
|
|
func testBatteryRuleTriggersOnlyWhileDischarging() {
|
|
engine.evaluate(context(battery: 8, hasBattery: true, onAC: false, charging: false))
|
|
XCTAssertEqual(engine.activeAlerts.first?.kind, .lowBattery)
|
|
}
|
|
|
|
func testBatteryRuleSkippedOnExternalPower() {
|
|
engine.evaluate(context(battery: 8, hasBattery: true, onAC: true, charging: false))
|
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
|
}
|
|
|
|
func testBatteryRuleSkippedWhileCharging() {
|
|
engine.evaluate(context(battery: 8, hasBattery: true, onAC: false, charging: true))
|
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
|
}
|
|
|
|
func testBatteryRuleSkippedWithoutBattery() {
|
|
engine.evaluate(context(battery: 5, hasBattery: false, onAC: false))
|
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
|
}
|
|
|
|
// MARK: Cooldown & Resolution
|
|
|
|
func testCooldownSuppressesRepeatNotifications() {
|
|
let t0 = Date()
|
|
engine.evaluate(context(peakTemp: 100, at: t0))
|
|
engine.evaluate(context(peakTemp: 100, at: t0.addingTimeInterval(60)))
|
|
XCTAssertEqual(mock.dispatchedAlerts.count, 1)
|
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
|
}
|
|
|
|
func testCooldownExpiresAndRefires() {
|
|
let t0 = Date()
|
|
engine.evaluate(context(peakTemp: 100, at: t0))
|
|
engine.evaluate(context(peakTemp: 100, at: t0.addingTimeInterval(16 * 60)))
|
|
XCTAssertEqual(mock.dispatchedAlerts.count, 2)
|
|
}
|
|
|
|
func testResolutionClearsAlertAndAllowsImmediateRealert() {
|
|
let t0 = Date()
|
|
engine.evaluate(context(peakTemp: 100, at: t0))
|
|
engine.evaluate(context(peakTemp: 60, at: t0.addingTimeInterval(60)))
|
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
|
XCTAssertTrue(engine.history.contains { $0.outcome == .resolved })
|
|
engine.evaluate(context(peakTemp: 100, at: t0.addingTimeInterval(120)))
|
|
XCTAssertEqual(mock.dispatchedAlerts.count, 2)
|
|
}
|
|
|
|
func testHistoryRecordsTriggeredEvents() {
|
|
engine.evaluate(context(peakTemp: 100))
|
|
XCTAssertEqual(engine.history.count, 1)
|
|
XCTAssertEqual(engine.history.first?.outcome, .triggered)
|
|
XCTAssertEqual(engine.history.first?.alertID, AlertKind.cpuTemperature.rawValue)
|
|
}
|
|
|
|
// MARK: Configuration & Enablement
|
|
|
|
func testGlobalDisableSuppressesAllRules() {
|
|
engine.configuration.alertsEnabled = false
|
|
engine.evaluate(context(peakTemp: 120, fans: [0: 0], freeMemMB: 10, battery: 1, hasBattery: true, onAC: false))
|
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
|
XCTAssertTrue(mock.dispatchedAlerts.isEmpty)
|
|
}
|
|
|
|
func testPerRuleDisableSuppressesThatRule() {
|
|
engine.configuration.cpuTempEnabled = false
|
|
engine.evaluate(context(peakTemp: 120))
|
|
XCTAssertTrue(engine.activeAlerts.isEmpty)
|
|
}
|
|
|
|
func testConfigurationPersistsToUserDefaults() {
|
|
engine.configuration.cpuTempThresholdC = 80
|
|
let reloaded = AlertConfiguration.load(from: defaults)
|
|
XCTAssertEqual(reloaded.cpuTempThresholdC, 80)
|
|
}
|
|
|
|
func testConfigurationDefaults() {
|
|
let config = AlertConfiguration()
|
|
XCTAssertTrue(config.alertsEnabled)
|
|
XCTAssertEqual(config.cooldownMinutes, 15)
|
|
XCTAssertEqual(config.cpuTempThresholdC, 95)
|
|
XCTAssertEqual(config.memoryFreeMBThreshold, 500)
|
|
XCTAssertEqual(config.storageFreeGBThreshold, 10)
|
|
XCTAssertEqual(config.batteryPercentThreshold, 10)
|
|
}
|
|
|
|
func testAuthorizationRequestForwarded() {
|
|
engine.requestNotificationAuthorization()
|
|
XCTAssertEqual(mock.authorizationRequests, 1)
|
|
}
|
|
}
|