fix(alerts): skip read-only/small volumes in low-storage rule (Issue #33) #37
@@ -702,7 +702,9 @@ public final class SystemTelemetryStore {
|
|||||||
freeBytes: $0.freeBytes,
|
freeBytes: $0.freeBytes,
|
||||||
freePercent: $0.totalBytes > 0
|
freePercent: $0.totalBytes > 0
|
||||||
? Double($0.freeBytes) / Double($0.totalBytes) * 100.0
|
? Double($0.freeBytes) / Double($0.totalBytes) * 100.0
|
||||||
: 0
|
: 0,
|
||||||
|
totalBytes: $0.totalBytes,
|
||||||
|
isReadOnly: $0.isReadOnly
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
context.batteryLevel = self.batteryHealth.maxCapacity > 0
|
context.batteryLevel = self.batteryHealth.maxCapacity > 0
|
||||||
|
|||||||
@@ -163,7 +163,10 @@ public final class AlertEngine {
|
|||||||
|
|
||||||
// 4. Low storage space (per mounted volume)
|
// 4. Low storage space (per mounted volume)
|
||||||
if cfg.storageEnabled {
|
if cfg.storageEnabled {
|
||||||
for volume in ctx.volumes {
|
// Skip read-only mounts (DMGs, snapshots) and trivially small
|
||||||
|
// volumes where free-space thresholds are meaningless.
|
||||||
|
let minTotalBytes: UInt64 = 1_073_741_824 // 1 GB
|
||||||
|
for volume in ctx.volumes where !volume.isReadOnly && volume.totalBytes >= minTotalBytes {
|
||||||
let freeGB = Double(volume.freeBytes) / (1024 * 1024 * 1024)
|
let freeGB = Double(volume.freeBytes) / (1024 * 1024 * 1024)
|
||||||
if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold {
|
if freeGB < cfg.storageFreeGBThreshold || volume.freePercent < cfg.storageFreePercentThreshold {
|
||||||
alerts.append(SystemAlert(
|
alerts.append(SystemAlert(
|
||||||
|
|||||||
@@ -70,12 +70,18 @@ public struct StorageVolumeContext: Equatable, Sendable {
|
|||||||
public let volumeName: String
|
public let volumeName: String
|
||||||
public let freeBytes: UInt64
|
public let freeBytes: UInt64
|
||||||
public let freePercent: Double
|
public let freePercent: Double
|
||||||
|
public let totalBytes: UInt64
|
||||||
|
/// Read-only mounts (DMG images, snapshots) are excluded from alerting.
|
||||||
|
public let isReadOnly: Bool
|
||||||
|
|
||||||
public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double) {
|
public init(mountPoint: String, volumeName: String, freeBytes: UInt64, freePercent: Double,
|
||||||
|
totalBytes: UInt64 = 0, isReadOnly: Bool = false) {
|
||||||
self.mountPoint = mountPoint
|
self.mountPoint = mountPoint
|
||||||
self.volumeName = volumeName
|
self.volumeName = volumeName
|
||||||
self.freeBytes = freeBytes
|
self.freeBytes = freeBytes
|
||||||
self.freePercent = freePercent
|
self.freePercent = freePercent
|
||||||
|
self.totalBytes = totalBytes
|
||||||
|
self.isReadOnly = isReadOnly
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -128,8 +128,10 @@ final class MMAlertsTests: XCTestCase {
|
|||||||
|
|
||||||
func testStorageRuleTriggersPerVolume() {
|
func testStorageRuleTriggersPerVolume() {
|
||||||
let volumes = [
|
let volumes = [
|
||||||
StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824, freePercent: 5),
|
StorageVolumeContext(mountPoint: "/", volumeName: "Macintosh HD", freeBytes: 5 * 1_073_741_824,
|
||||||
StorageVolumeContext(mountPoint: "/Volumes/Data", volumeName: "Data", freeBytes: 500 * 1_073_741_824, freePercent: 50)
|
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))
|
engine.evaluate(context(volumes: volumes))
|
||||||
XCTAssertEqual(engine.activeAlerts.count, 1)
|
XCTAssertEqual(engine.activeAlerts.count, 1)
|
||||||
@@ -137,11 +139,32 @@ final class MMAlertsTests: XCTestCase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func testStorageRuleTriggersOnLowPercent() {
|
func testStorageRuleTriggersOnLowPercent() {
|
||||||
let volumes = [StorageVolumeContext(mountPoint: "/Volumes/Big", volumeName: "Big", freeBytes: 200 * 1_073_741_824, freePercent: 4)]
|
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))
|
engine.evaluate(context(volumes: volumes))
|
||||||
XCTAssertEqual(engine.activeAlerts.count, 1)
|
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
|
// MARK: Battery
|
||||||
|
|
||||||
func testBatteryRuleTriggersOnlyWhileDischarging() {
|
func testBatteryRuleTriggersOnlyWhileDischarging() {
|
||||||
|
|||||||
Reference in New Issue
Block a user