From f2f43f84c69e5e23b79a9b2bfae86548fa736a13 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 24 Jul 2026 17:58:46 +1000 Subject: [PATCH 01/13] refactor(energy): define honest impact states --- MacActivity.xcodeproj/project.pbxproj | 4 + .../Providers/EnergyImpactProvider.swift | 130 +++++++------ .../Metrics/Providers/EnergyImpactTypes.swift | 181 ++++++++++++++++++ .../EnergyImpactProviderTests.swift | 134 +++++++++++-- 4 files changed, 368 insertions(+), 81 deletions(-) create mode 100644 Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift diff --git a/MacActivity.xcodeproj/project.pbxproj b/MacActivity.xcodeproj/project.pbxproj index 2c9f126..ab9a125 100644 --- a/MacActivity.xcodeproj/project.pbxproj +++ b/MacActivity.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 203728ECA31C7E130830CBD3 /* AudioDashboardModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E5EB0BFFA6A7936E4EAD230 /* AudioDashboardModelTests.swift */; }; 24E2A04F45694E2244E16C74 /* UpdateCandidateSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC750397189E5163AFA2AE33 /* UpdateCandidateSelector.swift */; }; 25C92B9E86A8C927EAD842BD /* AudioProcessOwnershipLease.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA4016667A7EB15DCF66B95 /* AudioProcessOwnershipLease.swift */; }; + 25F6AB66E8EE1DA07F240610 /* EnergyImpactTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90E95B0B56AA23B56343FB1C /* EnergyImpactTypes.swift */; }; 28B6837513F5A77FB3B8A6B1 /* MetricsScheduler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E1651676B31A0F49FF03FBD /* MetricsScheduler.swift */; }; 2997A768472FE6994B787F01 /* SparkleUpdateController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BC2395BD346EE615CD7787A /* SparkleUpdateController.swift */; }; 29B0CBE169F75666C6DBB021 /* DashboardModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C28EA386891B5D88473C012 /* DashboardModelTests.swift */; }; @@ -296,6 +297,7 @@ 8C1D390604B9381BB20F9F4A /* TrashCleanupServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashCleanupServiceTests.swift; sourceTree = ""; }; 8DFFF072E13B7DBEA690C77C /* EnergyImpactViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactViewTests.swift; sourceTree = ""; }; 90B7A168A4B1458DEE0615A8 /* UpdateCandidateSelectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateCandidateSelectorTests.swift; sourceTree = ""; }; + 90E95B0B56AA23B56343FB1C /* EnergyImpactTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactTypes.swift; sourceTree = ""; }; 96F211773A1D33DF65F59C8D /* FakeAudioProcessOwnershipLeaseBroker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeAudioProcessOwnershipLeaseBroker.swift; sourceTree = ""; }; 98B74985CC421B77A15255C4 /* ActiveProcessMemoryLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveProcessMemoryLayoutTests.swift; sourceTree = ""; }; 98D9223A23FC96035099E89A /* MemoryReleaseStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryReleaseStatusView.swift; sourceTree = ""; }; @@ -518,6 +520,7 @@ 420BEFFE5B99B07B33CAF5DE /* BatterySystemPowerSourceReader.swift */, 5F80A7CF6CB9E39D7858C692 /* CPUProvider.swift */, F6C57F502EC1B86CD070FE38 /* EnergyImpactProvider.swift */, + 90E95B0B56AA23B56343FB1C /* EnergyImpactTypes.swift */, 622432CA2E5A5BDE1D01C6D9 /* FanProvider.swift */, EDC3E242AE71B79619755C0D /* MemoryProvider.swift */, 4CC85A29241CC6F902A0531F /* MetricProvider.swift */, @@ -976,6 +979,7 @@ 1B3A5C1A4385179E39C90CDF /* DashboardModel.swift in Sources */, CF45AF27EDBD4FB898FC14D4 /* DiskCleanupService.swift in Sources */, 0F997EFB2933D1DF01944138 /* EnergyImpactProvider.swift in Sources */, + 25F6AB66E8EE1DA07F240610 /* EnergyImpactTypes.swift in Sources */, B1D58B1C23E57C4B2EFA43B2 /* FanProvider.swift in Sources */, B3C6A01640BB5B354B59AF73 /* LaunchAtLoginService.swift in Sources */, CC96AD1A721BD82A5462EF6A /* MemoryProvider.swift in Sources */, diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift index 453f569..02f3bff 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift @@ -2,54 +2,25 @@ import AppKit import Darwin import Foundation -public struct EnergyImpactEntry: Identifiable, Equatable, Sendable { - public let id: pid_t - public let processIdentifier: pid_t - public let name: String - public let bundleIdentifier: String? - public let bundleURL: URL? - public let impact: Double - public let isReadable: Bool - - public init( - processIdentifier: pid_t, - name: String, - bundleIdentifier: String?, - bundleURL: URL?, - impact: Double, - isReadable: Bool - ) { - self.id = processIdentifier - self.processIdentifier = processIdentifier - self.name = name - self.bundleIdentifier = bundleIdentifier - self.bundleURL = bundleURL - self.impact = impact - self.isReadable = isReadable - } - - public var formattedImpact: String { - guard isReadable else { return "Unavailable" } - return String(format: "%.1f", impact) - } -} - public struct EnergyImpactAppSnapshot: Equatable, Sendable { public let processIdentifier: pid_t public let name: String public let bundleIdentifier: String? public let bundleURL: URL? + public let kind: EnergyImpactAppKind public init( processIdentifier: pid_t, name: String, bundleIdentifier: String?, - bundleURL: URL? + bundleURL: URL?, + kind: EnergyImpactAppKind = .regular ) { self.processIdentifier = processIdentifier self.name = name self.bundleIdentifier = bundleIdentifier self.bundleURL = bundleURL + self.kind = kind } } @@ -129,55 +100,88 @@ public final class EnergyImpactService { ) var nextReadings: [pid_t: TimedProcessEnergyReading] = [:] let entries = apps.map { app -> EnergyImpactEntry in - var impact = 0.0 - var isReadable = false + let processIdentifiers = processIdentifiersByRoot[app.processIdentifier] ?? [app.processIdentifier] + var rootProcessStartAbsoluteTime: UInt64? + var totalPowerMicrowatts = 0.0 + var readableProcessCount = 0 + var validDeltaCount = 0 - for processIdentifier in processIdentifiersByRoot[app.processIdentifier] ?? [app.processIdentifier] { + for processIdentifier in processIdentifiers { guard let current = reader.reading(for: processIdentifier) else { continue } - isReadable = true + if processIdentifier == app.processIdentifier { + rootProcessStartAbsoluteTime = current.processStartAbsoluteTime + } + readableProcessCount += 1 nextReadings[processIdentifier] = TimedProcessEnergyReading( reading: current, sampleTime: sampleTime ) - if let previous = previousReadings[processIdentifier] { - impact += Self.impactRate(from: previous, to: current, sampleTime: sampleTime) + if let previous = previousReadings[processIdentifier], + let impactRate = Self.impactRate(from: previous, to: current, sampleTime: sampleTime) { + totalPowerMicrowatts += impactRate + validDeltaCount += 1 } } - guard isReadable else { - return EnergyImpactEntry( - processIdentifier: app.processIdentifier, - name: app.name, - bundleIdentifier: app.bundleIdentifier, - bundleURL: app.bundleURL, - impact: 0, - isReadable: false - ) + let identity = EnergyImpactAppIdentity( + rootProcessIdentifier: app.processIdentifier, + rootProcessStartAbsoluteTime: rootProcessStartAbsoluteTime + ) + let coverage = EnergyImpactCoverage( + discoveredProcessCount: processIdentifiers.count, + readableProcessCount: readableProcessCount, + validProcessSeconds: TimeInterval(validDeltaCount), + discoveredProcessSeconds: TimeInterval(processIdentifiers.count) + ) + let status: EnergyImpactStatus = if readableProcessCount == 0 { + .unavailable + } else if validDeltaCount == 0 { + .collecting + } else if validDeltaCount < processIdentifiers.count { + .partial + } else { + .stable } + let currentPower = validDeltaCount == 0 ? nil : totalPowerMicrowatts return EnergyImpactEntry( - processIdentifier: app.processIdentifier, + identity: identity, name: app.name, bundleIdentifier: app.bundleIdentifier, bundleURL: app.bundleURL, - impact: impact, - isReadable: true + kind: app.kind, + currentPowerMicrowatts: currentPower, + sustainedPowerMicrowatts: nil, + rankingScore: currentPower, + trend: .steady, + coverage: coverage, + status: status ) } previousReadings = nextReadings return Self.sortedByImpact(entries, limit: limit) } - public nonisolated static func sortedByImpact(_ entries: [EnergyImpactEntry], limit: Int) -> [EnergyImpactEntry] { - entries - .sorted { lhs, rhs in - if lhs.impact == rhs.impact { - return lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - return lhs.impact > rhs.impact + public nonisolated static func sortedByImpact( + _ entries: [EnergyImpactEntry], + limit: Int + ) -> [EnergyImpactEntry] { + entries.sorted { lhs, rhs in + switch (lhs.rankingScore, rhs.rankingScore) { + case let (left?, right?) where left != right: + return left > right + case (_?, nil): + return true + case (nil, _?): + return false + default: + let nameOrder = lhs.name.localizedCaseInsensitiveCompare(rhs.name) + if nameOrder != .orderedSame { return nameOrder == .orderedAscending } + return lhs.processIdentifier < rhs.processIdentifier } - .prefix(max(0, limit)) - .map { $0 } + } + .prefix(max(0, limit)) + .map { $0 } } public nonisolated static func processIdentifiersByRoot( @@ -205,13 +209,13 @@ public final class EnergyImpactService { from previous: TimedProcessEnergyReading, to current: ProcessEnergyReading, sampleTime: TimeInterval - ) -> Double { + ) -> Double? { guard current.processStartAbsoluteTime == previous.reading.processStartAbsoluteTime, current.energyNanojoules >= previous.reading.energyNanojoules else { - return 0 + return nil } let elapsedSeconds = sampleTime - previous.sampleTime - guard elapsedSeconds > 0 else { return 0 } + guard elapsedSeconds > 0 else { return nil } let deltaMicrojoules = Double(current.energyNanojoules - previous.reading.energyNanojoules) / 1_000.0 return deltaMicrojoules / elapsedSeconds } diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift new file mode 100644 index 0000000..7e959cb --- /dev/null +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift @@ -0,0 +1,181 @@ +import Darwin +import Foundation + +public struct EnergyImpactAppIdentity: Hashable, Sendable { + public let rootProcessIdentifier: pid_t + public let rootProcessStartAbsoluteTime: UInt64? + + public init( + rootProcessIdentifier: pid_t, + rootProcessStartAbsoluteTime: UInt64? + ) { + self.rootProcessIdentifier = rootProcessIdentifier + self.rootProcessStartAbsoluteTime = rootProcessStartAbsoluteTime + } + + public var generation: EnergyImpactProcessIdentity? { + guard let rootProcessStartAbsoluteTime else { return nil } + return EnergyImpactProcessIdentity( + processIdentifier: rootProcessIdentifier, + processStartAbsoluteTime: rootProcessStartAbsoluteTime + ) + } +} + +public struct EnergyImpactProcessIdentity: Hashable, Sendable { + public let processIdentifier: pid_t + public let processStartAbsoluteTime: UInt64 + + public init(processIdentifier: pid_t, processStartAbsoluteTime: UInt64) { + self.processIdentifier = processIdentifier + self.processStartAbsoluteTime = processStartAbsoluteTime + } +} + +public enum EnergyImpactAppKind: String, Equatable, Codable, Sendable { + case regular + case accessory +} + +public enum EnergyImpactAppScope: String, Equatable, Codable, CaseIterable, Sendable { + case regularOnly + case regularAndAccessory +} + +public enum EnergyImpactStatus: String, Equatable, Sendable { + case collecting + case stable + case partial + case stale + case unavailable +} + +public enum EnergyImpactTrend: String, Equatable, Sendable { + case rising + case steady + case falling +} + +public struct EnergyImpactCoverage: Equatable, Sendable { + public let discoveredProcessCount: Int + public let readableProcessCount: Int + public let validProcessSeconds: TimeInterval + public let discoveredProcessSeconds: TimeInterval + + public init( + discoveredProcessCount: Int, + readableProcessCount: Int, + validProcessSeconds: TimeInterval, + discoveredProcessSeconds: TimeInterval + ) { + self.discoveredProcessCount = max(0, discoveredProcessCount) + self.readableProcessCount = max(0, readableProcessCount) + self.validProcessSeconds = max(0, validProcessSeconds) + self.discoveredProcessSeconds = max(0, discoveredProcessSeconds) + } + + public var fraction: Double { + guard discoveredProcessSeconds > 0 else { return 0 } + return min(max(validProcessSeconds / discoveredProcessSeconds, 0), 1) + } + + public static let unavailable = EnergyImpactCoverage( + discoveredProcessCount: 0, + readableProcessCount: 0, + validProcessSeconds: 0, + discoveredProcessSeconds: 0 + ) +} + +public struct EnergyImpactEntry: Identifiable, Equatable, Sendable { + public let id: EnergyImpactAppIdentity + public let identity: EnergyImpactAppIdentity + public let name: String + public let bundleIdentifier: String? + public let bundleURL: URL? + public let kind: EnergyImpactAppKind + public let currentPowerMicrowatts: Double? + public let sustainedPowerMicrowatts: Double? + public let rankingScore: Double? + public let trend: EnergyImpactTrend + public let coverage: EnergyImpactCoverage + public let status: EnergyImpactStatus + + public init( + identity: EnergyImpactAppIdentity, + name: String, + bundleIdentifier: String?, + bundleURL: URL?, + kind: EnergyImpactAppKind = .regular, + currentPowerMicrowatts: Double?, + sustainedPowerMicrowatts: Double?, + rankingScore: Double?, + trend: EnergyImpactTrend, + coverage: EnergyImpactCoverage, + status: EnergyImpactStatus + ) { + self.id = identity + self.identity = identity + self.name = name + self.bundleIdentifier = bundleIdentifier + self.bundleURL = bundleURL + self.kind = kind + self.currentPowerMicrowatts = currentPowerMicrowatts + self.sustainedPowerMicrowatts = sustainedPowerMicrowatts + self.rankingScore = rankingScore + self.trend = trend + self.coverage = coverage + self.status = status + } + + public var processIdentifier: pid_t { + identity.rootProcessIdentifier + } + + public var displayPowerMicrowatts: Double? { + currentPowerMicrowatts + } + + @available(*, deprecated, message: "Use the identity/status initializer") + public init( + processIdentifier: pid_t, + name: String, + bundleIdentifier: String?, + bundleURL: URL?, + impact: Double, + isReadable: Bool + ) { + self.init( + identity: EnergyImpactAppIdentity( + rootProcessIdentifier: processIdentifier, + rootProcessStartAbsoluteTime: nil + ), + name: name, + bundleIdentifier: bundleIdentifier, + bundleURL: bundleURL, + currentPowerMicrowatts: isReadable ? impact : nil, + sustainedPowerMicrowatts: nil, + rankingScore: isReadable ? impact : nil, + trend: .steady, + coverage: isReadable + ? EnergyImpactCoverage( + discoveredProcessCount: 1, readableProcessCount: 1, + validProcessSeconds: 0, discoveredProcessSeconds: 0 + ) + : .unavailable, + status: isReadable ? .stable : .unavailable + ) + } + + @available(*, deprecated, message: "Use currentPowerMicrowatts and status") + public var impact: Double { currentPowerMicrowatts ?? 0 } + + @available(*, deprecated, message: "Use status") + public var isReadable: Bool { status != .unavailable } + + @available(*, deprecated, message: "Use EnergyImpactPresentation") + public var formattedImpact: String { + guard let currentPowerMicrowatts else { return "Unavailable" } + return String(format: "%.1f", currentPowerMicrowatts) + } +} diff --git a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift index 169cdd7..2215fb6 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift @@ -4,6 +4,89 @@ import XCTest @MainActor final class EnergyImpactProviderTests: XCTestCase { + func testEnergyImpactEntryRepresentsCollectingWithoutAFalseZero() { + let entry = EnergyImpactEntry( + identity: EnergyImpactAppIdentity( + rootProcessIdentifier: 101, + rootProcessStartAbsoluteTime: 10 + ), + name: "Safari", + bundleIdentifier: "com.apple.Safari", + bundleURL: nil, + currentPowerMicrowatts: nil, + sustainedPowerMicrowatts: nil, + rankingScore: nil, + trend: .steady, + coverage: EnergyImpactCoverage( + discoveredProcessCount: 1, + readableProcessCount: 1, + validProcessSeconds: 0, + discoveredProcessSeconds: 0 + ), + status: .collecting + ) + + XCTAssertNil(entry.displayPowerMicrowatts) + XCTAssertEqual(entry.status, .collecting) + } + + func testEnergyImpactCoverageUsesValidPIDTime() { + let coverage = EnergyImpactCoverage( + discoveredProcessCount: 4, + readableProcessCount: 3, + validProcessSeconds: 9, + discoveredProcessSeconds: 12 + ) + + XCTAssertEqual(coverage.fraction, 0.75, accuracy: 0.001) + } + + func testEnergyImpactCoverageIsZeroWithoutDiscoveredPIDTime() { + let coverage = EnergyImpactCoverage( + discoveredProcessCount: 0, + readableProcessCount: 0, + validProcessSeconds: 0, + discoveredProcessSeconds: 0 + ) + + XCTAssertEqual(coverage.fraction, 0) + } + + func testEnergyImpactServiceReportsPartialCoverageWhenOneDescendantHasNoValidDelta() throws { + let app = EnergyImpactAppSnapshot( + processIdentifier: 100, + name: "Browser", + bundleIdentifier: "com.example.browser", + bundleURL: nil + ) + let service = EnergyImpactService( + reader: ProcessEnergyReadingProviderStub(readings: [ + 100: [ + .init(energyNanojoules: 1_000, processStartAbsoluteTime: 10), + .init(energyNanojoules: 2_000, processStartAbsoluteTime: 10), + ], + 101: [ + .init(energyNanojoules: 2_000, processStartAbsoluteTime: 11), + .init(energyNanojoules: 3_000, processStartAbsoluteTime: 21), + ], + ]), + processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: [ + .init(processIdentifier: 101, parentProcessIdentifier: 100, residentMemoryBytes: 0), + ]), + appSnapshotProvider: { [app] }, + now: dateSequence([100, 101]) + ) + + _ = service.topApps(limit: 1) + let entry = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(entry.status, .partial) + XCTAssertEqual(entry.coverage.validProcessSeconds, 1) + XCTAssertEqual(entry.coverage.discoveredProcessSeconds, 2) + XCTAssertEqual(entry.coverage.fraction, 0.5, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(entry.currentPowerMicrowatts), 1, accuracy: 0.001) + } + func testSystemProcessEnergyReaderReadsCurrentProcess() throws { let reading = try XCTUnwrap(SystemProcessEnergyReader().reading(for: getpid())) @@ -20,7 +103,7 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertLessThanOrEqual(entries.count, 1) } - func testEnergyImpactServiceUsesPreviousRefreshSnapshotsForImpact() { + func testEnergyImpactServiceUsesPreviousRefreshSnapshotsForImpact() throws { let apps = [ EnergyImpactAppSnapshot( processIdentifier: 101, @@ -55,16 +138,16 @@ final class EnergyImpactProviderTests: XCTestCase { let firstEntries = service.topApps(limit: 2) let secondEntries = service.topApps(limit: 2) - XCTAssertEqual(firstEntries.map(\.impact), [0, 0]) - XCTAssertTrue(firstEntries.allSatisfy(\.isReadable)) + XCTAssertTrue(firstEntries.allSatisfy { $0.status == .collecting }) + XCTAssertTrue(firstEntries.allSatisfy { $0.currentPowerMicrowatts == nil }) XCTAssertEqual(secondEntries.map(\.name), ["Safari", "Notes"]) - XCTAssertEqual(secondEntries[0].impact, 2.5, accuracy: 0.001) - XCTAssertEqual(secondEntries[1].impact, 0.3, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(secondEntries[0].currentPowerMicrowatts), 2.5, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(secondEntries[1].currentPowerMicrowatts), 0.3, accuracy: 0.001) XCTAssertEqual(reader.readCount(for: 101), 2) XCTAssertEqual(reader.readCount(for: 102), 2) } - func testEnergyImpactServiceNormalizesImpactByElapsedTime() { + func testEnergyImpactServiceNormalizesImpactByElapsedTime() throws { let app = EnergyImpactAppSnapshot( processIdentifier: 101, name: "Safari", @@ -87,10 +170,10 @@ final class EnergyImpactProviderTests: XCTestCase { _ = service.topApps(limit: 1) let entries = service.topApps(limit: 1) - XCTAssertEqual(entries.first?.impact ?? 0, 5.0, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(entries.first?.currentPowerMicrowatts), 5.0, accuracy: 0.001) } - func testEnergyImpactServiceAggregatesDescendantEnergyIntoOwningApp() { + func testEnergyImpactServiceAggregatesDescendantEnergyIntoOwningApp() throws { let app = EnergyImpactAppSnapshot( processIdentifier: 100, name: "Browser", @@ -126,7 +209,7 @@ final class EnergyImpactProviderTests: XCTestCase { _ = service.topApps(limit: 1) let entries = service.topApps(limit: 1) - XCTAssertEqual(entries.first?.impact ?? 0, 2.5, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(entries.first?.currentPowerMicrowatts), 2.5, accuracy: 0.001) XCTAssertEqual(reader.readCount(for: 100), 2) XCTAssertEqual(reader.readCount(for: 101), 2) XCTAssertEqual(reader.readCount(for: 102), 2) @@ -156,7 +239,8 @@ final class EnergyImpactProviderTests: XCTestCase { _ = service.topApps(limit: 1) let entries = service.topApps(limit: 1) - XCTAssertEqual(entries.first?.impact ?? 0, 0, accuracy: 0.001) + XCTAssertEqual(entries.first?.status, .collecting) + XCTAssertNil(entries.first?.currentPowerMicrowatts) } func testEnergyImpactServiceKeepsUnreadableAppsAsUnavailableRows() { @@ -179,27 +263,41 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertEqual(entries.count, 1) XCTAssertEqual(entries[0].name, "Locked App") - XCTAssertEqual(entries[0].impact, 0) - XCTAssertFalse(entries[0].isReadable) + XCTAssertNil(entries[0].currentPowerMicrowatts) + XCTAssertEqual(entries[0].status, .unavailable) } func testEnergyImpactEntriesSortTiesByName() { let entries = [ EnergyImpactEntry( - processIdentifier: 101, + identity: EnergyImpactAppIdentity( + rootProcessIdentifier: 101, + rootProcessStartAbsoluteTime: 10 + ), name: "Notes", bundleIdentifier: "com.apple.Notes", bundleURL: nil, - impact: 4.2, - isReadable: true + currentPowerMicrowatts: 4.2, + sustainedPowerMicrowatts: nil, + rankingScore: 4.2, + trend: .steady, + coverage: .unavailable, + status: .stable ), EnergyImpactEntry( - processIdentifier: 102, + identity: EnergyImpactAppIdentity( + rootProcessIdentifier: 102, + rootProcessStartAbsoluteTime: 11 + ), name: "Calendar", bundleIdentifier: "com.apple.iCal", bundleURL: nil, - impact: 4.2, - isReadable: true + currentPowerMicrowatts: 4.2, + sustainedPowerMicrowatts: nil, + rankingScore: 4.2, + trend: .steady, + coverage: .unavailable, + status: .stable ), ] From 8f8dc10b3ba3c54ca6358ea414be4725f936be9a Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 24 Jul 2026 18:02:46 +1000 Subject: [PATCH 02/13] fix(energy): key baselines by process generation --- .../Metrics/Providers/EnergyImpactProvider.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift index 02f3bff..fb97a3f 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift @@ -65,7 +65,7 @@ public final class EnergyImpactService { private let processSnapshotReader: any ProcessMemorySnapshotReading private let appSnapshotProvider: () -> [EnergyImpactAppSnapshot] private let now: () -> Date - private var previousReadings: [pid_t: TimedProcessEnergyReading] = [:] + private var previousReadings: [EnergyImpactProcessIdentity: TimedProcessEnergyReading] = [:] public init( workspace: NSWorkspace = .shared, @@ -98,7 +98,7 @@ public final class EnergyImpactService { rootProcessIdentifiers: apps.map(\.processIdentifier), snapshots: processSnapshotReader.snapshots() ) - var nextReadings: [pid_t: TimedProcessEnergyReading] = [:] + var nextReadings: [EnergyImpactProcessIdentity: TimedProcessEnergyReading] = [:] let entries = apps.map { app -> EnergyImpactEntry in let processIdentifiers = processIdentifiersByRoot[app.processIdentifier] ?? [app.processIdentifier] var rootProcessStartAbsoluteTime: UInt64? @@ -112,11 +112,15 @@ public final class EnergyImpactService { rootProcessStartAbsoluteTime = current.processStartAbsoluteTime } readableProcessCount += 1 - nextReadings[processIdentifier] = TimedProcessEnergyReading( + let identity = EnergyImpactProcessIdentity( + processIdentifier: processIdentifier, + processStartAbsoluteTime: current.processStartAbsoluteTime + ) + nextReadings[identity] = TimedProcessEnergyReading( reading: current, sampleTime: sampleTime ) - if let previous = previousReadings[processIdentifier], + if let previous = previousReadings[identity], let impactRate = Self.impactRate(from: previous, to: current, sampleTime: sampleTime) { totalPowerMicrowatts += impactRate validDeltaCount += 1 From c63df905e4c3d054d4e24bb5d5c5e9add8d9a659 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 24 Jul 2026 18:18:35 +1000 Subject: [PATCH 03/13] feat(energy): clarify impact presentation --- MacActivity.xcodeproj/project.pbxproj | 8 + .../Localization/AppLocalization.swift | 8 + .../Models/EnergyImpactPresentation.swift | 57 +++++ .../Resources/de.lproj/Localizable.strings | 12 +- .../Resources/en.lproj/Localizable.strings | 12 +- .../Resources/fr.lproj/Localizable.strings | 12 +- .../Resources/ja.lproj/Localizable.strings | 12 +- .../Resources/ko.lproj/Localizable.strings | 12 +- .../zh-Hans.lproj/Localizable.strings | 12 +- .../zh-Hant.lproj/Localizable.strings | 12 +- .../Views/EnergyImpactView.swift | 109 +++++---- .../Metrics/Providers/EnergyImpactTypes.swift | 43 ---- .../EnergyImpactModelTests.swift | 66 ++---- .../EnergyImpactPresentationTests.swift | 57 +++++ .../EnergyImpactViewTests.swift | 213 ++++++------------ .../LocalizationTests.swift | 44 ++-- .../PreferencesViewTests.swift | 9 + 17 files changed, 372 insertions(+), 326 deletions(-) create mode 100644 Sources/MacActivityApp/Models/EnergyImpactPresentation.swift create mode 100644 Tests/MacActivityAppTests/EnergyImpactPresentationTests.swift diff --git a/MacActivity.xcodeproj/project.pbxproj b/MacActivity.xcodeproj/project.pbxproj index ab9a125..10f88e2 100644 --- a/MacActivity.xcodeproj/project.pbxproj +++ b/MacActivity.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 1B3A5C1A4385179E39C90CDF /* DashboardModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41333E534CBDB52CD351763E /* DashboardModel.swift */; }; 1E841E980F91E6BD85A42803 /* AudioSystemMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACFADB8DB7FD273E04239957 /* AudioSystemMonitorTests.swift */; }; 203728ECA31C7E130830CBD3 /* AudioDashboardModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E5EB0BFFA6A7936E4EAD230 /* AudioDashboardModelTests.swift */; }; + 233FD931524150238F6754C5 /* EnergyImpactPresentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 291B3BAC9EC9B7DBFADACE15 /* EnergyImpactPresentationTests.swift */; }; 24E2A04F45694E2244E16C74 /* UpdateCandidateSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC750397189E5163AFA2AE33 /* UpdateCandidateSelector.swift */; }; 25C92B9E86A8C927EAD842BD /* AudioProcessOwnershipLease.swift in Sources */ = {isa = PBXBuildFile; fileRef = EAA4016667A7EB15DCF66B95 /* AudioProcessOwnershipLease.swift */; }; 25F6AB66E8EE1DA07F240610 /* EnergyImpactTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 90E95B0B56AA23B56343FB1C /* EnergyImpactTypes.swift */; }; @@ -52,6 +53,7 @@ 45A7297E010B7FBDC275DADA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 524520F5A3160DC0ECB2A0A3 /* Assets.xcassets */; }; 481D37333B0C15E0EE471191 /* LazyShellControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1312C2453A14DC4E4201975A /* LazyShellControllerTests.swift */; }; 499E922334CC48A0F1656BBC /* AudioProcessProfileTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 35C82068288F70203DE52633 /* AudioProcessProfileTests.swift */; }; + 49C9BA829BAC11CEB659459C /* EnergyImpactPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = E03A2C158D947FE65167CEA8 /* EnergyImpactPresentation.swift */; }; 4A3E337DABE4743A0C235838 /* MacActivityCore.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CCC0344CCD781933A16D98D7 /* MacActivityCore.framework */; }; 4B9C952E9262F082C4242903 /* EnergyImpactView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB4F29166FB636C6A7A43659 /* EnergyImpactView.swift */; }; 4F1AE1B7F44F2D9407FA4746 /* SMCSensorReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 20D958280B26529B7B39C116 /* SMCSensorReaderTests.swift */; }; @@ -231,6 +233,7 @@ 24A3FB3175D5B77C9D81C6B1 /* SparkleUpdateControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdateControllerTests.swift; sourceTree = ""; }; 274F1746865BAAD6DCDC30E5 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; 28B16690F3A93F5DF6A08150 /* LocalizationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalizationTests.swift; sourceTree = ""; }; + 291B3BAC9EC9B7DBFADACE15 /* EnergyImpactPresentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactPresentationTests.swift; sourceTree = ""; }; 2B7F712CB5FD323E4BDCD1B9 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/InfoPlist.strings; sourceTree = ""; }; 2BC2395BD346EE615CD7787A /* SparkleUpdateController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SparkleUpdateController.swift; sourceTree = ""; }; 2E667EB81235A9CF840D61AB /* EnergyImpactModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactModelTests.swift; sourceTree = ""; }; @@ -338,6 +341,7 @@ D6A085010AE18BDEC0E3BB55 /* AudioControlModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioControlModels.swift; sourceTree = ""; }; D86CFDDB2D5CE86CDA46A87F /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; DAC3D5561158D1EC418C2BC9 /* AudioRoutingModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioRoutingModels.swift; sourceTree = ""; }; + E03A2C158D947FE65167CEA8 /* EnergyImpactPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactPresentation.swift; sourceTree = ""; }; E2E54E53FD145EE2DACD72F6 /* AudioAggregateTopologyResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioAggregateTopologyResolver.swift; sourceTree = ""; }; E49ECEEFF448A10DF483AD89 /* TemperatureProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TemperatureProvider.swift; sourceTree = ""; }; E623687BEBC14372C97ABDDF /* AudioTapHardware.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioTapHardware.swift; sourceTree = ""; }; @@ -432,6 +436,7 @@ FA74494A949B2E36D7F81079 /* AudioControlCoordinator.swift */, 6EE3F62C8712E5DD553C15FA /* AudioDashboardModel.swift */, A853062000D99B74117E656B /* EnergyImpactModel.swift */, + E03A2C158D947FE65167CEA8 /* EnergyImpactPresentation.swift */, ); path = Models; sourceTree = ""; @@ -685,6 +690,7 @@ B8233121C0FA90DBBF96DFE8 /* DashboardTrendChartLayoutTests.swift */, 5773E6B2EBA7BBAABE1570DF /* DashboardTrendLocalizationTests.swift */, 2E667EB81235A9CF840D61AB /* EnergyImpactModelTests.swift */, + 291B3BAC9EC9B7DBFADACE15 /* EnergyImpactPresentationTests.swift */, 8DFFF072E13B7DBEA690C77C /* EnergyImpactViewTests.swift */, 1312C2453A14DC4E4201975A /* LazyShellControllerTests.swift */, 28B16690F3A93F5DF6A08150 /* LocalizationTests.swift */, @@ -896,6 +902,7 @@ D490D4024E6274B6F559D0A3 /* DashboardTrendChartLayoutTests.swift in Sources */, B62368B572182AE9A25D2187 /* DashboardTrendLocalizationTests.swift in Sources */, FB7F3E4B37EC29A3688E3AEC /* EnergyImpactModelTests.swift in Sources */, + 233FD931524150238F6754C5 /* EnergyImpactPresentationTests.swift in Sources */, BDF8CCE31C580FDBF7ED7FA1 /* EnergyImpactViewTests.swift in Sources */, 481D37333B0C15E0EE471191 /* LazyShellControllerTests.swift in Sources */, 6C19FF93C33548A9CA841AB6 /* LocalizationTests.swift in Sources */, @@ -1030,6 +1037,7 @@ D2EED3D79D2450518952BAE5 /* DashboardView.swift in Sources */, 551B137A9AD7F434850FD5EB /* DiskCleanupStatusView.swift in Sources */, 15F7BD4C4625677230D370A6 /* EnergyImpactModel.swift in Sources */, + 49C9BA829BAC11CEB659459C /* EnergyImpactPresentation.swift in Sources */, 4B9C952E9262F082C4242903 /* EnergyImpactView.swift in Sources */, 63C09E96706D5628D706B2ED /* MemoryReleaseStatusView.swift in Sources */, E179E6DCA9F45E748B710AA4 /* PreferencesView.swift in Sources */, diff --git a/Sources/MacActivityApp/Localization/AppLocalization.swift b/Sources/MacActivityApp/Localization/AppLocalization.swift index cf57012..44c9d99 100644 --- a/Sources/MacActivityApp/Localization/AppLocalization.swift +++ b/Sources/MacActivityApp/Localization/AppLocalization.swift @@ -173,6 +173,14 @@ enum AppLocalization { case diskCleanupCategoryPlural = "diskCleanup.category.plural" case energyImpactEmpty = "energyImpact.empty" case energyImpactUnavailable = "energyImpact.unavailable" + case energyImpactTitle = "energyImpact.title" + case energyImpactSubtitleCurrent = "energyImpact.subtitle.current" + case energyImpactAppColumn = "energyImpact.column.app" + case energyImpactCurrentColumn = "energyImpact.column.current" + case energyImpactCollecting = "energyImpact.collecting" + case energyImpactPartial = "energyImpact.partial" + case energyImpactStale = "energyImpact.stale" + case energyImpactRowAccessibility = "energyImpact.accessibility.row" case processEmpty = "process.empty" case processFallbackName = "process.fallbackName" case processActionRequested = "process.action.requested" diff --git a/Sources/MacActivityApp/Models/EnergyImpactPresentation.swift b/Sources/MacActivityApp/Models/EnergyImpactPresentation.swift new file mode 100644 index 0000000..de8e51d --- /dev/null +++ b/Sources/MacActivityApp/Models/EnergyImpactPresentation.swift @@ -0,0 +1,57 @@ +import Foundation +import MacActivityCore + +enum EnergyImpactPresentation { + static func powerText(microwatts: Double, locale: Locale) -> String { + guard microwatts.isFinite, microwatts >= 0 else { return "—" } + if microwatts >= 1_000 { + let value = (microwatts / 1_000).formatted( + .number.locale(locale).precision(.fractionLength(0...1)) + ) + return "\(value) mW" + } + let value = microwatts.formatted( + .number.locale(locale).precision(.fractionLength(0...1)) + ) + return "\(value) µW" + } + + static func powerText( + microwatts: Double?, + status: EnergyImpactStatus, + bundle: Bundle? = nil + ) -> String { + guard let microwatts, microwatts.isFinite, microwatts >= 0 else { + let key: AppLocalization.Key = switch status { + case .collecting: .energyImpactCollecting + case .partial: .energyImpactPartial + case .stale: .energyImpactStale + case .stable, .unavailable: .energyImpactUnavailable + } + return AppLocalization.string(key, bundle: bundle) + } + return powerText( + microwatts: microwatts, + locale: AppLocalization.currentLocale(bundle: bundle) + ) + } + + static func accessibilityLabel( + entry: EnergyImpactEntry, + rank: Int, + bundle: Bundle? = nil + ) -> String { + let value = powerText( + microwatts: entry.displayPowerMicrowatts, + status: entry.status, + bundle: bundle + ) + return AppLocalization.string( + .energyImpactRowAccessibility, + entry.name, + rank, + value, + bundle: bundle + ) + } +} diff --git a/Sources/MacActivityApp/Resources/de.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/de.lproj/Localizable.strings index 06910ed..8ee345c 100644 --- a/Sources/MacActivityApp/Resources/de.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/de.lproj/Localizable.strings @@ -94,7 +94,7 @@ "preferences.temperatureHelp" = "Steuert die Temperaturmetrik in Statusleiste und Dashboard."; "preferences.hardwareBatteryPercentage" = "Hardware-Akku-Prozentsatz anzeigen"; "preferences.hardwareBatteryPercentageHelp" = "Verwendet die Rohkapazität von AppleSmartBattery, falls verfügbar; sonst wird auf den Systemprozentsatz zurückgegriffen."; -"preferences.processApplicationIdentifier" = "Anwendungs-ID in der aktiven Prozessliste anzeigen"; +"preferences.processApplicationIdentifier" = "App-ID in Prozesslisten anzeigen"; "preferences.diskCleanupScope" = "Bereinigungsumfang"; "preferences.diskCleanupHelp" = "Legt fest, welche Elemente die Actives-Datenträgerbereinigung scannt und löscht."; "preferences.menuBarMetrics" = "Metriken der Menüleiste"; @@ -109,7 +109,15 @@ "diskCleanup.category.trash" = "Papierkorb"; "diskCleanup.category.userLogs" = "Protokolle"; -"energyImpact.empty" = "Keine Vordergrund-Apps melden Energieauswirkungen."; +"energyImpact.title" = "Energieeinfluss"; +"energyImpact.subtitle.current" = "Aktuelle CPU-Energieschätzung · Niedriger ist besser"; +"energyImpact.column.app" = "App"; +"energyImpact.column.current" = "Aktuell"; +"energyImpact.collecting" = "Wird erfasst"; +"energyImpact.partial" = "Teilweise"; +"energyImpact.stale" = "Veraltet"; +"energyImpact.accessibility.row" = "%1$@, Rang %2$lld, %3$@"; +"energyImpact.empty" = "Keine regulären Apps melden eine Energieschätzung."; "energyImpact.unavailable" = "Nicht verfügbar"; "memoryRelease.action.release" = "Freigeben"; diff --git a/Sources/MacActivityApp/Resources/en.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/en.lproj/Localizable.strings index 5502152..87247ab 100644 --- a/Sources/MacActivityApp/Resources/en.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/en.lproj/Localizable.strings @@ -94,7 +94,7 @@ "preferences.temperatureHelp" = "Controls the Temperature metric in the status bar and dashboard."; "preferences.hardwareBatteryPercentage" = "Show hardware battery percentage"; "preferences.hardwareBatteryPercentageHelp" = "Uses raw AppleSmartBattery capacity when available; falls back to the system percentage."; -"preferences.processApplicationIdentifier" = "Show application ID in Actives process list"; +"preferences.processApplicationIdentifier" = "Show application ID in process lists"; "preferences.diskCleanupScope" = "Cleanup scope"; "preferences.diskCleanupHelp" = "Controls what Actives Disk Cleanup scans and deletes."; "preferences.menuBarMetrics" = "Menu bar metrics"; @@ -170,7 +170,15 @@ "diskCleanup.category.singular" = "category"; "diskCleanup.category.plural" = "categories"; -"energyImpact.empty" = "No foreground apps are reporting energy impact."; +"energyImpact.title" = "Energy Impact"; +"energyImpact.subtitle.current" = "Recent CPU energy estimate · Lower is better"; +"energyImpact.column.app" = "App"; +"energyImpact.column.current" = "Current"; +"energyImpact.collecting" = "Collecting"; +"energyImpact.partial" = "Partial"; +"energyImpact.stale" = "Stale"; +"energyImpact.accessibility.row" = "%1$@, rank %2$lld, %3$@"; +"energyImpact.empty" = "No regular apps are reporting an energy estimate."; "energyImpact.unavailable" = "Unavailable"; "process.empty" = "No foreground apps are reporting memory usage."; diff --git a/Sources/MacActivityApp/Resources/fr.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/fr.lproj/Localizable.strings index 91723d3..5f1f62d 100644 --- a/Sources/MacActivityApp/Resources/fr.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/fr.lproj/Localizable.strings @@ -94,7 +94,7 @@ "preferences.temperatureHelp" = "Contrôle la métrique de température dans la barre de menu et le tableau de bord."; "preferences.hardwareBatteryPercentage" = "Afficher le pourcentage matériel de la batterie"; "preferences.hardwareBatteryPercentageHelp" = "Utilise la capacité brute AppleSmartBattery quand elle est disponible ; sinon revient au pourcentage système."; -"preferences.processApplicationIdentifier" = "Afficher l’ID d’application dans la liste de processus actifs"; +"preferences.processApplicationIdentifier" = "Afficher l’identifiant d’app dans les listes de processus"; "preferences.diskCleanupScope" = "Portée du nettoyage"; "preferences.diskCleanupHelp" = "Contrôle les éléments que le nettoyage du disque d’Actives analyse et supprime."; "preferences.menuBarMetrics" = "Indicateurs de la barre de menu"; @@ -109,7 +109,15 @@ "diskCleanup.category.trash" = "Corbeille"; "diskCleanup.category.userLogs" = "Journaux"; -"energyImpact.empty" = "Aucune app au premier plan ne signale d’impact énergétique."; +"energyImpact.title" = "Impact énergétique"; +"energyImpact.subtitle.current" = "Estimation récente de l’énergie CPU · Plus bas est préférable"; +"energyImpact.column.app" = "App"; +"energyImpact.column.current" = "Actuel"; +"energyImpact.collecting" = "Collecte"; +"energyImpact.partial" = "Partiel"; +"energyImpact.stale" = "Obsolète"; +"energyImpact.accessibility.row" = "%1$@, rang %2$lld, %3$@"; +"energyImpact.empty" = "Aucune app standard ne fournit d’estimation énergétique."; "energyImpact.unavailable" = "Indisponible"; "memoryRelease.action.release" = "Libérer"; diff --git a/Sources/MacActivityApp/Resources/ja.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/ja.lproj/Localizable.strings index 9a480e6..80ac9fc 100644 --- a/Sources/MacActivityApp/Resources/ja.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/ja.lproj/Localizable.strings @@ -94,7 +94,7 @@ "preferences.temperatureHelp" = "ステータスバーとダッシュボードの温度メトリックを制御します。"; "preferences.hardwareBatteryPercentage" = "ハードウェアバッテリー残量を表示"; "preferences.hardwareBatteryPercentageHelp" = "利用可能な場合は AppleSmartBattery の実容量を使用し、利用できない場合はシステムの残量を使用します。"; -"preferences.processApplicationIdentifier" = "アクティブプロセス一覧にアプリ ID を表示"; +"preferences.processApplicationIdentifier" = "プロセスリストにアプリIDを表示"; "preferences.diskCleanupScope" = "クリーンアップ範囲"; "preferences.diskCleanupHelp" = "アクティブのディスククリーンアップでスキャンと削除を行う対象を制御します。"; "preferences.menuBarMetrics" = "メニューバー指標"; @@ -109,7 +109,15 @@ "diskCleanup.category.trash" = "ゴミ箱"; "diskCleanup.category.userLogs" = "ログ"; -"energyImpact.empty" = "エネルギー影響を報告している前面アプリはありません。"; +"energyImpact.title" = "エネルギー影響"; +"energyImpact.subtitle.current" = "最近のCPUエネルギー推定 · 低いほど良好"; +"energyImpact.column.app" = "アプリ"; +"energyImpact.column.current" = "現在"; +"energyImpact.collecting" = "収集中"; +"energyImpact.partial" = "一部データ"; +"energyImpact.stale" = "古いデータ"; +"energyImpact.accessibility.row" = "%1$@、%2$lld位、%3$@"; +"energyImpact.empty" = "エネルギー推定を報告している通常のアプリはありません。"; "energyImpact.unavailable" = "利用不可"; "memoryRelease.action.release" = "解放"; diff --git a/Sources/MacActivityApp/Resources/ko.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/ko.lproj/Localizable.strings index 84c5005..e08c0fe 100644 --- a/Sources/MacActivityApp/Resources/ko.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/ko.lproj/Localizable.strings @@ -94,7 +94,7 @@ "preferences.temperatureHelp" = "상태 막대와 대시보드의 온도 지표를 제어합니다."; "preferences.hardwareBatteryPercentage" = "하드웨어 배터리 퍼센트 표시"; "preferences.hardwareBatteryPercentageHelp" = "가능하면 AppleSmartBattery의 실제 용량을 사용하고, 없으면 시스템 퍼센트로 대체합니다."; -"preferences.processApplicationIdentifier" = "활성 프로세스 목록에 앱 ID 표시"; +"preferences.processApplicationIdentifier" = "프로세스 목록에 앱 ID 표시"; "preferences.diskCleanupScope" = "정리 범위"; "preferences.diskCleanupHelp" = "활성 디스크 정리에서 스캔/삭제할 항목을 제어합니다."; "preferences.menuBarMetrics" = "메뉴 막대 지표"; @@ -109,7 +109,15 @@ "diskCleanup.category.trash" = "휴지통"; "diskCleanup.category.userLogs" = "로그"; -"energyImpact.empty" = "에너지 영향을 보고하는 전면 앱이 없습니다."; +"energyImpact.title" = "에너지 영향"; +"energyImpact.subtitle.current" = "최근 CPU 에너지 추정치 · 낮을수록 좋음"; +"energyImpact.column.app" = "앱"; +"energyImpact.column.current" = "현재"; +"energyImpact.collecting" = "수집 중"; +"energyImpact.partial" = "일부 데이터"; +"energyImpact.stale" = "오래된 데이터"; +"energyImpact.accessibility.row" = "%1$@, %2$lld위, %3$@"; +"energyImpact.empty" = "에너지 추정치를 보고하는 일반 앱이 없습니다."; "energyImpact.unavailable" = "사용할 수 없음"; "memoryRelease.action.release" = "해제"; diff --git a/Sources/MacActivityApp/Resources/zh-Hans.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/zh-Hans.lproj/Localizable.strings index a5a1de8..2e146d8 100644 --- a/Sources/MacActivityApp/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/zh-Hans.lproj/Localizable.strings @@ -94,7 +94,7 @@ "preferences.temperatureHelp" = "控制菜单栏和仪表盘中的温度指标。"; "preferences.hardwareBatteryPercentage" = "显示硬件电池百分比"; "preferences.hardwareBatteryPercentageHelp" = "可用时使用 AppleSmartBattery 的原始容量;不可用时回退为系统百分比。"; -"preferences.processApplicationIdentifier" = "在“活跃”进程列表中显示应用 ID"; +"preferences.processApplicationIdentifier" = "在进程列表中显示应用 ID"; "preferences.diskCleanupScope" = "清理范围"; "preferences.diskCleanupHelp" = "控制 Actives 磁盘清理会扫描和删除的内容。"; "preferences.menuBarMetrics" = "菜单栏指标"; @@ -170,7 +170,15 @@ "diskCleanup.category.singular" = "个分类"; "diskCleanup.category.plural" = "个分类"; -"energyImpact.empty" = "当前没有前台应用报告耗电影响。"; +"energyImpact.title" = "耗电影响"; +"energyImpact.subtitle.current" = "近期 CPU 能耗估算 · 越低越好"; +"energyImpact.column.app" = "应用"; +"energyImpact.column.current" = "当前"; +"energyImpact.collecting" = "采集中"; +"energyImpact.partial" = "部分数据"; +"energyImpact.stale" = "数据已过期"; +"energyImpact.accessibility.row" = "%1$@,第 %2$lld 名,%3$@"; +"energyImpact.empty" = "当前没有普通应用报告能耗估算。"; "energyImpact.unavailable" = "不可读取"; "process.empty" = "当前没有前台应用报告内存用量。"; diff --git a/Sources/MacActivityApp/Resources/zh-Hant.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/zh-Hant.lproj/Localizable.strings index b059973..3f52d3a 100644 --- a/Sources/MacActivityApp/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/zh-Hant.lproj/Localizable.strings @@ -94,7 +94,7 @@ "preferences.temperatureHelp" = "控制選單列與儀表板的溫度指標。"; "preferences.hardwareBatteryPercentage" = "顯示硬體電池百分比"; "preferences.hardwareBatteryPercentageHelp" = "可用時使用 AppleSmartBattery 的原始容量;無法使用時改用系統百分比。"; -"preferences.processApplicationIdentifier" = "在「活躍」程序清單中顯示應用程式 ID"; +"preferences.processApplicationIdentifier" = "在程序列表中顯示應用程式 ID"; "preferences.diskCleanupScope" = "清理範圍"; "preferences.diskCleanupHelp" = "控制 Actives 磁碟清理要掃描並刪除的項目。"; "preferences.menuBarMetrics" = "選單列指標"; @@ -109,7 +109,15 @@ "diskCleanup.category.trash" = "垃圾桶"; "diskCleanup.category.userLogs" = "日誌"; -"energyImpact.empty" = "目前沒有前景應用程式回報耗電影響。"; +"energyImpact.title" = "耗電影響"; +"energyImpact.subtitle.current" = "近期 CPU 能耗估算 · 越低越好"; +"energyImpact.column.app" = "應用程式"; +"energyImpact.column.current" = "目前"; +"energyImpact.collecting" = "收集中"; +"energyImpact.partial" = "部分資料"; +"energyImpact.stale" = "資料已過期"; +"energyImpact.accessibility.row" = "%1$@,第 %2$lld 名,%3$@"; +"energyImpact.empty" = "目前沒有一般應用程式回報能耗估算。"; "energyImpact.unavailable" = "無法讀取"; "memoryRelease.action.release" = "釋放"; diff --git a/Sources/MacActivityApp/Views/EnergyImpactView.swift b/Sources/MacActivityApp/Views/EnergyImpactView.swift index 7b8f7cf..d50d1e5 100644 --- a/Sources/MacActivityApp/Views/EnergyImpactView.swift +++ b/Sources/MacActivityApp/Views/EnergyImpactView.swift @@ -11,6 +11,22 @@ struct EnergyImpactView: View { var body: some View { VStack(alignment: .leading, spacing: ActiveCleanReleaseLayout.processListSpacing) { + VStack(alignment: .leading, spacing: 4) { + Text(AppLocalization.string(.energyImpactTitle)) + .font(.headline) + Text(AppLocalization.string(.energyImpactSubtitleCurrent)) + .font(.caption) + .foregroundStyle(.secondary) + HStack { + Text(AppLocalization.string(.energyImpactAppColumn)) + Spacer() + Text(AppLocalization.string(.energyImpactCurrentColumn)) + } + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 12) + if model.entries.isEmpty { Text(Self.emptyMessage(isRefreshing: model.isRefreshing)) .font(.caption) @@ -22,15 +38,11 @@ struct EnergyImpactView: View { ) .padding(.horizontal, 12) } else { - let maximumReadableImpact = model.entries - .filter(\.isReadable) - .map(\.impact) - .max() ?? 0 VStack(alignment: .leading, spacing: ActiveCleanReleaseLayout.processListSpacing) { - ForEach(model.entries) { entry in + ForEach(Array(model.entries.enumerated()), id: \.element.id) { index, entry in EnergyImpactRow( entry: entry, - maximumImpact: maximumReadableImpact, + rank: index + 1, showsApplicationIdentifier: showsApplicationIdentifier ) } @@ -60,57 +72,46 @@ struct EnergyImpactView: View { } struct EnergyImpactRow: View { - @Environment(\.appearsActive) private var appearsActive let entry: EnergyImpactEntry - let maximumImpact: Double + let rank: Int let showsApplicationIdentifier: Bool - @State private var isHovered = false - - var body: some View { - GeometryReader { proxy in - let progressWidth = proxy.size.width * CGFloat( - Self.progressFraction(for: entry, maximumImpact: maximumImpact) - ) - - ZStack(alignment: .leading) { - Rectangle() - .fill(ActiveCleanupChrome.progressFillColor(appearsActive: appearsActive)) - .frame(width: progressWidth) - - HStack(spacing: 10) { - icon - - VStack(alignment: .leading, spacing: 2) { - Text(entry.name) - .font(.caption.weight(.semibold)) - .lineLimit(1) - if let identifier = Self.identifierText( - for: entry, - showsApplicationIdentifier: showsApplicationIdentifier - ) { - Text(identifier) - .font(.caption2) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - - Spacer(minLength: 8) + private var accessibilityLabel: String { + EnergyImpactPresentation.accessibilityLabel(entry: entry, rank: rank) + } - Text(Self.trailingText(for: entry)) - .font(.caption.monospacedDigit()) + var body: some View { + HStack(spacing: 10) { + icon + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 2) { + Text(entry.name) + .font(.caption.weight(.semibold)) + .lineLimit(1) + if let identifier = Self.identifierText( + for: entry, + showsApplicationIdentifier: showsApplicationIdentifier + ) { + Text(identifier) + .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) - .frame(width: ActiveProcessMemoryLayout.trailingActionWidth, alignment: .trailing) } - .padding(.horizontal, 12) } + + Spacer(minLength: 8) + + Text(Self.trailingText(for: entry)) + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(minWidth: 72, alignment: .trailing) } + .padding(.horizontal, 12) .frame(height: ActiveProcessMemoryLayout.rowHeight) - .background(isHovered ? AnyShapeStyle(.quaternary.opacity(0.5)) : AnyShapeStyle(.clear)) - .onHover { isHovered = $0 } - .clipped() + .accessibilityElement(children: .ignore) + .accessibilityLabel(accessibilityLabel) } @ViewBuilder @@ -140,16 +141,12 @@ struct EnergyImpactRow: View { return .bundle(bundleURL) } - static func progressFraction(for entry: EnergyImpactEntry, maximumImpact: Double) -> Double { - guard entry.isReadable, maximumImpact > 0 else { return 0 } - return min(max(entry.impact / maximumImpact, 0), 1) - } - static func trailingText(for entry: EnergyImpactEntry, bundle: Bundle? = nil) -> String { - guard entry.isReadable else { - return AppLocalization.string(.energyImpactUnavailable, bundle: bundle) - } - return entry.formattedImpact + EnergyImpactPresentation.powerText( + microwatts: entry.displayPowerMicrowatts, + status: entry.status, + bundle: bundle + ) } static func identifierText( diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift index 7e959cb..a338e56 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift @@ -135,47 +135,4 @@ public struct EnergyImpactEntry: Identifiable, Equatable, Sendable { public var displayPowerMicrowatts: Double? { currentPowerMicrowatts } - - @available(*, deprecated, message: "Use the identity/status initializer") - public init( - processIdentifier: pid_t, - name: String, - bundleIdentifier: String?, - bundleURL: URL?, - impact: Double, - isReadable: Bool - ) { - self.init( - identity: EnergyImpactAppIdentity( - rootProcessIdentifier: processIdentifier, - rootProcessStartAbsoluteTime: nil - ), - name: name, - bundleIdentifier: bundleIdentifier, - bundleURL: bundleURL, - currentPowerMicrowatts: isReadable ? impact : nil, - sustainedPowerMicrowatts: nil, - rankingScore: isReadable ? impact : nil, - trend: .steady, - coverage: isReadable - ? EnergyImpactCoverage( - discoveredProcessCount: 1, readableProcessCount: 1, - validProcessSeconds: 0, discoveredProcessSeconds: 0 - ) - : .unavailable, - status: isReadable ? .stable : .unavailable - ) - } - - @available(*, deprecated, message: "Use currentPowerMicrowatts and status") - public var impact: Double { currentPowerMicrowatts ?? 0 } - - @available(*, deprecated, message: "Use status") - public var isReadable: Bool { status != .unavailable } - - @available(*, deprecated, message: "Use EnergyImpactPresentation") - public var formattedImpact: String { - guard let currentPowerMicrowatts else { return "Unavailable" } - return String(format: "%.1f", currentPowerMicrowatts) - } } diff --git a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift index 20d918e..6b0b755 100644 --- a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift @@ -5,22 +5,8 @@ import MacActivityCore @MainActor final class EnergyImpactModelTests: XCTestCase { func testRefreshPrimesAndPublishesFollowUpEnergyImpactSample() async { - let baseline = EnergyImpactEntry( - processIdentifier: 101, - name: "Safari", - bundleIdentifier: "com.apple.Safari", - bundleURL: nil, - impact: 0, - isReadable: true - ) - let ranked = EnergyImpactEntry( - processIdentifier: 101, - name: "Safari", - bundleIdentifier: "com.apple.Safari", - bundleURL: nil, - impact: 8.4, - isReadable: true - ) + let baseline = entry(power: 0) + let ranked = entry(power: 8.4) let provider = EnergyImpactProviderStub(responses: [[baseline], [ranked]]) let model = EnergyImpactModel( provider: provider, @@ -32,7 +18,7 @@ final class EnergyImpactModelTests: XCTestCase { await model.refresh() XCTAssertEqual(model.entries.map(\.name), ["Safari"]) - XCTAssertEqual(model.entries.first?.impact, 8.4) + XCTAssertEqual(model.entries.first?.currentPowerMicrowatts, 8.4) XCTAssertEqual(provider.requestedLimits, [20, 20]) XCTAssertFalse(model.isRefreshing) } @@ -92,31 +78,7 @@ final class EnergyImpactModelTests: XCTestCase { } func testRefreshWhileVisibleRepeatsAfterVisibleRefreshInterval() async { - let baseline = EnergyImpactEntry( - processIdentifier: 101, - name: "Safari", - bundleIdentifier: "com.apple.Safari", - bundleURL: nil, - impact: 0, - isReadable: true - ) - let firstSample = EnergyImpactEntry( - processIdentifier: 101, - name: "Safari", - bundleIdentifier: "com.apple.Safari", - bundleURL: nil, - impact: 3.2, - isReadable: true - ) - let secondSample = EnergyImpactEntry( - processIdentifier: 101, - name: "Safari", - bundleIdentifier: "com.apple.Safari", - bundleURL: nil, - impact: 7.6, - isReadable: true - ) - let provider = EnergyImpactProviderStub(responses: [[baseline], [firstSample], [secondSample]]) + let provider = EnergyImpactProviderStub(responses: [[entry(power: 0)], [entry(power: 3.2)], [entry(power: 7.6)]]) var requestedSleeps: [UInt64] = [] let model = EnergyImpactModel( provider: provider, @@ -132,12 +94,30 @@ final class EnergyImpactModelTests: XCTestCase { await model.refreshWhileVisible(refreshIntervalNanoseconds: 3) - XCTAssertEqual(model.entries.map(\.impact), [7.6]) + XCTAssertEqual(model.entries.map(\.currentPowerMicrowatts), [7.6]) XCTAssertEqual(provider.requestedLimits, [20, 20, 20]) XCTAssertEqual(requestedSleeps, [1, 3, 3]) XCTAssertFalse(model.isRefreshing) XCTAssertEqual(provider.topApps(limit: 20), []) } + + private func entry(power: Double) -> EnergyImpactEntry { + EnergyImpactEntry( + identity: EnergyImpactAppIdentity( + rootProcessIdentifier: 101, + rootProcessStartAbsoluteTime: 1 + ), + name: "Safari", + bundleIdentifier: "com.apple.Safari", + bundleURL: nil, + currentPowerMicrowatts: power, + sustainedPowerMicrowatts: power, + rankingScore: power, + trend: .steady, + coverage: .unavailable, + status: .stable + ) + } } @MainActor diff --git a/Tests/MacActivityAppTests/EnergyImpactPresentationTests.swift b/Tests/MacActivityAppTests/EnergyImpactPresentationTests.swift new file mode 100644 index 0000000..33fdc29 --- /dev/null +++ b/Tests/MacActivityAppTests/EnergyImpactPresentationTests.swift @@ -0,0 +1,57 @@ +import XCTest +import MacActivityCore +@testable import MacActivityApp + +final class EnergyImpactPresentationTests: XCTestCase { + func testPowerTextUsesMicrowattsBelowOneMilliwatt() { + XCTAssertEqual( + EnergyImpactPresentation.powerText( + microwatts: 860, + locale: Locale(identifier: "en_US") + ), + "860 µW" + ) + } + + func testPowerTextUsesMilliwattsAtOneThousandMicrowatts() { + XCTAssertEqual( + EnergyImpactPresentation.powerText( + microwatts: 1_840, + locale: Locale(identifier: "en_US") + ), + "1.8 mW" + ) + } + + func testCollectingAndUnavailableDoNotRenderZero() { + XCTAssertEqual( + EnergyImpactPresentation.powerText( + microwatts: nil, + status: .collecting, + bundle: AppLocalization.bundle(forLanguageIdentifier: "en") + ), + "Collecting" + ) + XCTAssertEqual( + EnergyImpactPresentation.powerText( + microwatts: nil, + status: .unavailable, + bundle: AppLocalization.bundle(forLanguageIdentifier: "en") + ), + "Unavailable" + ) + } + + func testInvalidPowerValuesNeverRenderZeroMicrowatts() { + for value in [Double.nan, .infinity, -.infinity, -1] { + let text = EnergyImpactPresentation.powerText( + microwatts: value, + status: .unavailable, + bundle: AppLocalization.bundle(forLanguageIdentifier: "en") + ) + + XCTAssertNotEqual(text, "0 µW", "Invalid value \(value) must not appear as zero") + XCTAssertEqual(text, "Unavailable") + } + } +} diff --git a/Tests/MacActivityAppTests/EnergyImpactViewTests.swift b/Tests/MacActivityAppTests/EnergyImpactViewTests.swift index a163dc6..d5a7f3e 100644 --- a/Tests/MacActivityAppTests/EnergyImpactViewTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactViewTests.swift @@ -10,27 +10,32 @@ final class EnergyImpactViewTests: XCTestCase { AppLocalization.bundle(forLanguageIdentifier: "en")! } - func testEnergyImpactRowShowsFormattedImpact() { - let entry = EnergyImpactEntry( - processIdentifier: 101, - name: "Safari", - bundleIdentifier: "com.apple.Safari", - bundleURL: nil, - impact: 7.4, - isReadable: true + func testEnergyImpactRowShowsLocalizedPowerText() { + XCTAssertEqual( + EnergyImpactRow.trailingText(for: entry(power: 1_840), bundle: Self.englishBundle), + "1.8 mW" ) + } - XCTAssertEqual(EnergyImpactRow.trailingText(for: entry, bundle: Self.englishBundle), "7.4") + func testEnergyImpactRowDoesNotRenderCollectingOrUnavailableAsZero() { + XCTAssertEqual( + EnergyImpactRow.trailingText(for: entry(power: nil, status: .collecting), bundle: Self.englishBundle), + "Collecting" + ) + XCTAssertEqual( + EnergyImpactRow.trailingText(for: entry(power: nil, status: .unavailable), bundle: Self.englishBundle), + "Unavailable" + ) } - func testEnergyImpactViewShowsCollectingMessageWhileRefreshingWithoutRows() { + func testEnergyImpactViewShowsLocalizedEmptyMessage() { XCTAssertEqual( EnergyImpactView.emptyMessage(isRefreshing: true, bundle: Self.englishBundle), "Waiting for the first sample" ) XCTAssertEqual( EnergyImpactView.emptyMessage(isRefreshing: false, bundle: Self.englishBundle), - "No foreground apps are reporting energy impact." + "No regular apps are reporting an energy estimate." ) } @@ -38,7 +43,7 @@ final class EnergyImpactViewTests: XCTestCase { XCTAssertEqual(EnergyImpactView.visibleRefreshIntervalNanoseconds, 3_000_000_000) } - func testRenderedEnergyImpactViewShowsEmptyState() { + func testRenderedEnergyImpactViewShowsEmptyStateAtFourHundredTwentyPoints() { let model = EnergyImpactModel( provider: EnergyImpactViewProviderStub(responses: []), samplingDelayNanoseconds: 1, @@ -50,111 +55,69 @@ final class EnergyImpactViewTests: XCTestCase { refreshTrigger: 0, showsApplicationIdentifier: true ) - .frame(width: 360, height: 80) + .environment(\.locale, Locale(identifier: "en")) + .frame(width: 420, height: 120) ) renderer.scale = 1 XCTAssertNotNil(renderer.nsImage) } - func testRenderedEnergyImpactViewShowsEnergyRows() async { - let readableEntry = EnergyImpactEntry( - processIdentifier: 201, - name: "Safari", - bundleIdentifier: "com.apple.Safari", - bundleURL: nil, - impact: 8.4, - isReadable: true - ) - let unreadableEntry = EnergyImpactEntry( - processIdentifier: 202, - name: "Protected App", - bundleIdentifier: nil, - bundleURL: nil, - impact: 0, - isReadable: false - ) + func testRenderedEnergyImpactViewShowsEnglishAndSimplifiedChineseRowsAtFourHundredTwentyPoints() async { + defer { AppLocalization.setPreferredLanguageIdentifier(nil) } var sleepCount = 0 let model = EnergyImpactModel( - provider: EnergyImpactViewProviderStub(responses: [[readableEntry], [readableEntry, unreadableEntry]]), + provider: EnergyImpactViewProviderStub(responses: [[], [entry(power: 1_840)], []]), samplingDelayNanoseconds: 1, sleep: { _ in sleepCount += 1 guard sleepCount == 1 else { throw CancellationError() } } ) - await model.refresh() - let renderer = ImageRenderer( - content: EnergyImpactView( - model: model, - refreshTrigger: 0, - showsApplicationIdentifier: true + for localeIdentifier in ["en", "zh-Hans"] { + AppLocalization.setPreferredLanguageIdentifier(localeIdentifier) + let renderer = ImageRenderer( + content: EnergyImpactView( + model: model, + refreshTrigger: 0, + showsApplicationIdentifier: true + ) + .frame(width: 420, height: 160) ) - .frame(width: 360, height: 120) - ) - renderer.scale = 1 + renderer.scale = 1 - XCTAssertNotNil(renderer.nsImage) + XCTAssertNotNil(renderer.nsImage, localeIdentifier) + } } - func testRenderedEnergyImpactRowUsesBundleIcon() { - let entry = EnergyImpactEntry( - processIdentifier: 203, - name: "Test Host", - bundleIdentifier: Bundle.main.bundleIdentifier, - bundleURL: Bundle.main.bundleURL, - impact: 2.5, - isReadable: true - ) + func testRenderedEnergyImpactRowPreservesBundleIconAndApplicationIdentifier() { let renderer = ImageRenderer( content: EnergyImpactRow( - entry: entry, - maximumImpact: 5, + entry: entry(bundleURL: Bundle.main.bundleURL), + rank: 1, showsApplicationIdentifier: true ) - .frame(width: 360, height: ActiveProcessMemoryLayout.rowHeight) + .frame(width: 420, height: ActiveProcessMemoryLayout.rowHeight) ) renderer.scale = 1 XCTAssertNotNil(renderer.nsImage) } - func testEnergyImpactRowShowsUnavailableWhenUnreadable() { - let entry = EnergyImpactEntry( - processIdentifier: 102, - name: "Protected App", - bundleIdentifier: nil, - bundleURL: nil, - impact: 0, - isReadable: false - ) - - XCTAssertEqual(EnergyImpactRow.trailingText(for: entry, bundle: Self.englishBundle), "Unavailable") - } - func testEnergyImpactRowIdentifierCanBeHidden() { - let entry = EnergyImpactEntry( - processIdentifier: 103, - name: "Notes", - bundleIdentifier: "com.apple.Notes", - bundleURL: nil, - impact: 1, - isReadable: true - ) - XCTAssertEqual( EnergyImpactRow.identifierText( - for: entry, + for: entry(), showsApplicationIdentifier: true, bundle: Self.englishBundle ), - "com.apple.Notes" + "com.apple.Safari" ) XCTAssertNil( EnergyImpactRow.identifierText( - for: entry, + for: entry(), showsApplicationIdentifier: false, bundle: Self.englishBundle ) @@ -163,87 +126,53 @@ final class EnergyImpactViewTests: XCTestCase { func testEnergyImpactRowUsesBundleIconWhenBundleExists() { let bundleURL = URL(fileURLWithPath: "/Applications/Safari.app") - let entry = EnergyImpactEntry( - processIdentifier: 104, - name: "Safari", - bundleIdentifier: "com.apple.Safari", - bundleURL: bundleURL, - impact: 3.1, - isReadable: true - ) XCTAssertEqual( - EnergyImpactRow.iconSource(for: entry, fileExists: { _ in true }), + EnergyImpactRow.iconSource(for: entry(bundleURL: bundleURL), fileExists: { _ in true }), .bundle(bundleURL) ) } func testEnergyImpactRowFallsBackToSystemIconWhenBundleMissing() { - let entry = EnergyImpactEntry( - processIdentifier: 105, - name: "Unknown", - bundleIdentifier: nil, - bundleURL: URL(fileURLWithPath: "/Applications/Missing.app"), - impact: 0.2, - isReadable: true - ) - XCTAssertEqual( - EnergyImpactRow.iconSource(for: entry, fileExists: { _ in false }), + EnergyImpactRow.iconSource(for: entry(bundleURL: URL(fileURLWithPath: "/Applications/Missing.app")), fileExists: { _ in false }), .fallbackSystemSymbol ) } - func testEnergyImpactRowProgressFractionUsesReadableMaximumImpact() { - let entry = EnergyImpactEntry( - processIdentifier: 106, - name: "Safari", - bundleIdentifier: "com.apple.Safari", - bundleURL: nil, - impact: 3, - isReadable: true - ) - + func testEnergyImpactRowAccessibilityIncludesOneBasedRankAndPowerText() { XCTAssertEqual( - EnergyImpactRow.progressFraction(for: entry, maximumImpact: 6), - 0.5, - accuracy: 0.001 + EnergyImpactPresentation.accessibilityLabel( + entry: entry(name: "Safari", power: 860), + rank: 2, + bundle: Self.englishBundle + ), + "Safari, rank 2, 860 µW" ) } - func testEnergyImpactRowProgressFractionIsZeroForUnreadableOrZeroMaximum() { - let unreadableEntry = EnergyImpactEntry( - processIdentifier: 107, - name: "Protected App", - bundleIdentifier: nil, - bundleURL: nil, - impact: 8, - isReadable: false - ) - let readableEntry = EnergyImpactEntry( - processIdentifier: 108, - name: "Notes", - bundleIdentifier: "com.apple.Notes", - bundleURL: nil, - impact: 4, - isReadable: true - ) - - XCTAssertEqual(EnergyImpactRow.progressFraction(for: unreadableEntry, maximumImpact: 8), 0) - XCTAssertEqual(EnergyImpactRow.progressFraction(for: readableEntry, maximumImpact: 0), 0) - } - - func testEnergyImpactRowProgressFractionClampsAtOne() { - let entry = EnergyImpactEntry( - processIdentifier: 109, - name: "Xcode", - bundleIdentifier: "com.apple.dt.Xcode", - bundleURL: nil, - impact: 12, - isReadable: true + private func entry( + processIdentifier: pid_t = 101, + name: String = "Safari", + bundleURL: URL? = nil, + power: Double? = 860, + status: EnergyImpactStatus = .stable + ) -> EnergyImpactEntry { + EnergyImpactEntry( + identity: EnergyImpactAppIdentity( + rootProcessIdentifier: processIdentifier, + rootProcessStartAbsoluteTime: 1 + ), + name: name, + bundleIdentifier: "com.apple.Safari", + bundleURL: bundleURL, + currentPowerMicrowatts: power, + sustainedPowerMicrowatts: power, + rankingScore: power, + trend: .steady, + coverage: .unavailable, + status: status ) - - XCTAssertEqual(EnergyImpactRow.progressFraction(for: entry, maximumImpact: 6), 1) } } diff --git a/Tests/MacActivityAppTests/LocalizationTests.swift b/Tests/MacActivityAppTests/LocalizationTests.swift index ab241d7..b8ada4d 100644 --- a/Tests/MacActivityAppTests/LocalizationTests.swift +++ b/Tests/MacActivityAppTests/LocalizationTests.swift @@ -310,35 +310,23 @@ final class LocalizationTests: XCTestCase { } func testEnergyImpactPageStringsAreLocalized() throws { - let english = try XCTUnwrap(AppLocalization.bundle(forLanguageIdentifier: "en")) - - XCTAssertEqual( - AppLocalization.string(.dashboardTabEnergyImpact, bundle: english), - "Energy" - ) - XCTAssertEqual( - AppLocalization.string(.energyImpactEmpty, bundle: english), - "No foreground apps are reporting energy impact." - ) - XCTAssertEqual( - AppLocalization.string(.energyImpactUnavailable, bundle: english), - "Unavailable" - ) - - let localizedExpectations = [ - ("de", "Energie", "Keine Vordergrund-Apps melden Energieauswirkungen.", "Nicht verfügbar"), - ("fr", "Énergie", "Aucune app au premier plan ne signale d’impact énergétique.", "Indisponible"), - ("ja", "エネルギー", "エネルギー影響を報告している前面アプリはありません。", "利用不可"), - ("ko", "에너지", "에너지 영향을 보고하는 전면 앱이 없습니다.", "사용할 수 없음"), - ("zh-Hans", "耗电影响", "当前没有前台应用报告耗电影响。", "不可读取"), - ("zh-Hant", "耗電影響", "目前沒有前景應用程式回報耗電影響。", "無法讀取"), + let expectations: [String: [AppLocalization.Key: String]] = [ + "en": [.energyImpactTitle: "Energy Impact", .energyImpactSubtitleCurrent: "Recent CPU energy estimate · Lower is better", .energyImpactAppColumn: "App", .energyImpactCurrentColumn: "Current", .energyImpactCollecting: "Collecting", .energyImpactPartial: "Partial", .energyImpactStale: "Stale", .energyImpactRowAccessibility: "%1$@, rank %2$lld, %3$@", .energyImpactEmpty: "No regular apps are reporting an energy estimate.", .energyImpactUnavailable: "Unavailable", .preferencesProcessApplicationIdentifier: "Show application ID in process lists"], + "de": [.energyImpactTitle: "Energieeinfluss", .energyImpactSubtitleCurrent: "Aktuelle CPU-Energieschätzung · Niedriger ist besser", .energyImpactAppColumn: "App", .energyImpactCurrentColumn: "Aktuell", .energyImpactCollecting: "Wird erfasst", .energyImpactPartial: "Teilweise", .energyImpactStale: "Veraltet", .energyImpactRowAccessibility: "%1$@, Rang %2$lld, %3$@", .energyImpactEmpty: "Keine regulären Apps melden eine Energieschätzung.", .energyImpactUnavailable: "Nicht verfügbar", .preferencesProcessApplicationIdentifier: "App-ID in Prozesslisten anzeigen"], + "fr": [.energyImpactTitle: "Impact énergétique", .energyImpactSubtitleCurrent: "Estimation récente de l’énergie CPU · Plus bas est préférable", .energyImpactAppColumn: "App", .energyImpactCurrentColumn: "Actuel", .energyImpactCollecting: "Collecte", .energyImpactPartial: "Partiel", .energyImpactStale: "Obsolète", .energyImpactRowAccessibility: "%1$@, rang %2$lld, %3$@", .energyImpactEmpty: "Aucune app standard ne fournit d’estimation énergétique.", .energyImpactUnavailable: "Indisponible", .preferencesProcessApplicationIdentifier: "Afficher l’identifiant d’app dans les listes de processus"], + "ja": [.energyImpactTitle: "エネルギー影響", .energyImpactSubtitleCurrent: "最近のCPUエネルギー推定 · 低いほど良好", .energyImpactAppColumn: "アプリ", .energyImpactCurrentColumn: "現在", .energyImpactCollecting: "収集中", .energyImpactPartial: "一部データ", .energyImpactStale: "古いデータ", .energyImpactRowAccessibility: "%1$@、%2$lld位、%3$@", .energyImpactEmpty: "エネルギー推定を報告している通常のアプリはありません。", .energyImpactUnavailable: "利用不可", .preferencesProcessApplicationIdentifier: "プロセスリストにアプリIDを表示"], + "ko": [.energyImpactTitle: "에너지 영향", .energyImpactSubtitleCurrent: "최근 CPU 에너지 추정치 · 낮을수록 좋음", .energyImpactAppColumn: "앱", .energyImpactCurrentColumn: "현재", .energyImpactCollecting: "수집 중", .energyImpactPartial: "일부 데이터", .energyImpactStale: "오래된 데이터", .energyImpactRowAccessibility: "%1$@, %2$lld위, %3$@", .energyImpactEmpty: "에너지 추정치를 보고하는 일반 앱이 없습니다.", .energyImpactUnavailable: "사용할 수 없음", .preferencesProcessApplicationIdentifier: "프로세스 목록에 앱 ID 표시"], + "zh-Hans": [.energyImpactTitle: "耗电影响", .energyImpactSubtitleCurrent: "近期 CPU 能耗估算 · 越低越好", .energyImpactAppColumn: "应用", .energyImpactCurrentColumn: "当前", .energyImpactCollecting: "采集中", .energyImpactPartial: "部分数据", .energyImpactStale: "数据已过期", .energyImpactRowAccessibility: "%1$@,第 %2$lld 名,%3$@", .energyImpactEmpty: "当前没有普通应用报告能耗估算。", .energyImpactUnavailable: "不可读取", .preferencesProcessApplicationIdentifier: "在进程列表中显示应用 ID"], + "zh-Hant": [.energyImpactTitle: "耗電影響", .energyImpactSubtitleCurrent: "近期 CPU 能耗估算 · 越低越好", .energyImpactAppColumn: "應用程式", .energyImpactCurrentColumn: "目前", .energyImpactCollecting: "收集中", .energyImpactPartial: "部分資料", .energyImpactStale: "資料已過期", .energyImpactRowAccessibility: "%1$@,第 %2$lld 名,%3$@", .energyImpactEmpty: "目前沒有一般應用程式回報能耗估算。", .energyImpactUnavailable: "無法讀取", .preferencesProcessApplicationIdentifier: "在程序列表中顯示應用程式 ID"], ] - for (identifier, title, empty, unavailable) in localizedExpectations { + XCTAssertEqual(Set(expectations.keys), Set(AppLocalization.availableLanguageIdentifiers())) + + for (identifier, expectedStrings) in expectations { let bundle = try XCTUnwrap(AppLocalization.bundle(forLanguageIdentifier: identifier)) - XCTAssertEqual(AppLocalization.string(.dashboardTabEnergyImpact, bundle: bundle), title) - XCTAssertEqual(AppLocalization.string(.energyImpactEmpty, bundle: bundle), empty) - XCTAssertEqual(AppLocalization.string(.energyImpactUnavailable, bundle: bundle), unavailable) + for (key, expected) in expectedStrings { + XCTAssertEqual(AppLocalization.string(key, bundle: bundle), expected, "\(key.rawValue) in \(identifier)") + } } } @@ -472,11 +460,11 @@ final class LocalizationTests: XCTestCase { ) XCTAssertEqual( AppLocalization.string(.preferencesProcessApplicationIdentifier, bundle: english), - "Show application ID in Actives process list" + "Show application ID in process lists" ) XCTAssertEqual( AppLocalization.string(.preferencesProcessApplicationIdentifier, bundle: simplifiedChinese), - "在“活跃”进程列表中显示应用 ID" + "在进程列表中显示应用 ID" ) XCTAssertEqual(AppLocalization.string(.preferencesUpdateChannel, bundle: english), "Update channel") XCTAssertEqual(AppLocalization.string(.preferencesUpdateChannel, bundle: simplifiedChinese), "选择更新频道") diff --git a/Tests/MacActivityAppTests/PreferencesViewTests.swift b/Tests/MacActivityAppTests/PreferencesViewTests.swift index 7c52a26..ef91873 100644 --- a/Tests/MacActivityAppTests/PreferencesViewTests.swift +++ b/Tests/MacActivityAppTests/PreferencesViewTests.swift @@ -149,6 +149,15 @@ final class PreferencesViewTests: XCTestCase { XCTAssertFalse(String(describing: type(of: view.body)).isEmpty) } + func testProcessApplicationIdentifierPreferenceUsesSharedProcessListCopy() { + let english = AppLocalization.bundle(forLanguageIdentifier: "en")! + + XCTAssertEqual( + AppLocalization.string(.preferencesProcessApplicationIdentifier, bundle: english), + "Show application ID in process lists" + ) + } + func testPreferencesWindowControllerHostsPreferencesView() { let controller = PreferencesController( store: InMemoryPreferencesStore(initial: .default), From 083bc5ac1d2bfc0db9b3d0ff1cd72e2bd01e4fe4 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 24 Jul 2026 18:28:18 +1000 Subject: [PATCH 04/13] test(energy): isolate localized rendering fixture --- .../EnergyImpactViewTests.swift | 55 +++++++++++++++++-- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/Tests/MacActivityAppTests/EnergyImpactViewTests.swift b/Tests/MacActivityAppTests/EnergyImpactViewTests.swift index d5a7f3e..2b5c87a 100644 --- a/Tests/MacActivityAppTests/EnergyImpactViewTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactViewTests.swift @@ -63,11 +63,23 @@ final class EnergyImpactViewTests: XCTestCase { XCTAssertNotNil(renderer.nsImage) } - func testRenderedEnergyImpactViewShowsEnglishAndSimplifiedChineseRowsAtFourHundredTwentyPoints() async { - defer { AppLocalization.setPreferredLanguageIdentifier(nil) } + func testRenderedEnergyImpactViewShowsLocalizedContentAtFourHundredTwentyPointsAndRestoresPreferredLanguageOverride() async { + let initialPreferredLanguageIdentifier = AppLocalization.explicitPreferredLanguageIdentifier() + defer { AppLocalization.setPreferredLanguageIdentifier(initialPreferredLanguageIdentifier) } + + AppLocalization.setPreferredLanguageIdentifier("fr") + await assertLocalizedEnergyImpactViewRendersAtFourHundredTwentyPoints() + + XCTAssertEqual(AppLocalization.explicitPreferredLanguageIdentifier(), "fr") + } + + private func assertLocalizedEnergyImpactViewRendersAtFourHundredTwentyPoints() async { + let preferredLanguageIdentifier = AppLocalization.explicitPreferredLanguageIdentifier() + defer { AppLocalization.setPreferredLanguageIdentifier(preferredLanguageIdentifier) } var sleepCount = 0 + let renderedEntry = entry(power: 1_840) let model = EnergyImpactModel( - provider: EnergyImpactViewProviderStub(responses: [[], [entry(power: 1_840)], []]), + provider: EnergyImpactViewProviderStub(responses: [[], [renderedEntry], []]), samplingDelayNanoseconds: 1, sleep: { _ in sleepCount += 1 @@ -76,8 +88,39 @@ final class EnergyImpactViewTests: XCTestCase { ) await model.refresh() - for localeIdentifier in ["en", "zh-Hans"] { - AppLocalization.setPreferredLanguageIdentifier(localeIdentifier) + let expectations: [( + languageIdentifier: String, + title: String, + subtitle: String, + currentLabel: String, + accessibilityLabel: String + )] = [ + ( + "en", + "Energy Impact", + "Recent CPU energy estimate · Lower is better", + "Current", + "Safari, rank 1, 1.8 mW" + ), + ( + "zh-Hans", + "耗电影响", + "近期 CPU 能耗估算 · 越低越好", + "当前", + "Safari,第 1 名,1.8 mW" + ) + ] + + for expectation in expectations { + AppLocalization.setPreferredLanguageIdentifier(expectation.languageIdentifier) + XCTAssertEqual(AppLocalization.string(.energyImpactTitle), expectation.title) + XCTAssertEqual(AppLocalization.string(.energyImpactSubtitleCurrent), expectation.subtitle) + XCTAssertEqual(AppLocalization.string(.energyImpactCurrentColumn), expectation.currentLabel) + XCTAssertEqual( + EnergyImpactPresentation.accessibilityLabel(entry: renderedEntry, rank: 1), + expectation.accessibilityLabel + ) + let renderer = ImageRenderer( content: EnergyImpactView( model: model, @@ -88,7 +131,7 @@ final class EnergyImpactViewTests: XCTestCase { ) renderer.scale = 1 - XCTAssertNotNil(renderer.nsImage, localeIdentifier) + XCTAssertNotNil(renderer.nsImage, expectation.languageIdentifier) } } From 422aec5b866950faa3230a2c11e6bc500d131164 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Mon, 27 Jul 2026 00:21:51 +1000 Subject: [PATCH 05/13] fix(energy): use monotonic sample timing --- .../Providers/EnergyImpactProvider.swift | 20 +++-- .../Metrics/Providers/EnergyImpactTypes.swift | 46 +++++++++++ .../EnergyImpactProviderTests.swift | 82 ++++++++++++++++--- 3 files changed, 131 insertions(+), 17 deletions(-) diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift index fb97a3f..bf36c3c 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift @@ -64,7 +64,7 @@ public final class EnergyImpactService { private let reader: any ProcessEnergyReadingProvider private let processSnapshotReader: any ProcessMemorySnapshotReading private let appSnapshotProvider: () -> [EnergyImpactAppSnapshot] - private let now: () -> Date + private let clock: any EnergyImpactClock private var previousReadings: [EnergyImpactProcessIdentity: TimedProcessEnergyReading] = [:] public init( @@ -72,11 +72,11 @@ public final class EnergyImpactService { reader: any ProcessEnergyReadingProvider = SystemProcessEnergyReader(), processSnapshotReader: any ProcessMemorySnapshotReading = SystemProcessMemorySnapshotReader(), appSnapshotProvider: (() -> [EnergyImpactAppSnapshot])? = nil, - now: @escaping () -> Date = { Date() } + clock: any EnergyImpactClock = SystemEnergyImpactClock() ) { self.reader = reader self.processSnapshotReader = processSnapshotReader - self.now = now + self.clock = clock self.appSnapshotProvider = appSnapshotProvider ?? { workspace.runningApplications .filter { $0.activationPolicy == .regular } @@ -93,7 +93,7 @@ public final class EnergyImpactService { public func topApps(limit: Int = 20) -> [EnergyImpactEntry] { let apps = appSnapshotProvider() - let sampleTime = now().timeIntervalSinceReferenceDate + let sampleTime = clock.nowSeconds() let processIdentifiersByRoot = Self.processIdentifiersByRoot( rootProcessIdentifiers: apps.map(\.processIdentifier), snapshots: processSnapshotReader.snapshots() @@ -121,7 +121,12 @@ public final class EnergyImpactService { sampleTime: sampleTime ) if let previous = previousReadings[identity], - let impactRate = Self.impactRate(from: previous, to: current, sampleTime: sampleTime) { + let impactRate = Self.impactRate( + from: previous, + to: current, + sampleTime: sampleTime, + maximumGapSeconds: EnergyImpactConfiguration.production.maximumGapSeconds + ) { totalPowerMicrowatts += impactRate validDeltaCount += 1 } @@ -212,14 +217,15 @@ public final class EnergyImpactService { private nonisolated static func impactRate( from previous: TimedProcessEnergyReading, to current: ProcessEnergyReading, - sampleTime: TimeInterval + sampleTime: TimeInterval, + maximumGapSeconds: TimeInterval ) -> Double? { guard current.processStartAbsoluteTime == previous.reading.processStartAbsoluteTime, current.energyNanojoules >= previous.reading.energyNanojoules else { return nil } let elapsedSeconds = sampleTime - previous.sampleTime - guard elapsedSeconds > 0 else { return nil } + guard elapsedSeconds > 0, elapsedSeconds <= maximumGapSeconds else { return nil } let deltaMicrojoules = Double(current.energyNanojoules - previous.reading.energyNanojoules) / 1_000.0 return deltaMicrojoules / elapsedSeconds } diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift index a338e56..96ad0d7 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift @@ -136,3 +136,49 @@ public struct EnergyImpactEntry: Identifiable, Equatable, Sendable { currentPowerMicrowatts } } + +public protocol EnergyImpactClock: Sendable { + func nowSeconds() -> TimeInterval +} + +public struct SystemEnergyImpactClock: EnergyImpactClock { + private static let timebase: mach_timebase_info_data_t = { + var info = mach_timebase_info_data_t() + mach_timebase_info(&info) + return info + }() + + public init() {} + + public func nowSeconds() -> TimeInterval { + let ticks = mach_continuous_time() + let nanoseconds = Double(ticks) + * Double(Self.timebase.numer) + / Double(Self.timebase.denom) + return nanoseconds / 1_000_000_000 + } +} + +public struct EnergyImpactConfiguration: Equatable, Sendable { + public let sampleIntervalSeconds: TimeInterval + public let publicationIntervalSeconds: TimeInterval + public let maximumGapSeconds: TimeInterval + public let fastHalfLifeSeconds: TimeInterval + public let sustainedWindowSeconds: TimeInterval + + public init( + sampleIntervalSeconds: TimeInterval = 1, + publicationIntervalSeconds: TimeInterval = 3, + maximumGapSeconds: TimeInterval = 10, + fastHalfLifeSeconds: TimeInterval = 4, + sustainedWindowSeconds: TimeInterval = 30 + ) { + self.sampleIntervalSeconds = sampleIntervalSeconds + self.publicationIntervalSeconds = publicationIntervalSeconds + self.maximumGapSeconds = maximumGapSeconds + self.fastHalfLifeSeconds = fastHalfLifeSeconds + self.sustainedWindowSeconds = sustainedWindowSeconds + } + + public static let production = EnergyImpactConfiguration() +} diff --git a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift index 2215fb6..37cc9d9 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift @@ -74,7 +74,7 @@ final class EnergyImpactProviderTests: XCTestCase { .init(processIdentifier: 101, parentProcessIdentifier: 100, residentMemoryBytes: 0), ]), appSnapshotProvider: { [app] }, - now: dateSequence([100, 101]) + clock: EnergyImpactClockStub(times: [100, 101]) ) _ = service.topApps(limit: 1) @@ -93,6 +93,14 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertGreaterThan(reading.processStartAbsoluteTime, 0) } + func testSystemEnergyImpactClockProvidesMonotonicSeconds() { + let clock = SystemEnergyImpactClock() + let first = clock.nowSeconds() + + XCTAssertGreaterThan(first, 0) + XCTAssertGreaterThanOrEqual(clock.nowSeconds(), first) + } + func testDefaultWorkspaceSnapshotProviderBuildsEntriesFromRunningApplications() { let service = EnergyImpactService( reader: ProcessEnergyReadingProviderStub(readings: [:]), @@ -132,7 +140,7 @@ final class EnergyImpactProviderTests: XCTestCase { reader: reader, processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: []), appSnapshotProvider: { apps }, - now: dateSequence([100, 101]) + clock: EnergyImpactClockStub(times: [100, 101]) ) let firstEntries = service.topApps(limit: 2) @@ -164,7 +172,7 @@ final class EnergyImpactProviderTests: XCTestCase { reader: reader, processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: []), appSnapshotProvider: { [app] }, - now: dateSequence([100, 100.5]) + clock: EnergyImpactClockStub(times: [100, 100.5]) ) _ = service.topApps(limit: 1) @@ -203,7 +211,7 @@ final class EnergyImpactProviderTests: XCTestCase { ProcessMemorySnapshot(processIdentifier: 999, parentProcessIdentifier: 1, residentMemoryBytes: 0), ]), appSnapshotProvider: { [app] }, - now: dateSequence([100, 102]) + clock: EnergyImpactClockStub(times: [100, 102]) ) _ = service.topApps(limit: 1) @@ -233,7 +241,7 @@ final class EnergyImpactProviderTests: XCTestCase { reader: reader, processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: []), appSnapshotProvider: { [app] }, - now: dateSequence([100, 101]) + clock: EnergyImpactClockStub(times: [100, 101]) ) _ = service.topApps(limit: 1) @@ -243,6 +251,54 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertNil(entries.first?.currentPowerMicrowatts) } + func testLongGapRebaselinesInsteadOfPublishingADilutedValue() { + let clock = EnergyImpactClockStub(times: [0, 3, 20]) + let service = EnergyImpactService( + reader: ProcessEnergyReadingProviderStub(readings: [ + 101: [ + .init(energyNanojoules: 1_000, processStartAbsoluteTime: 10), + .init(energyNanojoules: 4_000, processStartAbsoluteTime: 10), + .init(energyNanojoules: 10_000, processStartAbsoluteTime: 10), + ], + ]), + processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: []), + appSnapshotProvider: { [ + .init(processIdentifier: 101, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: clock + ) + + _ = service.topApps(limit: 1) + XCTAssertEqual(service.topApps(limit: 1).first?.currentPowerMicrowatts ?? -1, 1) + let afterGap = service.topApps(limit: 1).first + + XCTAssertNil(afterGap?.currentPowerMicrowatts) + XCTAssertEqual(afterGap?.status, .collecting) + } + + func testClockRollbackCannotProduceANegativeOrInfinitePower() { + let clock = EnergyImpactClockStub(times: [3, 2]) + let service = EnergyImpactService( + reader: ProcessEnergyReadingProviderStub(readings: [ + 101: [ + .init(energyNanojoules: 1_000, processStartAbsoluteTime: 10), + .init(energyNanojoules: 5_000, processStartAbsoluteTime: 10), + ], + ]), + processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: []), + appSnapshotProvider: { [ + .init(processIdentifier: 101, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: clock + ) + + _ = service.topApps(limit: 1) + let entry = service.topApps(limit: 1).first + + XCTAssertNil(entry?.currentPowerMicrowatts) + XCTAssertEqual(entry?.status, .collecting) + } + func testEnergyImpactServiceKeepsUnreadableAppsAsUnavailableRows() { let reader = ProcessEnergyReadingProviderStub(readings: [:]) let service = EnergyImpactService( @@ -305,11 +361,17 @@ final class EnergyImpactProviderTests: XCTestCase { } } -private func dateSequence(_ offsets: [TimeInterval]) -> () -> Date { - var remainingOffsets = offsets - return { - let offset = remainingOffsets.isEmpty ? offsets.last ?? 0 : remainingOffsets.removeFirst() - return Date(timeIntervalSinceReferenceDate: offset) +private final class EnergyImpactClockStub: EnergyImpactClock, @unchecked Sendable { + private let lock = NSLock() + private var times: [TimeInterval] + + init(times: [TimeInterval]) { self.times = times } + + func nowSeconds() -> TimeInterval { + lock.lock() + defer { lock.unlock() } + precondition(times.isEmpty == false, "Clock fixture exhausted") + return times.removeFirst() } } From 3a3208757be9ba22294957833d06fff42dae5c91 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Mon, 27 Jul 2026 00:39:17 +1000 Subject: [PATCH 06/13] fix(energy): stabilize visible sampling --- MacActivity.xcodeproj/project.pbxproj | 12 + .../Models/EnergyImpactModel.swift | 152 +++++++- .../Providers/EnergyImpactStatistics.swift | 194 ++++++++++ .../EnergyImpactModelTests.swift | 356 ++++++++++++++---- .../EnergyImpactViewTests.swift | 4 +- .../EnergyImpactStatisticsTests.swift | 156 ++++++++ .../EnergyImpactTraceReplayTests.swift | 78 ++++ 7 files changed, 877 insertions(+), 75 deletions(-) create mode 100644 Sources/MacActivityCore/Metrics/Providers/EnergyImpactStatistics.swift create mode 100644 Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift create mode 100644 Tests/MacActivityCoreTests/EnergyImpactTraceReplayTests.swift diff --git a/MacActivity.xcodeproj/project.pbxproj b/MacActivity.xcodeproj/project.pbxproj index 10f88e2..a9ce99d 100644 --- a/MacActivity.xcodeproj/project.pbxproj +++ b/MacActivity.xcodeproj/project.pbxproj @@ -25,6 +25,7 @@ 17C8C8D688BB94F85DBDE1DC /* CPUProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F80A7CF6CB9E39D7858C692 /* CPUProvider.swift */; }; 1980CBF18D396837A7F5647C /* AudioControlComponentTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A8B0908BC77FCB252557723D /* AudioControlComponentTests.swift */; }; 1AA15D8699BC3962C6A808DE /* AudioFeatureAvailability.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E4B084B5C8220E7D26CBC42 /* AudioFeatureAvailability.swift */; }; + 1B0012B1804C865A55F713DA /* EnergyImpactTraceReplayTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B66490C62BF6EE5944B75C67 /* EnergyImpactTraceReplayTests.swift */; }; 1B3A5C1A4385179E39C90CDF /* DashboardModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 41333E534CBDB52CD351763E /* DashboardModel.swift */; }; 1E841E980F91E6BD85A42803 /* AudioSystemMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACFADB8DB7FD273E04239957 /* AudioSystemMonitorTests.swift */; }; 203728ECA31C7E130830CBD3 /* AudioDashboardModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E5EB0BFFA6A7936E4EAD230 /* AudioDashboardModelTests.swift */; }; @@ -39,6 +40,7 @@ 2B711103EEAA3A3737FE4207 /* AudioProcessProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 739D6F2737A0369CFC15403E /* AudioProcessProfile.swift */; }; 2CEB231CDBBCDDF36F125107 /* MetricsSchedulerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 091371EEC06035449AAFB391 /* MetricsSchedulerTests.swift */; }; 2D2E950DB64441F652DDBF7D /* ActiveCleanReleaseLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B9D95E670963442630FDF /* ActiveCleanReleaseLayout.swift */; }; + 2F976168C40F4D4D3D54A629 /* EnergyImpactStatistics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1C6E6EE79DD4E13EB9F43826 /* EnergyImpactStatistics.swift */; }; 308A6F8826A4F846A780EEF7 /* AudioDeviceVolumeServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 214C8E41A139972527F0B2AC /* AudioDeviceVolumeServiceTests.swift */; }; 31542F075E8801EA33B52763 /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 36396300E73F3D423CCDC77C /* MetricsStore.swift */; }; 33D87FB760621658DFD4DC22 /* AudioRouteMetadataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2BA440DCE79490C0E6C08F4 /* AudioRouteMetadataTests.swift */; }; @@ -85,6 +87,7 @@ 754E6D11CBB044D143AE0022 /* AppLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B63F02833B7A142E6D3C19 /* AppLanguage.swift */; }; 75D742F910AE1AC2B65D75A8 /* MetricKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF6C7817370CFE146811438 /* MetricKind.swift */; }; 7B1D24512C0A80195755C3BF /* ActiveProcessMemoryLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B74985CC421B77A15255C4 /* ActiveProcessMemoryLayoutTests.swift */; }; + 7CA75E9DA5AE16CBF75521EB /* EnergyImpactStatisticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7B5639CB348DA6FDAE9279 /* EnergyImpactStatisticsTests.swift */; }; 7D184325401391E30FCB55B5 /* ActiveAppMemoryProvidingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 782F556D94AA82856902F0AD /* ActiveAppMemoryProvidingTests.swift */; }; 81525E2FD6BFFE11475B033B /* AppSamplingControllerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16DC22E8B8CB44094748DE32 /* AppSamplingControllerTests.swift */; }; 845758400AD48C161ACC3D6F /* AudioControlModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 12F0B26F5641E9B7B5BD7B88 /* AudioControlModelsTests.swift */; }; @@ -225,6 +228,7 @@ 153AAE3970DB7FC89AF0F7CD /* UpdateChannel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateChannel.swift; sourceTree = ""; }; 15EE5D1ED08CA135E656FD09 /* AppPresentationCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppPresentationCoordinator.swift; sourceTree = ""; }; 16DC22E8B8CB44094748DE32 /* AppSamplingControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSamplingControllerTests.swift; sourceTree = ""; }; + 1C6E6EE79DD4E13EB9F43826 /* EnergyImpactStatistics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactStatistics.swift; sourceTree = ""; }; 1E1651676B31A0F49FF03FBD /* MetricsScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsScheduler.swift; sourceTree = ""; }; 20D958280B26529B7B39C116 /* SMCSensorReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SMCSensorReaderTests.swift; sourceTree = ""; }; 214C8E41A139972527F0B2AC /* AudioDeviceVolumeServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDeviceVolumeServiceTests.swift; sourceTree = ""; }; @@ -294,6 +298,7 @@ 797C5C451A320B16D6A15BE9 /* PreferencesStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreferencesStoreTests.swift; sourceTree = ""; }; 7AAFA8AD36E5C20E3BAE74EF /* AudioBufferListTestStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioBufferListTestStorage.swift; sourceTree = ""; }; 7B87283E6D09D63E041FCDE7 /* main.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = ""; }; + 7F7B5639CB348DA6FDAE9279 /* EnergyImpactStatisticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactStatisticsTests.swift; sourceTree = ""; }; 80D21F3C01CE00EFD700E003 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; 8428982312A6028D3AC20D1F /* MetricsStoreHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStoreHistoryTests.swift; sourceTree = ""; }; 8A4643019F12953DA4A2FBD2 /* AudioSystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSystemMonitor.swift; sourceTree = ""; }; @@ -317,6 +322,7 @@ AC0CEBE8C815796DC9899475 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = ""; }; ACFADB8DB7FD273E04239957 /* AudioSystemMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSystemMonitorTests.swift; sourceTree = ""; }; B541AF54F89E04066F33AD8E /* TrashCleanupService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashCleanupService.swift; sourceTree = ""; }; + B66490C62BF6EE5944B75C67 /* EnergyImpactTraceReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactTraceReplayTests.swift; sourceTree = ""; }; B8233121C0FA90DBBF96DFE8 /* DashboardTrendChartLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardTrendChartLayoutTests.swift; sourceTree = ""; }; B8D56AF8E736A0BDA52CD00E /* FakeProcessTapRetryScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeProcessTapRetryScheduler.swift; sourceTree = ""; }; B94D2FEA382B619F7773A1B4 /* MemoryProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryProviderTests.swift; sourceTree = ""; }; @@ -525,6 +531,7 @@ 420BEFFE5B99B07B33CAF5DE /* BatterySystemPowerSourceReader.swift */, 5F80A7CF6CB9E39D7858C692 /* CPUProvider.swift */, F6C57F502EC1B86CD070FE38 /* EnergyImpactProvider.swift */, + 1C6E6EE79DD4E13EB9F43826 /* EnergyImpactStatistics.swift */, 90E95B0B56AA23B56343FB1C /* EnergyImpactTypes.swift */, 622432CA2E5A5BDE1D01C6D9 /* FanProvider.swift */, EDC3E242AE71B79619755C0D /* MemoryProvider.swift */, @@ -642,6 +649,8 @@ 4C28EA386891B5D88473C012 /* DashboardModelTests.swift */, 9B26B36594393917E7097C16 /* DiskCleanupServiceTests.swift */, 4FBBF160184071B2E1059B58 /* EnergyImpactProviderTests.swift */, + 7F7B5639CB348DA6FDAE9279 /* EnergyImpactStatisticsTests.swift */, + B66490C62BF6EE5944B75C67 /* EnergyImpactTraceReplayTests.swift */, B94D2FEA382B619F7773A1B4 /* MemoryProviderTests.swift */, 5910039CDBA471C2F9513628 /* MemoryReleaseServiceTests.swift */, 018FE6516B13F8FCE943C7CB /* MetricProviderSampleTests.swift */, @@ -934,6 +943,8 @@ 29B0CBE169F75666C6DBB021 /* DashboardModelTests.swift in Sources */, F20363F876BE2F8C2CA12F53 /* DiskCleanupServiceTests.swift in Sources */, EDC980EF30033CA70792C40E /* EnergyImpactProviderTests.swift in Sources */, + 7CA75E9DA5AE16CBF75521EB /* EnergyImpactStatisticsTests.swift in Sources */, + 1B0012B1804C865A55F713DA /* EnergyImpactTraceReplayTests.swift in Sources */, 74DCCA48AC3A91932C22556A /* FakeAudioHALBackend.swift in Sources */, 594F48CA3A3CE03A9CDD5167 /* FakeAudioProcessOwnershipLeaseBroker.swift in Sources */, DE8E0B03803F9378E7495EC6 /* FakeAudioTapHardware.swift in Sources */, @@ -986,6 +997,7 @@ 1B3A5C1A4385179E39C90CDF /* DashboardModel.swift in Sources */, CF45AF27EDBD4FB898FC14D4 /* DiskCleanupService.swift in Sources */, 0F997EFB2933D1DF01944138 /* EnergyImpactProvider.swift in Sources */, + 2F976168C40F4D4D3D54A629 /* EnergyImpactStatistics.swift in Sources */, 25F6AB66E8EE1DA07F240610 /* EnergyImpactTypes.swift in Sources */, B1D58B1C23E57C4B2EFA43B2 /* FanProvider.swift in Sources */, B3C6A01640BB5B354B59AF73 /* LaunchAtLoginService.swift in Sources */, diff --git a/Sources/MacActivityApp/Models/EnergyImpactModel.swift b/Sources/MacActivityApp/Models/EnergyImpactModel.swift index a292fbf..e68e77b 100644 --- a/Sources/MacActivityApp/Models/EnergyImpactModel.swift +++ b/Sources/MacActivityApp/Models/EnergyImpactModel.swift @@ -16,26 +16,39 @@ final class EnergyImpactModel: ObservableObject { private let provider: any EnergyImpactProviding private let limit: Int - private let samplingDelayNanoseconds: UInt64 + private let initialWindowNanoseconds: UInt64 + private let clock: any EnergyImpactClock private let sleep: (UInt64) async throws -> Void + private let configuration = EnergyImpactConfiguration.production + + private var smoother = EnergyImpactSmoother( + halfLifeSeconds: EnergyImpactConfiguration.production.fastHalfLifeSeconds + ) + private var ranker = StableEnergyImpactRanker() + private var lastValidObservationTimes: [EnergyImpactProcessIdentity: TimeInterval] = [:] + private var lastPublicationTime: TimeInterval? init( provider: any EnergyImpactProviding = EnergyImpactService(), limit: Int = 20, - samplingDelayNanoseconds: UInt64 = 250_000_000, - sleep: @escaping (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) } + initialWindowNanoseconds: UInt64 = 3_000_000_000, + clock: any EnergyImpactClock = SystemEnergyImpactClock(), + sleep: @escaping (UInt64) async throws -> Void = { + try await Task.sleep(nanoseconds: $0) + } ) { self.provider = provider self.limit = limit - self.samplingDelayNanoseconds = samplingDelayNanoseconds + self.initialWindowNanoseconds = initialWindowNanoseconds + self.clock = clock self.sleep = sleep } func refresh() async { isRefreshing = true - _ = provider.topApps(limit: limit) + _ = provider.topApps(limit: .max) do { - try await sleep(samplingDelayNanoseconds) + try await sleep(initialWindowNanoseconds) } catch { isRefreshing = false return @@ -44,7 +57,7 @@ final class EnergyImpactModel: ObservableObject { isRefreshing = false return } - entries = provider.topApps(limit: limit) + publish(provider.topApps(limit: .max), at: clock.nowSeconds()) isRefreshing = false } @@ -57,7 +70,130 @@ final class EnergyImpactModel: ObservableObject { return } guard Task.isCancelled == false else { return } - entries = provider.topApps(limit: limit) + publish(provider.topApps(limit: .max), at: clock.nowSeconds()) + } + } + + private func publish(_ candidates: [EnergyImpactEntry], at publicationTime: TimeInterval) { + guard publicationTime.isFinite else { + resetStatistics() + entries = Array( + ranker.rank(candidates.map(Self.nonnumericUnavailable), atPublicationBoundary: true) + .prefix(max(0, limit)) + ) + return + } + + if let lastPublicationTime { + let publicationGap = publicationTime - lastPublicationTime + if publicationGap <= 0 || publicationGap > configuration.maximumGapSeconds { + resetStatistics() + } + } + + let currentGenerations = Set(candidates.compactMap(\.identity.generation)) + smoother.retainOnly(currentGenerations) + lastValidObservationTimes = lastValidObservationTimes.filter { + currentGenerations.contains($0.key) + } + + let processed = candidates.map { candidate in + process(candidate, at: publicationTime) } + lastPublicationTime = publicationTime + + entries = Array( + ranker.rank(processed, atPublicationBoundary: true) + .prefix(max(0, limit)) + ) + } + + private func process( + _ candidate: EnergyImpactEntry, + at publicationTime: TimeInterval + ) -> EnergyImpactEntry { + guard candidate.status == .stable || candidate.status == .partial else { + return candidate + } + guard let currentPower = candidate.currentPowerMicrowatts, + let rankingScore = candidate.rankingScore, + currentPower.isFinite, + currentPower >= 0, + rankingScore.isFinite, + rankingScore >= 0 else { + return Self.nonnumericUnavailable(candidate) + } + guard let generation = candidate.identity.generation else { + return candidate + } + + let elapsedSeconds: TimeInterval + if let lastValidObservationTime = lastValidObservationTimes[generation] { + elapsedSeconds = publicationTime - lastValidObservationTime + } else { + elapsedSeconds = configuration.publicationIntervalSeconds + } + guard elapsedSeconds.isFinite, elapsedSeconds > 0 else { + return Self.nonnumericUnavailable(candidate) + } + + if elapsedSeconds > configuration.maximumGapSeconds { + smoother.retainOnly( + Set(lastValidObservationTimes.keys).subtracting([generation]) + ) + lastValidObservationTimes[generation] = nil + } + let smoothingElapsed = min(elapsedSeconds, configuration.maximumGapSeconds) + guard let smoothed = smoother.update( + identity: generation, + value: currentPower, + elapsedSeconds: smoothingElapsed + ) else { + return Self.nonnumericUnavailable(candidate) + } + lastValidObservationTimes[generation] = publicationTime + return Self.replacingCurrentPower(in: candidate, with: smoothed) + } + + private func resetStatistics() { + smoother = EnergyImpactSmoother(halfLifeSeconds: configuration.fastHalfLifeSeconds) + ranker.reset() + lastValidObservationTimes.removeAll() + lastPublicationTime = nil + } + + private static func replacingCurrentPower( + in entry: EnergyImpactEntry, + with currentPower: Double + ) -> EnergyImpactEntry { + EnergyImpactEntry( + identity: entry.identity, + name: entry.name, + bundleIdentifier: entry.bundleIdentifier, + bundleURL: entry.bundleURL, + kind: entry.kind, + currentPowerMicrowatts: currentPower, + sustainedPowerMicrowatts: entry.sustainedPowerMicrowatts, + rankingScore: currentPower, + trend: entry.trend, + coverage: entry.coverage, + status: entry.status + ) + } + + private static func nonnumericUnavailable(_ entry: EnergyImpactEntry) -> EnergyImpactEntry { + EnergyImpactEntry( + identity: entry.identity, + name: entry.name, + bundleIdentifier: entry.bundleIdentifier, + bundleURL: entry.bundleURL, + kind: entry.kind, + currentPowerMicrowatts: nil, + sustainedPowerMicrowatts: nil, + rankingScore: nil, + trend: entry.trend, + coverage: entry.coverage, + status: .unavailable + ) } } diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactStatistics.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactStatistics.swift new file mode 100644 index 0000000..c8fb977 --- /dev/null +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactStatistics.swift @@ -0,0 +1,194 @@ +import Foundation + +public struct TimeAwareEnergyEMA: Equatable, Sendable { + public let halfLifeSeconds: TimeInterval + private var value: Double? + + public init(halfLifeSeconds: TimeInterval) { + precondition(halfLifeSeconds > 0) + self.halfLifeSeconds = halfLifeSeconds + } + + public mutating func update(value newValue: Double, elapsedSeconds: TimeInterval) -> Double? { + guard newValue.isFinite, newValue >= 0, + elapsedSeconds.isFinite, elapsedSeconds > 0 else { return nil } + guard let previous = value else { + value = newValue + return newValue + } + let alpha = 1 - pow(0.5, elapsedSeconds / halfLifeSeconds) + let smoothed = alpha * newValue + (1 - alpha) * previous + value = smoothed + return smoothed + } +} + +public struct EnergyImpactSmoother: Sendable { + private let halfLifeSeconds: TimeInterval + private var values: [EnergyImpactProcessIdentity: TimeAwareEnergyEMA] = [:] + + public init(halfLifeSeconds: TimeInterval) { + self.halfLifeSeconds = halfLifeSeconds + } + + public mutating func update( + identity: EnergyImpactProcessIdentity, + value: Double, + elapsedSeconds: TimeInterval + ) -> Double? { + guard value.isFinite, value >= 0, + elapsedSeconds.isFinite, elapsedSeconds > 0 else { return nil } + var ema = values[identity] ?? TimeAwareEnergyEMA(halfLifeSeconds: halfLifeSeconds) + guard let result = ema.update(value: value, elapsedSeconds: elapsedSeconds) else { return nil } + values[identity] = ema + return result + } + + public mutating func retainOnly(_ identities: Set) { + values = values.filter { identities.contains($0.key) } + } +} + +public struct StableEnergyImpactRanker: Sendable { + private struct ChallengerPair: Hashable, Sendable { + let challenger: EnergyImpactProcessIdentity + let incumbent: EnergyImpactProcessIdentity + } + + private var currentOrder: [EnergyImpactProcessIdentity] = [] + private var leadCounts: [ChallengerPair: Int] = [:] + + public init() {} + + public mutating func reset() { + currentOrder.removeAll() + leadCounts.removeAll() + } + + public mutating func rank( + _ entries: [EnergyImpactEntry], + atPublicationBoundary: Bool + ) -> [EnergyImpactEntry] { + let buckets = Dictionary(grouping: entries, by: Self.statusBucket) + let current = buckets[0, default: []] + let lowerBuckets = [1, 2, 3].flatMap { + buckets[$0, default: []].sorted(by: Self.deterministicBefore) + } + + guard atPublicationBoundary else { + return current.sorted(by: Self.deterministicBefore) + lowerBuckets + } + + let statefulByGeneration = Dictionary( + current.compactMap { entry in + entry.identity.generation.map { ($0, entry) } + }, + uniquingKeysWith: { first, _ in first } + ) + let currentGenerations = Set(statefulByGeneration.keys) + currentOrder.removeAll { currentGenerations.contains($0) == false } + + let newGenerations = currentGenerations.subtracting(Set(currentOrder)).sorted { lhs, rhs in + guard let left = statefulByGeneration[lhs], + let right = statefulByGeneration[rhs] else { return false } + return Self.deterministicBefore(left, right) + } + currentOrder.append(contentsOf: newGenerations) + + var movedImmediately = true + while movedImmediately, currentOrder.count > 1 { + movedImmediately = false + for index in 1..= 1.25 { + currentOrder.swapAt(index - 1, index) + movedImmediately = true + } + } + } + + var activePairs = Set() + if currentOrder.count > 1 { + for index in 1..= 1.10 { + let count = leadCounts[pair, default: 0] + 1 + leadCounts[pair] = count + if count >= 2 { + currentOrder.swapAt(index - 1, index) + leadCounts[pair] = nil + } + } else { + leadCounts[pair] = nil + } + } + } + + leadCounts = leadCounts.filter { activePairs.contains($0.key) } + let stateful = currentOrder.compactMap { statefulByGeneration[$0] } + let ephemeral = current + .filter { $0.identity.generation == nil } + .sorted(by: Self.deterministicBefore) + return stateful + ephemeral + lowerBuckets + } + + private func score( + for identity: EnergyImpactProcessIdentity, + in entries: [EnergyImpactProcessIdentity: EnergyImpactEntry] + ) -> Double { + entries[identity]?.rankingScore ?? -.infinity + } + + private func leadRatio( + challenger: EnergyImpactProcessIdentity, + incumbent: EnergyImpactProcessIdentity, + entries: [EnergyImpactProcessIdentity: EnergyImpactEntry] + ) -> Double { + let challengerScore = score(for: challenger, in: entries) + let incumbentScore = score(for: incumbent, in: entries) + if incumbentScore <= 0 { + return challengerScore > 0 ? .infinity : 1 + } + return challengerScore / incumbentScore + } + + private static func statusBucket(_ entry: EnergyImpactEntry) -> Int { + if entry.rankingScore != nil { + if entry.status == .stable || entry.status == .partial { return 0 } + if entry.status == .stale { return 1 } + } + switch entry.status { + case .collecting: return 2 + case .unavailable, .stable, .partial, .stale: return 3 + } + } + + private static func deterministicBefore( + _ lhs: EnergyImpactEntry, + _ rhs: EnergyImpactEntry + ) -> Bool { + if lhs.rankingScore != rhs.rankingScore { + return (lhs.rankingScore ?? -.infinity) > (rhs.rankingScore ?? -.infinity) + } + let nameOrder = lhs.name.localizedCaseInsensitiveCompare(rhs.name) + if nameOrder != .orderedSame { return nameOrder == .orderedAscending } + if lhs.processIdentifier != rhs.processIdentifier { + return lhs.processIdentifier < rhs.processIdentifier + } + return (lhs.identity.rootProcessStartAbsoluteTime ?? 0) + < (rhs.identity.rootProcessStartAbsoluteTime ?? 0) + } +} diff --git a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift index 6b0b755..9c57715 100644 --- a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift @@ -4,53 +4,56 @@ import MacActivityCore @MainActor final class EnergyImpactModelTests: XCTestCase { - func testRefreshPrimesAndPublishesFollowUpEnergyImpactSample() async { - let baseline = entry(power: 0) - let ranked = entry(power: 8.4) - let provider = EnergyImpactProviderStub(responses: [[baseline], [ranked]]) - let model = EnergyImpactModel( + func testRefreshWhileVisibleWaitsFullWindowAndSmoothsEveryPublishedSample() async throws { + let clock = EnergyImpactTestClock() + let provider = EnergyImpactProviderStub( + responses: [ + [entry(power: 0)], + [entry(power: 100)], + [entry(power: 0)] + ] + ) + var requestedSleeps: [UInt64] = [] + var model: EnergyImpactModel! + model = EnergyImpactModel( provider: provider, limit: 20, - samplingDelayNanoseconds: 1, - sleep: { _ in } + clock: clock, + sleep: { duration in + requestedSleeps.append(duration) + if requestedSleeps.count == 1 { + XCTAssertTrue(model.entries.isEmpty) + } + guard requestedSleeps.count < 3 else { throw CancellationError() } + clock.advance(seconds: 3) + } ) - await model.refresh() + await model.refreshWhileVisible() - XCTAssertEqual(model.entries.map(\.name), ["Safari"]) - XCTAssertEqual(model.entries.first?.currentPowerMicrowatts, 8.4) - XCTAssertEqual(provider.requestedLimits, [20, 20]) + XCTAssertEqual(requestedSleeps, [3_000_000_000, 3_000_000_000, 3_000_000_000]) + XCTAssertEqual(provider.requestedLimits, [.max, .max, .max]) + XCTAssertEqual( + try XCTUnwrap(model.entries.first?.currentPowerMicrowatts), + 59.460_355_75, + accuracy: 0.000_001 + ) XCTAssertFalse(model.isRefreshing) } - func testRefreshClearsRefreshingWhenSamplingSleepThrows() async { + func testRefreshClearsRefreshingWhenInitialWindowSleepThrows() async { let provider = EnergyImpactProviderStub(responses: [[]]) let model = EnergyImpactModel( provider: provider, limit: 20, - samplingDelayNanoseconds: 1, + initialWindowNanoseconds: 3_000_000_000, sleep: { _ in throw CancellationError() } ) await model.refresh() XCTAssertEqual(model.entries, []) - XCTAssertEqual(provider.requestedLimits, [20]) - XCTAssertFalse(model.isRefreshing) - } - - func testRefreshWithDefaultSleepCanUseZeroSamplingDelay() async { - let provider = EnergyImpactProviderStub(responses: [[], []]) - let model = EnergyImpactModel( - provider: provider, - limit: 1, - samplingDelayNanoseconds: 0 - ) - - await model.refresh() - - XCTAssertEqual(model.entries, []) - XCTAssertEqual(provider.requestedLimits, [1, 1]) + XCTAssertEqual(provider.requestedLimits, [.max]) XCTAssertFalse(model.isRefreshing) } @@ -59,7 +62,7 @@ final class EnergyImpactModelTests: XCTestCase { let model = EnergyImpactModel( provider: provider, limit: 20, - samplingDelayNanoseconds: 1, + initialWindowNanoseconds: 3_000_000_000, sleep: { _ in withUnsafeCurrentTask { task in task?.cancel() @@ -68,58 +71,271 @@ final class EnergyImpactModelTests: XCTestCase { ) let task = Task { - await model.refreshWhileVisible(refreshIntervalNanoseconds: 3) + await model.refreshWhileVisible() } await task.value XCTAssertEqual(model.entries, []) - XCTAssertEqual(provider.requestedLimits, [20]) + XCTAssertEqual(provider.requestedLimits, [.max]) XCTAssertFalse(model.isRefreshing) } - func testRefreshWhileVisibleRepeatsAfterVisibleRefreshInterval() async { - let provider = EnergyImpactProviderStub(responses: [[entry(power: 0)], [entry(power: 3.2)], [entry(power: 7.6)]]) - var requestedSleeps: [UInt64] = [] + func testPIDReuseStartsReplacementGenerationFromItsOwnRawValue() async throws { + let clock = EnergyImpactTestClock() + let provider = EnergyImpactProviderStub( + responses: [ + [entry(power: 0, startTime: 10)], + [entry(power: 100, startTime: 10)], + [entry(power: 5, startTime: 20)] + ] + ) + var sleepCount = 0 let model = EnergyImpactModel( provider: provider, - limit: 20, - samplingDelayNanoseconds: 1, - sleep: { duration in - requestedSleeps.append(duration) - guard requestedSleeps != [1, 3, 3] else { - throw CancellationError() + clock: clock, + sleep: { _ in + sleepCount += 1 + guard sleepCount < 3 else { throw CancellationError() } + clock.advance(seconds: 3) + } + ) + + await model.refreshWhileVisible() + + let replacement = try XCTUnwrap(model.entries.first) + XCTAssertEqual(replacement.identity.rootProcessStartAbsoluteTime, 20) + XCTAssertEqual(replacement.currentPowerMicrowatts, 5) + } + + func testMissingGenerationPublishesRawValuesWithoutRetainingEMAState() async throws { + let clock = EnergyImpactTestClock() + let provider = EnergyImpactProviderStub( + responses: [ + [entry(power: 0, startTime: nil)], + [entry(power: 100, startTime: nil)], + [entry(power: 0, startTime: nil)] + ] + ) + var sleepCount = 0 + let model = EnergyImpactModel( + provider: provider, + clock: clock, + sleep: { _ in + sleepCount += 1 + guard sleepCount < 3 else { throw CancellationError() } + clock.advance(seconds: 3) + } + ) + + await model.refreshWhileVisible() + + XCTAssertEqual(try XCTUnwrap(model.entries.first?.currentPowerMicrowatts), 0) + } + + func testStalePublicationDoesNotAdvanceEMARecoveryTime() async throws { + let clock = EnergyImpactTestClock() + let provider = EnergyImpactProviderStub( + responses: [ + [entry(power: 0)], + [entry(power: 100)], + [entry(power: 100, status: .stale)], + [entry(power: 0)] + ] + ) + var sleepCount = 0 + let model = EnergyImpactModel( + provider: provider, + clock: clock, + sleep: { _ in + sleepCount += 1 + guard sleepCount < 4 else { throw CancellationError() } + clock.advance(seconds: 3) + } + ) + + await model.refreshWhileVisible() + + XCTAssertEqual( + try XCTUnwrap(model.entries.first?.currentPowerMicrowatts), + 35.355_339_06, + accuracy: 0.000_001 + ) + } + + func testStalePublicationInterruptsRankConfirmation() async { + let clock = EnergyImpactTestClock() + let provider = EnergyImpactProviderStub( + responses: [ + [entry(pid: 1, power: 0), entry(pid: 2, power: 0)], + [entry(pid: 1, power: 100), entry(pid: 2, power: 90)], + [entry(pid: 1, power: 100), entry(pid: 2, power: 145)], + [entry(pid: 1, power: 100), entry(pid: 2, power: 145, status: .stale)], + [entry(pid: 1, power: 100), entry(pid: 2, power: 112)], + [entry(pid: 1, power: 100), entry(pid: 2, power: 112)] + ] + ) + var ordersBeforeSleep: [[pid_t]] = [] + var sleepCount = 0 + var model: EnergyImpactModel! + model = EnergyImpactModel( + provider: provider, + clock: clock, + sleep: { _ in + sleepCount += 1 + if model.entries.isEmpty == false { + ordersBeforeSleep.append(model.entries.map(\.processIdentifier)) } + guard sleepCount < 6 else { throw CancellationError() } + clock.advance(seconds: 3) } ) - await model.refreshWhileVisible(refreshIntervalNanoseconds: 3) + await model.refreshWhileVisible() - XCTAssertEqual(model.entries.map(\.currentPowerMicrowatts), [7.6]) - XCTAssertEqual(provider.requestedLimits, [20, 20, 20]) - XCTAssertEqual(requestedSleeps, [1, 3, 3]) - XCTAssertFalse(model.isRefreshing) - XCTAssertEqual(provider.topApps(limit: 20), []) + XCTAssertEqual( + ordersBeforeSleep, + [ + [1, 2], + [1, 2], + [1, 2], + [1, 2], + [2, 1] + ] + ) + } + + func testAllCandidatesAreSmoothedBeforeTopLimitIsApplied() async throws { + let clock = EnergyImpactTestClock() + let steady = (1...20).map { entry(pid: pid_t($0), power: 50) } + let lowCandidate = entry(pid: 21, power: 0) + let highCandidate = entry(pid: 21, power: 100) + let provider = EnergyImpactProviderStub( + responses: [ + steady + [lowCandidate], + steady + [lowCandidate], + steady + [highCandidate], + steady + [highCandidate] + ] + ) + var sleepCount = 0 + let model = EnergyImpactModel( + provider: provider, + limit: 20, + clock: clock, + sleep: { _ in + sleepCount += 1 + guard sleepCount < 4 else { throw CancellationError() } + clock.advance(seconds: 3) + } + ) + + await model.refreshWhileVisible() + + XCTAssertEqual(provider.requestedLimits, [.max, .max, .max, .max]) + let candidate = try XCTUnwrap( + model.entries.first { $0.processIdentifier == 21 } + ) + XCTAssertEqual( + try XCTUnwrap(candidate.currentPowerMicrowatts), + 64.644_660_94, + accuracy: 0.000_001 + ) + XCTAssertEqual(model.entries.count, 20) + } + + func testLongGapClearsPreviousSmoothingState() async throws { + let clock = EnergyImpactTestClock() + let provider = EnergyImpactProviderStub( + responses: [ + [entry(power: 0)], + [entry(power: 100)], + [entry(power: 0)] + ] + ) + var sleepCount = 0 + let model = EnergyImpactModel( + provider: provider, + clock: clock, + sleep: { _ in + sleepCount += 1 + guard sleepCount < 3 else { throw CancellationError() } + clock.advance(seconds: sleepCount == 1 ? 3 : 11) + } + ) + + await model.refreshWhileVisible() + + XCTAssertEqual(try XCTUnwrap(model.entries.first?.currentPowerMicrowatts), 0) } - private func entry(power: Double) -> EnergyImpactEntry { - EnergyImpactEntry( - identity: EnergyImpactAppIdentity( - rootProcessIdentifier: 101, - rootProcessStartAbsoluteTime: 1 - ), - name: "Safari", - bundleIdentifier: "com.apple.Safari", - bundleURL: nil, - currentPowerMicrowatts: power, - sustainedPowerMicrowatts: power, - rankingScore: power, - trend: .steady, - coverage: .unavailable, - status: .stable + func testInvalidCurrentValuePublishesNonnumericUnavailableAndDoesNotAdvanceEMA() async throws { + let clock = EnergyImpactTestClock() + let provider = EnergyImpactProviderStub( + responses: [ + [entry(power: 0)], + [entry(power: 100)], + [entry(power: .nan)], + [entry(power: 0)] + ] + ) + var sleepCount = 0 + var invalidPublication: EnergyImpactEntry? + var model: EnergyImpactModel! + model = EnergyImpactModel( + provider: provider, + clock: clock, + sleep: { _ in + sleepCount += 1 + if sleepCount == 3 { + invalidPublication = model.entries.first + } + guard sleepCount < 4 else { throw CancellationError() } + clock.advance(seconds: 3) + } + ) + + await model.refreshWhileVisible() + + let invalid = try XCTUnwrap(invalidPublication) + XCTAssertNil(invalid.currentPowerMicrowatts) + XCTAssertNil(invalid.rankingScore) + XCTAssertEqual(invalid.status, .unavailable) + XCTAssertEqual( + try XCTUnwrap(model.entries.first?.currentPowerMicrowatts), + 35.355_339_06, + accuracy: 0.000_001 ) } } +private func entry( + pid: pid_t = 101, + power: Double?, + startTime: UInt64? = 1, + status: EnergyImpactStatus = .stable +) -> EnergyImpactEntry { + EnergyImpactEntry( + identity: EnergyImpactAppIdentity( + rootProcessIdentifier: pid, + rootProcessStartAbsoluteTime: startTime + ), + name: "App \(pid)", + bundleIdentifier: "com.example.app-\(pid)", + bundleURL: nil, + currentPowerMicrowatts: power, + sustainedPowerMicrowatts: power, + rankingScore: power, + trend: .steady, + coverage: EnergyImpactCoverage( + discoveredProcessCount: 1, + readableProcessCount: 1, + validProcessSeconds: status == .stable || status == .partial ? 3 : 0, + discoveredProcessSeconds: 3 + ), + status: status + ) +} + @MainActor private final class EnergyImpactProviderStub: EnergyImpactProviding { private var responses: [[EnergyImpactEntry]] @@ -131,9 +347,19 @@ private final class EnergyImpactProviderStub: EnergyImpactProviding { func topApps(limit: Int) -> [EnergyImpactEntry] { requestedLimits.append(limit) - guard responses.isEmpty == false else { - return [] - } + guard responses.isEmpty == false else { return [] } return Array(responses.removeFirst().prefix(limit)) } } + +private final class EnergyImpactTestClock: EnergyImpactClock, @unchecked Sendable { + private var value: TimeInterval = 0 + + func nowSeconds() -> TimeInterval { + value + } + + func advance(seconds: TimeInterval) { + value += seconds + } +} diff --git a/Tests/MacActivityAppTests/EnergyImpactViewTests.swift b/Tests/MacActivityAppTests/EnergyImpactViewTests.swift index 2b5c87a..ad77faa 100644 --- a/Tests/MacActivityAppTests/EnergyImpactViewTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactViewTests.swift @@ -46,7 +46,7 @@ final class EnergyImpactViewTests: XCTestCase { func testRenderedEnergyImpactViewShowsEmptyStateAtFourHundredTwentyPoints() { let model = EnergyImpactModel( provider: EnergyImpactViewProviderStub(responses: []), - samplingDelayNanoseconds: 1, + initialWindowNanoseconds: 1, sleep: { _ in throw CancellationError() } ) let renderer = ImageRenderer( @@ -80,7 +80,7 @@ final class EnergyImpactViewTests: XCTestCase { let renderedEntry = entry(power: 1_840) let model = EnergyImpactModel( provider: EnergyImpactViewProviderStub(responses: [[], [renderedEntry], []]), - samplingDelayNanoseconds: 1, + initialWindowNanoseconds: 1, sleep: { _ in sleepCount += 1 guard sleepCount == 1 else { throw CancellationError() } diff --git a/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift b/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift new file mode 100644 index 0000000..70eb665 --- /dev/null +++ b/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift @@ -0,0 +1,156 @@ +import Darwin +import XCTest +@testable import MacActivityCore + +final class EnergyImpactStatisticsTests: XCTestCase { + func testTimeAwareEMAUsesFourSecondHalfLife() throws { + var ema = TimeAwareEnergyEMA(halfLifeSeconds: 4) + + XCTAssertEqual(ema.update(value: 100, elapsedSeconds: 3), 100) + let decayed = try XCTUnwrap(ema.update(value: 0, elapsedSeconds: 3)) + XCTAssertEqual( + decayed, + 59.460_355_75, + accuracy: 0.000_001 + ) + } + + func testNewProcessGenerationDoesNotInheritOldSmoothing() { + var smoother = EnergyImpactSmoother(halfLifeSeconds: 4) + let old = EnergyImpactProcessIdentity( + processIdentifier: 101, + processStartAbsoluteTime: 10 + ) + let replacement = EnergyImpactProcessIdentity( + processIdentifier: 101, + processStartAbsoluteTime: 20 + ) + + XCTAssertEqual(smoother.update(identity: old, value: 100, elapsedSeconds: 3), 100) + XCTAssertEqual(smoother.update(identity: replacement, value: 5, elapsedSeconds: 3), 5) + } + + func testInvalidEMAInputDoesNotBecomeZeroOrMutateState() { + var ema = TimeAwareEnergyEMA(halfLifeSeconds: 4) + XCTAssertEqual(ema.update(value: 100, elapsedSeconds: 1), 100) + XCTAssertNil(ema.update(value: .nan, elapsedSeconds: 1)) + XCTAssertNil(ema.update(value: -1, elapsedSeconds: 1)) + XCTAssertEqual(ema.update(value: 100, elapsedSeconds: 1), 100) + } + + func testRankerRequiresTwoTenPercentLeadsButAcceptsTwentyFivePercentImmediately() { + var ranker = StableEnergyImpactRanker() + let a = fixtureEntry(pid: 1, score: 100) + let b = fixtureEntry(pid: 2, score: 90) + XCTAssertEqual(ranker.rank([a, b], atPublicationBoundary: true).map(\.processIdentifier), [1, 2]) + + let nearLead = fixtureEntry(pid: 2, score: 112) + XCTAssertEqual(ranker.rank([a, nearLead], atPublicationBoundary: true).map(\.processIdentifier), [1, 2]) + XCTAssertEqual(ranker.rank([a, nearLead], atPublicationBoundary: true).map(\.processIdentifier), [2, 1]) + + let immediate = fixtureEntry(pid: 1, score: 150) + XCTAssertEqual(ranker.rank([immediate, nearLead], atPublicationBoundary: true).map(\.processIdentifier), [1, 2]) + } + + func testStableEntryRanksAheadOfHigherScoringStaleEntry() { + var ranker = StableEnergyImpactRanker() + let stable = fixtureEntry(pid: 1, score: 1, status: .stable) + let stale = fixtureEntry(pid: 2, score: 1_000, status: .stale) + + XCTAssertEqual( + ranker.rank([stale, stable], atPublicationBoundary: true).map(\.processIdentifier), + [1, 2] + ) + } + + func testChallengerLosesConfirmationWhenItStopsBeingAdjacent() { + var ranker = StableEnergyImpactRanker() + let incumbent = fixtureEntry(pid: 1, score: 100) + let challenger = fixtureEntry(pid: 2, score: 112) + + _ = ranker.rank( + [incumbent, fixtureEntry(pid: 2, score: 90)], + atPublicationBoundary: true + ) + XCTAssertEqual( + ranker.rank([incumbent, challenger], atPublicationBoundary: true) + .map(\.processIdentifier), + [1, 2] + ) + XCTAssertEqual( + ranker.rank([incumbent], atPublicationBoundary: true) + .map(\.processIdentifier), + [1] + ) + XCTAssertEqual( + ranker.rank([incumbent, challenger], atPublicationBoundary: true) + .map(\.processIdentifier), + [1, 2] + ) + XCTAssertEqual( + ranker.rank([incumbent, challenger], atPublicationBoundary: true) + .map(\.processIdentifier), + [2, 1] + ) + } + + func testNilGenerationEntriesUseNameThenPIDTieBreaks() { + var ranker = StableEnergyImpactRanker() + let stateful = fixtureEntry(pid: 4, score: 1, name: "Last") + let zulu = fixtureEntry(pid: 1, score: 100, hasGeneration: false, name: "Zulu") + let alphaHighPID = fixtureEntry(pid: 3, score: 100, hasGeneration: false, name: "Alpha") + let alphaLowPID = fixtureEntry(pid: 2, score: 100, hasGeneration: false, name: "alpha") + + XCTAssertEqual( + ranker.rank( + [zulu, alphaHighPID, stateful, alphaLowPID], + atPublicationBoundary: true + ) + .map(\.processIdentifier), + [4, 2, 3, 1] + ) + } + + func testSameNameAndPIDGenerationsUseStartTimeTieBreak() { + var ranker = StableEnergyImpactRanker() + let newer = fixtureEntry(pid: 1, score: 100, startTime: 20, name: "App") + let older = fixtureEntry(pid: 1, score: 100, startTime: 10, name: "App") + + XCTAssertEqual( + ranker.rank([newer, older], atPublicationBoundary: false) + .map(\.identity.rootProcessStartAbsoluteTime), + [10, 20] + ) + } +} + +private func fixtureEntry( + pid: pid_t, + score: Double, + status: EnergyImpactStatus = .stable, + startTime: UInt64? = nil, + hasGeneration: Bool = true, + name: String? = nil +) -> EnergyImpactEntry { + let identity = EnergyImpactAppIdentity( + rootProcessIdentifier: pid, + rootProcessStartAbsoluteTime: hasGeneration ? startTime ?? UInt64(pid) : nil + ) + return EnergyImpactEntry( + identity: identity, + name: name ?? "App \(pid)", + bundleIdentifier: nil, + bundleURL: nil, + currentPowerMicrowatts: score, + sustainedPowerMicrowatts: nil, + rankingScore: score, + trend: .steady, + coverage: EnergyImpactCoverage( + discoveredProcessCount: 1, + readableProcessCount: 1, + validProcessSeconds: 3, + discoveredProcessSeconds: 3 + ), + status: status + ) +} diff --git a/Tests/MacActivityCoreTests/EnergyImpactTraceReplayTests.swift b/Tests/MacActivityCoreTests/EnergyImpactTraceReplayTests.swift new file mode 100644 index 0000000..f7ce76a --- /dev/null +++ b/Tests/MacActivityCoreTests/EnergyImpactTraceReplayTests.swift @@ -0,0 +1,78 @@ +import Darwin +import Foundation +import XCTest +@testable import MacActivityCore + +final class EnergyImpactTraceReplayTests: XCTestCase { + func testAlternatingQuantizationNoiseLowersCVByAtLeastHalf() throws { + let raw = Array(repeating: [70.0, 130.0], count: 20).flatMap { $0 } + var ema = TimeAwareEnergyEMA(halfLifeSeconds: 4) + let filtered = try raw.map { try XCTUnwrap(ema.update(value: $0, elapsedSeconds: 3)) } + let rawCV = coefficientOfVariation(Array(raw.dropFirst(5))) + let filteredCV = coefficientOfVariation(Array(filtered.dropFirst(5))) + + print( + "energy-trace raw-cv=\(rawCV) filtered-cv=\(filteredCV) " + + "reduction=\(1 - filteredCV / rawCV)" + ) + XCTAssertLessThanOrEqual(filteredCV, rawCV * 0.5) + } + + func testCrossingNearTieDoesNotThrashButTwentyFivePercentLeadMovesImmediately() { + var ranker = StableEnergyImpactRanker() + var swaps = 0 + var previous: [pid_t] = [] + for index in 0..<20 { + let a = traceEntry(pid: 1, score: index.isMultiple(of: 2) ? 100 : 109) + let b = traceEntry(pid: 2, score: index.isMultiple(of: 2) ? 109 : 100) + let order = ranker.rank([a, b], atPublicationBoundary: true).map(\.processIdentifier) + if previous.isEmpty == false, previous != order { swaps += 1 } + previous = order + } + XCTAssertEqual(swaps, 0) + + var immediateRanker = StableEnergyImpactRanker() + _ = immediateRanker.rank( + [traceEntry(pid: 1, score: 100), traceEntry(pid: 2, score: 90)], + atPublicationBoundary: true + ) + let decisive = traceEntry(pid: 2, score: 130) + let baseline = traceEntry(pid: 1, score: 100) + let decisiveFirst = immediateRanker.rank( + [baseline, decisive], + atPublicationBoundary: true + ).first?.processIdentifier + + print("energy-trace near-tie-swaps=\(swaps) decisive-first-pid=\(decisiveFirst ?? -1)") + XCTAssertEqual(decisiveFirst, 2) + } +} + +private func coefficientOfVariation(_ values: [Double]) -> Double { + let mean = values.reduce(0, +) / Double(values.count) + let variance = values.reduce(0) { $0 + pow($1 - mean, 2) } / Double(values.count) + return sqrt(variance) / mean +} + +private func traceEntry(pid: pid_t, score: Double) -> EnergyImpactEntry { + EnergyImpactEntry( + identity: .init( + rootProcessIdentifier: pid, + rootProcessStartAbsoluteTime: UInt64(pid) + ), + name: "App \(pid)", + bundleIdentifier: nil, + bundleURL: nil, + currentPowerMicrowatts: score, + sustainedPowerMicrowatts: nil, + rankingScore: score, + trend: .steady, + coverage: .init( + discoveredProcessCount: 1, + readableProcessCount: 1, + validProcessSeconds: 3, + discoveredProcessSeconds: 3 + ), + status: .stable + ) +} From 5edeae46d03cd3b30221924e2567631c69c02cb2 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Mon, 27 Jul 2026 00:51:14 +1000 Subject: [PATCH 07/13] fix(energy): preserve ranking and status state --- .../Models/EnergyImpactModel.swift | 57 ++++++++++++++---- .../Providers/EnergyImpactStatistics.swift | 8 ++- .../EnergyImpactModelTests.swift | 58 ++++++++++++++++++- .../EnergyImpactStatisticsTests.swift | 50 ++++++++++++++++ 4 files changed, 158 insertions(+), 15 deletions(-) diff --git a/Sources/MacActivityApp/Models/EnergyImpactModel.swift b/Sources/MacActivityApp/Models/EnergyImpactModel.swift index e68e77b..093f8ac 100644 --- a/Sources/MacActivityApp/Models/EnergyImpactModel.swift +++ b/Sources/MacActivityApp/Models/EnergyImpactModel.swift @@ -78,7 +78,10 @@ final class EnergyImpactModel: ObservableObject { guard publicationTime.isFinite else { resetStatistics() entries = Array( - ranker.rank(candidates.map(Self.nonnumericUnavailable), atPublicationBoundary: true) + ranker.rank( + candidates.map(Self.sanitizedForInvalidClock), + atPublicationBoundary: true + ) .prefix(max(0, limit)) ) return @@ -112,19 +115,20 @@ final class EnergyImpactModel: ObservableObject { _ candidate: EnergyImpactEntry, at publicationTime: TimeInterval ) -> EnergyImpactEntry { - guard candidate.status == .stable || candidate.status == .partial else { - return candidate + let sanitized = Self.sanitizingInvalidNumerics(candidate) + guard sanitized.status == .stable || sanitized.status == .partial else { + return sanitized } - guard let currentPower = candidate.currentPowerMicrowatts, - let rankingScore = candidate.rankingScore, + guard let currentPower = sanitized.currentPowerMicrowatts, + let rankingScore = sanitized.rankingScore, currentPower.isFinite, currentPower >= 0, rankingScore.isFinite, rankingScore >= 0 else { - return Self.nonnumericUnavailable(candidate) + return Self.nonnumericUnavailable(sanitized) } - guard let generation = candidate.identity.generation else { - return candidate + guard let generation = sanitized.identity.generation else { + return sanitized } let elapsedSeconds: TimeInterval @@ -134,7 +138,7 @@ final class EnergyImpactModel: ObservableObject { elapsedSeconds = configuration.publicationIntervalSeconds } guard elapsedSeconds.isFinite, elapsedSeconds > 0 else { - return Self.nonnumericUnavailable(candidate) + return Self.nonnumericUnavailable(sanitized) } if elapsedSeconds > configuration.maximumGapSeconds { @@ -149,10 +153,10 @@ final class EnergyImpactModel: ObservableObject { value: currentPower, elapsedSeconds: smoothingElapsed ) else { - return Self.nonnumericUnavailable(candidate) + return Self.nonnumericUnavailable(sanitized) } lastValidObservationTimes[generation] = publicationTime - return Self.replacingCurrentPower(in: candidate, with: smoothed) + return Self.replacingCurrentPower(in: sanitized, with: smoothed) } private func resetStatistics() { @@ -182,6 +186,35 @@ final class EnergyImpactModel: ObservableObject { } private static func nonnumericUnavailable(_ entry: EnergyImpactEntry) -> EnergyImpactEntry { + nonnumeric(entry, status: .unavailable) + } + + private static func sanitizedForInvalidClock(_ entry: EnergyImpactEntry) -> EnergyImpactEntry { + let sanitized = sanitizingInvalidNumerics(entry) + if sanitized.status == .stable || sanitized.status == .partial { + return nonnumericUnavailable(sanitized) + } + return sanitized + } + + private static func sanitizingInvalidNumerics(_ entry: EnergyImpactEntry) -> EnergyImpactEntry { + let numericValues = [ + entry.currentPowerMicrowatts, + entry.sustainedPowerMicrowatts, + entry.rankingScore, + ].compactMap { $0 } + guard numericValues.contains(where: { $0.isFinite == false || $0 < 0 }) else { + return entry + } + let status: EnergyImpactStatus = + entry.status == .stable || entry.status == .partial ? .unavailable : entry.status + return nonnumeric(entry, status: status) + } + + private static func nonnumeric( + _ entry: EnergyImpactEntry, + status: EnergyImpactStatus + ) -> EnergyImpactEntry { EnergyImpactEntry( identity: entry.identity, name: entry.name, @@ -193,7 +226,7 @@ final class EnergyImpactModel: ObservableObject { rankingScore: nil, trend: entry.trend, coverage: entry.coverage, - status: .unavailable + status: status ) } } diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactStatistics.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactStatistics.swift index c8fb977..575e40f 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactStatistics.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactStatistics.swift @@ -112,13 +112,11 @@ public struct StableEnergyImpactRanker: Sendable { } } - var activePairs = Set() if currentOrder.count > 1 { for index in 1.. TimeInterval { value diff --git a/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift b/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift index 70eb665..8e6360c 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift @@ -94,6 +94,56 @@ final class EnergyImpactStatisticsTests: XCTestCase { ) } + func testDisplacedPairDoesNotRetainConfirmationWhenItBecomesAdjacentAgain() { + var ranker = StableEnergyImpactRanker() + + XCTAssertEqual( + ranker.rank( + [ + fixtureEntry(pid: 1, score: 100), + fixtureEntry(pid: 2, score: 90), + fixtureEntry(pid: 3, score: 80), + ], + atPublicationBoundary: true + ).map(\.processIdentifier), + [1, 2, 3] + ) + XCTAssertEqual( + ranker.rank( + [ + fixtureEntry(pid: 1, score: 100), + fixtureEntry(pid: 2, score: 90), + fixtureEntry(pid: 3, score: 100), + ], + atPublicationBoundary: true + ).map(\.processIdentifier), + [1, 2, 3] + ) + XCTAssertEqual( + ranker.rank( + [ + fixtureEntry(pid: 1, score: 100), + fixtureEntry(pid: 2, score: 112), + fixtureEntry(pid: 3, score: 126), + ], + atPublicationBoundary: true + ).map(\.processIdentifier), + [1, 3, 2] + ) + + XCTAssertEqual( + ranker.rank( + [ + fixtureEntry(pid: 1, score: 100), + fixtureEntry(pid: 2, score: 112), + fixtureEntry(pid: 3, score: 80), + ], + atPublicationBoundary: true + ).map(\.processIdentifier), + [1, 2, 3] + ) + } + func testNilGenerationEntriesUseNameThenPIDTieBreaks() { var ranker = StableEnergyImpactRanker() let stateful = fixtureEntry(pid: 4, score: 1, name: "Last") From d03ab32e3e6e1265f3e728657fbcc6b7a9447d1a Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 31 Jul 2026 21:27:36 +1000 Subject: [PATCH 08/13] refactor(energy): assign unique process owners --- MacActivity.xcodeproj/project.pbxproj | 16 +++ .../Providers/EnergyImpactOwnership.swift | 44 +++++++ .../Providers/EnergyImpactProcessReader.swift | 100 ++++++++++++++++ .../Providers/EnergyImpactProvider.swift | 63 +++-------- .../EnergyImpactOwnershipTests.swift | 40 +++++++ .../EnergyImpactProcessReaderTests.swift | 39 +++++++ .../EnergyImpactProviderTests.swift | 107 +++++++++++++----- 7 files changed, 334 insertions(+), 75 deletions(-) create mode 100644 Sources/MacActivityCore/Metrics/Providers/EnergyImpactOwnership.swift create mode 100644 Sources/MacActivityCore/Metrics/Providers/EnergyImpactProcessReader.swift create mode 100644 Tests/MacActivityCoreTests/EnergyImpactOwnershipTests.swift create mode 100644 Tests/MacActivityCoreTests/EnergyImpactProcessReaderTests.swift diff --git a/MacActivity.xcodeproj/project.pbxproj b/MacActivity.xcodeproj/project.pbxproj index a9ce99d..bda2b23 100644 --- a/MacActivity.xcodeproj/project.pbxproj +++ b/MacActivity.xcodeproj/project.pbxproj @@ -86,6 +86,7 @@ 74DCCA48AC3A91932C22556A /* FakeAudioHALBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = BC74E94C6EA89C81461BEAE6 /* FakeAudioHALBackend.swift */; }; 754E6D11CBB044D143AE0022 /* AppLanguage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46B63F02833B7A142E6D3C19 /* AppLanguage.swift */; }; 75D742F910AE1AC2B65D75A8 /* MetricKind.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CF6C7817370CFE146811438 /* MetricKind.swift */; }; + 7A14AE60E94454E455406530 /* EnergyImpactProcessReaderTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E756788A431A050E50FDAA /* EnergyImpactProcessReaderTests.swift */; }; 7B1D24512C0A80195755C3BF /* ActiveProcessMemoryLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98B74985CC421B77A15255C4 /* ActiveProcessMemoryLayoutTests.swift */; }; 7CA75E9DA5AE16CBF75521EB /* EnergyImpactStatisticsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7B5639CB348DA6FDAE9279 /* EnergyImpactStatisticsTests.swift */; }; 7D184325401391E30FCB55B5 /* ActiveAppMemoryProvidingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 782F556D94AA82856902F0AD /* ActiveAppMemoryProvidingTests.swift */; }; @@ -109,7 +110,9 @@ B008413B7CBE75EA36C9FA65 /* ProcessTapVolumeEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 751602B497EFBC73C88631CA /* ProcessTapVolumeEngine.swift */; }; B1D58B1C23E57C4B2EFA43B2 /* FanProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 622432CA2E5A5BDE1D01C6D9 /* FanProvider.swift */; }; B2FCD7583F98E66D0FB507E4 /* DashboardPopoverController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D95C239D721596A4768CE46 /* DashboardPopoverController.swift */; }; + B318222CE0D85B319EA3573F /* EnergyImpactOwnershipTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5BF7D4EC5F491FFCA175F513 /* EnergyImpactOwnershipTests.swift */; }; B3C6A01640BB5B354B59AF73 /* LaunchAtLoginService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DFFA707E889556C69367275 /* LaunchAtLoginService.swift */; }; + B4056ECF58614FBE94D6B1F1 /* EnergyImpactProcessReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89A4859CDCAE97D91FDA5990 /* EnergyImpactProcessReader.swift */; }; B517658B9F459DABA4512734 /* ActiveProcessMemoryRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 443C3EE222DFFEB15FA514D2 /* ActiveProcessMemoryRow.swift */; }; B5B1142091C4E8D575C3F253 /* BatteryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 399F9E57090E6EAEA2C0D0EB /* BatteryProvider.swift */; }; B5EF90985452340276CD86DB /* AudioFeatureAvailabilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F35053D8A15E42D79FE09D8F /* AudioFeatureAvailabilityTests.swift */; }; @@ -137,6 +140,7 @@ D831AE2FAF5D40E0DB1A0A5E /* AppPresentationCoordinatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E1BBE46FD4D32BD54A627F /* AppPresentationCoordinatorTests.swift */; }; DA4161AA7426E46102112DA9 /* ProcessTapDSPTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CD64811702A042AB76985D4 /* ProcessTapDSPTests.swift */; }; DDE03109BB32E7B770459990 /* MetricProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CC85A29241CC6F902A0531F /* MetricProvider.swift */; }; + DE87E8B9F0BBFA1E45FF2961 /* EnergyImpactOwnership.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7244116397B5BA1B99ACDEBD /* EnergyImpactOwnership.swift */; }; DE8E0B03803F9378E7495EC6 /* FakeAudioTapHardware.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3D0B3BDDD156D72062607540 /* FakeAudioTapHardware.swift */; }; DFC2BBA5FD8590A1EE115FC8 /* AudioControlCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA74494A949B2E36D7F81079 /* AudioControlCoordinator.swift */; }; E179E6DCA9F45E748B710AA4 /* PreferencesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6FC2DC1E33B102AB8EE4AFA /* PreferencesView.swift */; }; @@ -277,6 +281,7 @@ 5910039CDBA471C2F9513628 /* MemoryReleaseServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryReleaseServiceTests.swift; sourceTree = ""; }; 5A485B93CFFE51CC3584E09D /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Localizable.strings; sourceTree = ""; }; 5B4CC9E01B6EB74B7E39DFF8 /* ProcessTapDSP.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessTapDSP.swift; sourceTree = ""; }; + 5BF7D4EC5F491FFCA175F513 /* EnergyImpactOwnershipTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactOwnershipTests.swift; sourceTree = ""; }; 5F80A7CF6CB9E39D7858C692 /* CPUProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUProvider.swift; sourceTree = ""; }; 5FC8340436E36F7FCE415A82 /* AudioDashboardViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioDashboardViewTests.swift; sourceTree = ""; }; 622432CA2E5A5BDE1D01C6D9 /* FanProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FanProvider.swift; sourceTree = ""; }; @@ -289,6 +294,7 @@ 716895D62F7DE80DDB7F6D63 /* ActiveAppMemoryProviding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveAppMemoryProviding.swift; sourceTree = ""; }; 7215BA634217D46E3C2AFD25 /* MetricsSnapshotTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSnapshotTests.swift; sourceTree = ""; }; 722D7AC69D9FCBD920BA78EF /* Mac Activity.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Mac Activity.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 7244116397B5BA1B99ACDEBD /* EnergyImpactOwnership.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactOwnership.swift; sourceTree = ""; }; 739D6F2737A0369CFC15403E /* AudioProcessProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioProcessProfile.swift; sourceTree = ""; }; 751602B497EFBC73C88631CA /* ProcessTapVolumeEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessTapVolumeEngine.swift; sourceTree = ""; }; 76A0523ECC4403F91DB89229 /* ActiveProcessMemoryLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActiveProcessMemoryLayout.swift; sourceTree = ""; }; @@ -301,6 +307,7 @@ 7F7B5639CB348DA6FDAE9279 /* EnergyImpactStatisticsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactStatisticsTests.swift; sourceTree = ""; }; 80D21F3C01CE00EFD700E003 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; 8428982312A6028D3AC20D1F /* MetricsStoreHistoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStoreHistoryTests.swift; sourceTree = ""; }; + 89A4859CDCAE97D91FDA5990 /* EnergyImpactProcessReader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactProcessReader.swift; sourceTree = ""; }; 8A4643019F12953DA4A2FBD2 /* AudioSystemMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSystemMonitor.swift; sourceTree = ""; }; 8C1D390604B9381BB20F9F4A /* TrashCleanupServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashCleanupServiceTests.swift; sourceTree = ""; }; 8DFFF072E13B7DBEA690C77C /* EnergyImpactViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactViewTests.swift; sourceTree = ""; }; @@ -323,6 +330,7 @@ ACFADB8DB7FD273E04239957 /* AudioSystemMonitorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioSystemMonitorTests.swift; sourceTree = ""; }; B541AF54F89E04066F33AD8E /* TrashCleanupService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrashCleanupService.swift; sourceTree = ""; }; B66490C62BF6EE5944B75C67 /* EnergyImpactTraceReplayTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactTraceReplayTests.swift; sourceTree = ""; }; + B6E756788A431A050E50FDAA /* EnergyImpactProcessReaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnergyImpactProcessReaderTests.swift; sourceTree = ""; }; B8233121C0FA90DBBF96DFE8 /* DashboardTrendChartLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardTrendChartLayoutTests.swift; sourceTree = ""; }; B8D56AF8E736A0BDA52CD00E /* FakeProcessTapRetryScheduler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeProcessTapRetryScheduler.swift; sourceTree = ""; }; B94D2FEA382B619F7773A1B4 /* MemoryProviderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryProviderTests.swift; sourceTree = ""; }; @@ -530,6 +538,8 @@ 399F9E57090E6EAEA2C0D0EB /* BatteryProvider.swift */, 420BEFFE5B99B07B33CAF5DE /* BatterySystemPowerSourceReader.swift */, 5F80A7CF6CB9E39D7858C692 /* CPUProvider.swift */, + 7244116397B5BA1B99ACDEBD /* EnergyImpactOwnership.swift */, + 89A4859CDCAE97D91FDA5990 /* EnergyImpactProcessReader.swift */, F6C57F502EC1B86CD070FE38 /* EnergyImpactProvider.swift */, 1C6E6EE79DD4E13EB9F43826 /* EnergyImpactStatistics.swift */, 90E95B0B56AA23B56343FB1C /* EnergyImpactTypes.swift */, @@ -648,6 +658,8 @@ 32BA3F0E90FEDB093D4676DC /* BatteryProviderTests.swift */, 4C28EA386891B5D88473C012 /* DashboardModelTests.swift */, 9B26B36594393917E7097C16 /* DiskCleanupServiceTests.swift */, + 5BF7D4EC5F491FFCA175F513 /* EnergyImpactOwnershipTests.swift */, + B6E756788A431A050E50FDAA /* EnergyImpactProcessReaderTests.swift */, 4FBBF160184071B2E1059B58 /* EnergyImpactProviderTests.swift */, 7F7B5639CB348DA6FDAE9279 /* EnergyImpactStatisticsTests.swift */, B66490C62BF6EE5944B75C67 /* EnergyImpactTraceReplayTests.swift */, @@ -942,6 +954,8 @@ 44A8EA3A8D04C637634D8037 /* BatteryProviderTests.swift in Sources */, 29B0CBE169F75666C6DBB021 /* DashboardModelTests.swift in Sources */, F20363F876BE2F8C2CA12F53 /* DiskCleanupServiceTests.swift in Sources */, + B318222CE0D85B319EA3573F /* EnergyImpactOwnershipTests.swift in Sources */, + 7A14AE60E94454E455406530 /* EnergyImpactProcessReaderTests.swift in Sources */, EDC980EF30033CA70792C40E /* EnergyImpactProviderTests.swift in Sources */, 7CA75E9DA5AE16CBF75521EB /* EnergyImpactStatisticsTests.swift in Sources */, 1B0012B1804C865A55F713DA /* EnergyImpactTraceReplayTests.swift in Sources */, @@ -996,6 +1010,8 @@ D7A61186072D694962A32AEC /* CoreAudioHALBackend.swift in Sources */, 1B3A5C1A4385179E39C90CDF /* DashboardModel.swift in Sources */, CF45AF27EDBD4FB898FC14D4 /* DiskCleanupService.swift in Sources */, + DE87E8B9F0BBFA1E45FF2961 /* EnergyImpactOwnership.swift in Sources */, + B4056ECF58614FBE94D6B1F1 /* EnergyImpactProcessReader.swift in Sources */, 0F997EFB2933D1DF01944138 /* EnergyImpactProvider.swift in Sources */, 2F976168C40F4D4D3D54A629 /* EnergyImpactStatistics.swift in Sources */, 25F6AB66E8EE1DA07F240610 /* EnergyImpactTypes.swift in Sources */, diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactOwnership.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactOwnership.swift new file mode 100644 index 0000000..c008ab0 --- /dev/null +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactOwnership.swift @@ -0,0 +1,44 @@ +import Darwin +import Foundation + +public enum EnergyImpactOwnership { + public static func nearestRootOwners( + rootProcessIdentifiers: [pid_t], + snapshots: [ProcessParentSnapshot] + ) -> [pid_t: pid_t] { + let roots = Set(rootProcessIdentifiers) + let parentByProcess = Dictionary( + snapshots.map { ($0.processIdentifier, $0.parentProcessIdentifier) }, + uniquingKeysWith: { first, _ in first } + ) + let candidates = Set(parentByProcess.keys).union(roots) + var resolved: [pid_t: pid_t] = [:] + + for candidate in candidates { + if let owner = nearestRoot( + for: candidate, + roots: roots, + parentByProcess: parentByProcess + ) { + resolved[candidate] = owner + } + } + return resolved + } + + private static func nearestRoot( + for processIdentifier: pid_t, + roots: Set, + parentByProcess: [pid_t: pid_t] + ) -> pid_t? { + var current = processIdentifier + var visited = Set() + + while visited.insert(current).inserted { + if roots.contains(current) { return current } + guard let parent = parentByProcess[current], parent > 0 else { return nil } + current = parent + } + return nil + } +} diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProcessReader.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProcessReader.swift new file mode 100644 index 0000000..58e7fcc --- /dev/null +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProcessReader.swift @@ -0,0 +1,100 @@ +import Darwin +import Foundation + +public enum ProcessEnergyReadFailure: Equatable, Sendable { + case exited + case permissionDenied + case unsupported + case other(Int32) +} + +public enum ProcessEnergyReadResult: Equatable, Sendable { + case success(ProcessEnergyReading) + case failure(ProcessEnergyReadFailure) +} + +public protocol ProcessEnergyReadingProvider: Sendable { + func reading(for processIdentifier: pid_t) -> ProcessEnergyReadResult +} + +public struct SystemProcessEnergyReader: ProcessEnergyReadingProvider { + public init() {} + + public func reading(for processIdentifier: pid_t) -> ProcessEnergyReadResult { + var info = rusage_info_v6() + let result = withUnsafeMutablePointer(to: &info) { pointer in + pointer.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { rebound in + proc_pid_rusage(processIdentifier, RUSAGE_INFO_V6, rebound) + } + } + guard result == 0 else { + let code = errno + return .failure(Self.failure(for: code)) + } + return .success(ProcessEnergyReading( + energyNanojoules: info.ri_energy_nj, + processStartAbsoluteTime: info.ri_proc_start_abstime, + userCPUTime: info.ri_user_time, + systemCPUTime: info.ri_system_time + )) + } + + static func failure(for code: Int32) -> ProcessEnergyReadFailure { + switch code { + case ESRCH: + return .exited + case EPERM, EACCES: + return .permissionDenied + case ENOTSUP, EINVAL: + return .unsupported + default: + return .other(code) + } + } +} + +public struct ProcessParentSnapshot: Equatable, Sendable { + public let processIdentifier: pid_t + public let parentProcessIdentifier: pid_t + + public init(processIdentifier: pid_t, parentProcessIdentifier: pid_t) { + self.processIdentifier = processIdentifier + self.parentProcessIdentifier = parentProcessIdentifier + } +} + +public protocol ProcessParentSnapshotReading: Sendable { + func snapshots() -> [ProcessParentSnapshot] +} + +public struct SystemProcessParentSnapshotReader: ProcessParentSnapshotReading { + public init() {} + + public func snapshots() -> [ProcessParentSnapshot] { + let initialCount = max(Int(proc_listallpids(nil, 0)), 0) + guard initialCount > 0 else { return [] } + var processIdentifiers = [pid_t](repeating: 0, count: initialCount + 32) + let count = processIdentifiers.withUnsafeMutableBytes { buffer in + proc_listallpids(buffer.baseAddress, Int32(buffer.count)) + } + guard count > 0 else { return [] } + + return processIdentifiers.prefix(Int(count)).compactMap { processIdentifier in + guard processIdentifier > 0 else { return nil } + var info = proc_bsdinfo() + let size = MemoryLayout.size + let read = proc_pidinfo( + processIdentifier, + PROC_PIDTBSDINFO, + 0, + &info, + Int32(size) + ) + guard read == Int32(size) else { return nil } + return ProcessParentSnapshot( + processIdentifier: processIdentifier, + parentProcessIdentifier: pid_t(info.pbi_ppid) + ) + } + } +} diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift index bf36c3c..91341c9 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift @@ -27,42 +27,26 @@ public struct EnergyImpactAppSnapshot: Equatable, Sendable { public struct ProcessEnergyReading: Equatable, Sendable { public let energyNanojoules: UInt64 public let processStartAbsoluteTime: UInt64 + public let userCPUTime: UInt64 + public let systemCPUTime: UInt64 public init( energyNanojoules: UInt64, - processStartAbsoluteTime: UInt64 = 0 + processStartAbsoluteTime: UInt64 = 0, + userCPUTime: UInt64 = 0, + systemCPUTime: UInt64 = 0 ) { self.energyNanojoules = energyNanojoules self.processStartAbsoluteTime = processStartAbsoluteTime - } -} - -public protocol ProcessEnergyReadingProvider: Sendable { - func reading(for processIdentifier: pid_t) -> ProcessEnergyReading? -} - -public struct SystemProcessEnergyReader: ProcessEnergyReadingProvider { - public init() {} - - public func reading(for processIdentifier: pid_t) -> ProcessEnergyReading? { - var info = rusage_info_v6() - let result = withUnsafeMutablePointer(to: &info) { pointer in - pointer.withMemoryRebound(to: rusage_info_t?.self, capacity: 1) { reboundPointer in - proc_pid_rusage(processIdentifier, RUSAGE_INFO_V6, reboundPointer) - } - } - guard result == 0 else { return nil } - return ProcessEnergyReading( - energyNanojoules: info.ri_energy_nj, - processStartAbsoluteTime: info.ri_proc_start_abstime - ) + self.userCPUTime = userCPUTime + self.systemCPUTime = systemCPUTime } } @MainActor public final class EnergyImpactService { private let reader: any ProcessEnergyReadingProvider - private let processSnapshotReader: any ProcessMemorySnapshotReading + private let processSnapshotReader: any ProcessParentSnapshotReading private let appSnapshotProvider: () -> [EnergyImpactAppSnapshot] private let clock: any EnergyImpactClock private var previousReadings: [EnergyImpactProcessIdentity: TimedProcessEnergyReading] = [:] @@ -70,7 +54,7 @@ public final class EnergyImpactService { public init( workspace: NSWorkspace = .shared, reader: any ProcessEnergyReadingProvider = SystemProcessEnergyReader(), - processSnapshotReader: any ProcessMemorySnapshotReading = SystemProcessMemorySnapshotReader(), + processSnapshotReader: any ProcessParentSnapshotReading = SystemProcessParentSnapshotReader(), appSnapshotProvider: (() -> [EnergyImpactAppSnapshot])? = nil, clock: any EnergyImpactClock = SystemEnergyImpactClock() ) { @@ -94,10 +78,14 @@ public final class EnergyImpactService { public func topApps(limit: Int = 20) -> [EnergyImpactEntry] { let apps = appSnapshotProvider() let sampleTime = clock.nowSeconds() - let processIdentifiersByRoot = Self.processIdentifiersByRoot( + let owners = EnergyImpactOwnership.nearestRootOwners( rootProcessIdentifiers: apps.map(\.processIdentifier), snapshots: processSnapshotReader.snapshots() ) + let processIdentifiersByRoot = Dictionary( + grouping: owners.keys, + by: { owners[$0]! } + ) var nextReadings: [EnergyImpactProcessIdentity: TimedProcessEnergyReading] = [:] let entries = apps.map { app -> EnergyImpactEntry in let processIdentifiers = processIdentifiersByRoot[app.processIdentifier] ?? [app.processIdentifier] @@ -107,7 +95,7 @@ public final class EnergyImpactService { var validDeltaCount = 0 for processIdentifier in processIdentifiers { - guard let current = reader.reading(for: processIdentifier) else { continue } + guard case let .success(current) = reader.reading(for: processIdentifier) else { continue } if processIdentifier == app.processIdentifier { rootProcessStartAbsoluteTime = current.processStartAbsoluteTime } @@ -193,27 +181,6 @@ public final class EnergyImpactService { .map { $0 } } - public nonisolated static func processIdentifiersByRoot( - rootProcessIdentifiers: [pid_t], - snapshots: [ProcessMemorySnapshot] - ) -> [pid_t: [pid_t]] { - let childrenByParent = Dictionary(grouping: snapshots, by: \.parentProcessIdentifier) - - return Dictionary(uniqueKeysWithValues: rootProcessIdentifiers.map { rootProcessIdentifier in - var identifiers = [rootProcessIdentifier] - var visited = Set([rootProcessIdentifier]) - var stack = childrenByParent[rootProcessIdentifier] ?? [] - - while let child = stack.popLast() { - guard visited.insert(child.processIdentifier).inserted else { continue } - identifiers.append(child.processIdentifier) - stack.append(contentsOf: childrenByParent[child.processIdentifier] ?? []) - } - - return (rootProcessIdentifier, identifiers) - }) - } - private nonisolated static func impactRate( from previous: TimedProcessEnergyReading, to current: ProcessEnergyReading, diff --git a/Tests/MacActivityCoreTests/EnergyImpactOwnershipTests.swift b/Tests/MacActivityCoreTests/EnergyImpactOwnershipTests.swift new file mode 100644 index 0000000..b5717f9 --- /dev/null +++ b/Tests/MacActivityCoreTests/EnergyImpactOwnershipTests.swift @@ -0,0 +1,40 @@ +import Darwin +import XCTest +@testable import MacActivityCore + +final class EnergyImpactOwnershipTests: XCTestCase { + func testNearestRegularRootOwnsNestedRootDescendantsExactlyOnce() { + let owners = EnergyImpactOwnership.nearestRootOwners( + rootProcessIdentifiers: [100, 200], + snapshots: [ + ProcessParentSnapshot(processIdentifier: 100, parentProcessIdentifier: 1), + ProcessParentSnapshot(processIdentifier: 150, parentProcessIdentifier: 100), + ProcessParentSnapshot(processIdentifier: 200, parentProcessIdentifier: 150), + ProcessParentSnapshot(processIdentifier: 250, parentProcessIdentifier: 200), + ] + ) + + XCTAssertEqual(owners[100], 100) + XCTAssertEqual(owners[150], 100) + XCTAssertEqual(owners[200], 200) + XCTAssertEqual(owners[250], 200) + XCTAssertEqual(owners.values.filter { $0 == 100 }.count, 2) + XCTAssertEqual(owners.values.filter { $0 == 200 }.count, 2) + } + + func testCycleAndOrphanDoNotAcquireAnOwner() { + let owners = EnergyImpactOwnership.nearestRootOwners( + rootProcessIdentifiers: [100], + snapshots: [ + ProcessParentSnapshot(processIdentifier: 100, parentProcessIdentifier: 1), + ProcessParentSnapshot(processIdentifier: 300, parentProcessIdentifier: 301), + ProcessParentSnapshot(processIdentifier: 301, parentProcessIdentifier: 300), + ProcessParentSnapshot(processIdentifier: 400, parentProcessIdentifier: 999), + ] + ) + + XCTAssertNil(owners[300]) + XCTAssertNil(owners[301]) + XCTAssertNil(owners[400]) + } +} diff --git a/Tests/MacActivityCoreTests/EnergyImpactProcessReaderTests.swift b/Tests/MacActivityCoreTests/EnergyImpactProcessReaderTests.swift new file mode 100644 index 0000000..9139a2b --- /dev/null +++ b/Tests/MacActivityCoreTests/EnergyImpactProcessReaderTests.swift @@ -0,0 +1,39 @@ +import Darwin +import XCTest +@testable import MacActivityCore + +final class EnergyImpactProcessReaderTests: XCTestCase { + func testCurrentProcessReadReturnsStructuredSuccessWithActivityEvidence() throws { + let result = SystemProcessEnergyReader().reading(for: getpid()) + + guard case let .success(reading) = result else { + return XCTFail("The current process must produce a successful structured reading") + } + XCTAssertGreaterThan(reading.processStartAbsoluteTime, 0) + XCTAssertGreaterThan(reading.userCPUTime + reading.systemCPUTime, 0) + } + + func testInvalidProcessReadReturnsAClassifiedFailure() { + let result = SystemProcessEnergyReader().reading(for: Int32.max) + + guard case let .failure(failure) = result else { + return XCTFail("An invalid process must not produce an energy reading") + } + switch failure { + case .exited, .other: + break + case .permissionDenied, .unsupported: + XCTFail("Invalid PID failure was misclassified as \(failure)") + } + } + + func testAccessErrorsMapToPermissionDenied() { + XCTAssertEqual(SystemProcessEnergyReader.failure(for: EPERM), .permissionDenied) + XCTAssertEqual(SystemProcessEnergyReader.failure(for: EACCES), .permissionDenied) + } + + func testUnsupportedErrorsIncludeInvalidArgument() { + XCTAssertEqual(SystemProcessEnergyReader.failure(for: ENOTSUP), .unsupported) + XCTAssertEqual(SystemProcessEnergyReader.failure(for: EINVAL), .unsupported) + } +} diff --git a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift index 37cc9d9..c8e6ae0 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift @@ -70,8 +70,8 @@ final class EnergyImpactProviderTests: XCTestCase { .init(energyNanojoules: 3_000, processStartAbsoluteTime: 21), ], ]), - processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: [ - .init(processIdentifier: 101, parentProcessIdentifier: 100, residentMemoryBytes: 0), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ + .init(processIdentifier: 101, parentProcessIdentifier: 100), ]), appSnapshotProvider: { [app] }, clock: EnergyImpactClockStub(times: [100, 101]) @@ -87,12 +87,6 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(entry.currentPowerMicrowatts), 1, accuracy: 0.001) } - func testSystemProcessEnergyReaderReadsCurrentProcess() throws { - let reading = try XCTUnwrap(SystemProcessEnergyReader().reading(for: getpid())) - - XCTAssertGreaterThan(reading.processStartAbsoluteTime, 0) - } - func testSystemEnergyImpactClockProvidesMonotonicSeconds() { let clock = SystemEnergyImpactClock() let first = clock.nowSeconds() @@ -104,7 +98,7 @@ final class EnergyImpactProviderTests: XCTestCase { func testDefaultWorkspaceSnapshotProviderBuildsEntriesFromRunningApplications() { let service = EnergyImpactService( reader: ProcessEnergyReadingProviderStub(readings: [:]), - processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: []) + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []) ) let entries = service.topApps(limit: 1) @@ -138,7 +132,7 @@ final class EnergyImpactProviderTests: XCTestCase { ]) let service = EnergyImpactService( reader: reader, - processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: []), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), appSnapshotProvider: { apps }, clock: EnergyImpactClockStub(times: [100, 101]) ) @@ -170,7 +164,7 @@ final class EnergyImpactProviderTests: XCTestCase { ]) let service = EnergyImpactService( reader: reader, - processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: []), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), appSnapshotProvider: { [app] }, clock: EnergyImpactClockStub(times: [100, 100.5]) ) @@ -204,11 +198,11 @@ final class EnergyImpactProviderTests: XCTestCase { ]) let service = EnergyImpactService( reader: reader, - processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: [ - ProcessMemorySnapshot(processIdentifier: 100, parentProcessIdentifier: 1, residentMemoryBytes: 0), - ProcessMemorySnapshot(processIdentifier: 101, parentProcessIdentifier: 100, residentMemoryBytes: 0), - ProcessMemorySnapshot(processIdentifier: 102, parentProcessIdentifier: 101, residentMemoryBytes: 0), - ProcessMemorySnapshot(processIdentifier: 999, parentProcessIdentifier: 1, residentMemoryBytes: 0), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ + ProcessParentSnapshot(processIdentifier: 100, parentProcessIdentifier: 1), + ProcessParentSnapshot(processIdentifier: 101, parentProcessIdentifier: 100), + ProcessParentSnapshot(processIdentifier: 102, parentProcessIdentifier: 101), + ProcessParentSnapshot(processIdentifier: 999, parentProcessIdentifier: 1), ]), appSnapshotProvider: { [app] }, clock: EnergyImpactClockStub(times: [100, 102]) @@ -224,6 +218,65 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertEqual(reader.readCount(for: 999), 0) } + func testEnergyImpactServiceAssignsNestedRegularRootProcessesToNearestRootExactlyOnce() throws { + let apps = [ + EnergyImpactAppSnapshot( + processIdentifier: 100, + name: "Browser", + bundleIdentifier: "com.example.browser", + bundleURL: nil + ), + EnergyImpactAppSnapshot( + processIdentifier: 200, + name: "Nested App", + bundleIdentifier: "com.example.nested", + bundleURL: nil + ), + ] + let reader = ProcessEnergyReadingProviderStub(readings: [ + 100: [ + .init(energyNanojoules: 1_000, processStartAbsoluteTime: 10), + .init(energyNanojoules: 2_000, processStartAbsoluteTime: 10), + ], + 150: [ + .init(energyNanojoules: 1_000, processStartAbsoluteTime: 15), + .init(energyNanojoules: 3_000, processStartAbsoluteTime: 15), + ], + 200: [ + .init(energyNanojoules: 1_000, processStartAbsoluteTime: 20), + .init(energyNanojoules: 4_000, processStartAbsoluteTime: 20), + ], + 250: [ + .init(energyNanojoules: 1_000, processStartAbsoluteTime: 25), + .init(energyNanojoules: 5_000, processStartAbsoluteTime: 25), + ], + ]) + let service = EnergyImpactService( + reader: reader, + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ + .init(processIdentifier: 100, parentProcessIdentifier: 1), + .init(processIdentifier: 150, parentProcessIdentifier: 100), + .init(processIdentifier: 200, parentProcessIdentifier: 150), + .init(processIdentifier: 250, parentProcessIdentifier: 200), + ]), + appSnapshotProvider: { apps }, + clock: EnergyImpactClockStub(times: [100, 101]) + ) + + _ = service.topApps(limit: 2) + let entries = service.topApps(limit: 2) + let powerByProcess = Dictionary(uniqueKeysWithValues: entries.map { + ($0.processIdentifier, $0.currentPowerMicrowatts) + }) + + XCTAssertEqual(try XCTUnwrap(powerByProcess[100] ?? nil), 3, accuracy: 0.001) + XCTAssertEqual(try XCTUnwrap(powerByProcess[200] ?? nil), 7, accuracy: 0.001) + XCTAssertEqual(reader.readCount(for: 100), 2) + XCTAssertEqual(reader.readCount(for: 150), 2) + XCTAssertEqual(reader.readCount(for: 200), 2) + XCTAssertEqual(reader.readCount(for: 250), 2) + } + func testEnergyImpactServiceRejectsDeltasWhenPIDIsReused() { let app = EnergyImpactAppSnapshot( processIdentifier: 101, @@ -239,7 +292,7 @@ final class EnergyImpactProviderTests: XCTestCase { ]) let service = EnergyImpactService( reader: reader, - processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: []), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), appSnapshotProvider: { [app] }, clock: EnergyImpactClockStub(times: [100, 101]) ) @@ -261,7 +314,7 @@ final class EnergyImpactProviderTests: XCTestCase { .init(energyNanojoules: 10_000, processStartAbsoluteTime: 10), ], ]), - processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: []), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), appSnapshotProvider: { [ .init(processIdentifier: 101, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), ] }, @@ -285,7 +338,7 @@ final class EnergyImpactProviderTests: XCTestCase { .init(energyNanojoules: 5_000, processStartAbsoluteTime: 10), ], ]), - processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: []), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), appSnapshotProvider: { [ .init(processIdentifier: 101, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), ] }, @@ -303,7 +356,7 @@ final class EnergyImpactProviderTests: XCTestCase { let reader = ProcessEnergyReadingProviderStub(readings: [:]) let service = EnergyImpactService( reader: reader, - processSnapshotReader: ProcessMemorySnapshotReaderStub(snapshots: []), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), appSnapshotProvider: { [ EnergyImpactAppSnapshot( @@ -383,14 +436,14 @@ private final class ProcessEnergyReadingProviderStub: ProcessEnergyReadingProvid self.readings = readings } - func reading(for processIdentifier: pid_t) -> ProcessEnergyReading? { + func reading(for processIdentifier: pid_t) -> ProcessEnergyReadResult { readCounts[processIdentifier, default: 0] += 1 guard var values = readings[processIdentifier], values.isEmpty == false else { - return nil + return .failure(.other(0)) } let value = values.removeFirst() readings[processIdentifier] = values - return value + return .success(value) } func readCount(for processIdentifier: pid_t) -> Int { @@ -398,14 +451,14 @@ private final class ProcessEnergyReadingProviderStub: ProcessEnergyReadingProvid } } -private struct ProcessMemorySnapshotReaderStub: ProcessMemorySnapshotReading { - let snapshotValues: [ProcessMemorySnapshot] +private struct ProcessParentSnapshotReaderStub: ProcessParentSnapshotReading { + let snapshotValues: [ProcessParentSnapshot] - init(snapshots: [ProcessMemorySnapshot]) { + init(snapshots: [ProcessParentSnapshot]) { self.snapshotValues = snapshots } - func snapshots() -> [ProcessMemorySnapshot] { + func snapshots() -> [ProcessParentSnapshot] { snapshotValues } } From 59e00784160587ae223ba973b9a4696598f2be76 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 31 Jul 2026 21:42:02 +1000 Subject: [PATCH 09/13] fix(energy): preserve partial sampling state --- .../Providers/EnergyImpactProvider.swift | 316 ++++++++++--- .../Metrics/Providers/EnergyImpactTypes.swift | 24 + .../EnergyImpactProviderTests.swift | 422 +++++++++++++++++- 3 files changed, 690 insertions(+), 72 deletions(-) diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift index 91341c9..26bb7aa 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift @@ -49,18 +49,25 @@ public final class EnergyImpactService { private let processSnapshotReader: any ProcessParentSnapshotReading private let appSnapshotProvider: () -> [EnergyImpactAppSnapshot] private let clock: any EnergyImpactClock - private var previousReadings: [EnergyImpactProcessIdentity: TimedProcessEnergyReading] = [:] + private let configuration: EnergyImpactConfiguration + private var baselines: [EnergyImpactProcessIdentity: ProcessEnergyBaseline] = [:] + private var identityByProcessIdentifier: [pid_t: EnergyImpactProcessIdentity] = [:] + private var currentIdentityByRootProcessIdentifier: [pid_t: EnergyImpactAppIdentity] = [:] + private var displayByIdentity: [EnergyImpactAppIdentity: EnergyImpactDisplayState] = [:] + private var previousSampleTime: TimeInterval? public init( workspace: NSWorkspace = .shared, reader: any ProcessEnergyReadingProvider = SystemProcessEnergyReader(), processSnapshotReader: any ProcessParentSnapshotReading = SystemProcessParentSnapshotReader(), appSnapshotProvider: (() -> [EnergyImpactAppSnapshot])? = nil, - clock: any EnergyImpactClock = SystemEnergyImpactClock() + clock: any EnergyImpactClock = SystemEnergyImpactClock(), + configuration: EnergyImpactConfiguration = .production ) { self.reader = reader self.processSnapshotReader = processSnapshotReader self.clock = clock + self.configuration = configuration self.appSnapshotProvider = appSnapshotProvider ?? { workspace.runningApplications .filter { $0.activationPolicy == .regular } @@ -76,8 +83,24 @@ public final class EnergyImpactService { } public func topApps(limit: Int = 20) -> [EnergyImpactEntry] { + let samples = sampleApps() + return Self.sortedByImpact(samples.map(\.entry), limit: limit) + } + + private func sampleApps() -> [EnergyImpactAppSample] { let apps = appSnapshotProvider() let sampleTime = clock.nowSeconds() + let observationInterval: Range? + if let previousSampleTime { + let elapsed = sampleTime - previousSampleTime + observationInterval = elapsed > 0 && elapsed <= configuration.maximumGapSeconds + ? previousSampleTime.. EnergyImpactEntry in - let processIdentifiers = processIdentifiersByRoot[app.processIdentifier] ?? [app.processIdentifier] - var rootProcessStartAbsoluteTime: UInt64? - var totalPowerMicrowatts = 0.0 + let samples = apps.map { app -> EnergyImpactAppSample in + let processIdentifiers = (processIdentifiersByRoot[app.processIdentifier] ?? [app.processIdentifier]) + .sorted { lhs, rhs in + if lhs == app.processIdentifier { return true } + if rhs == app.processIdentifier { return false } + return lhs < rhs + } + var contributions: [ProcessEnergyContribution] = [] var readableProcessCount = 0 - var validDeltaCount = 0 + var unsupportedProcessCount = 0 for processIdentifier in processIdentifiers { - guard case let .success(current) = reader.reading(for: processIdentifier) else { continue } - if processIdentifier == app.processIdentifier { - rootProcessStartAbsoluteTime = current.processStartAbsoluteTime - } - readableProcessCount += 1 - let identity = EnergyImpactProcessIdentity( - processIdentifier: processIdentifier, - processStartAbsoluteTime: current.processStartAbsoluteTime - ) - nextReadings[identity] = TimedProcessEnergyReading( - reading: current, - sampleTime: sampleTime - ) - if let previous = previousReadings[identity], - let impactRate = Self.impactRate( - from: previous, - to: current, - sampleTime: sampleTime, - maximumGapSeconds: EnergyImpactConfiguration.production.maximumGapSeconds - ) { - totalPowerMicrowatts += impactRate - validDeltaCount += 1 + switch reader.reading(for: processIdentifier) { + case let .failure(failure): + let baseline = identityByProcessIdentifier[processIdentifier].flatMap { baselines[$0] } + if failure == .unsupported || baseline?.counterUnsupported == true { + unsupportedProcessCount += 1 + if failure == .unsupported, + let identity = identityByProcessIdentifier[processIdentifier], + let existing = baselines[identity] { + baselines[identity] = existing.markingCounterUnsupported() + } + } + + case let .success(current): + let identity = EnergyImpactProcessIdentity( + processIdentifier: processIdentifier, + processStartAbsoluteTime: current.processStartAbsoluteTime + ) + if let oldIdentity = identityByProcessIdentifier[processIdentifier], + oldIdentity != identity { + baselines.removeValue(forKey: oldIdentity) + } + identityByProcessIdentifier[processIdentifier] = identity + + if processIdentifier == app.processIdentifier { + let appIdentity = EnergyImpactAppIdentity( + rootProcessIdentifier: processIdentifier, + rootProcessStartAbsoluteTime: current.processStartAbsoluteTime + ) + if let oldIdentity = currentIdentityByRootProcessIdentifier[processIdentifier], + oldIdentity != appIdentity { + displayByIdentity.removeValue(forKey: oldIdentity) + } + currentIdentityByRootProcessIdentifier[processIdentifier] = appIdentity + } + + let previous = baselines[identity] + let elapsed = previous.map { sampleTime - $0.sampleTime } + let canUsePrevious = observationInterval != nil + && previous?.ownerRootProcessIdentifier == app.processIdentifier + && elapsed.map { $0 > 0 && $0 <= configuration.maximumGapSeconds } == true + var zeroEnergyWithCPUActivitySeconds = canUsePrevious + ? previous?.zeroEnergyWithCPUActivitySeconds ?? 0 + : 0 + var counterUnsupported = canUsePrevious + ? previous?.counterUnsupported ?? false + : false + + if let previous, let elapsed, canUsePrevious, + current.energyNanojoules >= previous.reading.energyNanojoules { + if current.energyNanojoules > previous.reading.energyNanojoules { + zeroEnergyWithCPUActivitySeconds = 0 + counterUnsupported = false + } else if current.energyNanojoules == 0, + previous.reading.energyNanojoules == 0, + (current.userCPUTime > previous.reading.userCPUTime + || current.systemCPUTime > previous.reading.systemCPUTime) { + zeroEnergyWithCPUActivitySeconds += elapsed + if zeroEnergyWithCPUActivitySeconds >= configuration.maximumGapSeconds { + counterUnsupported = true + } + } + + if counterUnsupported == false, observationInterval != nil { + contributions.append(ProcessEnergyContribution( + processIdentity: identity, + ownerRootProcessIdentifier: app.processIdentifier, + startTimeSeconds: previous.sampleTime, + endTimeSeconds: sampleTime, + energyMicrojoules: Double( + current.energyNanojoules - previous.reading.energyNanojoules + ) / 1_000.0 + )) + } + } else if previous != nil { + zeroEnergyWithCPUActivitySeconds = 0 + counterUnsupported = false + } + + baselines[identity] = ProcessEnergyBaseline( + reading: current, + sampleTime: sampleTime, + lastObservedAt: sampleTime, + ownerRootProcessIdentifier: app.processIdentifier, + zeroEnergyWithCPUActivitySeconds: zeroEnergyWithCPUActivitySeconds, + counterUnsupported: counterUnsupported + ) + if counterUnsupported { + unsupportedProcessCount += 1 + } else { + readableProcessCount += 1 + } } } - let identity = EnergyImpactAppIdentity( - rootProcessIdentifier: app.processIdentifier, - rootProcessStartAbsoluteTime: rootProcessStartAbsoluteTime - ) - let coverage = EnergyImpactCoverage( + let identity = currentIdentityByRootProcessIdentifier[app.processIdentifier] + ?? EnergyImpactAppIdentity( + rootProcessIdentifier: app.processIdentifier, + rootProcessStartAbsoluteTime: nil + ) + let clipped = observationInterval.map { interval in + contributions.compactMap { $0.clipped(to: interval) } + } ?? [] + let energyMicrojoules = clipped.reduce(0) { $0 + $1.energyMicrojoules } + let validProcessSeconds = clipped.reduce(0) { $0 + $1.durationSeconds } + let observationDuration = observationInterval.map { + $0.upperBound - $0.lowerBound + } ?? 0 + let discoveredProcessSeconds = Double(processIdentifiers.count) * observationDuration + let currentPowerMicrowatts = validProcessSeconds > 0 && observationDuration > 0 + ? energyMicrojoules / observationDuration + : nil + let currentCoverage = EnergyImpactCoverage( discoveredProcessCount: processIdentifiers.count, readableProcessCount: readableProcessCount, - validProcessSeconds: TimeInterval(validDeltaCount), - discoveredProcessSeconds: TimeInterval(processIdentifiers.count) + validProcessSeconds: validProcessSeconds, + discoveredProcessSeconds: discoveredProcessSeconds ) - let status: EnergyImpactStatus = if readableProcessCount == 0 { - .unavailable - } else if validDeltaCount == 0 { - .collecting - } else if validDeltaCount < processIdentifiers.count { - .partial + let previousDisplay = displayByIdentity[identity] + let displayAge = previousDisplay.map { sampleTime - $0.sampleTime } + let canPublishStale = displayAge.map { + $0 >= 0 && $0 <= configuration.maximumGapSeconds + } == true + let allProcessesUnsupported = processIdentifiers.isEmpty == false + && unsupportedProcessCount == processIdentifiers.count + + let status: EnergyImpactStatus + if allProcessesUnsupported { + status = .unavailable + } else if discoveredProcessSeconds > 0 + && validProcessSeconds >= discoveredProcessSeconds - 0.000_001 { + status = .stable + } else if validProcessSeconds > 0 { + status = .partial + } else if canPublishStale { + status = .stale + } else if readableProcessCount > 0 { + status = .collecting } else { - .stable + status = .unavailable } - let currentPower = validDeltaCount == 0 ? nil : totalPowerMicrowatts - return EnergyImpactEntry( - identity: identity, + let publishedIdentity = status == .stale ? previousDisplay!.entry.identity : identity + let publishedPower = status == .stale + ? previousDisplay!.entry.currentPowerMicrowatts + : (status == .stable || status == .partial ? currentPowerMicrowatts : nil) + let publishedCoverage = status == .stale + ? previousDisplay!.entry.coverage + : currentCoverage + let entry = EnergyImpactEntry( + identity: publishedIdentity, name: app.name, bundleIdentifier: app.bundleIdentifier, bundleURL: app.bundleURL, kind: app.kind, - currentPowerMicrowatts: currentPower, + currentPowerMicrowatts: publishedPower, sustainedPowerMicrowatts: nil, - rankingScore: currentPower, + rankingScore: status == .stable || status == .partial ? publishedPower : nil, trend: .steady, - coverage: coverage, + coverage: publishedCoverage, status: status ) + + if (status == .stable || status == .partial), publishedIdentity.generation != nil { + displayByIdentity[publishedIdentity] = EnergyImpactDisplayState( + entry: entry, + sampleTime: sampleTime + ) + } else if allProcessesUnsupported { + displayByIdentity.removeValue(forKey: identity) + } + return EnergyImpactAppSample(entry: entry, contributions: contributions) } - previousReadings = nextReadings - return Self.sortedByImpact(entries, limit: limit) + + pruneState(at: sampleTime) + return samples } public nonisolated static func sortedByImpact( @@ -164,7 +303,13 @@ public final class EnergyImpactService { limit: Int ) -> [EnergyImpactEntry] { entries.sorted { lhs, rhs in - switch (lhs.rankingScore, rhs.rankingScore) { + let leftBucket = statusSortBucket(for: lhs) + let rightBucket = statusSortBucket(for: rhs) + if leftBucket != rightBucket { return leftBucket < rightBucket } + + let leftScore = lhs.rankingScore ?? lhs.currentPowerMicrowatts + let rightScore = rhs.rankingScore ?? rhs.currentPowerMicrowatts + switch (leftScore, rightScore) { case let (left?, right?) where left != right: return left > right case (_?, nil): @@ -181,24 +326,61 @@ public final class EnergyImpactService { .map { $0 } } - private nonisolated static func impactRate( - from previous: TimedProcessEnergyReading, - to current: ProcessEnergyReading, - sampleTime: TimeInterval, - maximumGapSeconds: TimeInterval - ) -> Double? { - guard current.processStartAbsoluteTime == previous.reading.processStartAbsoluteTime, - current.energyNanojoules >= previous.reading.energyNanojoules else { - return nil + private nonisolated static func statusSortBucket(for entry: EnergyImpactEntry) -> Int { + switch (entry.status, entry.currentPowerMicrowatts) { + case (.stable, .some), (.partial, .some): 0 + case (.stale, .some): 1 + case (.collecting, _): 2 + case (.unavailable, _): 3 + default: 3 + } + } + + private func pruneState(at sampleTime: TimeInterval) { + let expiredIdentities = baselines.compactMap { identity, baseline in + let age = sampleTime - baseline.lastObservedAt + return age > configuration.maximumGapSeconds ? identity : nil + } + for identity in expiredIdentities { + baselines.removeValue(forKey: identity) + if identityByProcessIdentifier[identity.processIdentifier] == identity { + identityByProcessIdentifier.removeValue(forKey: identity.processIdentifier) + } + } + + displayByIdentity = displayByIdentity.filter { _, display in + let age = sampleTime - display.sampleTime + return age >= 0 && age <= configuration.maximumGapSeconds } - let elapsedSeconds = sampleTime - previous.sampleTime - guard elapsedSeconds > 0, elapsedSeconds <= maximumGapSeconds else { return nil } - let deltaMicrojoules = Double(current.energyNanojoules - previous.reading.energyNanojoules) / 1_000.0 - return deltaMicrojoules / elapsedSeconds } } -private struct TimedProcessEnergyReading: Sendable { +private struct ProcessEnergyBaseline: Sendable { let reading: ProcessEnergyReading let sampleTime: TimeInterval + let lastObservedAt: TimeInterval + let ownerRootProcessIdentifier: pid_t + let zeroEnergyWithCPUActivitySeconds: TimeInterval + let counterUnsupported: Bool + + func markingCounterUnsupported() -> ProcessEnergyBaseline { + ProcessEnergyBaseline( + reading: reading, + sampleTime: sampleTime, + lastObservedAt: lastObservedAt, + ownerRootProcessIdentifier: ownerRootProcessIdentifier, + zeroEnergyWithCPUActivitySeconds: zeroEnergyWithCPUActivitySeconds, + counterUnsupported: true + ) + } +} + +private struct EnergyImpactDisplayState: Sendable { + let entry: EnergyImpactEntry + let sampleTime: TimeInterval +} + +private struct EnergyImpactAppSample: Sendable { + let entry: EnergyImpactEntry + let contributions: [ProcessEnergyContribution] } diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift index 96ad0d7..f6cfcb0 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactTypes.swift @@ -32,6 +32,30 @@ public struct EnergyImpactProcessIdentity: Hashable, Sendable { } } +public struct ProcessEnergyContribution: Equatable, Sendable { + public let processIdentity: EnergyImpactProcessIdentity + public let ownerRootProcessIdentifier: pid_t + public let startTimeSeconds: TimeInterval + public let endTimeSeconds: TimeInterval + public let energyMicrojoules: Double + + public var durationSeconds: TimeInterval { endTimeSeconds - startTimeSeconds } + + public func clipped(to interval: Range) -> ProcessEnergyContribution? { + let start = max(startTimeSeconds, interval.lowerBound) + let end = min(endTimeSeconds, interval.upperBound) + guard durationSeconds > 0, end > start else { return nil } + let fraction = (end - start) / durationSeconds + return ProcessEnergyContribution( + processIdentity: processIdentity, + ownerRootProcessIdentifier: ownerRootProcessIdentifier, + startTimeSeconds: start, + endTimeSeconds: end, + energyMicrojoules: energyMicrojoules * fraction + ) + } +} + public enum EnergyImpactAppKind: String, Equatable, Codable, Sendable { case regular case accessory diff --git a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift index c8e6ae0..574a918 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift @@ -4,6 +4,113 @@ import XCTest @MainActor final class EnergyImpactProviderTests: XCTestCase { + private func reading( + energy: UInt64, + start: UInt64 = 10, + userCPU: UInt64 = 0, + systemCPU: UInt64 = 0 + ) -> ProcessEnergyReading { + ProcessEnergyReading( + energyNanojoules: energy, + processStartAbsoluteTime: start, + userCPUTime: userCPU, + systemCPUTime: systemCPU + ) + } + + private func makeService( + results: [ProcessEnergyReadResult], + times: [TimeInterval] + ) -> EnergyImpactService { + EnergyImpactService( + reader: ProcessEnergyReadingProviderStub(results: [101: results]), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: []), + appSnapshotProvider: { [ + .init(processIdentifier: 101, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: EnergyImpactClockStub(times: times) + ) + } + + private func makeTwoProcessService( + rootResults: [ProcessEnergyReadResult], + helperResults: [ProcessEnergyReadResult], + times: [TimeInterval] + ) -> EnergyImpactService { + EnergyImpactService( + reader: ProcessEnergyReadingProviderStub(results: [ + 100: rootResults, + 101: helperResults, + ]), + processSnapshotReader: ProcessParentSnapshotReaderStub(snapshots: [ + .init(processIdentifier: 101, parentProcessIdentifier: 100), + ]), + appSnapshotProvider: { [ + .init(processIdentifier: 100, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: EnergyImpactClockStub(times: times) + ) + } + + private func makeReparentingService( + ownersBySample: [pid_t], + energies: [UInt64], + times: [TimeInterval] + ) -> EnergyImpactService { + EnergyImpactService( + reader: ProcessEnergyReadingProviderStub(results: [ + 100: ownersBySample.map { _ in .failure(.permissionDenied) }, + 200: ownersBySample.map { _ in .failure(.permissionDenied) }, + 300: energies.map { .success(reading(energy: $0, start: 30)) }, + ]), + processSnapshotReader: SequencedProcessParentSnapshotReaderStub( + snapshotsByCall: ownersBySample.map { owner in + [.init(processIdentifier: 300, parentProcessIdentifier: owner)] + } + ), + appSnapshotProvider: { [ + .init(processIdentifier: 100, name: "First", bundleIdentifier: nil, bundleURL: nil), + .init(processIdentifier: 200, name: "Second", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: EnergyImpactClockStub(times: times) + ) + } + + private func makeMixedGapService( + rootEnergies: [UInt64], + helperResults: [ProcessEnergyReadResult], + times: [TimeInterval] + ) -> EnergyImpactService { + makeTwoProcessService( + rootResults: rootEnergies.map { .success(reading(energy: $0, start: 10)) }, + helperResults: helperResults, + times: times + ) + } + + private func entry( + processIdentifier: pid_t, + name: String, + power: Double?, + status: EnergyImpactStatus + ) -> EnergyImpactEntry { + EnergyImpactEntry( + identity: EnergyImpactAppIdentity( + rootProcessIdentifier: processIdentifier, + rootProcessStartAbsoluteTime: UInt64(processIdentifier) + ), + name: name, + bundleIdentifier: nil, + bundleURL: nil, + currentPowerMicrowatts: power, + sustainedPowerMicrowatts: nil, + rankingScore: power, + trend: .steady, + coverage: .unavailable, + status: status + ) + } + func testEnergyImpactEntryRepresentsCollectingWithoutAFalseZero() { let entry = EnergyImpactEntry( identity: EnergyImpactAppIdentity( @@ -376,6 +483,291 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertEqual(entries[0].status, .unavailable) } + // Production break caught: an unreadable helper is collapsed into a false full-coverage value. + func testOneUnreadableHelperProducesPartialCoverageWithoutAFalseZero() throws { + let service = makeTwoProcessService( + rootResults: [ + .success(reading(energy: 1_000)), + .success(reading(energy: 4_000)), + ], + helperResults: [ + .failure(.permissionDenied), + .failure(.permissionDenied), + ], + times: [0, 3] + ) + + _ = service.topApps(limit: 1) + let entry = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(entry.status, .partial) + XCTAssertEqual(try XCTUnwrap(entry.currentPowerMicrowatts), 1, accuracy: 0.001) + XCTAssertEqual(entry.coverage.readableProcessCount, 1) + XCTAssertEqual(entry.coverage.discoveredProcessCount, 2) + XCTAssertEqual(entry.coverage.validProcessSeconds, 3, accuracy: 0.001) + XCTAssertEqual(entry.coverage.discoveredProcessSeconds, 6, accuracy: 0.001) + XCTAssertEqual(entry.coverage.fraction, 0.5, accuracy: 0.001) + } + + // Production break caught: a temporary read failure deletes the generation baseline needed for recovery. + func testTemporaryFailureKeepsBaselineAndRecoveryUsesTheBoundedInterval() throws { + let service = makeService( + results: [ + .success(reading(energy: 1_000)), + .failure(.exited), + .success(reading(energy: 7_000)), + ], + times: [0, 3, 6] + ) + + _ = service.topApps(limit: 1) + let failed = try XCTUnwrap(service.topApps(limit: 1).first) + let recovered = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertNotEqual(failed.status, .stable) + XCTAssertEqual(try XCTUnwrap(recovered.currentPowerMicrowatts), 1, accuracy: 0.001) + XCTAssertEqual(recovered.status, .stable) + } + + // Production break caught: a baseline older than the ten-second bound still emits a diluted recovery value. + func testFailurePastTenSecondsExpiresTheBaseline() throws { + let service = makeService( + results: [ + .success(reading(energy: 1_000)), + .failure(.permissionDenied), + .success(reading(energy: 20_000)), + ], + times: [0, 3, 14] + ) + + _ = service.topApps(limit: 1) + _ = service.topApps(limit: 1) + let entry = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertNil(entry.currentPowerMicrowatts) + XCTAssertEqual(entry.status, .collecting) + } + + // Production break caught: an unsupported zero-only counter is published forever as a confirmed zero. + func testZeroEnergyCounterWithAdvancingCPUBecomesUnsupportedAfterTenSeconds() throws { + let service = makeService( + results: [ + .success(reading(energy: 0, userCPU: 1_000, systemCPU: 100)), + .success(reading(energy: 0, userCPU: 2_000, systemCPU: 100)), + .success(reading(energy: 0, userCPU: 3_000, systemCPU: 100)), + .success(reading(energy: 0, userCPU: 4_000, systemCPU: 100)), + .success(reading(energy: 0, userCPU: 5_000, systemCPU: 100)), + ], + times: [0, 3, 6, 9, 12] + ) + + _ = service.topApps(limit: 1) + _ = service.topApps(limit: 1) + _ = service.topApps(limit: 1) + let confirmedZero = try XCTUnwrap(service.topApps(limit: 1).first) + let entry = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(try XCTUnwrap(confirmedZero.currentPowerMicrowatts), 0, accuracy: 0.001) + XCTAssertEqual(confirmedZero.status, .stable) + XCTAssertNil(entry.currentPowerMicrowatts) + XCTAssertNil(entry.rankingScore) + XCTAssertEqual(entry.status, .unavailable) + XCTAssertEqual(entry.coverage.discoveredProcessCount, 1) + XCTAssertEqual(entry.coverage.readableProcessCount, 0) + } + + // Production break caught: a rollback interval advances zero-counter evidence from an older retained baseline. + func testClockRollbackAfterFailureRebaselinesZeroCounterEvidence() throws { + let service = makeService( + results: [ + .success(reading(energy: 0, userCPU: 1_000)), + .failure(.permissionDenied), + .success(reading(energy: 0, userCPU: 2_000)), + .success(reading(energy: 0, userCPU: 3_000)), + ], + times: [0, 3, 2, 11] + ) + + _ = service.topApps(limit: 1) + _ = service.topApps(limit: 1) + let rebaselined = try XCTUnwrap(service.topApps(limit: 1).first) + let validInterval = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(rebaselined.status, .collecting) + XCTAssertNil(rebaselined.currentPowerMicrowatts) + XCTAssertEqual(validInterval.status, .stable) + XCTAssertEqual(try XCTUnwrap(validInterval.currentPowerMicrowatts), 0, accuracy: 0.001) + } + + // Production break caught: a helper delta spanning an owner change is assigned to the new root. + func testOwnerChangeDiscardsTheTransitionInterval() { + let service = makeReparentingService( + ownersBySample: [100, 200], + energies: [1_000, 9_000], + times: [0, 3] + ) + + _ = service.topApps(limit: 2) + let entries = service.topApps(limit: 2) + + XCTAssertTrue(entries.allSatisfy { $0.currentPowerMicrowatts == nil }) + XCTAssertTrue(entries.allSatisfy { $0.status != .stable }) + } + + // Production break caught: recovered helper energy uses six seconds of numerator against three seconds of PID-time. + func testRootAndRecoveredHelperUseMatchingIntervalEnergyAndCoverage() throws { + let service = makeMixedGapService( + rootEnergies: [1_000, 4_000, 7_000], + helperResults: [ + .success(reading(energy: 1_000, start: 11)), + .failure(.permissionDenied), + .success(reading(energy: 7_000, start: 11)), + ], + times: [0, 3, 6] + ) + + _ = service.topApps(limit: 1) + _ = service.topApps(limit: 1) + let recovered = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(try XCTUnwrap(recovered.currentPowerMicrowatts), 2, accuracy: 0.001) + XCTAssertEqual(recovered.status, .stable) + XCTAssertEqual(recovered.coverage.validProcessSeconds, 6, accuracy: 0.001) + XCTAssertEqual(recovered.coverage.discoveredProcessSeconds, 6, accuracy: 0.001) + } + + // Production break caught: stale output loses the confirmed root generation or refreshes its own grace window. + func testStableFailureRecoverySequencePreservesFullIdentityWithoutConfirmingStale() throws { + let service = makeService( + results: [ + .success(reading(energy: 1_000, start: 10)), + .success(reading(energy: 4_000, start: 10)), + .failure(.permissionDenied), + .success(reading(energy: 10_000, start: 10)), + ], + times: [0, 3, 6, 9] + ) + + let collecting = try XCTUnwrap(service.topApps(limit: 1).first) + let stable = try XCTUnwrap(service.topApps(limit: 1).first) + let stale = try XCTUnwrap(service.topApps(limit: 1).first) + let recovered = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(collecting.status, .collecting) + XCTAssertEqual(stable.status, .stable) + XCTAssertEqual(stable.currentPowerMicrowatts, 1) + XCTAssertEqual(stale.status, .stale) + XCTAssertEqual(stale.identity, stable.identity) + XCTAssertEqual(stale.currentPowerMicrowatts, stable.currentPowerMicrowatts) + XCTAssertEqual(stale.coverage, stable.coverage) + XCTAssertNil(stale.rankingScore) + XCTAssertEqual(recovered.status, .stable) + XCTAssertEqual(recovered.identity, stable.identity) + XCTAssertEqual(try XCTUnwrap(recovered.currentPowerMicrowatts), 1, accuracy: 0.001) + } + + // Production break caught: publishing stale repeatedly extends a three-second observation past ten seconds. + func testStalePublicationDoesNotExtendItsOwnGrace() throws { + let service = makeService( + results: [ + .success(reading(energy: 1_000)), + .success(reading(energy: 4_000)), + .failure(.permissionDenied), + .failure(.permissionDenied), + .failure(.permissionDenied), + ], + times: [0, 3, 6, 12, 14] + ) + + _ = service.topApps(limit: 1) + _ = service.topApps(limit: 1) + XCTAssertEqual(service.topApps(limit: 1).first?.status, .stale) + XCTAssertEqual(service.topApps(limit: 1).first?.status, .stale) + let expired = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(expired.status, .unavailable) + XCTAssertNil(expired.currentPowerMicrowatts) + } + + // Production break caught: a successful root generation change reuses the prior generation's stale display. + func testRootGenerationChangeImmediatelyDiscardsOldDisplay() throws { + let service = makeTwoProcessService( + rootResults: [ + .success(reading(energy: 1_000, start: 10)), + .success(reading(energy: 4_000, start: 10)), + .success(reading(energy: 1_000, start: 20)), + .failure(.permissionDenied), + ], + helperResults: [ + .success(reading(energy: 1_000, start: 11)), + .success(reading(energy: 4_000, start: 11)), + .success(reading(energy: 7_000, start: 11)), + .failure(.permissionDenied), + ], + times: [0, 3, 6, 9] + ) + + _ = service.topApps(limit: 1) + let old = try XCTUnwrap(service.topApps(limit: 1).first) + let changed = try XCTUnwrap(service.topApps(limit: 1).first) + let stale = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(old.status, .stable) + XCTAssertEqual(old.identity.rootProcessStartAbsoluteTime, 10) + XCTAssertEqual(changed.identity.rootProcessStartAbsoluteTime, 20) + XCTAssertEqual(changed.status, .partial) + XCTAssertEqual(changed.currentPowerMicrowatts, 1) + XCTAssertEqual(stale.status, .stale) + XCTAssertEqual(stale.identity.rootProcessStartAbsoluteTime, 20) + XCTAssertEqual(stale.currentPowerMicrowatts, 1) + } + + // Production break caught: stale wins when only some PIDs are unsupported, or survives when all are unsupported. + func testAllExplicitlyUnsupportedProcessesOverrideBoundedStaleDisplay() throws { + let service = makeTwoProcessService( + rootResults: [ + .success(reading(energy: 1_000)), + .success(reading(energy: 4_000)), + .failure(.unsupported), + .failure(.unsupported), + ], + helperResults: [ + .success(reading(energy: 1_000, start: 11)), + .success(reading(energy: 4_000, start: 11)), + .failure(.permissionDenied), + .failure(.unsupported), + ], + times: [0, 3, 6, 9] + ) + + _ = service.topApps(limit: 1) + XCTAssertEqual(service.topApps(limit: 1).first?.status, .stable) + let mixed = try XCTUnwrap(service.topApps(limit: 1).first) + let unsupported = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(mixed.status, .stale) + XCTAssertEqual(mixed.currentPowerMicrowatts, 2) + XCTAssertEqual(unsupported.status, .unavailable) + XCTAssertNil(unsupported.currentPowerMicrowatts) + XCTAssertNil(unsupported.rankingScore) + } + + // Production break caught: a large stale numeric value outranks fresh stable or partial rows. + func testEnergyImpactEntriesSortByStatusBucketBeforeNumericValue() { + let entries = [ + entry(processIdentifier: 1, name: "Unavailable", power: nil, status: .unavailable), + entry(processIdentifier: 2, name: "Stale", power: 1_000, status: .stale), + entry(processIdentifier: 3, name: "Collecting", power: nil, status: .collecting), + entry(processIdentifier: 4, name: "Partial", power: 1, status: .partial), + entry(processIdentifier: 5, name: "Stable", power: 2, status: .stable), + ] + + XCTAssertEqual( + EnergyImpactService.sortedByImpact(entries, limit: 5).map(\.name), + ["Stable", "Partial", "Stale", "Collecting", "Unavailable"] + ) + } + func testEnergyImpactEntriesSortTiesByName() { let entries = [ EnergyImpactEntry( @@ -429,21 +821,25 @@ private final class EnergyImpactClockStub: EnergyImpactClock, @unchecked Sendabl } private final class ProcessEnergyReadingProviderStub: ProcessEnergyReadingProvider, @unchecked Sendable { - private var readings: [pid_t: [ProcessEnergyReading]] + private var results: [pid_t: [ProcessEnergyReadResult]] private var readCounts: [pid_t: Int] = [:] init(readings: [pid_t: [ProcessEnergyReading]]) { - self.readings = readings + results = readings.mapValues { $0.map(ProcessEnergyReadResult.success) } + } + + init(results: [pid_t: [ProcessEnergyReadResult]]) { + self.results = results } func reading(for processIdentifier: pid_t) -> ProcessEnergyReadResult { readCounts[processIdentifier, default: 0] += 1 - guard var values = readings[processIdentifier], values.isEmpty == false else { + guard var values = results[processIdentifier], values.isEmpty == false else { return .failure(.other(0)) } let value = values.removeFirst() - readings[processIdentifier] = values - return .success(value) + results[processIdentifier] = values + return value } func readCount(for processIdentifier: pid_t) -> Int { @@ -462,3 +858,19 @@ private struct ProcessParentSnapshotReaderStub: ProcessParentSnapshotReading { snapshotValues } } + +private final class SequencedProcessParentSnapshotReaderStub: ProcessParentSnapshotReading, @unchecked Sendable { + private let lock = NSLock() + private var snapshotValuesByCall: [[ProcessParentSnapshot]] + + init(snapshotsByCall: [[ProcessParentSnapshot]]) { + snapshotValuesByCall = snapshotsByCall + } + + func snapshots() -> [ProcessParentSnapshot] { + lock.lock() + defer { lock.unlock() } + precondition(snapshotValuesByCall.isEmpty == false, "Process snapshot fixture exhausted") + return snapshotValuesByCall.removeFirst() + } +} From 4618f7705b52f35ba43bd1c539062f08ad5ad262 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 31 Jul 2026 21:52:42 +1000 Subject: [PATCH 10/13] fix(energy): invalidate broken sampling continuity --- .../Providers/EnergyImpactProvider.swift | 12 ++++ .../EnergyImpactProviderTests.swift | 64 +++++++++++++++++++ 2 files changed, 76 insertions(+) diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift index 26bb7aa..6d863b3 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift @@ -91,15 +91,22 @@ public final class EnergyImpactService { let apps = appSnapshotProvider() let sampleTime = clock.nowSeconds() let observationInterval: Range? + let breaksBaselineContinuity: Bool if let previousSampleTime { let elapsed = sampleTime - previousSampleTime observationInterval = elapsed > 0 && elapsed <= configuration.maximumGapSeconds ? previousSampleTime.. Date: Fri, 31 Jul 2026 22:17:20 +1000 Subject: [PATCH 11/13] fix(energy): close ownership and stale state gaps --- .../Providers/EnergyImpactProvider.swift | 46 +++++++++-- .../Providers/EnergyImpactStatistics.swift | 10 ++- .../EnergyImpactModelTests.swift | 39 +++++++++ .../EnergyImpactProviderTests.swift | 82 +++++++++++++++++++ .../EnergyImpactStatisticsTests.swift | 26 +++++- 5 files changed, 192 insertions(+), 11 deletions(-) diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift index 6d863b3..42bc7c2 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift @@ -107,10 +107,16 @@ public final class EnergyImpactService { baselines.removeAll() identityByProcessIdentifier.removeAll() } + pruneState(at: sampleTime) + let processSnapshots = processSnapshotReader.snapshots() let owners = EnergyImpactOwnership.nearestRootOwners( rootProcessIdentifiers: apps.map(\.processIdentifier), - snapshots: processSnapshotReader.snapshots() + snapshots: processSnapshots + ) + invalidateObservedOwnerTransitions( + snapshots: processSnapshots, + owners: owners ) let processIdentifiersByRoot = Dictionary( grouping: owners.keys, @@ -131,7 +137,7 @@ public final class EnergyImpactService { if let identity = identityByProcessIdentifier[processIdentifier], let baseline = baselines[identity], baseline.ownerRootProcessIdentifier != app.processIdentifier { - baselines.removeValue(forKey: identity) + removeBaseline(for: identity) } switch reader.reading(for: processIdentifier) { case let .failure(failure): @@ -152,7 +158,7 @@ public final class EnergyImpactService { ) if let oldIdentity = identityByProcessIdentifier[processIdentifier], oldIdentity != identity { - baselines.removeValue(forKey: oldIdentity) + removeBaseline(for: oldIdentity) } identityByProcessIdentifier[processIdentifier] = identity @@ -354,16 +360,42 @@ public final class EnergyImpactService { return age > configuration.maximumGapSeconds ? identity : nil } for identity in expiredIdentities { - baselines.removeValue(forKey: identity) - if identityByProcessIdentifier[identity.processIdentifier] == identity { - identityByProcessIdentifier.removeValue(forKey: identity.processIdentifier) - } + removeBaseline(for: identity) } displayByIdentity = displayByIdentity.filter { _, display in let age = sampleTime - display.sampleTime return age >= 0 && age <= configuration.maximumGapSeconds } + identityByProcessIdentifier = identityByProcessIdentifier.filter { + baselines[$0.value] != nil + } + currentIdentityByRootProcessIdentifier = currentIdentityByRootProcessIdentifier.filter { + _, identity in + let hasGenerationBaseline = identity.generation.map { baselines[$0] != nil } == true + return hasGenerationBaseline || displayByIdentity[identity] != nil + } + } + + private func invalidateObservedOwnerTransitions( + snapshots: [ProcessParentSnapshot], + owners: [pid_t: pid_t] + ) { + for processIdentifier in Set(snapshots.map(\.processIdentifier)) { + guard let identity = identityByProcessIdentifier[processIdentifier], + let baseline = baselines[identity], + owners[processIdentifier] != baseline.ownerRootProcessIdentifier else { + continue + } + removeBaseline(for: identity) + } + } + + private func removeBaseline(for identity: EnergyImpactProcessIdentity) { + baselines.removeValue(forKey: identity) + if identityByProcessIdentifier[identity.processIdentifier] == identity { + identityByProcessIdentifier.removeValue(forKey: identity.processIdentifier) + } } } diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactStatistics.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactStatistics.swift index 575e40f..baa4f89 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactStatistics.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactStatistics.swift @@ -170,9 +170,11 @@ public struct StableEnergyImpactRanker: Sendable { } private static func statusBucket(_ entry: EnergyImpactEntry) -> Int { + if entry.status == .stale, entry.currentPowerMicrowatts != nil { + return 1 + } if entry.rankingScore != nil { if entry.status == .stable || entry.status == .partial { return 0 } - if entry.status == .stale { return 1 } } switch entry.status { case .collecting: return 2 @@ -184,8 +186,10 @@ public struct StableEnergyImpactRanker: Sendable { _ lhs: EnergyImpactEntry, _ rhs: EnergyImpactEntry ) -> Bool { - if lhs.rankingScore != rhs.rankingScore { - return (lhs.rankingScore ?? -.infinity) > (rhs.rankingScore ?? -.infinity) + let lhsScore = lhs.rankingScore ?? lhs.currentPowerMicrowatts ?? -.infinity + let rhsScore = rhs.rankingScore ?? rhs.currentPowerMicrowatts ?? -.infinity + if lhsScore != rhsScore { + return lhsScore > rhsScore } let nameOrder = lhs.name.localizedCaseInsensitiveCompare(rhs.name) if nameOrder != .orderedSame { return nameOrder == .orderedAscending } diff --git a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift index 7401c6e..1a22a48 100644 --- a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift @@ -204,6 +204,45 @@ final class EnergyImpactModelTests: XCTestCase { ) } + func testNumericStaleEntryRemainsInTopTwentyAheadOfCollectingEntries() async throws { + let stale = EnergyImpactEntry( + identity: EnergyImpactAppIdentity( + rootProcessIdentifier: 101, + rootProcessStartAbsoluteTime: 1 + ), + name: "Stale", + bundleIdentifier: "com.example.stale", + bundleURL: nil, + currentPowerMicrowatts: 100, + sustainedPowerMicrowatts: nil, + rankingScore: nil, + trend: .steady, + coverage: EnergyImpactCoverage( + discoveredProcessCount: 1, + readableProcessCount: 1, + validProcessSeconds: 3, + discoveredProcessSeconds: 3 + ), + status: .stale + ) + let collecting = (1...20).map { + entry(pid: pid_t(200 + $0), power: nil, status: .collecting) + } + let provider = EnergyImpactProviderStub(responses: [[], collecting + [stale]]) + let model = EnergyImpactModel(provider: provider, limit: 20, sleep: { _ in }) + + await model.refresh() + + XCTAssertEqual(model.entries.count, 20) + let publishedStale = try XCTUnwrap( + model.entries.first { $0.processIdentifier == stale.processIdentifier } + ) + XCTAssertEqual(model.entries.first?.processIdentifier, stale.processIdentifier) + XCTAssertEqual(publishedStale.status, .stale) + XCTAssertEqual(publishedStale.currentPowerMicrowatts, 100) + XCTAssertNil(publishedStale.rankingScore) + } + func testAllCandidatesAreSmoothedBeforeTopLimitIsApplied() async throws { let clock = EnergyImpactTestClock() let steady = (1...20).map { entry(pid: pid_t($0), power: 50) } diff --git a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift index 0df1743..e833ba5 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift @@ -678,6 +678,41 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertEqual(returnedOwner.coverage.discoveredProcessSeconds, 6, accuracy: 0.001) } + // Production break caught: an observed helper temporarily outside every regular root reconnects to its old baseline. + func testObservedUnownedHelperBreaksRecoveredContinuity() throws { + let service = EnergyImpactService( + reader: ProcessEnergyReadingProviderStub(results: [ + 100: [ + .failure(.permissionDenied), + .failure(.permissionDenied), + .failure(.permissionDenied), + ], + 300: [ + .success(reading(energy: 1_000, start: 30)), + .success(reading(energy: 7_000, start: 30)), + ], + ]), + processSnapshotReader: SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + [.init(processIdentifier: 300, parentProcessIdentifier: 100)], + [.init(processIdentifier: 300, parentProcessIdentifier: 999)], + [.init(processIdentifier: 300, parentProcessIdentifier: 100)], + ]), + appSnapshotProvider: { [ + .init(processIdentifier: 100, name: "Fixture", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: EnergyImpactClockStub(times: [0, 3, 6]) + ) + + _ = service.topApps(limit: 1) + _ = service.topApps(limit: 1) + let returned = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(returned.status, .collecting) + XCTAssertNil(returned.currentPowerMicrowatts) + XCTAssertEqual(returned.coverage.validProcessSeconds, 0, accuracy: 0.001) + XCTAssertEqual(returned.coverage.discoveredProcessSeconds, 6, accuracy: 0.001) + } + // Production break caught: recovered helper energy uses six seconds of numerator against three seconds of PID-time. func testRootAndRecoveredHelperUseMatchingIntervalEnergyAndCoverage() throws { let service = makeMixedGapService( @@ -786,6 +821,53 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertEqual(stale.currentPowerMicrowatts, 1) } + // Production break caught: an expired root locator assigns helper-only data to a reused PID's old generation. + func testExpiredRootLocatorDoesNotLabelReusedPIDHelperDataWithOldGeneration() throws { + let service = EnergyImpactService( + reader: ProcessEnergyReadingProviderStub(results: [ + 100: [ + .success(reading(energy: 1_000, start: 10)), + .success(reading(energy: 4_000, start: 10)), + .failure(.permissionDenied), + .failure(.permissionDenied), + .success(reading(energy: 1_000, start: 20)), + ], + 300: [ + .success(reading(energy: 1_000, start: 30)), + .success(reading(energy: 4_000, start: 30)), + .success(reading(energy: 7_000, start: 30)), + ], + ]), + processSnapshotReader: SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + [], + [], + [.init(processIdentifier: 300, parentProcessIdentifier: 100)], + [.init(processIdentifier: 300, parentProcessIdentifier: 100)], + [.init(processIdentifier: 300, parentProcessIdentifier: 100)], + ]), + appSnapshotProvider: { [ + .init(processIdentifier: 100, name: "Reused", bundleIdentifier: nil, bundleURL: nil), + ] }, + clock: EnergyImpactClockStub(times: [0, 3, 14, 17, 20]) + ) + + _ = service.topApps(limit: 1) + let old = try XCTUnwrap(service.topApps(limit: 1).first) + let expired = try XCTUnwrap(service.topApps(limit: 1).first) + let helperOnly = try XCTUnwrap(service.topApps(limit: 1).first) + let established = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(old.status, .stable) + XCTAssertEqual(old.identity.rootProcessStartAbsoluteTime, 10) + XCTAssertEqual(expired.status, .collecting) + XCTAssertNil(expired.identity.rootProcessStartAbsoluteTime) + XCTAssertEqual(helperOnly.status, .partial) + XCTAssertEqual(helperOnly.currentPowerMicrowatts, 1) + XCTAssertNil(helperOnly.identity.rootProcessStartAbsoluteTime) + XCTAssertEqual(established.status, .partial) + XCTAssertEqual(established.identity.rootProcessStartAbsoluteTime, 20) + } + // Production break caught: stale wins when only some PIDs are unsupported, or survives when all are unsupported. func testAllExplicitlyUnsupportedProcessesOverrideBoundedStaleDisplay() throws { let service = makeTwoProcessService( diff --git a/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift b/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift index 8e6360c..1490d22 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift @@ -63,6 +63,29 @@ final class EnergyImpactStatisticsTests: XCTestCase { ) } + func testNumericStaleEntriesUseDisplayPowerForDeterministicOrdering() { + var ranker = StableEnergyImpactRanker() + let low = fixtureEntry( + pid: 1, + score: 1, + status: .stale, + hasRankingScore: false, + name: "Alpha" + ) + let high = fixtureEntry( + pid: 2, + score: 100, + status: .stale, + hasRankingScore: false, + name: "Zulu" + ) + + let ranked = ranker.rank([low, high], atPublicationBoundary: true) + + XCTAssertEqual(ranked.map(\.processIdentifier), [2, 1]) + XCTAssertTrue(ranked.allSatisfy { $0.rankingScore == nil }) + } + func testChallengerLosesConfirmationWhenItStopsBeingAdjacent() { var ranker = StableEnergyImpactRanker() let incumbent = fixtureEntry(pid: 1, score: 100) @@ -178,6 +201,7 @@ private func fixtureEntry( pid: pid_t, score: Double, status: EnergyImpactStatus = .stable, + hasRankingScore: Bool = true, startTime: UInt64? = nil, hasGeneration: Bool = true, name: String? = nil @@ -193,7 +217,7 @@ private func fixtureEntry( bundleURL: nil, currentPowerMicrowatts: score, sustainedPowerMicrowatts: nil, - rankingScore: score, + rankingScore: hasRankingScore ? score : nil, trend: .steady, coverage: EnergyImpactCoverage( discoveredProcessCount: 1, From 311389a571eb8d6ac479bcd642153cf85ea9f93e Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 7 Aug 2026 16:57:00 +1000 Subject: [PATCH 12/13] fix(energy): address PR review and coverage --- .../Localization/AppLocalization.swift | 1 + .../Models/EnergyImpactModel.swift | 4 + .../Models/EnergyImpactPresentation.swift | 13 ++ .../Resources/de.lproj/Localizable.strings | 1 + .../Resources/en.lproj/Localizable.strings | 1 + .../Resources/fr.lproj/Localizable.strings | 1 + .../Resources/ja.lproj/Localizable.strings | 1 + .../Resources/ko.lproj/Localizable.strings | 1 + .../zh-Hans.lproj/Localizable.strings | 1 + .../zh-Hant.lproj/Localizable.strings | 1 + .../Providers/EnergyImpactProvider.swift | 3 +- .../EnergyImpactModelTests.swift | 97 ++++++++++ .../EnergyImpactPresentationTests.swift | 21 +++ .../EnergyImpactViewTests.swift | 17 ++ .../LocalizationTests.swift | 14 +- .../EnergyImpactProcessReaderTests.swift | 4 + .../EnergyImpactProviderTests.swift | 165 ++++++++++++++++++ .../EnergyImpactStatisticsTests.swift | 19 ++ 18 files changed, 356 insertions(+), 9 deletions(-) diff --git a/Sources/MacActivityApp/Localization/AppLocalization.swift b/Sources/MacActivityApp/Localization/AppLocalization.swift index 44c9d99..f84cdf5 100644 --- a/Sources/MacActivityApp/Localization/AppLocalization.swift +++ b/Sources/MacActivityApp/Localization/AppLocalization.swift @@ -180,6 +180,7 @@ enum AppLocalization { case energyImpactCollecting = "energyImpact.collecting" case energyImpactPartial = "energyImpact.partial" case energyImpactStale = "energyImpact.stale" + case energyImpactStaleWithValue = "energyImpact.staleWithValue" case energyImpactRowAccessibility = "energyImpact.accessibility.row" case processEmpty = "process.empty" case processFallbackName = "process.fallbackName" diff --git a/Sources/MacActivityApp/Models/EnergyImpactModel.swift b/Sources/MacActivityApp/Models/EnergyImpactModel.swift index 093f8ac..2a22164 100644 --- a/Sources/MacActivityApp/Models/EnergyImpactModel.swift +++ b/Sources/MacActivityApp/Models/EnergyImpactModel.swift @@ -148,12 +148,16 @@ final class EnergyImpactModel: ObservableObject { lastValidObservationTimes[generation] = nil } let smoothingElapsed = min(elapsedSeconds, configuration.maximumGapSeconds) + // Inputs above satisfy EnergyImpactSmoother's nil-return preconditions; retain the + // defensive fallback in case that contract changes. guard let smoothed = smoother.update( identity: generation, value: currentPower, elapsedSeconds: smoothingElapsed ) else { + // codecov:ignore start return Self.nonnumericUnavailable(sanitized) + // codecov:ignore end } lastValidObservationTimes[generation] = publicationTime return Self.replacingCurrentPower(in: sanitized, with: smoothed) diff --git a/Sources/MacActivityApp/Models/EnergyImpactPresentation.swift b/Sources/MacActivityApp/Models/EnergyImpactPresentation.swift index de8e51d..d103009 100644 --- a/Sources/MacActivityApp/Models/EnergyImpactPresentation.swift +++ b/Sources/MacActivityApp/Models/EnergyImpactPresentation.swift @@ -21,6 +21,19 @@ enum EnergyImpactPresentation { status: EnergyImpactStatus, bundle: Bundle? = nil ) -> String { + if status == .stale { + guard let microwatts, microwatts.isFinite, microwatts >= 0 else { + return AppLocalization.string(.energyImpactStale, bundle: bundle) + } + return AppLocalization.string( + .energyImpactStaleWithValue, + powerText( + microwatts: microwatts, + locale: AppLocalization.currentLocale(bundle: bundle) + ), + bundle: bundle + ) + } guard let microwatts, microwatts.isFinite, microwatts >= 0 else { let key: AppLocalization.Key = switch status { case .collecting: .energyImpactCollecting diff --git a/Sources/MacActivityApp/Resources/de.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/de.lproj/Localizable.strings index 8ee345c..3647dcf 100644 --- a/Sources/MacActivityApp/Resources/de.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/de.lproj/Localizable.strings @@ -116,6 +116,7 @@ "energyImpact.collecting" = "Wird erfasst"; "energyImpact.partial" = "Teilweise"; "energyImpact.stale" = "Veraltet"; +"energyImpact.staleWithValue" = "Veraltet · %1$@"; "energyImpact.accessibility.row" = "%1$@, Rang %2$lld, %3$@"; "energyImpact.empty" = "Keine regulären Apps melden eine Energieschätzung."; "energyImpact.unavailable" = "Nicht verfügbar"; diff --git a/Sources/MacActivityApp/Resources/en.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/en.lproj/Localizable.strings index 87247ab..591fb20 100644 --- a/Sources/MacActivityApp/Resources/en.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/en.lproj/Localizable.strings @@ -177,6 +177,7 @@ "energyImpact.collecting" = "Collecting"; "energyImpact.partial" = "Partial"; "energyImpact.stale" = "Stale"; +"energyImpact.staleWithValue" = "Stale · %1$@"; "energyImpact.accessibility.row" = "%1$@, rank %2$lld, %3$@"; "energyImpact.empty" = "No regular apps are reporting an energy estimate."; "energyImpact.unavailable" = "Unavailable"; diff --git a/Sources/MacActivityApp/Resources/fr.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/fr.lproj/Localizable.strings index 5f1f62d..b079eaf 100644 --- a/Sources/MacActivityApp/Resources/fr.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/fr.lproj/Localizable.strings @@ -116,6 +116,7 @@ "energyImpact.collecting" = "Collecte"; "energyImpact.partial" = "Partiel"; "energyImpact.stale" = "Obsolète"; +"energyImpact.staleWithValue" = "Obsolète · %1$@"; "energyImpact.accessibility.row" = "%1$@, rang %2$lld, %3$@"; "energyImpact.empty" = "Aucune app standard ne fournit d’estimation énergétique."; "energyImpact.unavailable" = "Indisponible"; diff --git a/Sources/MacActivityApp/Resources/ja.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/ja.lproj/Localizable.strings index 80ac9fc..f82feff 100644 --- a/Sources/MacActivityApp/Resources/ja.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/ja.lproj/Localizable.strings @@ -116,6 +116,7 @@ "energyImpact.collecting" = "収集中"; "energyImpact.partial" = "一部データ"; "energyImpact.stale" = "古いデータ"; +"energyImpact.staleWithValue" = "古いデータ · %1$@"; "energyImpact.accessibility.row" = "%1$@、%2$lld位、%3$@"; "energyImpact.empty" = "エネルギー推定を報告している通常のアプリはありません。"; "energyImpact.unavailable" = "利用不可"; diff --git a/Sources/MacActivityApp/Resources/ko.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/ko.lproj/Localizable.strings index e08c0fe..4ed4926 100644 --- a/Sources/MacActivityApp/Resources/ko.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/ko.lproj/Localizable.strings @@ -116,6 +116,7 @@ "energyImpact.collecting" = "수집 중"; "energyImpact.partial" = "일부 데이터"; "energyImpact.stale" = "오래된 데이터"; +"energyImpact.staleWithValue" = "오래된 데이터 · %1$@"; "energyImpact.accessibility.row" = "%1$@, %2$lld위, %3$@"; "energyImpact.empty" = "에너지 추정치를 보고하는 일반 앱이 없습니다."; "energyImpact.unavailable" = "사용할 수 없음"; diff --git a/Sources/MacActivityApp/Resources/zh-Hans.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/zh-Hans.lproj/Localizable.strings index 2e146d8..84a03c1 100644 --- a/Sources/MacActivityApp/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/zh-Hans.lproj/Localizable.strings @@ -177,6 +177,7 @@ "energyImpact.collecting" = "采集中"; "energyImpact.partial" = "部分数据"; "energyImpact.stale" = "数据已过期"; +"energyImpact.staleWithValue" = "数据已过期 · %1$@"; "energyImpact.accessibility.row" = "%1$@,第 %2$lld 名,%3$@"; "energyImpact.empty" = "当前没有普通应用报告能耗估算。"; "energyImpact.unavailable" = "不可读取"; diff --git a/Sources/MacActivityApp/Resources/zh-Hant.lproj/Localizable.strings b/Sources/MacActivityApp/Resources/zh-Hant.lproj/Localizable.strings index 3f52d3a..fea9fe6 100644 --- a/Sources/MacActivityApp/Resources/zh-Hant.lproj/Localizable.strings +++ b/Sources/MacActivityApp/Resources/zh-Hant.lproj/Localizable.strings @@ -116,6 +116,7 @@ "energyImpact.collecting" = "收集中"; "energyImpact.partial" = "部分資料"; "energyImpact.stale" = "資料已過期"; +"energyImpact.staleWithValue" = "資料已過期 · %1$@"; "energyImpact.accessibility.row" = "%1$@,第 %2$lld 名,%3$@"; "energyImpact.empty" = "目前沒有一般應用程式回報能耗估算。"; "energyImpact.unavailable" = "無法讀取"; diff --git a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift index 42bc7c2..4c5c6d3 100644 --- a/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift +++ b/Sources/MacActivityCore/Metrics/Providers/EnergyImpactProvider.swift @@ -372,8 +372,7 @@ public final class EnergyImpactService { } currentIdentityByRootProcessIdentifier = currentIdentityByRootProcessIdentifier.filter { _, identity in - let hasGenerationBaseline = identity.generation.map { baselines[$0] != nil } == true - return hasGenerationBaseline || displayByIdentity[identity] != nil + identity.generation.map { baselines[$0] != nil } == true } } diff --git a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift index 1a22a48..4c763d0 100644 --- a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift @@ -4,6 +4,22 @@ import MacActivityCore @MainActor final class EnergyImpactModelTests: XCTestCase { + func testRefreshUsesDefaultSleepImplementation() async throws { + let model = EnergyImpactModel( + provider: EnergyImpactProviderStub( + responses: [ + [], + [entry(power: 1)], + ] + ), + initialWindowNanoseconds: 0 + ) + + await model.refresh() + + XCTAssertEqual(try XCTUnwrap(model.entries.first?.currentPowerMicrowatts), 1) + } + func testRefreshWhileVisibleWaitsFullWindowAndSmoothsEveryPublishedSample() async throws { let clock = EnergyImpactTestClock() let provider = EnergyImpactProviderStub( @@ -346,6 +362,52 @@ final class EnergyImpactModelTests: XCTestCase { ) } + func testStableEntryWithMissingNumericFieldsPublishesUnavailable() async throws { + let clock = EnergyImpactTestClock() + let model = EnergyImpactModel( + provider: EnergyImpactProviderStub( + responses: [ + [], + [entry(power: nil, status: .stable)], + ] + ), + clock: clock, + sleep: { _ in clock.advance(seconds: 3) } + ) + + await model.refresh() + + let unavailable = try XCTUnwrap(model.entries.first) + XCTAssertEqual(unavailable.status, .unavailable) + XCTAssertNil(unavailable.currentPowerMicrowatts) + XCTAssertNil(unavailable.rankingScore) + } + + func testDuplicateGenerationInOnePublicationMarksSecondEntryUnavailable() async throws { + let clock = EnergyImpactTestClock() + let model = EnergyImpactModel( + provider: EnergyImpactProviderStub( + responses: [ + [], + [ + entry(pid: 101, power: 10, startTime: 10), + entry(pid: 101, power: 20, startTime: 10), + ], + ] + ), + clock: clock, + sleep: { _ in clock.advance(seconds: 3) } + ) + + await model.refresh() + + XCTAssertEqual(model.entries.count, 2) + XCTAssertEqual(model.entries.filter { $0.status == .stable }.count, 1) + let unavailable = try XCTUnwrap(model.entries.first { $0.status == .unavailable }) + XCTAssertNil(unavailable.currentPowerMicrowatts) + XCTAssertNil(unavailable.rankingScore) + } + func testInvalidStaleNumericsAreStrippedWithoutChangingStaleStatus() async throws { let clock = EnergyImpactTestClock() let provider = EnergyImpactProviderStub( @@ -378,6 +440,8 @@ final class EnergyImpactModelTests: XCTestCase { entry(pid: 1, power: nil, status: .collecting), entry(pid: 2, power: 100, status: .stale), entry(pid: 3, power: nil, status: .unavailable), + entry(pid: 4, power: 100, status: .stable), + entry(pid: 5, power: 100, status: .partial), ], ] ) @@ -396,6 +460,39 @@ final class EnergyImpactModelTests: XCTestCase { XCTAssertEqual(try XCTUnwrap(byPID[2]).status, .stale) XCTAssertEqual(try XCTUnwrap(byPID[2]).currentPowerMicrowatts, 100) XCTAssertEqual(try XCTUnwrap(byPID[3]).status, .unavailable) + XCTAssertEqual(try XCTUnwrap(byPID[4]).status, .unavailable) + XCTAssertNil(try XCTUnwrap(byPID[4]).currentPowerMicrowatts) + XCTAssertEqual(try XCTUnwrap(byPID[5]).status, .unavailable) + XCTAssertNil(try XCTUnwrap(byPID[5]).currentPowerMicrowatts) + } + + func testRecoveryAfterStaleSeriesBeyondMaximumGapStartsFreshEMA() async throws { + let clock = EnergyImpactTestClock() + let provider = EnergyImpactProviderStub( + responses: [ + [], + [entry(power: 100)], + [entry(power: 100, status: .stale)], + [entry(power: 100, status: .stale)], + [entry(power: 100, status: .stale)], + [entry(power: 0)], + ] + ) + var sleepCount = 0 + let model = EnergyImpactModel( + provider: provider, + clock: clock, + sleep: { _ in + sleepCount += 1 + guard sleepCount < 6 else { throw CancellationError() } + clock.advance(seconds: 3) + } + ) + + await model.refreshWhileVisible() + + XCTAssertEqual(try XCTUnwrap(model.entries.first?.status), .stable) + XCTAssertEqual(try XCTUnwrap(model.entries.first?.currentPowerMicrowatts), 0) } } diff --git a/Tests/MacActivityAppTests/EnergyImpactPresentationTests.swift b/Tests/MacActivityAppTests/EnergyImpactPresentationTests.swift index 33fdc29..eec20e6 100644 --- a/Tests/MacActivityAppTests/EnergyImpactPresentationTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactPresentationTests.swift @@ -54,4 +54,25 @@ final class EnergyImpactPresentationTests: XCTestCase { XCTAssertEqual(text, "Unavailable") } } + + func testNumericStalePowerTextPreservesValueAndMarksItStale() { + let englishBundle = AppLocalization.bundle(forLanguageIdentifier: "en") + + XCTAssertEqual( + EnergyImpactPresentation.powerText( + microwatts: 1_840, + status: .stale, + bundle: englishBundle + ), + "Stale · 1.8 mW" + ) + XCTAssertEqual( + EnergyImpactPresentation.powerText( + microwatts: nil, + status: .stale, + bundle: englishBundle + ), + "Stale" + ) + } } diff --git a/Tests/MacActivityAppTests/EnergyImpactViewTests.swift b/Tests/MacActivityAppTests/EnergyImpactViewTests.swift index ad77faa..7566d19 100644 --- a/Tests/MacActivityAppTests/EnergyImpactViewTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactViewTests.swift @@ -194,6 +194,23 @@ final class EnergyImpactViewTests: XCTestCase { ) } + func testNumericStaleRowMarksRetainedPowerInVisibleAndAccessibilityText() { + let stale = entry(name: "Safari", power: 1_840, status: .stale) + + XCTAssertEqual( + EnergyImpactRow.trailingText(for: stale, bundle: Self.englishBundle), + "Stale · 1.8 mW" + ) + XCTAssertEqual( + EnergyImpactPresentation.accessibilityLabel( + entry: stale, + rank: 2, + bundle: Self.englishBundle + ), + "Safari, rank 2, Stale · 1.8 mW" + ) + } + private func entry( processIdentifier: pid_t = 101, name: String = "Safari", diff --git a/Tests/MacActivityAppTests/LocalizationTests.swift b/Tests/MacActivityAppTests/LocalizationTests.swift index b8ada4d..43532ba 100644 --- a/Tests/MacActivityAppTests/LocalizationTests.swift +++ b/Tests/MacActivityAppTests/LocalizationTests.swift @@ -311,13 +311,13 @@ final class LocalizationTests: XCTestCase { func testEnergyImpactPageStringsAreLocalized() throws { let expectations: [String: [AppLocalization.Key: String]] = [ - "en": [.energyImpactTitle: "Energy Impact", .energyImpactSubtitleCurrent: "Recent CPU energy estimate · Lower is better", .energyImpactAppColumn: "App", .energyImpactCurrentColumn: "Current", .energyImpactCollecting: "Collecting", .energyImpactPartial: "Partial", .energyImpactStale: "Stale", .energyImpactRowAccessibility: "%1$@, rank %2$lld, %3$@", .energyImpactEmpty: "No regular apps are reporting an energy estimate.", .energyImpactUnavailable: "Unavailable", .preferencesProcessApplicationIdentifier: "Show application ID in process lists"], - "de": [.energyImpactTitle: "Energieeinfluss", .energyImpactSubtitleCurrent: "Aktuelle CPU-Energieschätzung · Niedriger ist besser", .energyImpactAppColumn: "App", .energyImpactCurrentColumn: "Aktuell", .energyImpactCollecting: "Wird erfasst", .energyImpactPartial: "Teilweise", .energyImpactStale: "Veraltet", .energyImpactRowAccessibility: "%1$@, Rang %2$lld, %3$@", .energyImpactEmpty: "Keine regulären Apps melden eine Energieschätzung.", .energyImpactUnavailable: "Nicht verfügbar", .preferencesProcessApplicationIdentifier: "App-ID in Prozesslisten anzeigen"], - "fr": [.energyImpactTitle: "Impact énergétique", .energyImpactSubtitleCurrent: "Estimation récente de l’énergie CPU · Plus bas est préférable", .energyImpactAppColumn: "App", .energyImpactCurrentColumn: "Actuel", .energyImpactCollecting: "Collecte", .energyImpactPartial: "Partiel", .energyImpactStale: "Obsolète", .energyImpactRowAccessibility: "%1$@, rang %2$lld, %3$@", .energyImpactEmpty: "Aucune app standard ne fournit d’estimation énergétique.", .energyImpactUnavailable: "Indisponible", .preferencesProcessApplicationIdentifier: "Afficher l’identifiant d’app dans les listes de processus"], - "ja": [.energyImpactTitle: "エネルギー影響", .energyImpactSubtitleCurrent: "最近のCPUエネルギー推定 · 低いほど良好", .energyImpactAppColumn: "アプリ", .energyImpactCurrentColumn: "現在", .energyImpactCollecting: "収集中", .energyImpactPartial: "一部データ", .energyImpactStale: "古いデータ", .energyImpactRowAccessibility: "%1$@、%2$lld位、%3$@", .energyImpactEmpty: "エネルギー推定を報告している通常のアプリはありません。", .energyImpactUnavailable: "利用不可", .preferencesProcessApplicationIdentifier: "プロセスリストにアプリIDを表示"], - "ko": [.energyImpactTitle: "에너지 영향", .energyImpactSubtitleCurrent: "최근 CPU 에너지 추정치 · 낮을수록 좋음", .energyImpactAppColumn: "앱", .energyImpactCurrentColumn: "현재", .energyImpactCollecting: "수집 중", .energyImpactPartial: "일부 데이터", .energyImpactStale: "오래된 데이터", .energyImpactRowAccessibility: "%1$@, %2$lld위, %3$@", .energyImpactEmpty: "에너지 추정치를 보고하는 일반 앱이 없습니다.", .energyImpactUnavailable: "사용할 수 없음", .preferencesProcessApplicationIdentifier: "프로세스 목록에 앱 ID 표시"], - "zh-Hans": [.energyImpactTitle: "耗电影响", .energyImpactSubtitleCurrent: "近期 CPU 能耗估算 · 越低越好", .energyImpactAppColumn: "应用", .energyImpactCurrentColumn: "当前", .energyImpactCollecting: "采集中", .energyImpactPartial: "部分数据", .energyImpactStale: "数据已过期", .energyImpactRowAccessibility: "%1$@,第 %2$lld 名,%3$@", .energyImpactEmpty: "当前没有普通应用报告能耗估算。", .energyImpactUnavailable: "不可读取", .preferencesProcessApplicationIdentifier: "在进程列表中显示应用 ID"], - "zh-Hant": [.energyImpactTitle: "耗電影響", .energyImpactSubtitleCurrent: "近期 CPU 能耗估算 · 越低越好", .energyImpactAppColumn: "應用程式", .energyImpactCurrentColumn: "目前", .energyImpactCollecting: "收集中", .energyImpactPartial: "部分資料", .energyImpactStale: "資料已過期", .energyImpactRowAccessibility: "%1$@,第 %2$lld 名,%3$@", .energyImpactEmpty: "目前沒有一般應用程式回報能耗估算。", .energyImpactUnavailable: "無法讀取", .preferencesProcessApplicationIdentifier: "在程序列表中顯示應用程式 ID"], + "en": [.energyImpactTitle: "Energy Impact", .energyImpactSubtitleCurrent: "Recent CPU energy estimate · Lower is better", .energyImpactAppColumn: "App", .energyImpactCurrentColumn: "Current", .energyImpactCollecting: "Collecting", .energyImpactPartial: "Partial", .energyImpactStale: "Stale", .energyImpactStaleWithValue: "Stale · %1$@", .energyImpactRowAccessibility: "%1$@, rank %2$lld, %3$@", .energyImpactEmpty: "No regular apps are reporting an energy estimate.", .energyImpactUnavailable: "Unavailable", .preferencesProcessApplicationIdentifier: "Show application ID in process lists"], + "de": [.energyImpactTitle: "Energieeinfluss", .energyImpactSubtitleCurrent: "Aktuelle CPU-Energieschätzung · Niedriger ist besser", .energyImpactAppColumn: "App", .energyImpactCurrentColumn: "Aktuell", .energyImpactCollecting: "Wird erfasst", .energyImpactPartial: "Teilweise", .energyImpactStale: "Veraltet", .energyImpactStaleWithValue: "Veraltet · %1$@", .energyImpactRowAccessibility: "%1$@, Rang %2$lld, %3$@", .energyImpactEmpty: "Keine regulären Apps melden eine Energieschätzung.", .energyImpactUnavailable: "Nicht verfügbar", .preferencesProcessApplicationIdentifier: "App-ID in Prozesslisten anzeigen"], + "fr": [.energyImpactTitle: "Impact énergétique", .energyImpactSubtitleCurrent: "Estimation récente de l’énergie CPU · Plus bas est préférable", .energyImpactAppColumn: "App", .energyImpactCurrentColumn: "Actuel", .energyImpactCollecting: "Collecte", .energyImpactPartial: "Partiel", .energyImpactStale: "Obsolète", .energyImpactStaleWithValue: "Obsolète · %1$@", .energyImpactRowAccessibility: "%1$@, rang %2$lld, %3$@", .energyImpactEmpty: "Aucune app standard ne fournit d’estimation énergétique.", .energyImpactUnavailable: "Indisponible", .preferencesProcessApplicationIdentifier: "Afficher l’identifiant d’app dans les listes de processus"], + "ja": [.energyImpactTitle: "エネルギー影響", .energyImpactSubtitleCurrent: "最近のCPUエネルギー推定 · 低いほど良好", .energyImpactAppColumn: "アプリ", .energyImpactCurrentColumn: "現在", .energyImpactCollecting: "収集中", .energyImpactPartial: "一部データ", .energyImpactStale: "古いデータ", .energyImpactStaleWithValue: "古いデータ · %1$@", .energyImpactRowAccessibility: "%1$@、%2$lld位、%3$@", .energyImpactEmpty: "エネルギー推定を報告している通常のアプリはありません。", .energyImpactUnavailable: "利用不可", .preferencesProcessApplicationIdentifier: "プロセスリストにアプリIDを表示"], + "ko": [.energyImpactTitle: "에너지 영향", .energyImpactSubtitleCurrent: "최근 CPU 에너지 추정치 · 낮을수록 좋음", .energyImpactAppColumn: "앱", .energyImpactCurrentColumn: "현재", .energyImpactCollecting: "수집 중", .energyImpactPartial: "일부 데이터", .energyImpactStale: "오래된 데이터", .energyImpactStaleWithValue: "오래된 데이터 · %1$@", .energyImpactRowAccessibility: "%1$@, %2$lld위, %3$@", .energyImpactEmpty: "에너지 추정치를 보고하는 일반 앱이 없습니다.", .energyImpactUnavailable: "사용할 수 없음", .preferencesProcessApplicationIdentifier: "프로세스 목록에 앱 ID 표시"], + "zh-Hans": [.energyImpactTitle: "耗电影响", .energyImpactSubtitleCurrent: "近期 CPU 能耗估算 · 越低越好", .energyImpactAppColumn: "应用", .energyImpactCurrentColumn: "当前", .energyImpactCollecting: "采集中", .energyImpactPartial: "部分数据", .energyImpactStale: "数据已过期", .energyImpactStaleWithValue: "数据已过期 · %1$@", .energyImpactRowAccessibility: "%1$@,第 %2$lld 名,%3$@", .energyImpactEmpty: "当前没有普通应用报告能耗估算。", .energyImpactUnavailable: "不可读取", .preferencesProcessApplicationIdentifier: "在进程列表中显示应用 ID"], + "zh-Hant": [.energyImpactTitle: "耗電影響", .energyImpactSubtitleCurrent: "近期 CPU 能耗估算 · 越低越好", .energyImpactAppColumn: "應用程式", .energyImpactCurrentColumn: "目前", .energyImpactCollecting: "收集中", .energyImpactPartial: "部分資料", .energyImpactStale: "資料已過期", .energyImpactStaleWithValue: "資料已過期 · %1$@", .energyImpactRowAccessibility: "%1$@,第 %2$lld 名,%3$@", .energyImpactEmpty: "目前沒有一般應用程式回報能耗估算。", .energyImpactUnavailable: "無法讀取", .preferencesProcessApplicationIdentifier: "在程序列表中顯示應用程式 ID"], ] XCTAssertEqual(Set(expectations.keys), Set(AppLocalization.availableLanguageIdentifiers())) diff --git a/Tests/MacActivityCoreTests/EnergyImpactProcessReaderTests.swift b/Tests/MacActivityCoreTests/EnergyImpactProcessReaderTests.swift index 9139a2b..1be619c 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactProcessReaderTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactProcessReaderTests.swift @@ -36,4 +36,8 @@ final class EnergyImpactProcessReaderTests: XCTestCase { XCTAssertEqual(SystemProcessEnergyReader.failure(for: ENOTSUP), .unsupported) XCTAssertEqual(SystemProcessEnergyReader.failure(for: EINVAL), .unsupported) } + + func testUnknownErrorMapsToOtherPreservingCode() { + XCTAssertEqual(SystemProcessEnergyReader.failure(for: 12_345), .other(12_345)) + } } diff --git a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift index e833ba5..84dccc1 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactProviderTests.swift @@ -436,6 +436,71 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertEqual(afterGap?.status, .collecting) } + func testEnergyCounterRegressionRebaselinesBeforePublishingSubsequentDelta() throws { + let service = makeService( + results: [ + .success(reading(energy: 4_000)), + .success(reading(energy: 1_000)), + .success(reading(energy: 4_000)), + ], + times: [0, 3, 6] + ) + + _ = service.topApps(limit: 1) + let regression = try XCTUnwrap(service.topApps(limit: 1).first) + let recovered = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(regression.status, .collecting) + XCTAssertNil(regression.currentPowerMicrowatts) + XCTAssertEqual(recovered.status, .stable) + XCTAssertEqual(recovered.currentPowerMicrowatts, 1) + } + + func testNewRegularRootWithoutSnapshotCannotReuseFormerOwnerBaseline() throws { + let root = EnergyImpactAppSnapshot( + processIdentifier: 100, + name: "Root", + bundleIdentifier: nil, + bundleURL: nil + ) + let promotedHelper = EnergyImpactAppSnapshot( + processIdentifier: 200, + name: "Promoted", + bundleIdentifier: nil, + bundleURL: nil + ) + var appSnapshots = [ + [root], + [root, promotedHelper], + ] + let service = EnergyImpactService( + reader: ProcessEnergyReadingProviderStub(results: [ + 100: [ + .success(reading(energy: 1_000)), + .success(reading(energy: 4_000)), + ], + 200: [ + .success(reading(energy: 1_000, start: 20)), + .success(reading(energy: 4_000, start: 20)), + ], + ]), + processSnapshotReader: SequencedProcessParentSnapshotReaderStub(snapshotsByCall: [ + [.init(processIdentifier: 200, parentProcessIdentifier: 100)], + [], + ]), + appSnapshotProvider: { appSnapshots.removeFirst() }, + clock: EnergyImpactClockStub(times: [0, 3]) + ) + + _ = service.topApps(limit: 2) + let promoted = try XCTUnwrap( + service.topApps(limit: 2).first { $0.processIdentifier == 200 } + ) + + XCTAssertEqual(promoted.status, .collecting) + XCTAssertNil(promoted.currentPowerMicrowatts) + } + func testClockRollbackCannotProduceANegativeOrInfinitePower() { let clock = EnergyImpactClockStub(times: [3, 2]) let service = EnergyImpactService( @@ -788,6 +853,27 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertNil(expired.currentPowerMicrowatts) } + func testRootGenerationRemainsConfirmedAtMaximumGapBoundary() throws { + let service = makeService( + results: [ + .success(reading(energy: 1_000)), + .success(reading(energy: 4_000)), + .failure(.permissionDenied), + ], + times: [0, 3, 13] + ) + + _ = service.topApps(limit: 1) + let stable = try XCTUnwrap(service.topApps(limit: 1).first) + let atBoundary = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(stable.status, .stable) + XCTAssertEqual(stable.identity.rootProcessStartAbsoluteTime, 10) + XCTAssertEqual(atBoundary.status, .stale) + XCTAssertEqual(atBoundary.identity.rootProcessStartAbsoluteTime, 10) + XCTAssertEqual(atBoundary.currentPowerMicrowatts, 1) + } + // Production break caught: a successful root generation change reuses the prior generation's stale display. func testRootGenerationChangeImmediatelyDiscardsOldDisplay() throws { let service = makeTwoProcessService( @@ -821,6 +907,47 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertEqual(stale.currentPowerMicrowatts, 1) } + // Production break caught: helper-only partial samples renew an expired root generation. + func testPartialDescendantSamplesDoNotExtendRootGenerationPastConfirmationGap() throws { + let service = makeTwoProcessService( + rootResults: [ + .success(reading(energy: 1_000, start: 10)), + .success(reading(energy: 4_000, start: 10)), + .failure(.permissionDenied), + .failure(.permissionDenied), + .failure(.permissionDenied), + .failure(.permissionDenied), + ], + helperResults: [ + .success(reading(energy: 1_000, start: 11)), + .success(reading(energy: 4_000, start: 11)), + .success(reading(energy: 7_000, start: 11)), + .success(reading(energy: 10_000, start: 11)), + .success(reading(energy: 13_000, start: 11)), + .success(reading(energy: 16_000, start: 11)), + ], + times: [0, 3, 6, 9, 12, 15] + ) + + _ = service.topApps(limit: 1) + let confirmed = try XCTUnwrap(service.topApps(limit: 1).first) + _ = service.topApps(limit: 1) + _ = service.topApps(limit: 1) + let withinConfirmationGap = try XCTUnwrap(service.topApps(limit: 1).first) + let expired = try XCTUnwrap(service.topApps(limit: 1).first) + + XCTAssertEqual(confirmed.status, .stable) + XCTAssertEqual(confirmed.identity.rootProcessStartAbsoluteTime, 10) + XCTAssertEqual(withinConfirmationGap.status, .partial) + XCTAssertEqual(withinConfirmationGap.identity.rootProcessStartAbsoluteTime, 10) + XCTAssertEqual(withinConfirmationGap.currentPowerMicrowatts, 1) + XCTAssertEqual(expired.status, .partial) + XCTAssertNil(expired.identity.rootProcessStartAbsoluteTime) + XCTAssertEqual(expired.currentPowerMicrowatts, 1) + XCTAssertEqual(expired.coverage.validProcessSeconds, 3) + XCTAssertEqual(expired.coverage.discoveredProcessSeconds, 6) + } + // Production break caught: an expired root locator assigns helper-only data to a reused PID's old generation. func testExpiredRootLocatorDoesNotLabelReusedPIDHelperDataWithOldGeneration() throws { let service = EnergyImpactService( @@ -950,6 +1077,44 @@ final class EnergyImpactProviderTests: XCTestCase { XCTAssertEqual(EnergyImpactService.sortedByImpact(entries, limit: 2).map(\.name), ["Calendar", "Notes"]) } + + func testUnavailableNumericEntrySortsBeforeNonnumericRegardlessOfInputOrder() { + let numeric = entry( + processIdentifier: 1, + name: "Numeric", + power: 1, + status: .unavailable + ) + let nonnumeric = entry( + processIdentifier: 2, + name: "Nonnumeric", + power: nil, + status: .unavailable + ) + + XCTAssertEqual( + EnergyImpactService.sortedByImpact([numeric, nonnumeric], limit: 2) + .map(\.processIdentifier), + [1, 2] + ) + XCTAssertEqual( + EnergyImpactService.sortedByImpact([nonnumeric, numeric], limit: 2) + .map(\.processIdentifier), + [1, 2] + ) + } + + func testSameNameSameScoreSortsByProcessIdentifier() { + let entries = [ + entry(processIdentifier: 2, name: "Same", power: 4.2, status: .stable), + entry(processIdentifier: 1, name: "Same", power: 4.2, status: .stable), + ] + + XCTAssertEqual( + EnergyImpactService.sortedByImpact(entries, limit: 2).map(\.processIdentifier), + [1, 2] + ) + } } private final class EnergyImpactClockStub: EnergyImpactClock, @unchecked Sendable { diff --git a/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift b/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift index 1490d22..5b9d51b 100644 --- a/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift +++ b/Tests/MacActivityCoreTests/EnergyImpactStatisticsTests.swift @@ -52,6 +52,25 @@ final class EnergyImpactStatisticsTests: XCTestCase { XCTAssertEqual(ranker.rank([immediate, nearLead], atPublicationBoundary: true).map(\.processIdentifier), [1, 2]) } + func testPositiveChallengerImmediatelyPassesZeroIncumbent() { + var ranker = StableEnergyImpactRanker() + let incumbent = fixtureEntry(pid: 1, score: 0) + let zeroChallenger = fixtureEntry(pid: 2, score: 0) + + XCTAssertEqual( + ranker.rank([incumbent, zeroChallenger], atPublicationBoundary: true) + .map(\.processIdentifier), + [1, 2] + ) + + let positiveChallenger = fixtureEntry(pid: 2, score: 1) + XCTAssertEqual( + ranker.rank([incumbent, positiveChallenger], atPublicationBoundary: true) + .map(\.processIdentifier), + [2, 1] + ) + } + func testStableEntryRanksAheadOfHigherScoringStaleEntry() { var ranker = StableEnergyImpactRanker() let stable = fixtureEntry(pid: 1, score: 1, status: .stable) From 19beba0a0f39adbd69c2f54b834e7a11cab1efe7 Mon Sep 17 00:00:00 2001 From: bigtomcat Date: Fri, 7 Aug 2026 17:12:10 +1000 Subject: [PATCH 13/13] test(energy): cover smoothing fallback --- .../Models/EnergyImpactModel.swift | 31 +++++++++++++------ .../EnergyImpactModelTests.swift | 22 +++++++++++++ 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/Sources/MacActivityApp/Models/EnergyImpactModel.swift b/Sources/MacActivityApp/Models/EnergyImpactModel.swift index 2a22164..b6ce2a9 100644 --- a/Sources/MacActivityApp/Models/EnergyImpactModel.swift +++ b/Sources/MacActivityApp/Models/EnergyImpactModel.swift @@ -19,6 +19,11 @@ final class EnergyImpactModel: ObservableObject { private let initialWindowNanoseconds: UInt64 private let clock: any EnergyImpactClock private let sleep: (UInt64) async throws -> Void + private let smoothingOverrideForTesting: (( + EnergyImpactProcessIdentity, + Double, + TimeInterval + ) -> Double?)? private let configuration = EnergyImpactConfiguration.production private var smoother = EnergyImpactSmoother( @@ -33,6 +38,7 @@ final class EnergyImpactModel: ObservableObject { limit: Int = 20, initialWindowNanoseconds: UInt64 = 3_000_000_000, clock: any EnergyImpactClock = SystemEnergyImpactClock(), + smoothingOverrideForTesting: ((EnergyImpactProcessIdentity, Double, TimeInterval) -> Double?)? = nil, sleep: @escaping (UInt64) async throws -> Void = { try await Task.sleep(nanoseconds: $0) } @@ -42,6 +48,7 @@ final class EnergyImpactModel: ObservableObject { self.initialWindowNanoseconds = initialWindowNanoseconds self.clock = clock self.sleep = sleep + self.smoothingOverrideForTesting = smoothingOverrideForTesting } func refresh() async { @@ -148,16 +155,22 @@ final class EnergyImpactModel: ObservableObject { lastValidObservationTimes[generation] = nil } let smoothingElapsed = min(elapsedSeconds, configuration.maximumGapSeconds) - // Inputs above satisfy EnergyImpactSmoother's nil-return preconditions; retain the - // defensive fallback in case that contract changes. - guard let smoothed = smoother.update( - identity: generation, - value: currentPower, - elapsedSeconds: smoothingElapsed - ) else { - // codecov:ignore start + let smoothed: Double? + if let smoothingOverrideForTesting { + smoothed = smoothingOverrideForTesting( + generation, + currentPower, + smoothingElapsed + ) + } else { + smoothed = smoother.update( + identity: generation, + value: currentPower, + elapsedSeconds: smoothingElapsed + ) + } + guard let smoothed else { return Self.nonnumericUnavailable(sanitized) - // codecov:ignore end } lastValidObservationTimes[generation] = publicationTime return Self.replacingCurrentPower(in: sanitized, with: smoothed) diff --git a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift index 4c763d0..ed4547f 100644 --- a/Tests/MacActivityAppTests/EnergyImpactModelTests.swift +++ b/Tests/MacActivityAppTests/EnergyImpactModelTests.swift @@ -383,6 +383,28 @@ final class EnergyImpactModelTests: XCTestCase { XCTAssertNil(unavailable.rankingScore) } + func testSmoothingFailurePublishesNonnumericUnavailable() async throws { + let clock = EnergyImpactTestClock() + let model = EnergyImpactModel( + provider: EnergyImpactProviderStub( + responses: [ + [], + [entry(power: 100)], + ] + ), + clock: clock, + smoothingOverrideForTesting: { _, _, _ in nil }, + sleep: { _ in clock.advance(seconds: 3) } + ) + + await model.refresh() + + let unavailable = try XCTUnwrap(model.entries.first) + XCTAssertEqual(unavailable.status, .unavailable) + XCTAssertNil(unavailable.currentPowerMicrowatts) + XCTAssertNil(unavailable.rankingScore) + } + func testDuplicateGenerationInOnePublicationMarksSecondEntryUnavailable() async throws { let clock = EnergyImpactTestClock() let model = EnergyImpactModel(