Increment 1: persistent metric history + extended time-range charts

- MetricsStore: thread-safe on-disk ring buffer (Application Support), throttled
  background saves, flush on quit/background, 24h/20k cap
- MetricsMath: pure trim + downsample (unit-tested)
- HistoryRange (Live/5m/30m/1h/24h) + HistoryRangePicker + MetricTrendChart
- Wire recording into SystemMonitor; range picker in CPU/Memory/Network tabs
- Fix durability: flush history on applicationWillTerminate + scenePhase.background
- 8 new tests (30 total green); runtime smoke: no crash, 7 samples persisted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
hariel1985
2026-07-02 19:59:05 +02:00
szülő d59bf2b7b0
commit 222638fc98
7 fájl változott, egészen pontosan 469 új sor hozzáadva és 8 régi sor törölve

Fájl megtekintése

@@ -12,6 +12,7 @@
136DAD1FE90FD682DF666667 /* TopManager_icon.png in Resources */ = {isa = PBXBuildFile; fileRef = 8E4A6E8E2305CEDC119AC6BC /* TopManager_icon.png */; };
1F212CE158B7D14B29077B18 /* CPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44281079654C7E1C9E16806C /* CPUInfo.swift */; };
1F25A84B560709AE71C27D23 /* GPUInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F481045B4FC6017F50849FF1 /* GPUInfo.swift */; };
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */; };
266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */; };
341C12CED58388D32ED6C5BB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4F1ACD0492CAB741595DEAA /* ContentView.swift */; };
35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D71CC63FC2FEF52A069372E /* MenuBarView.swift */; };
@@ -23,9 +24,11 @@
833976FD8DC4CCC45078CF9A /* DiskMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */; };
8F9ED99AB6D8D90AE79B5179 /* DiskUsageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */; };
9146403BC02D18A234444DF6 /* ProcessMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 295232B4C746511757DCF8FE /* ProcessMonitor.swift */; };
9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */; };
A0649565A902BA48DB10F592 /* CPUMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19ED582C412C63A0A2E2103E /* CPUMonitor.swift */; };
A7EC9FB55D5088D669491C11 /* AppsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 813543165B39EBD213EB3BC7 /* AppsView.swift */; };
B62B18DF32A8734D5908B16B /* DiskInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 352CA1BE2A3B43643A9DBD72 /* DiskInfo.swift */; };
BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */; };
C077A279E20A90FD6038CCB0 /* ProcessDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 110A314A63AE355A9E04990D /* ProcessDetailView.swift */; };
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBFADE83965AA863B775A910 /* NetworkChartView.swift */; };
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = A404EBD588B7ACD36079A95D /* TopManagerApp.swift */; };
@@ -50,6 +53,8 @@
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStore.swift; sourceTree = "<group>"; };
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsStoreTests.swift; sourceTree = "<group>"; };
0F5A69E0EE7FDD44157911D7 /* TopManagerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TopManagerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
110A314A63AE355A9E04990D /* ProcessDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProcessDetailView.swift; sourceTree = "<group>"; };
162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskMonitor.swift; sourceTree = "<group>"; };
@@ -74,6 +79,7 @@
8F402CE13EF67DE550786B79 /* MemoryDonutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MemoryDonutView.swift; sourceTree = "<group>"; };
94D5A13CC5B9CF1B90CC2214 /* CPUChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CPUChartView.swift; sourceTree = "<group>"; };
95B3F62455B01B2E9C0D76A9 /* DiskUsageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiskUsageView.swift; sourceTree = "<group>"; };
9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrendChartView.swift; sourceTree = "<group>"; };
9D71CC63FC2FEF52A069372E /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = "<group>"; };
A3A3D0B1EB522C9975F71DA8 /* TopManager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TopManager.app; sourceTree = BUILT_PRODUCTS_DIR; };
A404EBD588B7ACD36079A95D /* TopManagerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TopManagerApp.swift; sourceTree = "<group>"; };
@@ -94,6 +100,7 @@
162FC58F6C44EC1DD7022A86 /* DiskMonitor.swift */,
BE3BB62AE0DC43777AD00658 /* GPUMonitor.swift */,
27FDD19B8DBB7D955A5ECB5B /* MemoryMonitor.swift */,
05CC33DC4E93F6ED2E52F4E1 /* MetricsStore.swift */,
347E47F825E496F40FD8ED11 /* NetworkMonitor.swift */,
295232B4C746511757DCF8FE /* ProcessMonitor.swift */,
279B972C08A878053821285D /* SystemMonitor.swift */,
@@ -147,6 +154,7 @@
7326AE4B2D75476BDCFE3FFB /* MemoryLineChart.swift */,
BBFADE83965AA863B775A910 /* NetworkChartView.swift */,
F80A817DCDA254349DEA6F06 /* PerformanceView.swift */,
9AA15E30C4B694CB36DC0893 /* TrendChartView.swift */,
);
path = Performance;
sourceTree = "<group>";
@@ -201,6 +209,7 @@
isa = PBXGroup;
children = (
301352EB5B362C732CE1A246 /* FormattingTests.swift */,
0E786723FC46A64EFF5D3CE4 /* MetricsStoreTests.swift */,
);
path = TopManagerTests;
sourceTree = "<group>";
@@ -319,6 +328,7 @@
buildActionMask = 2147483647;
files = (
49D85FF611927294159C89D1 /* FormattingTests.swift in Sources */,
24F63686869A69C8B0FCD7FA /* MetricsStoreTests.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -342,6 +352,7 @@
266B888ADE2FDDBCEC8D28A5 /* MemoryLineChart.swift in Sources */,
F5722D2B18EF7ABF43847EE4 /* MemoryMonitor.swift in Sources */,
35852165B2DA76AB7F073723 /* MenuBarView.swift in Sources */,
BCBAC37351B9A3878CC3F01B /* MetricsStore.swift in Sources */,
CB7F422FBF9F6D152FC4F41B /* NetworkChartView.swift in Sources */,
E4AF7F7CA1E1FE8638CA5A77 /* NetworkInfo.swift in Sources */,
EB0D84848F42134927F9C9B1 /* NetworkMonitor.swift in Sources */,
@@ -353,6 +364,7 @@
0AA7D351AEB05078FA9B4B93 /* ProcessView.swift in Sources */,
DB3603F30B6F559BE66E4349 /* SystemMonitor.swift in Sources */,
D6FC8D1EACCB4DED5DF39372 /* TopManagerApp.swift in Sources */,
9F3A8E658859AD9C325E52A8 /* TrendChartView.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

Fájl megtekintése

@@ -0,0 +1,184 @@
import Foundation
/// A single point-in-time snapshot of system-wide metrics, persisted to disk so
/// history survives app restarts (the live in-RAM histories only hold ~60 points).
struct MetricsSample: Codable, Identifiable, Equatable {
let t: Date // timestamp
let cpu: Double // global CPU usage %
let memUsed: UInt64
let memTotal: UInt64
let netDown: Double // bytes/sec
let netUp: Double // bytes/sec
var id: Date { t }
var memPercent: Double {
memTotal > 0 ? Double(memUsed) / Double(memTotal) * 100 : 0
}
}
/// Pure, side-effect-free math over samples. Kept separate from `MetricsStore`
/// so it is trivially unit-testable without touching disk or the clock.
enum MetricsMath {
/// Drop samples older than `maxAge` (relative to `now`) and cap total count,
/// keeping the most recent `maxCount`.
static func trim(_ samples: [MetricsSample], now: Date, maxAge: TimeInterval, maxCount: Int) -> [MetricsSample] {
let cutoff = now.addingTimeInterval(-maxAge)
var result = samples.filter { $0.t >= cutoff }
if result.count > maxCount {
result = Array(result.suffix(maxCount))
}
return result
}
/// Average samples into fixed-width time buckets so long ranges (e.g. 24h)
/// plot ~100 points instead of tens of thousands. `bucket <= 0` returns input.
static func downsample(_ samples: [MetricsSample], bucket: TimeInterval) -> [MetricsSample] {
guard bucket > 0, samples.count > 1 else { return samples }
var groups: [Int64: [MetricsSample]] = [:]
for s in samples {
let key = Int64((s.t.timeIntervalSince1970 / bucket).rounded(.down))
groups[key, default: []].append(s)
}
return groups.keys.sorted().map { key in
let group = groups[key]!
let n = Double(group.count)
let midpoint = Date(timeIntervalSince1970: (Double(key) + 0.5) * bucket)
return MetricsSample(
t: midpoint,
cpu: group.reduce(0) { $0 + $1.cpu } / n,
memUsed: UInt64(group.reduce(0.0) { $0 + Double($1.memUsed) } / n),
memTotal: group.map { $0.memTotal }.max() ?? 0,
netDown: group.reduce(0) { $0 + $1.netDown } / n,
netUp: group.reduce(0) { $0 + $1.netUp } / n
)
}
}
}
/// Selectable time window for trend charts.
enum HistoryRange: String, CaseIterable, Identifiable {
case live = "Live"
case m5 = "5m"
case m30 = "30m"
case h1 = "1h"
case h24 = "24h"
var id: String { rawValue }
/// Window length in seconds; `nil` for the live (in-RAM) view.
var seconds: TimeInterval? {
switch self {
case .live: return nil
case .m5: return 5 * 60
case .m30: return 30 * 60
case .h1: return 60 * 60
case .h24: return 24 * 60 * 60
}
}
/// Downsample bucket size chosen to keep ~100–150 plotted points.
var bucket: TimeInterval {
switch self {
case .live: return 0
case .m5: return 0 // ~raw (refresh cadence already ~3s)
case .m30: return 15
case .h1: return 30
case .h24: return 600
}
}
}
/// Thread-safe on-disk ring buffer of `MetricsSample`s.
///
/// Access to the backing array is guarded by a lock; file writes happen on a
/// background queue from a snapshot so the recording thread never blocks on I/O.
final class MetricsStore {
static let shared = MetricsStore()
private let lock = NSLock()
private var _samples: [MetricsSample] = []
private let fileURL: URL
private let maxAge: TimeInterval
private let maxCount: Int
private let saveEvery: Int
private var unsaved = 0
private let ioQueue = DispatchQueue(label: "com.topmanager.metricsstore.io", qos: .utility)
init(fileURL: URL? = nil,
maxAge: TimeInterval = 24 * 60 * 60,
maxCount: Int = 20_000,
saveEvery: Int = 10) {
self.fileURL = fileURL ?? Self.defaultFileURL()
self.maxAge = maxAge
self.maxCount = maxCount
self.saveEvery = saveEvery
load()
}
static func defaultFileURL() -> URL {
let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
?? URL(fileURLWithPath: NSTemporaryDirectory())
let dir = base.appendingPathComponent("TopManager", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("metrics-history.json")
}
/// Append a sample, trim, and persist on a throttled cadence.
func record(_ sample: MetricsSample) {
lock.lock()
_samples.append(sample)
_samples = MetricsMath.trim(_samples, now: sample.t, maxAge: maxAge, maxCount: maxCount)
unsaved += 1
let snapshot: [MetricsSample]? = unsaved >= saveEvery ? _samples : nil
if snapshot != nil { unsaved = 0 }
lock.unlock()
if let snapshot { persist(snapshot) }
}
func samples(since date: Date) -> [MetricsSample] {
lock.lock(); defer { lock.unlock() }
return _samples.filter { $0.t >= date }
}
var count: Int {
lock.lock(); defer { lock.unlock() }
return _samples.count
}
/// Samples for a chart range, downsampled for long windows.
func downsampled(for range: HistoryRange, now: Date = Date()) -> [MetricsSample] {
guard let secs = range.seconds else { return [] }
let windowed = samples(since: now.addingTimeInterval(-secs))
return MetricsMath.downsample(windowed, bucket: range.bucket)
}
/// Force an immediate save (e.g. on stop / quit).
func flush() {
lock.lock()
let snapshot = _samples
unsaved = 0
lock.unlock()
persist(snapshot)
}
private func persist(_ snapshot: [MetricsSample]) {
ioQueue.async { [fileURL] in
guard let data = try? JSONEncoder().encode(snapshot) else { return }
try? data.write(to: fileURL, options: .atomic)
}
}
private func load() {
guard let data = try? Data(contentsOf: fileURL),
let decoded = try? JSONDecoder().decode([MetricsSample].self, from: data) else {
return
}
lock.lock()
_samples = MetricsMath.trim(decoded, now: Date(), maxAge: maxAge, maxCount: maxCount)
lock.unlock()
}
}

Fájl megtekintése

@@ -81,6 +81,7 @@ final class SystemMonitor: ObservableObject {
@MainActor func stopMonitoring() {
timer?.invalidate()
timer = nil
MetricsStore.shared.flush()
}
private func refreshAllBackground() {
@@ -177,6 +178,18 @@ final class SystemMonitor: ObservableObject {
if let info = gpu {
gpuInfo = info
}
// Persist a system-wide sample so history survives restarts and long ranges.
if let c = cpuInfo, let m = memoryInfo {
MetricsStore.shared.record(MetricsSample(
t: c.timestamp,
cpu: c.globalUsage,
memUsed: m.usedMemory,
memTotal: m.totalMemory,
netDown: networkInfo?.totalDownloadRate ?? 0,
netUp: networkInfo?.totalUploadRate ?? 0
))
}
}
// Process control methods with PID validation

Fájl megtekintése

@@ -1,8 +1,18 @@
import SwiftUI
/// Flushes persisted metrics history when the app is quit so history isn't lost
/// between the throttled background saves.
final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
MetricsStore.shared.flush()
}
}
@main
struct TopManagerApp: App {
@StateObject private var monitor = SystemMonitor.shared
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
@Environment(\.scenePhase) private var scenePhase
var body: some Scene {
WindowGroup {
@@ -14,6 +24,11 @@ struct TopManagerApp: App {
}
.windowStyle(.automatic)
.defaultSize(width: 1000, height: 700)
.onChange(of: scenePhase) { phase in
if phase == .background {
MetricsStore.shared.flush()
}
}
.commands {
CommandGroup(replacing: .newItem) { }
}

Fájl megtekintése

@@ -31,6 +31,7 @@ struct PerformanceView: View {
struct CPUTabView: View {
@EnvironmentObject var monitor: SystemMonitor
@State private var range: HistoryRange = .live
private let coreColumns = [
GridItem(.flexible()),
@@ -48,6 +49,7 @@ struct CPUTabView: View {
HStack {
Text("Total CPU Usage")
.font(.headline)
HistoryRangePicker(range: $range)
Spacer()
if let cpu = monitor.cpuInfo {
Text(String(format: "%.1f%%", cpu.globalUsage))
@@ -57,8 +59,13 @@ struct CPUTabView: View {
}
}
CPULineChart(history: monitor.cpuHistory)
.frame(height: 120)
if range == .live {
CPULineChart(history: monitor.cpuHistory)
.frame(height: 120)
} else {
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .cpu)
.frame(height: 120)
}
// Legend
HStack(spacing: 20) {
@@ -201,6 +208,7 @@ struct CoreLineChartView: View {
struct MemoryTabView: View {
@EnvironmentObject var monitor: SystemMonitor
@State private var range: HistoryRange = .live
var body: some View {
ScrollView {
@@ -230,6 +238,7 @@ struct MemoryTabView: View {
HStack {
Text("Memory Usage Over Time")
.font(.headline)
HistoryRangePicker(range: $range)
Spacer()
if let mem = monitor.memoryInfo {
Text(String(format: "%.1f%%", mem.usagePercentage))
@@ -239,8 +248,13 @@ struct MemoryTabView: View {
}
}
MemoryLineChart(history: monitor.memoryHistory)
.frame(height: 150)
if range == .live {
MemoryLineChart(history: monitor.memoryHistory)
.frame(height: 150)
} else {
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .memory)
.frame(height: 150)
}
if let mem = monitor.memoryInfo {
HStack {
@@ -394,6 +408,7 @@ struct MemoryStatItem: View {
struct NetworkTabView: View {
@EnvironmentObject var monitor: SystemMonitor
@State private var range: HistoryRange = .live
var body: some View {
ScrollView {
@@ -405,6 +420,7 @@ struct NetworkTabView: View {
Label("Download", systemImage: "arrow.down.circle")
.font(.headline)
.foregroundColor(.blue)
HistoryRangePicker(range: $range)
Spacer()
if let net = monitor.networkInfo {
Text(formatBytesPerSecond(net.totalDownloadRate))
@@ -414,8 +430,13 @@ struct NetworkTabView: View {
}
}
DownloadLineChart(history: monitor.networkHistory)
.frame(height: 150)
if range == .live {
DownloadLineChart(history: monitor.networkHistory)
.frame(height: 150)
} else {
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .download)
.frame(height: 150)
}
}
.padding(.vertical, 8)
}
@@ -436,8 +457,13 @@ struct NetworkTabView: View {
}
}
UploadLineChart(history: monitor.networkHistory)
.frame(height: 150)
if range == .live {
UploadLineChart(history: monitor.networkHistory)
.frame(height: 150)
} else {
MetricTrendChart(samples: MetricsStore.shared.downsampled(for: range), metric: .upload)
.frame(height: 150)
}
}
.padding(.vertical, 8)
}

Fájl megtekintése

@@ -0,0 +1,105 @@
import SwiftUI
import Charts
/// A range selector segmented control used above the live/trend charts.
struct HistoryRangePicker: View {
@Binding var range: HistoryRange
var body: some View {
Picker("", selection: $range) {
ForEach(HistoryRange.allCases) { r in
Text(r.rawValue).tag(r)
}
}
.pickerStyle(.segmented)
.labelsHidden()
.fixedSize()
}
}
/// Store-backed trend chart. Plots one metric of `MetricsSample` over a chosen
/// window. Used for the non-live ranges (5m / 30m / 1h / 24h).
struct MetricTrendChart: View {
enum Metric { case cpu, memory, download, upload }
let samples: [MetricsSample]
let metric: Metric
private var color: Color {
switch metric {
case .cpu: return .gray
case .memory: return .purple
case .download: return .blue
case .upload: return .green
}
}
private func value(_ s: MetricsSample) -> Double {
switch metric {
case .cpu: return s.cpu
case .memory: return s.memPercent
case .download: return s.netDown
case .upload: return s.netUp
}
}
private var isPercent: Bool {
metric == .cpu || metric == .memory
}
private var yDomain: ClosedRange<Double> {
if isPercent { return -5...105 }
let maxV = max(samples.map(value).max() ?? 0, 1024)
return 0...(maxV * 1.1)
}
var body: some View {
Group {
if samples.isEmpty {
VStack {
Spacer()
Text("Collecting history…")
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
.frame(maxWidth: .infinity)
} else {
Chart {
ForEach(samples) { point in
LineMark(
x: .value("Time", point.t),
y: .value("Value", value(point))
)
.foregroundStyle(color)
.lineStyle(StrokeStyle(lineWidth: 1.5))
AreaMark(
x: .value("Time", point.t),
y: .value("Value", value(point))
)
.foregroundStyle(color.opacity(0.12))
}
}
.chartYScale(domain: yDomain)
.chartYAxis {
AxisMarks(position: .leading) { value in
AxisGridLine()
AxisValueLabel {
if let v = value.as(Double.self) {
Text(isPercent ? "\(Int(v))%" : formatBytesPerSecond(v))
.font(.caption2)
}
}
}
}
.chartXAxis {
AxisMarks(values: .automatic(desiredCount: 4)) { _ in
AxisGridLine()
}
}
.drawingGroup()
}
}
}
}

Fájl megtekintése

@@ -0,0 +1,106 @@
import XCTest
@testable import TopManager
final class MetricsStoreTests: XCTestCase {
private func sample(_ secondsAgo: TimeInterval, cpu: Double = 0, memUsed: UInt64 = 0,
memTotal: UInt64 = 100, down: Double = 0, up: Double = 0,
now: Date) -> MetricsSample {
MetricsSample(t: now.addingTimeInterval(-secondsAgo), cpu: cpu,
memUsed: memUsed, memTotal: memTotal, netDown: down, netUp: up)
}
// MARK: - trim
func testTrimDropsOldSamples() {
let now = Date()
let samples = [
sample(10_000, now: now), // older than 1h
sample(100, now: now), // within 1h
sample(50, now: now)
]
let trimmed = MetricsMath.trim(samples, now: now, maxAge: 3600, maxCount: 100)
XCTAssertEqual(trimmed.count, 2)
XCTAssertFalse(trimmed.contains { $0.t < now.addingTimeInterval(-3600) })
}
func testTrimCapsCountKeepingNewest() {
let now = Date()
// 10 samples, 0..9 seconds ago; keep newest 5
let samples = (0..<10).map { sample(Double($0), cpu: Double($0), now: now) }
let trimmed = MetricsMath.trim(samples, now: now, maxAge: 3600, maxCount: 5)
XCTAssertEqual(trimmed.count, 5)
// suffix keeps the last 5 of the input order (cpu 5..9)
XCTAssertEqual(trimmed.map { $0.cpu }, [5, 6, 7, 8, 9])
}
// MARK: - downsample
func testDownsampleZeroBucketIsPassthrough() {
let now = Date()
let samples = (0..<5).map { sample(Double($0), cpu: Double($0), now: now) }
XCTAssertEqual(MetricsMath.downsample(samples, bucket: 0).count, 5)
}
func testDownsampleAveragesWithinBucket() {
// Two samples 0s and 5s into the same 60s bucket → averaged to one point.
let epoch = Date(timeIntervalSince1970: 1_000_000) // aligned into bucket 16666
let s1 = MetricsSample(t: epoch, cpu: 20, memUsed: 40, memTotal: 100, netDown: 100, netUp: 10)
let s2 = MetricsSample(t: epoch.addingTimeInterval(5), cpu: 40, memUsed: 60, memTotal: 100, netDown: 300, netUp: 30)
let out = MetricsMath.downsample([s1, s2], bucket: 60)
XCTAssertEqual(out.count, 1)
XCTAssertEqual(out[0].cpu, 30, accuracy: 0.001)
XCTAssertEqual(out[0].memUsed, 50)
XCTAssertEqual(out[0].netDown, 200, accuracy: 0.001)
XCTAssertEqual(out[0].netUp, 20, accuracy: 0.001)
}
func testDownsampleReducesPointCountForLongRange() {
let base = Date(timeIntervalSince1970: 2_000_000)
// 600 samples one second apart → into 60s buckets ≈ 10 points
let samples = (0..<600).map {
MetricsSample(t: base.addingTimeInterval(Double($0)), cpu: 1, memUsed: 1, memTotal: 100, netDown: 0, netUp: 0)
}
let out = MetricsMath.downsample(samples, bucket: 60)
XCTAssertLessThanOrEqual(out.count, 11)
XCTAssertGreaterThanOrEqual(out.count, 10)
// output must be time-sorted
XCTAssertEqual(out, out.sorted { $0.t < $1.t })
}
// MARK: - persistence round-trip
func testStorePersistsAndReloads() {
let tmp = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent("tm-test-\(UUID().uuidString).json")
defer { try? FileManager.default.removeItem(at: tmp) }
let store = MetricsStore(fileURL: tmp, maxAge: 3600, maxCount: 100, saveEvery: 1)
let now = Date()
store.record(MetricsSample(t: now, cpu: 42, memUsed: 5, memTotal: 10, netDown: 1, netUp: 2))
// saveEvery: 1 persists on the background io queue; wait for the file to appear.
let deadline = Date().addingTimeInterval(2)
while !FileManager.default.fileExists(atPath: tmp.path), Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.02))
}
XCTAssertTrue(FileManager.default.fileExists(atPath: tmp.path), "history file was not written")
let reloaded = MetricsStore(fileURL: tmp, maxAge: 3600, maxCount: 100, saveEvery: 1)
XCTAssertEqual(reloaded.count, 1)
XCTAssertEqual(reloaded.samples(since: now.addingTimeInterval(-1)).first?.cpu, 42)
}
// MARK: - range config sanity
func testHistoryRangeLiveHasNoWindow() {
XCTAssertNil(HistoryRange.live.seconds)
XCTAssertEqual(HistoryRange.live.bucket, 0)
}
func testHistoryRangeWindowsIncrease() {
let windows = HistoryRange.allCases.compactMap { $0.seconds }
XCTAssertEqual(windows, windows.sorted())
XCTAssertEqual(HistoryRange.h24.seconds, 86_400)
}
}