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) + } +}