Files
MacMonitor/Tests/MMAlertsTests.swift
T
gronodandDevin <158243242+devin-ai-integration[bot]@users.noreply.github.com> a7f13542ae feat(alerts): implement threshold alert engine and Notification Center integration (fixes #24)
Introduce Sources/Intelligence with an @Observable AlertEngine that evaluates
five configurable rules (CPU temperature, fan stall, low memory, low storage,
low battery) against a Sendable AlertEvaluationContext built after each
telemetry decode pass. Breached conditions dispatch through a mockable
AlertNotificationDispatching protocol to UNUserNotificationCenter with
per-alert cooldowns, edge-triggered resolve detection, and a capped
in-memory history log.

Add a macOS Settings scene (AlertsSettingsView) for thresholds, cooldown,
permission status, and test notifications; a new Alerts and Notifications
sidebar tab with active-alert cards and the history log; header and menu bar
warning indicators; and an AppNavigationBus so notification actions
(Open MacMonitor, View Processes) steer the main window.

Also fix the memory snapshot decode to read memoryPressureStatus so
pressureLevel is populated correctly.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-09-08 13:53:48 +01:00

237 lines
8.4 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),
StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824, freePercent: 50)
]
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)]
engine.evaluate(context(volumes: volumes))
XCTAssertEqual(engine.activeAlerts.count, 1)
}
// 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)
}
}