- 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>
107 sor
4.5 KiB
Swift
107 sor
4.5 KiB
Swift
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)
|
|
}
|
|
}
|