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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ ProcessBarMonitor gives you a compact Activity Monitor-style summary without kee
## Features
- Menu bar utility with live summary in the menu bar title
- Overall CPU usage
- Memory used / total memory
- Memory pressure (system-wide active + inactive + wired + compressor)
- Thermal state (Nominal / Fair / Serious / Critical)
- Top apps by CPU
- Top apps by memory
Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ ProcessBarMonitor 想解决的是:你只想快速看一眼系统负载和当
## 功能
- 菜单栏常驻工具,标题里显示实时摘要
- 整体 CPU 使用率
- 已用内存 / 总内存
- 系统内存压力(active + inactive + wired + compressor)
- 热状态(Nominal / Fair / Serious / Critical)
- CPU 占用最高应用列表
- 内存占用最高应用列表
Expand Down
2 changes: 1 addition & 1 deletion Sources/ProcessBarMonitor/Localization.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ enum L10n {
String(format: string(key), locale: Locale.current, arguments: arguments)
}

private static func localizationCandidates(for language: String) -> [String] {
static func localizationCandidates(for language: String) -> [String] {
let parts = language.split(separator: "-").map(String.init)
guard !parts.isEmpty else { return [language] }

Expand Down
8 changes: 4 additions & 4 deletions Sources/ProcessBarMonitor/MonitorViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -100,16 +100,16 @@ final class MonitorViewModel: ObservableObject {

var menuBarTitle: String {
let cpu = String(format: "%.0f%%", summary.cpuPercent)
let memoryUsed = shortMemoryString(bytes: summary.systemMemoryUsedBytes)
let mem = String(format: "%.0f%%", summary.memoryPressurePercent)
let temp = summary.cpuTemperatureC.map { String(format: "%.0f°", $0) } ?? "--°"

switch menuBarDisplayMode {
case .compact:
return L10n.format("menu_bar_title.compact", cpu, memoryUsed, temp)
return L10n.format("menu_bar_title.compact", cpu, mem, temp)
case .labeled:
return L10n.format("menu_bar_title.labeled", cpu, memoryUsed, temp)
return L10n.format("menu_bar_title.labeled", cpu, mem, temp)
case .temperatureFirst:
return L10n.format("menu_bar_title.temperature_first", temp, cpu, memoryUsed)
return L10n.format("menu_bar_title.temperature_first", temp, cpu, mem)
}
}

Expand Down
10 changes: 5 additions & 5 deletions Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"summary.cpu" = "CPU";
"summary.ram" = "RAM (System-wide)";
"summary.ram" = "Memory pressure";
"summary.temp" = "Temp";
"summary.thermal" = "Thermal";
"metric.used_percent" = "%.0f%% used";
"health.line" = "CPU %@ · RAM %@ · Temp %@";
"metric.memory_pressure" = "%.0f%%";
"health.line" = "CPU %@ · Mem %@ · Temp %@";
"trend.cpu" = "CPU Trend";
"trend.ram" = "RAM Trend";
"trend.ram" = "Memory pressure";
"trend.temp" = "Temp Trend";
"section.filter_display" = "Filter & Display";
"picker.menu_bar" = "Menu Bar";
Expand Down Expand Up @@ -42,7 +42,7 @@
"menu_display.labeled" = "Labeled";
"menu_display.temperature_first" = "Temperature First";
"menu_bar_title.compact" = "%@ %@ %@";
"menu_bar_title.labeled" = "CPU %@ RAM %@ %@";
"menu_bar_title.labeled" = "CPU %@ Mem %@ %@";
"menu_bar_title.temperature_first" = "%@ %@ %@";
"temp_mode.hottest_cpu" = "Hottest CPU";
"temp_mode.average_cpu" = "Average CPU";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
"summary.cpu" = "CPU";
"summary.ram" = "内存 (系统级)";
"summary.ram" = "内存压力";
"summary.temp" = "温度";
"summary.thermal" = "热状态";
"metric.used_percent" = "已用 %.0f%%";
"health.line" = "CPU %@ · 内存 %@ · 温度 %@";
"metric.memory_pressure" = "%.0f%%";
"health.line" = "CPU %@ · %@%% · 温度 %@";
"trend.cpu" = "CPU 趋势";
"trend.ram" = "内存趋势";
"trend.ram" = "内存压力";
"trend.temp" = "温度趋势";
"section.filter_display" = "筛选与显示";
"picker.menu_bar" = "菜单栏";
Expand Down
7 changes: 6 additions & 1 deletion Sources/ProcessBarMonitor/SystemMetricsProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -185,13 +185,18 @@ actor SystemMetricsProvider {
return extractTemperature(from: raw)
}

private func extractTemperature(from raw: String) -> Double? {
private nonisolated func extractTemperature(from raw: String) -> Double? {
let matches = raw.matches(of: /-?\d+(?:\.\d+)?/)
guard let first = matches.first,
let value = Double(first.output), value > 1, value < 120 else { return nil }
return value
}

/// Test-only: exposes extractTemperature to the test target without breaking actor isolation.
nonisolated func extractTemperatureTest(from raw: String) -> Double? {
extractTemperature(from: raw)
}

private func architectureAndTemperatureNote(temperatureAvailable: Bool, mode: TemperatureMode) -> String {
if temperatureAvailable {
return L10n.format("note.temperature.available", mode.title)
Expand Down
14 changes: 12 additions & 2 deletions Sources/ProcessBarMonitor/Views.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
struct SummaryCardView: View {
let title: String
let value: String
var subtitle: String? = nil
var accent: Color = .primary

var body: some View {
Expand All @@ -23,6 +24,11 @@ struct SummaryCardView: View {
.foregroundStyle(accent)
.lineLimit(2)
.minimumScaleFactor(0.7)
if let subtitle {
Text(subtitle)
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
Expand Down Expand Up @@ -138,13 +144,17 @@ struct MenuBarContentView: View {
}

private var memorySummary: String {
String(format: "%.0f%%", viewModel.summary.memoryPressurePercent)
}

private var memoryBytesDetail: String {
let used = ByteCountFormatter.string(fromByteCount: Int64(viewModel.summary.systemMemoryUsedBytes), countStyle: .memory)
let total = ByteCountFormatter.string(fromByteCount: Int64(viewModel.summary.memoryTotalBytes), countStyle: .memory)
return "\(used) / \(total)"
}

private var memoryCompact: String {
L10n.format("metric.used_percent", viewModel.summary.memoryPressurePercent)
L10n.format("metric.memory_pressure", viewModel.summary.memoryPressurePercent)
}

private var currentTemperatureColor: Color {
Expand All @@ -164,7 +174,7 @@ struct MenuBarContentView: View {
VStack(alignment: .leading, spacing: 12) {
HStack(spacing: 8) {
SummaryCardView(title: L10n.string("summary.cpu"), value: String(format: "%.1f %%", viewModel.summary.cpuPercent), accent: .primary)
SummaryCardView(title: L10n.string("summary.ram"), value: memorySummary, accent: .blue)
SummaryCardView(title: L10n.string("summary.ram"), value: memorySummary, subtitle: memoryBytesDetail, accent: .blue)
SummaryCardView(title: L10n.string("summary.temp"), value: viewModel.summary.cpuTemperatureC.map { String(format: "%.1f °C", $0) } ?? "--", accent: currentTemperatureColor)
SummaryCardView(title: L10n.string("summary.thermal"), value: viewModel.thermalText(viewModel.summary.thermalState), accent: .pink)
}
Expand Down
149 changes: 149 additions & 0 deletions Tests/ProcessBarMonitorTests/DiagnosticsTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import XCTest
@testable import ProcessBarMonitor

/// Regression tests for ProcessSnapshotDiagnostics state machine (issue #30).
/// Verifies that each mutating method produces the expected side-effects
/// without interfering with other fields.
final class DiagnosticsTests: XCTestCase {

private var diagnostics: ProcessSnapshotDiagnostics!
private let fixedDate = Date(timeIntervalSince1970: 1_234_567_890)

override func setUp() {
super.setUp()
diagnostics = ProcessSnapshotDiagnostics()
}

override func tearDown() {
diagnostics = nil
super.tearDown()
}

// MARK: - Initial state

func testInitialState_allCountersZero() {
XCTAssertEqual(diagnostics.attemptCount, 0)
XCTAssertEqual(diagnostics.successCount, 0)
XCTAssertEqual(diagnostics.failureCount, 0)
}

func testInitialState_allDatesNil() {
XCTAssertNil(diagnostics.lastAttemptAt)
XCTAssertNil(diagnostics.lastSuccessAt)
XCTAssertNil(diagnostics.lastFailureAt)
}

func testInitialState_noErrorMessage() {
XCTAssertNil(diagnostics.lastFailureMessage)
XCTAssertNil(diagnostics.lastFailureDetails)
}

func testInitialState_zeroProcessCounts() {
XCTAssertEqual(diagnostics.lastSnapshotProcessCount, 0)
XCTAssertEqual(diagnostics.lastTopCPUCount, 0)
XCTAssertEqual(diagnostics.lastTopMemoryCount, 0)
}

// MARK: - markAttempt

func testMarkAttempt_incrementsAttemptCount() {
diagnostics.markAttempt(at: fixedDate)
XCTAssertEqual(diagnostics.attemptCount, 1)
XCTAssertEqual(diagnostics.lastAttemptAt, fixedDate)
}

func testMarkAttempt_multipleCallsAccumulate() {
let d1 = Date(timeIntervalSince1970: 1)
let d2 = Date(timeIntervalSince1970: 2)
let d3 = Date(timeIntervalSince1970: 3)
diagnostics.markAttempt(at: d1)
diagnostics.markAttempt(at: d2)
diagnostics.markAttempt(at: d3)
XCTAssertEqual(diagnostics.attemptCount, 3)
XCTAssertEqual(diagnostics.lastAttemptAt, d3)
}

func testMarkAttempt_doesNotAffectSuccessFailureCounts() {
diagnostics.markAttempt(at: fixedDate)
XCTAssertEqual(diagnostics.successCount, 0)
XCTAssertEqual(diagnostics.failureCount, 0)
}

// MARK: - markSuccess

func testMarkSuccess_incrementsSuccessCount() {
diagnostics.markSuccess(processCount: 42, topCPUCount: 5, topMemoryCount: 5, at: fixedDate)
XCTAssertEqual(diagnostics.successCount, 1)
}

func testMarkSuccess_setsLastSuccessAt() {
diagnostics.markSuccess(processCount: 42, topCPUCount: 5, topMemoryCount: 5, at: fixedDate)
XCTAssertEqual(diagnostics.lastSuccessAt, fixedDate)
}

func testMarkSuccess_capturesProcessCounts() {
diagnostics.markSuccess(processCount: 42, topCPUCount: 8, topMemoryCount: 12, at: fixedDate)
XCTAssertEqual(diagnostics.lastSnapshotProcessCount, 42)
XCTAssertEqual(diagnostics.lastTopCPUCount, 8)
XCTAssertEqual(diagnostics.lastTopMemoryCount, 12)
}

func testMarkSuccess_doesNotAffectAttemptOrFailureCount() {
diagnostics.markSuccess(processCount: 1, topCPUCount: 1, topMemoryCount: 1, at: fixedDate)
XCTAssertEqual(diagnostics.attemptCount, 0)
XCTAssertEqual(diagnostics.failureCount, 0)
}

// MARK: - markFailure

func testMarkFailure_incrementsFailureCount() {
diagnostics.markFailure(message: "ps exited 1", details: "signal 9", at: fixedDate)
XCTAssertEqual(diagnostics.failureCount, 1)
}

func testMarkFailure_setsLastFailureAt() {
diagnostics.markFailure(message: "ps exited 1", details: "signal 9", at: fixedDate)
XCTAssertEqual(diagnostics.lastFailureAt, fixedDate)
}

func testMarkFailure_capturesMessageAndDetails() {
diagnostics.markFailure(message: "access denied", details: "PermissionError", at: fixedDate)
XCTAssertEqual(diagnostics.lastFailureMessage, "access denied")
XCTAssertEqual(diagnostics.lastFailureDetails, "PermissionError")
}

func testMarkFailure_doesNotAffectAttemptOrSuccessCount() {
diagnostics.markFailure(message: "err", details: "detail", at: fixedDate)
XCTAssertEqual(diagnostics.attemptCount, 0)
XCTAssertEqual(diagnostics.successCount, 0)
}

// MARK: - Full lifecycle

func testFullLifecycle_attemptThenSuccess() {
let d1 = Date(timeIntervalSince1970: 10)
let d2 = Date(timeIntervalSince1970: 20)
diagnostics.markAttempt(at: d1)
diagnostics.markSuccess(processCount: 10, topCPUCount: 5, topMemoryCount: 5, at: d2)

XCTAssertEqual(diagnostics.attemptCount, 1)
XCTAssertEqual(diagnostics.successCount, 1)
XCTAssertEqual(diagnostics.failureCount, 0)
XCTAssertEqual(diagnostics.lastAttemptAt, d1)
XCTAssertEqual(diagnostics.lastSuccessAt, d2)
}

func testFullLifecycle_attemptThenFailure() {
let d1 = Date(timeIntervalSince1970: 10)
let d2 = Date(timeIntervalSince1970: 20)
diagnostics.markAttempt(at: d1)
diagnostics.markFailure(message: "err", details: "det", at: d2)

XCTAssertEqual(diagnostics.attemptCount, 1)
XCTAssertEqual(diagnostics.successCount, 0)
XCTAssertEqual(diagnostics.failureCount, 1)
XCTAssertEqual(diagnostics.lastAttemptAt, d1)
XCTAssertEqual(diagnostics.lastFailureAt, d2)
XCTAssertEqual(diagnostics.lastFailureMessage, "err")
}
}
Loading