Files
MacMonitor/Tests/MMAlertsTests.swift
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> 807fcae6b2 fix(alerts): skip read-only and trivially small volumes in low-storage rule (fixes #33)
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>
2026-09-08 15:16:45 +01:00

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