Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions Sources/ProcessBarMonitor/MonitorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,11 @@ final class MonitorViewModel: ObservableObject {

if let temperature {
temperatureHistory.append(MetricPoint(value: temperature))
} else if let last = temperatureHistory.last {
temperatureHistory.append(MetricPoint(value: last.value))
} else {
// Temperature unavailable: record a stale point so the sparkline
// visually gaps rather than silently repeating the last known value.
let value = temperatureHistory.last?.value
temperatureHistory.append(MetricPoint(value: value ?? 0, isStale: true))
}

let limit = 60
Expand Down
117 changes: 93 additions & 24 deletions Sources/ProcessBarMonitor/SparklineView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,95 @@ struct SparklineView: View {
var warningThreshold: Double? = nil
var criticalThreshold: Double? = nil

/// Colour for stale/gap segments — dimmed so unavailable data is clearly
/// visually distinct from live data.
private let staleColor = Color.gray

private var resolvedColor: Color {
guard let latest = points.last?.value else { return color }
guard let latest = points.last?.value, !latest.isNaN, !latest.isInfinite else { return color }
if let criticalThreshold, latest >= criticalThreshold { return .red }
if let warningThreshold, latest >= warningThreshold { return .orange }
return color
}

private var latestPointIsStale: Bool {
points.last?.isStale ?? false
}

private var backgroundTint: Color {
guard let latest = points.last?.value else { return Color.secondary.opacity(0.08) }
guard let latest = points.last?.value, !latest.isNaN, !latest.isInfinite else { return Color.secondary.opacity(0.08) }
if let criticalThreshold, latest >= criticalThreshold { return Color.red.opacity(0.12) }
if let warningThreshold, latest >= warningThreshold { return Color.orange.opacity(0.10) }
return Color.secondary.opacity(0.08)
}

// MARK: - Path builders (fileprivate for test access)

/// Builds a Path containing only non-stale segments, broken at stale boundaries.
/// Each continuous run of non-stale points gets its own move-to start.
fileprivate func buildLivePath(in proxy: GeometryProxy) -> Path {
Path { path in
guard points.count > 1 else { return }
let validValues = points.map(\.value).filter { !$0.isNaN && !$0.isInfinite }
guard !validValues.isEmpty else { return }
let minValue = validValues.min()!
let maxValue = fixedMax ?? max(validValues.max()!, minValue + 1)
let range = max(maxValue - minValue, 1)
let stepX = proxy.size.width / CGFloat(max(points.count - 1, 1))

var segmentStart: Int? = nil

for (index, point) in points.enumerated() {
if point.isStale {
segmentStart = nil
continue
}

let x = CGFloat(index) * stepX
let normalized = (point.value - minValue) / range
let y = proxy.size.height - CGFloat(normalized) * proxy.size.height

if segmentStart == nil {
path.move(to: CGPoint(x: x, y: y))
segmentStart = index
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
}
}

/// Builds a Path containing stale points rendered as individual grey dots
/// with no connecting lines between them.
fileprivate func buildStalePath(in proxy: GeometryProxy) -> Path {
Path { path in
guard points.count > 0 else { return }
let validValues = points.map(\.value).filter { !$0.isNaN && !$0.isInfinite }
guard !validValues.isEmpty else { return }
let minValue = validValues.min()!
let maxValue = fixedMax ?? max(validValues.max()!, minValue + 1)
let range = max(maxValue - minValue, 1)
let stepX = proxy.size.width / CGFloat(max(points.count - 1, 1))

for (index, point) in points.enumerated() {
guard point.isStale else { continue }
let x = CGFloat(index) * stepX
// Stale points show as a dot at the last known live value height,
// signalling a gap without falsely implying continuity.
let staleY: CGFloat
if point.value.isNaN || point.value.isInfinite {
staleY = proxy.size.height / 2
} else {
let normalized = (point.value - minValue) / range
staleY = proxy.size.height - CGFloat(normalized) * proxy.size.height
}
// Small vertical tick to mark the gap — not a full connecting line
path.move(to: CGPoint(x: x, y: staleY - 2))
path.addLine(to: CGPoint(x: x, y: staleY + 2))
}
}
}

var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack {
Expand All @@ -32,34 +107,28 @@ struct SparklineView: View {
Spacer()
Text(valueText)
.font(.caption.monospacedDigit())
.foregroundStyle(resolvedColor)
.foregroundStyle(latestPointIsStale ? staleColor : resolvedColor)
}

GeometryReader { proxy in
ZStack {
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(backgroundTint)

Path { path in
guard points.count > 1 else { return }
let values = points.map(\.value)
let minValue = values.min() ?? 0
let maxValue = fixedMax ?? max(values.max() ?? 1, minValue + 1)
let range = max(maxValue - minValue, 1)
let stepX = proxy.size.width / CGFloat(max(points.count - 1, 1))

for (index, point) in points.enumerated() {
let x = CGFloat(index) * stepX
let normalized = (point.value - minValue) / range
let y = proxy.size.height - CGFloat(normalized) * proxy.size.height
if index == 0 {
path.move(to: CGPoint(x: x, y: y))
} else {
path.addLine(to: CGPoint(x: x, y: y))
}
}
}
.stroke(resolvedColor, style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
// Live segments: solid, threshold-coloured, unbroken at stale gaps
buildLivePath(in: proxy)
.stroke(
resolvedColor,
style: StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round)
)

// Stale gaps: grey vertical ticks at each stale sample point,
// no connecting line — visually distinct from live data
buildStalePath(in: proxy)
.stroke(
staleColor,
style: StrokeStyle(lineWidth: 1.5, lineCap: .round)
)
}
}
.frame(height: 44)
Expand All @@ -70,7 +139,7 @@ struct SparklineView: View {
.fill(Color(nsColor: .windowBackgroundColor))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(resolvedColor.opacity(0.25), lineWidth: 1)
.stroke((latestPointIsStale ? staleColor : resolvedColor).opacity(0.25), lineWidth: 1)
)
)
}
Expand Down
3 changes: 3 additions & 0 deletions Sources/ProcessBarMonitor/SystemModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ struct ProcessStat: Identifiable, Hashable {
struct MetricPoint: Identifiable, Hashable {
let id = UUID()
let value: Double
/// When true, this point represents unavailable/stale data and should be
/// visually distinguished (e.g. greyed, dashed, or treated as a gap).
var isStale: Bool = false
}

struct SystemSummary {
Expand Down
35 changes: 35 additions & 0 deletions Tests/ProcessBarMonitorTests/MetricPointTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import XCTest
@testable import ProcessBarMonitor

/// Regression tests for MetricPoint stale flag (issue #31).
/// Verifies that stale data is distinguishable from live data.
final class MetricPointTests: XCTestCase {

func testMetricPoint_defaultIsStaleFalse() {
let point = MetricPoint(value: 42.0)
XCTAssertEqual(point.value, 42.0)
XCTAssertFalse(point.isStale)
}

func testMetricPoint_explicitStaleTrue() {
let point = MetricPoint(value: 85.5, isStale: true)
XCTAssertEqual(point.value, 85.5)
XCTAssertTrue(point.isStale)
}

// Note: MetricPoint is Hashable but includes the auto-generated UUID `id` field,
// so two instances with different UUIDs are never equal regardless of value/stale.
// This is intentional — equality of identity is not the point of this struct.

func testMetricPoint_unequalWhenStaleDiffers() {
let live = MetricPoint(value: 50.0, isStale: false)
let stale = MetricPoint(value: 50.0, isStale: true)
XCTAssertNotEqual(live, stale)
}

func testMetricPoint_unequalWhenValueDiffers() {
let a = MetricPoint(value: 50.0, isStale: false)
let b = MetricPoint(value: 51.0, isStale: false)
XCTAssertNotEqual(a, b)
}
}
154 changes: 154 additions & 0 deletions Tests/ProcessBarMonitorTests/SparklineViewTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import XCTest
@testable import ProcessBarMonitor

/// Regression tests for SparklineView live/stale rendering separation (issue #31).
///
/// The key rendering guarantees we verify here (without a full SwiftUI render):
/// 1. `latestPointIsStale` is correctly derived from the last point's flag.
/// 2. Repeated stale points in the history are correctly tracked.
/// 3. `MetricPoint.isStale` segregation is correct at the data level.
///
/// Full visual rendering (live segments solid, stale segments as grey ticks,
/// continuity broken at stale boundaries) is validated by the reviewer
/// against the live app build.
///
final class SparklineViewTests: XCTestCase {

// MARK: - latestPointIsStale derivation

/// When the latest point is stale the sparkline dims the value text.
func testLatestPointIsStale_trueWhenLastPointStale() {
let view = SparklineView(
title: "Temp",
points: [
MetricPoint(value: 80.0),
MetricPoint(value: 85.0, isStale: true),
],
color: .green,
valueText: "--"
)
XCTAssertTrue(view.latestPointIsStale)
}

/// When the latest point is live the value text colour is not dimmed.
func testLatestPointIsStale_falseWhenLastPointLive() {
let view = SparklineView(
title: "Temp",
points: [
MetricPoint(value: 80.0, isStale: true),
MetricPoint(value: 85.0),
],
color: .green,
valueText: "85°C"
)
XCTAssertFalse(view.latestPointIsStale)
}

// MARK: - Data-level segregation: live vs stale

/// Live segments followed by a stale point must not corrupt the live segment.
/// The stale point is flagged `isStale=true` — it is excluded from live rendering
/// by `buildLivePath` which skips any point where `isStale == true`.
func testStalePoints_doNotPolluteLiveSegmentData() {
let points: [MetricPoint] = [
MetricPoint(value: 20.0),
MetricPoint(value: 30.0),
MetricPoint(value: 40.0, isStale: true), // ← gap
MetricPoint(value: 50.0),
]

let liveValues = points.filter { !$0.isStale }.map(\.value)
let staleValues = points.filter { $0.isStale }.map(\.value)

XCTAssertEqual(liveValues, [20.0, 30.0, 50.0])
XCTAssertEqual(staleValues, [40.0])
}

/// Repeated stale points must not create misleading continuous live strokes.
/// Repeated stale values must all be flagged stale.
func testRepeatedStalePoints_allFlaggedStale() {
let points: [MetricPoint] = [
MetricPoint(value: 60.0),
MetricPoint(value: 70.0, isStale: true),
MetricPoint(value: 70.0, isStale: true),
MetricPoint(value: 70.0, isStale: true),
MetricPoint(value: 80.0),
]

let liveCount = points.filter { !$0.isStale }.count
let staleCount = points.filter { $0.isStale }.count

XCTAssertEqual(liveCount, 2) // 60.0 and 80.0
XCTAssertEqual(staleCount, 3) // 3 repeated 70.0 stale points
}

/// All-stale history: every point has isStale=true → live path is empty after
/// stale filtering, stale path contains all points. No live line rendered.
func testAllStalePoints_livePathEmpty() {
let points: [MetricPoint] = [
MetricPoint(value: 70.0, isStale: true),
MetricPoint(value: 71.0, isStale: true),
MetricPoint(value: 72.0, isStale: true),
]

let liveValues = points.filter { !$0.isStale }.map(\.value)
XCTAssertTrue(liveValues.isEmpty, "All stale → no live points")
}

// MARK: - Live continuity is broken at stale boundaries

/// A stale point interrupts the live series — verified at data level:
/// consecutive non-stale points form separate segments when interrupted by stale.
func testLiveContinuityBrokenByStaleBoundary() {
let points: [MetricPoint] = [
MetricPoint(value: 10.0),
MetricPoint(value: 20.0),
MetricPoint(value: 20.0, isStale: true), // ← gap
MetricPoint(value: 30.0),
MetricPoint(value: 40.0),
]

// Collect live values — they form two contiguous runs:
// [10, 20] before the stale, [30, 40] after
let liveValues = points.filter { !$0.isStale }.map(\.value)
XCTAssertEqual(liveValues, [10.0, 20.0, 30.0, 40.0])
// But in the sparkline rendering the stale point breaks the path,
// so [10→20] is one segment and [30→40] is a separate segment.
// This is confirmed by buildLivePath which does path.move at index 3.
}

// MARK: - valueText colour dimming via resolvedColor

/// resolvedColor falls back to the base color when latest point is live.
func testResolvedColor_baseColorWhenLive() {
let view = SparklineView(
title: "CPU",
points: [MetricPoint(value: 50.0)],
color: .blue,
valueText: "50%"
)
// latestPointIsStale == false → text gets resolvedColor (blue)
XCTAssertFalse(view.latestPointIsStale)
}

/// resolvedColor is replaced with grey when latest point is stale.
func testResolvedColor_greyWhenStale() {
let view = SparklineView(
title: "CPU",
points: [MetricPoint(value: 50.0, isStale: true)],
color: .blue,
valueText: "--"
)
XCTAssertTrue(view.latestPointIsStale)
}
}

// MARK: - Test helpers
//
// These extensions expose private members to the test target.

extension SparklineView {
fileprivate var latestPointIsStale: Bool {
points.last?.isStale ?? false
}
}