From 6e682f76360d700d0ac5b3516ae99e6540d399c2 Mon Sep 17 00:00:00 2001 From: LMZ Date: Tue, 21 Apr 2026 18:11:54 +0800 Subject: [PATCH 1/2] test: add regression tests for issue #30 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TemperatureParsingTests: 17 tests covering extractTemperature boundaries (valid range 1-120°C), invalid inputs, and multiple-number handling. EnumSavedValueTests: 18 tests for TemperatureMode and MenuBarDisplayMode savedValue initializers — both modern rawValue strings and legacy display-name strings (e.g. 'Hottest CPU'). LocalizationTests: 15 tests for L10n localizationCandidates fallback chain across en-*/zh-*/fr-/ja-JP locales, deduplication, and edge cases. DiagnosticsTests: 17 tests for ProcessSnapshotDiagnostics state machine (markAttempt / markSuccess / markFailure) and full lifecycle transitions. MonitorViewModelTests: 15 tests for ViewModel defaults, legacy settings migration (temperatureMode + menuBarDisplayMode), and fallbacks. TestExpose.swift: test-only L10n wrapper exposing localizationCandidates to the test target. Source changes: - Localization.swift: make localizationCandidates internal (was private) - SystemMetricsProvider.swift: add nonisolated extractTemperatureTest --- Sources/ProcessBarMonitor/Localization.swift | 2 +- .../SystemMetricsProvider.swift | 7 +- .../DiagnosticsTests.swift | 149 ++++++++++++++++++ .../EnumSavedValueTests.swift | 99 ++++++++++++ .../LocalizationTests.swift | 97 ++++++++++++ .../MonitorViewModelTests.swift | 144 +++++++++++++++++ .../TemperatureParsingTests.swift | 102 ++++++++++++ Tests/ProcessBarMonitorTests/TestExpose.swift | 15 ++ 8 files changed, 613 insertions(+), 2 deletions(-) create mode 100644 Tests/ProcessBarMonitorTests/DiagnosticsTests.swift create mode 100644 Tests/ProcessBarMonitorTests/EnumSavedValueTests.swift create mode 100644 Tests/ProcessBarMonitorTests/LocalizationTests.swift create mode 100644 Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift create mode 100644 Tests/ProcessBarMonitorTests/TemperatureParsingTests.swift create mode 100644 Tests/ProcessBarMonitorTests/TestExpose.swift diff --git a/Sources/ProcessBarMonitor/Localization.swift b/Sources/ProcessBarMonitor/Localization.swift index 2a51dac..894344e 100644 --- a/Sources/ProcessBarMonitor/Localization.swift +++ b/Sources/ProcessBarMonitor/Localization.swift @@ -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] } diff --git a/Sources/ProcessBarMonitor/SystemMetricsProvider.swift b/Sources/ProcessBarMonitor/SystemMetricsProvider.swift index 9a0478d..9478ef5 100644 --- a/Sources/ProcessBarMonitor/SystemMetricsProvider.swift +++ b/Sources/ProcessBarMonitor/SystemMetricsProvider.swift @@ -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) diff --git a/Tests/ProcessBarMonitorTests/DiagnosticsTests.swift b/Tests/ProcessBarMonitorTests/DiagnosticsTests.swift new file mode 100644 index 0000000..b3036f9 --- /dev/null +++ b/Tests/ProcessBarMonitorTests/DiagnosticsTests.swift @@ -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") + } +} diff --git a/Tests/ProcessBarMonitorTests/EnumSavedValueTests.swift b/Tests/ProcessBarMonitorTests/EnumSavedValueTests.swift new file mode 100644 index 0000000..db5a920 --- /dev/null +++ b/Tests/ProcessBarMonitorTests/EnumSavedValueTests.swift @@ -0,0 +1,99 @@ +import XCTest +@testable import ProcessBarMonitor + +/// Regression tests for legacy settings migration via savedValue initialisers +/// on TemperatureMode and MenuBarDisplayMode (issue #30). +/// +/// These initialisers bridge old display-name strings (e.g. "Hottest CPU") +/// stored in UserDefaults with the new rawValue strings (e.g. "hottestCPU"), +/// ensuring users' existing preferences are preserved after UI rename. +/// +final class EnumSavedValueTests: XCTestCase { + + // MARK: - TemperatureMode — modern rawValue strings + + func testTemperatureMode_modernHottestCPU() { + XCTAssertEqual(TemperatureMode(savedValue: "hottestCPU"), .hottestCPU) + } + + func testTemperatureMode_modernAverageCPU() { + XCTAssertEqual(TemperatureMode(savedValue: "averageCPU"), .averageCPU) + } + + func testTemperatureMode_modernHottestSoC() { + XCTAssertEqual(TemperatureMode(savedValue: "hottestSoC"), .hottestSoC) + } + + // MARK: - TemperatureMode — legacy display-name strings + + func testTemperatureMode_legacyHottestCPU() { + XCTAssertEqual(TemperatureMode(savedValue: "Hottest CPU"), .hottestCPU) + } + + func testTemperatureMode_legacyAverageCPU() { + XCTAssertEqual(TemperatureMode(savedValue: "Average CPU"), .averageCPU) + } + + func testTemperatureMode_legacyHottestSoC() { + XCTAssertEqual(TemperatureMode(savedValue: "Hottest SoC"), .hottestSoC) + } + + // MARK: - TemperatureMode — invalid / unknown strings + + func testTemperatureMode_invalidString() { + XCTAssertNil(TemperatureMode(savedValue: "not a mode")) + } + + func testTemperatureMode_emptyString() { + XCTAssertNil(TemperatureMode(savedValue: "")) + } + + func testTemperatureMode_caseSensitive() { + // Modern rawValue is lowercase-first camel. + XCTAssertNil(TemperatureMode(savedValue: "HottestCPU")) + XCTAssertNil(TemperatureMode(savedValue: "HOTTESTCPU")) + } + + // MARK: - MenuBarDisplayMode — modern rawValue strings + + func testMenuBarDisplayMode_modernCompact() { + XCTAssertEqual(MenuBarDisplayMode(savedValue: "compact"), .compact) + } + + func testMenuBarDisplayMode_modernLabeled() { + XCTAssertEqual(MenuBarDisplayMode(savedValue: "labeled"), .labeled) + } + + func testMenuBarDisplayMode_modernTemperatureFirst() { + XCTAssertEqual(MenuBarDisplayMode(savedValue: "temperatureFirst"), .temperatureFirst) + } + + // MARK: - MenuBarDisplayMode — legacy display-name strings + + func testMenuBarDisplayMode_legacyCompact() { + XCTAssertEqual(MenuBarDisplayMode(savedValue: "Compact"), .compact) + } + + func testMenuBarDisplayMode_legacyLabeled() { + XCTAssertEqual(MenuBarDisplayMode(savedValue: "Labeled"), .labeled) + } + + func testMenuBarDisplayMode_legacyTemperatureFirst() { + XCTAssertEqual(MenuBarDisplayMode(savedValue: "Temperature First"), .temperatureFirst) + } + + // MARK: - MenuBarDisplayMode — invalid / unknown strings + + func testMenuBarDisplayMode_invalidString() { + XCTAssertNil(MenuBarDisplayMode(savedValue: "foobar")) + } + + func testMenuBarDisplayMode_emptyString() { + XCTAssertNil(MenuBarDisplayMode(savedValue: "")) + } + + func testMenuBarDisplayMode_caseSensitive() { + XCTAssertNil(MenuBarDisplayMode(savedValue: "compact ")) + XCTAssertNil(MenuBarDisplayMode(savedValue: "COMPACT")) + } +} diff --git a/Tests/ProcessBarMonitorTests/LocalizationTests.swift b/Tests/ProcessBarMonitorTests/LocalizationTests.swift new file mode 100644 index 0000000..679ec82 --- /dev/null +++ b/Tests/ProcessBarMonitorTests/LocalizationTests.swift @@ -0,0 +1,97 @@ +import XCTest +@testable import ProcessBarMonitor + +/// Regression tests for localisation candidate chain (issue #30). +/// Verifies the fallback precedence order in L10n.localizationCandidates. +final class LocalizationTests: XCTestCase { + + // MARK: - English variants + + func testLocalizationCandidates_enUS() { + let got = L10n.localizationCandidatesTest(for: "en-US") + XCTAssertEqual(got, ["en-US", "en"]) + } + + func testLocalizationCandidates_enGB() { + let got = L10n.localizationCandidatesTest(for: "en-GB") + XCTAssertEqual(got, ["en-GB", "en"]) + } + + func testLocalizationCandidates_enAU() { + let got = L10n.localizationCandidatesTest(for: "en-AU") + XCTAssertEqual(got, ["en-AU", "en"]) + } + + // MARK: - Chinese variants + + func testLocalizationCandidates_zhHansCN() { + let got = L10n.localizationCandidatesTest(for: "zh-Hans-CN") + // Order: zh-Hans-CN (full), zh-Hans (script), zh (language) + XCTAssertEqual(got, ["zh-Hans-CN", "zh-Hans", "zh"]) + } + + func testLocalizationCandidates_zhHantTW() { + let got = L10n.localizationCandidatesTest(for: "zh-Hant-TW") + XCTAssertEqual(got, ["zh-Hant-TW", "zh-Hant", "zh"]) + } + + func testLocalizationCandidates_zhHans() { + let got = L10n.localizationCandidatesTest(for: "zh-Hans") + XCTAssertEqual(got, ["zh-Hans", "zh"]) + } + + func testLocalizationCandidates_zhHant() { + let got = L10n.localizationCandidatesTest(for: "zh-Hant") + XCTAssertEqual(got, ["zh-Hant", "zh"]) + } + + func testLocalizationCandidates_zh() { + let got = L10n.localizationCandidatesTest(for: "zh") + XCTAssertEqual(got, ["zh"]) + } + + // MARK: - Other languages + + func testLocalizationCandidates_de() { + let got = L10n.localizationCandidatesTest(for: "de") + XCTAssertEqual(got, ["de"]) + } + + func testLocalizationCandidates_frFR() { + let got = L10n.localizationCandidatesTest(for: "fr-FR") + XCTAssertEqual(got, ["fr-FR", "fr"]) + } + + func testLocalizationCandidates_jaJP() { + let got = L10n.localizationCandidatesTest(for: "ja-JP") + XCTAssertEqual(got, ["ja-JP", "ja"]) + } + + // MARK: - Deduplication + + func testLocalizationCandidates_noDuplicates() { + // en-US produces ["en-us", "en"] — no duplicate "en". + let got = L10n.localizationCandidatesTest(for: "en-US") + XCTAssertEqual(got.count, Set(got).count, "No duplicate entries expected") + } + + func testLocalizationCandidates_zhHansCN_noDuplicates() { + let got = L10n.localizationCandidatesTest(for: "zh-Hans-CN") + XCTAssertEqual(got.count, Set(got).count, "No duplicate entries expected") + } + + // MARK: - Language-only input (no region) + + func testLocalizationCandidates_languageOnly() { + let got = L10n.localizationCandidatesTest(for: "de") + XCTAssertEqual(got, ["de"]) + } + + // MARK: - Empty / malformed + + func testLocalizationCandidates_emptyString() { + let got = L10n.localizationCandidatesTest(for: "") + // Empty string is treated as-is (matches guard clause in implementation). + XCTAssertEqual(got, [""]) + } +} diff --git a/Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift b/Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift new file mode 100644 index 0000000..d09eb58 --- /dev/null +++ b/Tests/ProcessBarMonitorTests/MonitorViewModelTests.swift @@ -0,0 +1,144 @@ +import XCTest +@testable import ProcessBarMonitor + +/// Regression tests for MonitorViewModel initialisation and settings migration +/// behaviour (issue #30). +/// +/// These tests focus on the initial state that can be verified without +/// triggering async refresh or timer-based work. The @MainActor constraint +/// means tests must be async and run on the main thread — XCTest handles +/// this via Task { @MainActor in }. +final class MonitorViewModelTests: XCTestCase { + + // MARK: - Default values when no saved settings exist + + @MainActor + func testDefault_temperatureMode_isHottestCPU() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.temperatureMode, .hottestCPU) + } + + @MainActor + func testDefault_menuBarDisplayMode_isCompact() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.menuBarDisplayMode, .compact) + } + + @MainActor + func testDefault_statusMessage_isNil() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let vm = MonitorViewModel(defaults: defaults) + XCTAssertNil(vm.statusMessage) + } + + @MainActor + func testDefault_processLimit_isValid() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let vm = MonitorViewModel(defaults: defaults) + XCTAssertTrue([5, 8, 12, 20].contains(vm.processLimit), + "processLimit should be one of the allowed values") + } + + @MainActor + func testDefault_processDiagnostics_allZero() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + let vm = MonitorViewModel(defaults: defaults) + let diag = vm.processDiagnostics + XCTAssertEqual(diag.attemptCount, 0) + XCTAssertEqual(diag.successCount, 0) + XCTAssertEqual(diag.failureCount, 0) + XCTAssertNil(diag.lastAttemptAt) + XCTAssertNil(diag.lastSuccessAt) + XCTAssertNil(diag.lastFailureAt) + } + + // MARK: - Settings migration: legacy display-name strings + + @MainActor + func testMigration_legacyHottestCPUString() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("Hottest CPU", forKey: "temperatureMode") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.temperatureMode, .hottestCPU) + } + + @MainActor + func testMigration_legacyAverageCPUString() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("Average CPU", forKey: "temperatureMode") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.temperatureMode, .averageCPU) + } + + @MainActor + func testMigration_legacyLabeledString() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("Labeled", forKey: "menuBarDisplayMode") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.menuBarDisplayMode, .labeled) + } + + @MainActor + func testMigration_legacyTemperatureFirstString() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("Temperature First", forKey: "menuBarDisplayMode") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.menuBarDisplayMode, .temperatureFirst) + } + + // MARK: - Settings migration: modern rawValue strings + + @MainActor + func testMigration_modernHottestCPUString() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("hottestCPU", forKey: "temperatureMode") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.temperatureMode, .hottestCPU) + } + + @MainActor + func testMigration_modernCompactString() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("compact", forKey: "menuBarDisplayMode") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.menuBarDisplayMode, .compact) + } + + // MARK: - Invalid saved values fall back to defaults + + @MainActor + func testMigration_invalidTemperatureModeFallsBack() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("not a mode", forKey: "temperatureMode") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.temperatureMode, .hottestCPU, "invalid value should fall back to .hottestCPU") + } + + @MainActor + func testMigration_invalidMenuBarDisplayModeFallsBack() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set("foobar", forKey: "menuBarDisplayMode") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.menuBarDisplayMode, .compact, "invalid value should fall back to .compact") + } + + // MARK: - processLimit boundary values + + @MainActor + func testMigration_validProcessLimit() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set(8, forKey: "processLimit") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.processLimit, 8) + } + + @MainActor + func testMigration_invalidProcessLimitFallsBack() async { + let defaults = UserDefaults(suiteName: UUID().uuidString)! + defaults.set(99, forKey: "processLimit") + let vm = MonitorViewModel(defaults: defaults) + XCTAssertEqual(vm.processLimit, 5, "invalid processLimit should fall back to 5") + } +} diff --git a/Tests/ProcessBarMonitorTests/TemperatureParsingTests.swift b/Tests/ProcessBarMonitorTests/TemperatureParsingTests.swift new file mode 100644 index 0000000..5eb2be6 --- /dev/null +++ b/Tests/ProcessBarMonitorTests/TemperatureParsingTests.swift @@ -0,0 +1,102 @@ +import XCTest +@testable import ProcessBarMonitor + +/// Regression tests for temperature extraction logic (issue #30). +/// Verifies the contract of the private extractTemperature method via the +/// test-only extractTemperatureTest wrapper (see TestExpose.swift). +final class TemperatureParsingTests: XCTestCase { + + private var provider: SystemMetricsProvider! + + override func setUp() { + super.setUp() + provider = SystemMetricsProvider() + } + + override func tearDown() { + provider = nil + super.tearDown() + } + + // MARK: - Valid inputs → parsed value + + func testExtractTemperature_validDecimal() { + XCTAssertEqual(provider.extractTemperatureTest(from: "42.5"), 42.5) + } + + func testExtractTemperature_validInteger() { + XCTAssertEqual(provider.extractTemperatureTest(from: "85"), 85.0) + } + + func testExtractTemperature_withUnitSuffix() { + XCTAssertEqual(provider.extractTemperatureTest(from: "CPU: 55.3 C"), 55.3) + } + + func testExtractTemperature_leadingWhitespace() { + XCTAssertEqual(provider.extractTemperatureTest(from: " 67.0"), 67.0) + } + + // MARK: - Out-of-range → nil + + func testExtractTemperature_negativeRejected() { + XCTAssertNil(provider.extractTemperatureTest(from: "-5.2")) + } + + func testExtractTemperature_zeroRejected() { + XCTAssertNil(provider.extractTemperatureTest(from: "0")) + } + + func testExtractTemperature_justBelowMinimum() { + XCTAssertNil(provider.extractTemperatureTest(from: "0.9")) + } + + func testExtractTemperature_justAboveMinimum() { + // Guard is value > 1 (strictly), so 1.0 is rejected. + XCTAssertNil(provider.extractTemperatureTest(from: "1.0")) + } + + func testExtractTemperature_justBelowMaximum() { + XCTAssertEqual(provider.extractTemperatureTest(from: "119.9"), 119.9) + } + + func testExtractTemperature_justAboveMaximum() { + XCTAssertNil(provider.extractTemperatureTest(from: "120.0")) + } + + func testExtractTemperature_wayAboveMaximum() { + XCTAssertNil(provider.extractTemperatureTest(from: "500")) + } + + // MARK: - Invalid inputs → nil + + func testExtractTemperature_emptyString() { + XCTAssertNil(provider.extractTemperatureTest(from: "")) + } + + func testExtractTemperature_garbageOnly() { + XCTAssertNil(provider.extractTemperatureTest(from: "no numbers here")) + } + + // MARK: - Multiple numbers → first wins + + func testExtractTemperature_multipleNumbersFirstWins() { + // Multiple temperature-like numbers: first is returned if valid. + XCTAssertEqual(provider.extractTemperatureTest(from: "42.5 38.2 99.0"), 42.5) + } + + func testExtractTemperature_firstInvalidSecondValid() { + // First number is invalid (out of range), provider returns nil. + XCTAssertNil(provider.extractTemperatureTest(from: "0.5 55.0")) + } + + // MARK: - Realistic tool output samples + + func testExtractTemperature_realisticCsensorsOutput() { + // Typical output format: just the temperature number. + XCTAssertEqual(provider.extractTemperatureTest(from: "68.125"), 68.125) + } + + func testExtractTemperature_realisticFloat_format() { + XCTAssertEqual(provider.extractTemperatureTest(from: "temperature: 72.5°C"), 72.5) + } +} diff --git a/Tests/ProcessBarMonitorTests/TestExpose.swift b/Tests/ProcessBarMonitorTests/TestExpose.swift new file mode 100644 index 0000000..97432b5 --- /dev/null +++ b/Tests/ProcessBarMonitorTests/TestExpose.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import ProcessBarMonitor + +// MARK: - Test-access wrappers for private implementation details +// +// These extensions expose private members to the test target so that +// regression tests can directly verify implementation behaviour without +// compromising the public API. + +extension L10n { + /// Test-only wrapper exposing the private localisationCandidates chain. + static func localizationCandidatesTest(for language: String) -> [String] { + localizationCandidates(for: language) + } +} From 951d702ec9a6fdd4d9445497fe072a3f350a6a2d Mon Sep 17 00:00:00 2001 From: LMZ Date: Tue, 21 Apr 2026 18:32:33 +0800 Subject: [PATCH 2/2] feat: align memory metric naming with implementation semantics (issue #33) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename memory UI copy to 'Memory pressure' to accurately reflect the system-wide active+inactive+wired+compressor metric rather than implying a simple used/total partition. Changes: - Views.swift: memorySummary now shows pressure % instead of used/total bytes; SummaryCardView gains optional subtitle parameter to show bytes detail; RAM card displays 'XX%' with 'X GB / Y GB' as subtitle - MonitorViewModel.swift: menuBarTitle now uses memoryPressurePercent for all three display modes (compact/labeled/temperatureFirst) instead of shortMemoryString(bytes:); aligned with popup semantic model - en.lproj: summary.ram 'RAM (System-wide)' -> 'Memory pressure'; metric.used_percent -> metric.memory_pressure '%.0f%%'; menu_bar_title.labeled 'CPU %@ RAM %@ %@' -> 'CPU %@ Mem %@ %@'; health.line 'RAM' -> 'Mem'; trend.ram -> 'Memory pressure' - zh-Hans.lproj: summary.ram '内存 (系统级)' -> '内存压力'; metric.used_percent -> metric.memory_pressure '%.0f%%'; trend.ram -> '内存压力' - README.md / README.zh-CN.md: 'Memory used / total memory' now reads 'Memory pressure (system-wide active + inactive + wired + compressor)' Consistent memory pressure semantics across all UI surfaces: - Summary card: '72%' primary + '8 GB / 16 GB' subtitle - Menu bar compact: '50% 72% 65°' (CPU · Mem · Temp) - Menu bar labeled: 'CPU 50% Mem 72% 65°' - Menu bar temperature_first: '65° CPU 50% Mem 72%' --- README.md | 2 +- README.zh-CN.md | 2 +- Sources/ProcessBarMonitor/MonitorViewModel.swift | 8 ++++---- .../Resources/en.lproj/Localizable.strings | 10 +++++----- .../Resources/zh-Hans.lproj/Localizable.strings | 8 ++++---- Sources/ProcessBarMonitor/Views.swift | 14 ++++++++++++-- 6 files changed, 27 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 26e7bfc..573de96 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/README.zh-CN.md b/README.zh-CN.md index 5f1cf39..fd0bfb6 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -27,7 +27,7 @@ ProcessBarMonitor 想解决的是:你只想快速看一眼系统负载和当 ## 功能 - 菜单栏常驻工具,标题里显示实时摘要 - 整体 CPU 使用率 -- 已用内存 / 总内存 +- 系统内存压力(active + inactive + wired + compressor) - 热状态(Nominal / Fair / Serious / Critical) - CPU 占用最高应用列表 - 内存占用最高应用列表 diff --git a/Sources/ProcessBarMonitor/MonitorViewModel.swift b/Sources/ProcessBarMonitor/MonitorViewModel.swift index 8514961..5ef51e3 100644 --- a/Sources/ProcessBarMonitor/MonitorViewModel.swift +++ b/Sources/ProcessBarMonitor/MonitorViewModel.swift @@ -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) } } diff --git a/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings b/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings index de6f4dd..19ebcbf 100644 --- a/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings +++ b/Sources/ProcessBarMonitor/Resources/en.lproj/Localizable.strings @@ -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"; @@ -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"; diff --git a/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings b/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings index 3230ecd..072f809 100644 --- a/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings +++ b/Sources/ProcessBarMonitor/Resources/zh-Hans.lproj/Localizable.strings @@ -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" = "菜单栏"; diff --git a/Sources/ProcessBarMonitor/Views.swift b/Sources/ProcessBarMonitor/Views.swift index eefc300..678a6d4 100644 --- a/Sources/ProcessBarMonitor/Views.swift +++ b/Sources/ProcessBarMonitor/Views.swift @@ -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 { @@ -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) @@ -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 { @@ -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) }