From 7f0604a62c9f6c5f267c921ba7e964a348d2bc43 Mon Sep 17 00:00:00 2001 From: jtn0123 Date: Sat, 16 May 2026 16:41:50 -0700 Subject: [PATCH 1/6] Constrain @AppDefault to Equatable for a direct value compare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AppDefault's update() compared values by casting to AnyHashable and returned false for any non-AnyHashable value — forcing a redundant DispatchQueue.main.async write on every body pass. Constraining Value: Equatable lets it compare directly; every @AppDefault value type is already Equatable. (grade-report C2) Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Utilities/AppDefault.swift | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/Sources/Utilities/AppDefault.swift b/Sources/Utilities/AppDefault.swift index 5adfe5e..18525ea 100644 --- a/Sources/Utilities/AppDefault.swift +++ b/Sources/Utilities/AppDefault.swift @@ -14,7 +14,7 @@ import Combine /// /// See ADR 0004 for the migration plan from `@AppStorage`. @propertyWrapper -struct AppDefault: DynamicProperty { +struct AppDefault: DynamicProperty { private let keyPath: ReferenceWritableKeyPath @State private var value: Value @StateObject private var observer: AppDefaultObserver @@ -45,15 +45,10 @@ struct AppDefault: DynamicProperty { // Re-read once per body invocation in case the underlying store changed // out from under us (e.g. user toggled a setting in another window). let current = AppDefaults.self[keyPath: keyPath] - if !valuesEqual(current, value) { + if current != value { DispatchQueue.main.async { self.value = current } } } - - private func valuesEqual(_ lhsValue: Value, _ rhsValue: Value) -> Bool { - guard let lhs = lhsValue as? AnyHashable, let rhs = rhsValue as? AnyHashable else { return false } - return lhs == rhs - } } /// Listens for UserDefaults change notifications and triggers a SwiftUI From b28bf6abd96fef0e72fa38c73783d99879cfeb04 Mon Sep 17 00:00:00 2001 From: jtn0123 Date: Sat, 16 May 2026 16:41:50 -0700 Subject: [PATCH 2/6] Move retention cleanup off the transcription-save critical path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit saveTranscription awaited cleanupExpiredRecordsQuietly() — a full predicate fetch + delete — before returning, so every save (and the UI waiting on it) paid that cost. Cleanup now runs in a detached Task, matching how initial cleanup already runs. (grade-report B5) Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/Stores/DataManager.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/Stores/DataManager.swift b/Sources/Stores/DataManager.swift index f0032e8..5d4b05b 100644 --- a/Sources/Stores/DataManager.swift +++ b/Sources/Stores/DataManager.swift @@ -163,10 +163,11 @@ internal final class DataManager: DataManagerProtocol { try context.save() Logger.dataManager.info("Saved transcription record with ID: \(record.id)") - - // Perform cleanup after save to maintain retention policy - await cleanupExpiredRecordsQuietly() - + + // Retention cleanup runs off the save critical path — the caller + // (and the UI) shouldn't wait on a full predicate fetch + delete. + Task { await cleanupExpiredRecordsQuietly() } + } catch { Logger.dataManager.error("Failed to save transcription record: \(error.localizedDescription)") throw DataManagerError.saveFailed(error) From 39c4a87abb796eb9970bf25f57c232286208e0b8 Mon Sep 17 00:00:00 2001 From: jtn0123 Date: Sat, 16 May 2026 16:41:50 -0700 Subject: [PATCH 3/6] Pass a minimal allowlisted environment to the Python daemon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MLX/Parakeet daemon subprocess inherited the full parent environment. It now receives only an allowlist — PATH, HOME, PYTHONUNBUFFERED, plus locale, temp, cache, and venv/HuggingFace variables the daemon genuinely needs — for least privilege. (grade-report E3) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Managers/MLDaemonManager+Process.swift | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/Sources/Managers/MLDaemonManager+Process.swift b/Sources/Managers/MLDaemonManager+Process.swift index 73d81fa..61bab21 100644 --- a/Sources/Managers/MLDaemonManager+Process.swift +++ b/Sources/Managers/MLDaemonManager+Process.swift @@ -24,7 +24,7 @@ internal extension MLDaemonManager { let proc = Process() proc.executableURL = python proc.arguments = [script.path] - proc.environment = ProcessInfo.processInfo.environment.merging(["PYTHONUNBUFFERED": "1"]) { _, new in new } + proc.environment = Self.daemonEnvironment() let stdin = Pipe() let stdout = Pipe() @@ -59,6 +59,46 @@ internal extension MLDaemonManager { startStdoutReader(pipe: stdout) } + /// Builds a minimal, allowlisted environment for the Python daemon instead + /// of inheriting the user's entire process environment (security hardening, + /// audit item E3). The daemon gets exactly what it needs to run Parakeet/MLX + /// transcription and nothing more. + /// + /// Always-present keys: `PATH` (subprocess resolution), `HOME` (HuggingFace + /// cache root `~/.cache/huggingface`), and `PYTHONUNBUFFERED` (line-buffered + /// stdout so JSON-RPC responses flush immediately). + /// + /// Pass-through-if-set keys: locale (`LANG`/`LC_ALL` — Python text codec), + /// `TMPDIR` (macOS per-user temp many ML libs use), `AUDIOWHISPER_APP_SUPPORT_DIR` + /// (test/override path resolution shared with `UvBootstrap`), virtualenv vars + /// (`VIRTUAL_ENV`, `PYTHONPATH` — keep tooling consistent even though the venv + /// python is invoked directly), `uv` vars (`UV_CACHE_DIR`, `UV_PYTHON`), and + /// HuggingFace cache overrides (`HF_HOME`, `HF_HUB_CACHE`, `HUGGINGFACE_HUB_CACHE`, + /// `XDG_CACHE_HOME`) so the daemon finds models the user already downloaded. + static func daemonEnvironment() -> [String: String] { + let parent = ProcessInfo.processInfo.environment + + var env: [String: String] = [ + "PATH": parent["PATH"] ?? "/usr/bin:/bin:/usr/sbin:/sbin", + "HOME": parent["HOME"] ?? NSHomeDirectory(), + "PYTHONUNBUFFERED": "1" + ] + + let passThroughIfSet = [ + "LANG", "LC_ALL", "LC_CTYPE", "TMPDIR", + "AUDIOWHISPER_APP_SUPPORT_DIR", + "VIRTUAL_ENV", "PYTHONPATH", + "UV_CACHE_DIR", "UV_PYTHON", + "HF_HOME", "HF_HUB_CACHE", "HUGGINGFACE_HUB_CACHE", "XDG_CACHE_HOME" + ] + for key in passThroughIfSet { + if let value = parent[key], !value.isEmpty { + env[key] = value + } + } + return env + } + func startStdoutReader(pipe: Pipe) { stdoutReaderTask?.cancel() let handle = pipe.fileHandleForReading From 1a83d50f2b49df4eb2c002fa67ffcb8029b70055 Mon Sep 17 00:00:00 2001 From: jtn0123 Date: Sat, 16 May 2026 16:42:14 -0700 Subject: [PATCH 4/6] Pin known-good hashes for shipped models, TOFU only for user models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ModelIntegrity trusted the first download of any model unconditionally. It now verifies app-shipped models against a known-good SHA-256 table (hard fail on mismatch, even on first download), mirroring how the bundled uv binary is verified; user-added models keep trust-on-first- use. The hash table ships empty with a documented placeholder — real release hashes must be populated before the pin takes effect. (grade-report E1) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Services/MLXModelManager+Downloads.swift | 2 +- Sources/Services/ModelManager.swift | 2 +- .../Utilities/DiskMutationSerializer.swift | 97 ++++++++++++++++--- 3 files changed, 87 insertions(+), 14 deletions(-) diff --git a/Sources/Services/MLXModelManager+Downloads.swift b/Sources/Services/MLXModelManager+Downloads.swift index a9b17b5..3a0179b 100644 --- a/Sources/Services/MLXModelManager+Downloads.swift +++ b/Sources/Services/MLXModelManager+Downloads.swift @@ -289,7 +289,7 @@ extension MLXModelManager { // Best-effort integrity verification. TOFU on first hit; failures // are logged at the call site that triggers a re-download. do { - try ModelIntegrity.verify(at: refsMain) + try ModelIntegrity.verify(at: refsMain, modelIdentifier: repo) return true } catch { logger.error( diff --git a/Sources/Services/ModelManager.swift b/Sources/Services/ModelManager.swift index 6574386..98a8504 100644 --- a/Sources/Services/ModelManager.swift +++ b/Sources/Services/ModelManager.swift @@ -57,7 +57,7 @@ internal class ModelManager { // missing and a redownload can be triggered. guard let representative = ModelManager.representativeFileURL(for: model) else { return true } do { - try ModelIntegrity.verify(at: representative) + try ModelIntegrity.verify(at: representative, modelIdentifier: model.rawValue) return true } catch { Logger.modelManager.error("Integrity check failed for cached \(model.rawValue): \(error.localizedDescription)") diff --git a/Sources/Utilities/DiskMutationSerializer.swift b/Sources/Utilities/DiskMutationSerializer.swift index 9402cfc..c40996d 100644 --- a/Sources/Utilities/DiskMutationSerializer.swift +++ b/Sources/Utilities/DiskMutationSerializer.swift @@ -36,16 +36,62 @@ internal actor DiskMutationSerializer { /// /// After a successful download, callers record a hash of a representative /// file (typically a manifest or small config). Before loading a cached -/// model, callers verify against that recorded hash. The check is -/// trust-on-first-use: if no sidecar exists yet, `verify` records one and -/// returns successfully — this keeps existing caches from a pre-integrity -/// build working without forcing a redownload. +/// model, callers verify against that recorded hash. +/// +/// Two verification modes (audit item E1): +/// +/// 1. **Known-good hash** — for models the app itself ships/recommends +/// (`knownHashes` table below). The representative file's hash is +/// compared against a hash baked into this build, exactly like +/// `UvBootstrap.verifyBundledUvIfNeeded` does for the bundled `uv` +/// binary. A mismatch is a HARD FAIL — this is what prevents a +/// poisoned *first* download from being trusted. +/// +/// 2. **Trust-on-first-use** — for user-added models we have no shipped +/// hash for. If no sidecar exists yet, `verify` records one and +/// returns successfully; later launches verify against it. This keeps +/// pre-integrity caches and arbitrary user models working without a +/// forced redownload. /// /// This is defense in depth, not cryptographic assurance over every byte: /// TLS already protects downloads in transit. The check guards against /// cache corruption, partial/interrupted writes that left a truncated /// file, and tampering by another local process. internal enum ModelIntegrity { + /// Known-good SHA-256 hashes of the *representative integrity file* for + /// models the app ships or recommends. Keyed by the caller's model + /// identifier: `WhisperModel.rawValue` for WhisperKit models, or the + /// HuggingFace `repo` string for MLX/Parakeet models. + /// + /// When an identifier is present here, `verify` uses known-good-hash mode + /// (hard fail on mismatch) instead of trust-on-first-use, so a poisoned + /// first download cannot be silently accepted. + /// + /// IMPORTANT: This table is intentionally EMPTY. The real hashes are not + /// known at the time this mechanism was built and fabricating values + /// would be worse than an empty table (it would reject every legitimate + /// download). Populating it later is a one-liner per model, e.g.: + /// + /// "openai_whisper-base": "a1b2c3…", // WhisperKit + /// "mlx-community/parakeet-tdt-0.6b-v2": "d4e5f6…", // MLX/Parakeet + /// + /// The hash must be the SHA-256 of the representative file that the + /// matching caller passes to `record`/`verify` (`config.json` for + /// WhisperKit via `ModelManager.representativeFileURL`, `refs/main` for + /// MLX via `MLXModelManager.integrityFileURL`). Compute it from a release + /// build's cached download and paste it in here. + /// + /// >>> ACTION REQUIRED <<< Populate this table with real release hashes + /// before shipping; until then app-shipped models silently fall through + /// to trust-on-first-use (the documented gap for audit item E1). + static let knownHashes: [String: String] = [:] + + /// Returns the known-good hash for `modelIdentifier`, or nil if the model + /// is not one the app ships (user-added model → trust-on-first-use). + static func knownHash(for modelIdentifier: String?) -> String? { + guard let modelIdentifier else { return nil } + return knownHashes[modelIdentifier] + } /// Compute SHA-256 of file at `url`. Streams the file in 64KB chunks so /// gigabyte-sized model files don't blow up memory. static func sha256(of url: URL) throws -> String { @@ -69,12 +115,36 @@ internal enum ModelIntegrity { try hash.write(to: sidecarURL(for: modelURL), atomically: true, encoding: .utf8) } - /// Verify the sidecar hash matches; if missing, record it - /// (trust-on-first-use). Throws `ModelIntegrityError.mismatch` only - /// when a stored hash exists and the actual hash differs. - static func verify(at modelURL: URL) throws { - let sidecar = sidecarURL(for: modelURL) + /// Verify the integrity of a cached model's representative file. + /// + /// If `modelIdentifier` is in `knownHashes` (an app-shipped model), the + /// file's hash is compared against that known-good value and any mismatch + /// is a HARD FAIL — even on the very first download — defeating a poisoned + /// first download. Otherwise falls back to trust-on-first-use against a + /// sidecar hash. + /// + /// Throws `ModelIntegrityError.pinnedMismatch` when an app-shipped model + /// fails its known-good hash, or `.mismatch` when a TOFU sidecar differs. + static func verify(at modelURL: URL, modelIdentifier: String? = nil) throws { let actual = try sha256(of: modelURL) + + if let pinned = knownHash(for: modelIdentifier) { + // App-shipped model: verify against the hash baked into this build. + // Hard fail on mismatch — no trust-on-first-use escape hatch. + guard pinned.lowercased() == actual.lowercased() else { + throw ModelIntegrityError.pinnedMismatch( + model: modelIdentifier ?? "", + expected: pinned, + actual: actual + ) + } + // Keep the sidecar in sync so quick TOFU checks elsewhere agree. + try? actual.write(to: sidecarURL(for: modelURL), atomically: true, encoding: .utf8) + return + } + + // User-added model: trust-on-first-use against a sidecar hash. + let sidecar = sidecarURL(for: modelURL) if let stored = try? String(contentsOf: sidecar, encoding: .utf8) .trimmingCharacters(in: .whitespacesAndNewlines), !stored.isEmpty { guard stored.lowercased() == actual.lowercased() else { @@ -86,12 +156,12 @@ internal enum ModelIntegrity { } } - /// Returns true if a sidecar exists and matches; false on any mismatch, + /// Returns true if integrity verification passes; false on any mismatch, /// missing-file error, or unreadable file. Never throws — useful for /// background verification where we don't want to surface noise. - static func quietVerify(at modelURL: URL) -> Bool { + static func quietVerify(at modelURL: URL, modelIdentifier: String? = nil) -> Bool { do { - try verify(at: modelURL) + try verify(at: modelURL, modelIdentifier: modelIdentifier) return true } catch { return false @@ -105,11 +175,14 @@ internal enum ModelIntegrity { internal enum ModelIntegrityError: LocalizedError { case mismatch(expected: String, actual: String) + case pinnedMismatch(model: String, expected: String, actual: String) var errorDescription: String? { switch self { case let .mismatch(expected, actual): return "Model integrity check failed (expected \(expected.prefix(8))…, got \(actual.prefix(8))…). The cached model may be corrupted; re-download it from Settings." + case let .pinnedMismatch(model, expected, actual): + return "Integrity check failed for app-provided model \"\(model)\" (expected \(expected.prefix(8))…, got \(actual.prefix(8))…). The download does not match the version shipped with AudioWhisper and was rejected. Re-download it from Settings or reinstall AudioWhisper from a trusted source." } } } From 6e6caa12eac5c9d1b3afee53de7472e7a2af890d Mon Sep 17 00:00:00 2001 From: jtn0123 Date: Sat, 16 May 2026 16:42:14 -0700 Subject: [PATCH 5/6] Stop waveform animation timers when views are off-screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each of the 8 waveform renderers installed a 30fps Timer.publish that autoconnected on view init and ran forever — even off-screen or idle, churning the main thread (the Visuals grid alone = ~240 updates/sec). A new FrameTimer helper makes the timer subscription start on onAppear and fully cancel on onDisappear; on-screen visual behavior is unchanged. (grade-report G1) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Waveform/ClassicWaveformView.swift | 9 ++++- .../Waveform/ConstellationWaveformView.swift | 9 ++--- .../Waveform/DialWaveformView.swift | 9 ++--- .../Components/Waveform/FrameTimer.swift | 39 +++++++++++++++++++ .../Waveform/HaloWaveformView.swift | 9 ++--- .../Waveform/HeartbeatPulseView.swift | 9 ++--- .../Waveform/NeonWaveformView.swift | 9 ++--- .../Waveform/SpectrumWaveformView.swift | 5 ++- .../Waveform/StreamWaveformView.swift | 9 ++--- .../Waveform/WaveformContainer.swift | 5 +++ 10 files changed, 80 insertions(+), 32 deletions(-) create mode 100644 Sources/Views/Components/Waveform/FrameTimer.swift diff --git a/Sources/Views/Components/Waveform/ClassicWaveformView.swift b/Sources/Views/Components/Waveform/ClassicWaveformView.swift index 815d80d..4f44a8d 100644 --- a/Sources/Views/Components/Waveform/ClassicWaveformView.swift +++ b/Sources/Views/Components/Waveform/ClassicWaveformView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Combine /// Classic level-based waveform visualization with animated bars. /// Features gravity-based physics: bars rise quickly, fall with acceleration, and bounce. @@ -20,6 +21,10 @@ struct ClassicWaveformView: View { @State private var velocities: [CGFloat] = [] @State private var idlePhase: CGFloat = 0 + // Stoppable per-frame timer: only runs while the view is on-screen so + // idle/off-screen tiles don't burn main-thread cycles. + @State private var frameTimer = FrameTimer(interval: 0.033) + var body: some View { GeometryReader { geometry in let totalSpacing = barSpacing * CGFloat(barCount - 1) @@ -38,8 +43,10 @@ struct ClassicWaveformView: View { .onAppear { barHeights = Array(repeating: minHeight, count: barCount) velocities = Array(repeating: 0, count: barCount) + frameTimer.start() } - .onReceive(Timer.publish(every: 0.033, on: .main, in: .common).autoconnect()) { _ in + .onDisappear { frameTimer.stop() } + .onReceive(frameTimer.publisher) { _ in updatePhysics() } } diff --git a/Sources/Views/Components/Waveform/ConstellationWaveformView.swift b/Sources/Views/Components/Waveform/ConstellationWaveformView.swift index 30034b2..40ab6f8 100644 --- a/Sources/Views/Components/Waveform/ConstellationWaveformView.swift +++ b/Sources/Views/Components/Waveform/ConstellationWaveformView.swift @@ -7,7 +7,7 @@ struct ConstellationWaveformView: View { let isActive: Bool @State private var time: CGFloat = 0 - @State private var isViewActive = false + @State private var frameTimer = FrameTimer(interval: 0.033) private let count = 18 private let coral = Color(red: 0.85, green: 0.45, blue: 0.30) @@ -65,10 +65,9 @@ struct ConstellationWaveformView: View { ) } } - .onAppear { isViewActive = true } - .onDisappear { isViewActive = false } - .onReceive(Timer.publish(every: 0.033, on: .main, in: .common).autoconnect()) { _ in - guard isViewActive else { return } + .onAppear { frameTimer.start() } + .onDisappear { frameTimer.stop() } + .onReceive(frameTimer.publisher) { _ in time += 0.033 } } diff --git a/Sources/Views/Components/Waveform/DialWaveformView.swift b/Sources/Views/Components/Waveform/DialWaveformView.swift index 592d344..33a0639 100644 --- a/Sources/Views/Components/Waveform/DialWaveformView.swift +++ b/Sources/Views/Components/Waveform/DialWaveformView.swift @@ -9,7 +9,7 @@ struct DialWaveformView: View { let isActive: Bool @State private var time: CGFloat = 0 - @State private var isViewActive = false + @State private var frameTimer = FrameTimer(interval: 0.033) private let dotCount = 36 private let coral = Color(red: 0.85, green: 0.45, blue: 0.30) @@ -80,10 +80,9 @@ struct DialWaveformView: View { } .position(center) } - .onAppear { isViewActive = true } - .onDisappear { isViewActive = false } - .onReceive(Timer.publish(every: 0.033, on: .main, in: .common).autoconnect()) { _ in - guard isViewActive else { return } + .onAppear { frameTimer.start() } + .onDisappear { frameTimer.stop() } + .onReceive(frameTimer.publisher) { _ in time += 0.033 } } diff --git a/Sources/Views/Components/Waveform/FrameTimer.swift b/Sources/Views/Components/Waveform/FrameTimer.swift new file mode 100644 index 0000000..428eece --- /dev/null +++ b/Sources/Views/Components/Waveform/FrameTimer.swift @@ -0,0 +1,39 @@ +import SwiftUI +import Combine + +/// A stoppable per-frame timer for waveform animations. +/// +/// `Timer.publish(...).autoconnect()` runs forever the moment a view is +/// created — even for waveform tiles that are off-screen or in an idle state. +/// With the Visuals grid showing 8 styles at once that is hundreds of +/// main-thread state mutations per second while nothing is visible. +/// +/// `FrameTimer` instead exposes a passthrough `publisher` that only emits +/// while `start()` has been called and `stop()` has not. Views drive it from +/// `onAppear`/`onDisappear`, so the timer is fully cancelled — not merely +/// ignored — whenever the view leaves the screen. +final class FrameTimer { + /// Per-frame tick stream. Emits only between `start()` and `stop()`. + let publisher = PassthroughSubject() + + private let interval: TimeInterval + private var cancellable: AnyCancellable? + + init(interval: TimeInterval) { + self.interval = interval + } + + /// Begin emitting ticks. No-op if already running. + func start() { + guard cancellable == nil else { return } + cancellable = Timer.publish(every: interval, on: .main, in: .common) + .autoconnect() + .sink { [publisher] date in publisher.send(date) } + } + + /// Stop emitting ticks and cancel the underlying timer. + func stop() { + cancellable?.cancel() + cancellable = nil + } +} diff --git a/Sources/Views/Components/Waveform/HaloWaveformView.swift b/Sources/Views/Components/Waveform/HaloWaveformView.swift index cf1b507..4ab3fd1 100644 --- a/Sources/Views/Components/Waveform/HaloWaveformView.swift +++ b/Sources/Views/Components/Waveform/HaloWaveformView.swift @@ -8,7 +8,7 @@ struct HaloWaveformView: View { let isActive: Bool @State private var time: CGFloat = 0 - @State private var isViewActive = false + @State private var frameTimer = FrameTimer(interval: 0.033) private let barCount = 28 private let coral = Color(red: 0.85, green: 0.45, blue: 0.30) @@ -108,10 +108,9 @@ struct HaloWaveformView: View { } .position(center) } - .onAppear { isViewActive = true } - .onDisappear { isViewActive = false } - .onReceive(Timer.publish(every: 0.033, on: .main, in: .common).autoconnect()) { _ in - guard isViewActive else { return } + .onAppear { frameTimer.start() } + .onDisappear { frameTimer.stop() } + .onReceive(frameTimer.publisher) { _ in time += 0.033 } } diff --git a/Sources/Views/Components/Waveform/HeartbeatPulseView.swift b/Sources/Views/Components/Waveform/HeartbeatPulseView.swift index 775208d..2796782 100644 --- a/Sources/Views/Components/Waveform/HeartbeatPulseView.swift +++ b/Sources/Views/Components/Waveform/HeartbeatPulseView.swift @@ -10,7 +10,7 @@ struct HeartbeatPulseView: View { @State private var rings: [Ring] = [] @State private var lastPeakTime: Date = .distantPast @State private var idlePhase: CGFloat = 0 - @State private var isViewActive = false + @State private var frameTimer = FrameTimer(interval: 0.033) private let coral = Color(red: 0.85, green: 0.45, blue: 0.30) private let coralDeep = Color(red: 0.70, green: 0.34, blue: 0.23) @@ -77,10 +77,9 @@ struct HeartbeatPulseView: View { } .position(center) } - .onAppear { isViewActive = true } - .onDisappear { isViewActive = false } - .onReceive(Timer.publish(every: 0.033, on: .main, in: .common).autoconnect()) { _ in - guard isViewActive else { return } + .onAppear { frameTimer.start() } + .onDisappear { frameTimer.stop() } + .onReceive(frameTimer.publisher) { _ in updateRings() } } diff --git a/Sources/Views/Components/Waveform/NeonWaveformView.swift b/Sources/Views/Components/Waveform/NeonWaveformView.swift index 79ee71e..ae7ebb5 100644 --- a/Sources/Views/Components/Waveform/NeonWaveformView.swift +++ b/Sources/Views/Components/Waveform/NeonWaveformView.swift @@ -24,7 +24,7 @@ struct NeonWaveformView: View { @State private var waveformHistory: [[Float]] = [] @State private var smoothedSamples: [Float] = [] @State private var phase: CGFloat = 0 - @State private var isViewActive = false + @State private var frameTimer = FrameTimer(interval: 0.033) private let decayFactor: Float = 0.55 @@ -48,10 +48,9 @@ struct NeonWaveformView: View { waveformReflection(in: geometry.size) } } - .onAppear { isViewActive = true } - .onDisappear { isViewActive = false } - .onReceive(Timer.publish(every: 0.033, on: .main, in: .common).autoconnect()) { _ in - guard isViewActive else { return } + .onAppear { frameTimer.start() } + .onDisappear { frameTimer.stop() } + .onReceive(frameTimer.publisher) { _ in phase += 0.05 updateSmoothedSamples() updateHistory() diff --git a/Sources/Views/Components/Waveform/SpectrumWaveformView.swift b/Sources/Views/Components/Waveform/SpectrumWaveformView.swift index 045e6ca..44323c8 100644 --- a/Sources/Views/Components/Waveform/SpectrumWaveformView.swift +++ b/Sources/Views/Components/Waveform/SpectrumWaveformView.swift @@ -29,6 +29,7 @@ struct SpectrumWaveformView: View { @State private var peakLevels: [Float] = Array(repeating: 0, count: 8) @State private var animatedLevels: [Float] = Array(repeating: 0, count: 8) @State private var idlePhase: CGFloat = 0 + @State private var frameTimer = FrameTimer(interval: 0.033) private let barSpacing: CGFloat = 6 private let minHeight: CGFloat = 4 @@ -76,7 +77,9 @@ struct SpectrumWaveformView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity) } - .onReceive(Timer.publish(every: 0.033, on: .main, in: .common).autoconnect()) { _ in + .onAppear { frameTimer.start() } + .onDisappear { frameTimer.stop() } + .onReceive(frameTimer.publisher) { _ in updateLevels() } } diff --git a/Sources/Views/Components/Waveform/StreamWaveformView.swift b/Sources/Views/Components/Waveform/StreamWaveformView.swift index 4dd13c8..c0e04e9 100644 --- a/Sources/Views/Components/Waveform/StreamWaveformView.swift +++ b/Sources/Views/Components/Waveform/StreamWaveformView.swift @@ -8,7 +8,7 @@ struct StreamWaveformView: View { let isActive: Bool @State private var time: CGFloat = 0 - @State private var isViewActive = false + @State private var frameTimer = FrameTimer(interval: 0.033) // AW palette private let coral = Color(red: 0.85, green: 0.45, blue: 0.30) @@ -61,10 +61,9 @@ struct StreamWaveformView: View { with: .color(color.opacity(0.45 + Double(level) * 0.4))) } } - .onAppear { isViewActive = true } - .onDisappear { isViewActive = false } - .onReceive(Timer.publish(every: 0.033, on: .main, in: .common).autoconnect()) { _ in - guard isViewActive else { return } + .onAppear { frameTimer.start() } + .onDisappear { frameTimer.stop() } + .onReceive(frameTimer.publisher) { _ in time += 0.033 } } diff --git a/Sources/Views/Components/Waveform/WaveformContainer.swift b/Sources/Views/Components/Waveform/WaveformContainer.swift index 7bb188f..d63ed19 100644 --- a/Sources/Views/Components/Waveform/WaveformContainer.swift +++ b/Sources/Views/Components/Waveform/WaveformContainer.swift @@ -434,6 +434,11 @@ private struct ProcessingShimmerView: View { phase = 1 } } + .onDisappear { + // Halt the repeating shimmer animation when off-screen so it + // doesn't keep driving re-renders for a hidden view. + withAnimation(.linear(duration: 0)) { phase = 0 } + } } private func dotOpacity(for index: Int) -> Double { From 093505b0eeb4da7b5ec8dbab116370137bd36b6b Mon Sep 17 00:00:00 2001 From: jtn0123 Date: Sat, 16 May 2026 16:42:14 -0700 Subject: [PATCH 6/6] Strengthen test assertions: real pipeline coverage, no tautologies The core TranscriptionPipeline tests caught every error into XCTAssertTrue(true), so a broken pipeline still passed. They now inject a stub speech service and assert the returned transcript, the correction outcome, and error propagation. Separately, all ~52 XCTAssertTrue(true) tautologies across the suite are replaced with concrete assertions (or removed where reaching the line is itself the assertion). (grade-report D1, D2) Co-Authored-By: Claude Opus 4.7 (1M context) --- Tests/AppDelegate/AppDelegateMenuTests.swift | 22 ++- .../AppDelegateNotificationsTests.swift | 100 ++++++------ .../AppDelegateRecordingWindowTests.swift | 33 ++-- Tests/AudioEngineRecorderTests.swift | 17 -- Tests/ErrorPresenterExpandedTests.swift | 12 +- .../ErrorPropagationLayerTests.swift | 13 +- .../ErrorRecoveryIntegrationTests.swift | 121 ++++++++------ Tests/SpeechToTextServiceTests.swift | 14 +- Tests/TranscriptionPipelineTests.swift | 147 ++++++++++++++++-- Tests/Utilities/LoggerTests.swift | 17 +- Tests/Utilities/ResourceLocatorTests.swift | 79 ++++++---- Tests/Utilities/WindowTitlesTests.swift | 6 - .../Views/Components/InkRippleViewTests.swift | 7 +- .../DashboardTranscriptsViewTests.swift | 19 ++- .../MLXModelManagementViewTests.swift | 12 +- .../Dashboard/UsageDashboardViewTests.swift | 13 +- .../TranscriptionRecordRowTests.swift | 10 +- Tests/Views/WelcomeViewTests.swift | 7 +- Tests/Waveform/ParticleSystemTests.swift | 4 +- Tests/WindowControllerTests.swift | 7 +- 20 files changed, 439 insertions(+), 221 deletions(-) diff --git a/Tests/AppDelegate/AppDelegateMenuTests.swift b/Tests/AppDelegate/AppDelegateMenuTests.swift index f5e4fff..1c3d1f5 100644 --- a/Tests/AppDelegate/AppDelegateMenuTests.swift +++ b/Tests/AppDelegate/AppDelegateMenuTests.swift @@ -76,17 +76,25 @@ final class AppDelegateMenuTests: XCTestCase { // MARK: - Menu Action Tests - func testShowHistoryDoesNotCrash() { - // Just verify the method can be called without crashing - // Actual window verification would require UI testing + func testShowHistoryDoesNotMutateStatusItem() { + // showHistory delegates to HistoryWindowManager, which no-ops in the + // test environment; it must not create or mutate the status item. + XCTAssertNil(appDelegate.statusItem) appDelegate.showHistory() - XCTAssertTrue(true) + XCTAssertNil(appDelegate.statusItem) } - func testShowDashboardDoesNotCrash() { - // Just verify the method can be called without crashing + func testDashboardMenuItemTargetsShowDashboard() { + // The Dashboard menu item must be wired to the showDashboard selector. + let menu = appDelegate.makeStatusMenu() + let dashboardItem = menu.items.first { $0.title == "Dashboard" } + XCTAssertNotNil(dashboardItem, "Menu should contain a Dashboard item") + XCTAssertEqual(dashboardItem?.action, #selector(AppDelegate.showDashboard)) + + // Invoking the action must not mutate the status item (no-op in tests). + XCTAssertNil(appDelegate.statusItem) appDelegate.showDashboard() - XCTAssertTrue(true) + XCTAssertNil(appDelegate.statusItem) } func testShowHelpDoesNotCrash() { diff --git a/Tests/AppDelegate/AppDelegateNotificationsTests.swift b/Tests/AppDelegate/AppDelegateNotificationsTests.swift index a3423c0..e3d326a 100644 --- a/Tests/AppDelegate/AppDelegateNotificationsTests.swift +++ b/Tests/AppDelegate/AppDelegateNotificationsTests.swift @@ -21,65 +21,74 @@ final class AppDelegateNotificationsTests: XCTestCase { // MARK: - Notification Observer Setup Tests - func testSetupNotificationObserversDoesNotCrash() { - // Just verify the method can be called without crashing - appDelegate.setupNotificationObservers() - XCTAssertTrue(true) + /// Posts `.pressAndHoldSettingsChanged` and waits for the observer to run. + private func awaitNotificationProcessed(name: Notification.Name, object: Any?) { + NotificationCenter.default.post(name: name, object: object) + let processed = expectation(description: "Notification processed") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { processed.fulfill() } + wait(for: [processed], timeout: 1.0) } - func testSetupNotificationObserversRegistersAllObservers() { - // Set up observers + func testSetupNotificationObserversWiresPressAndHoldObserver() { appDelegate.setupNotificationObservers() - // Verify by posting notifications and checking no crash - // We can't easily verify observer registration without mocking NotificationCenter + // Posting the settings-changed notification must reach the observer, + // which runs `configureShortcutMonitors()` and updates the stored + // configuration. Use an enabled config so the effect is observable. + let newConfig = PressAndHoldConfiguration(enabled: true, key: .leftOption, mode: .toggle) + awaitNotificationProcessed(name: .pressAndHoldSettingsChanged, object: newConfig) + + // configureShortcutMonitors() reads PressAndHoldSettings.configuration() + // and assigns it to pressAndHoldConfiguration. After the observer runs, + // the stored configuration must match the freshly read settings value. + XCTAssertEqual(appDelegate.pressAndHoldConfiguration, + PressAndHoldSettings.configuration()) + } - // The notifications being observed are: - // - .welcomeCompleted - // - .restoreFocusToPreviousApp - // - .recordingStopped - // - .pressAndHoldSettingsChanged + func testSetupNotificationObserversRegistersAllObservers() { + appDelegate.setupNotificationObservers() - // Just verify setup completed - XCTAssertTrue(true) + // Each observed notification must be handled without crashing. The + // recordingStopped handler is the one with an inspectable post-state: + // with no status item it must leave statusItem nil. + XCTAssertNil(appDelegate.statusItem) + NotificationCenter.default.post(name: .welcomeCompleted, object: nil) + NotificationCenter.default.post(name: .restoreFocusToPreviousApp, object: nil) + NotificationCenter.default.post(name: .recordingStopped, object: nil) + XCTAssertNil(appDelegate.statusItem, + "recordingStopped must handle a nil status item gracefully") } // MARK: - Notification Response Tests func testWelcomeCompletedNotificationShowsDashboard() { - // Set up observers appDelegate.setupNotificationObservers() - // Post notification + // The welcomeCompleted handler must not mutate the status item. + XCTAssertNil(appDelegate.statusItem) NotificationCenter.default.post(name: .welcomeCompleted, object: nil) - - // Dashboard opening would happen - verify no crash - XCTAssertTrue(true) + XCTAssertNil(appDelegate.statusItem) } func testRestoreFocusToPreviousAppNotificationCallsWindowController() { - // Set up observers appDelegate.setupNotificationObservers() - // Post notification + // The restore-focus handler must not mutate the status item. + XCTAssertNil(appDelegate.statusItem) NotificationCenter.default.post(name: .restoreFocusToPreviousApp, object: nil) - - // Window controller method would be called - verify no crash - XCTAssertTrue(true) + XCTAssertNil(appDelegate.statusItem) } func testRecordingStoppedNotificationCallsHandler() { // Initially no status item XCTAssertNil(appDelegate.statusItem) - // Set up observers appDelegate.setupNotificationObservers() - // Post notification - should handle nil status item gracefully + // Posting recordingStopped with a nil status item must be handled + // gracefully and must not create a status item. NotificationCenter.default.post(name: .recordingStopped, object: nil) - - // Verify no crash with nil status item - XCTAssertTrue(true) + XCTAssertNil(appDelegate.statusItem) } func testPressAndHoldSettingsChangedReconfiguresMonitors() { @@ -129,44 +138,41 @@ final class AppDelegateNotificationsTests: XCTestCase { // MARK: - Observer Cleanup Tests func testNotificationObserversAreCleanedUpOnDeinit() { - // Create a temporary delegate + // Create a temporary delegate and register observers. var tempDelegate: AppDelegate? = AppDelegate() tempDelegate?.setupNotificationObservers() - // Set to nil to trigger deinit + // Set to nil to trigger deinit (which removes observers). tempDelegate = nil + XCTAssertNil(tempDelegate) - // If observers weren't cleaned up properly, posting might cause issues - // But NotificationCenter uses weak references, so this should be fine + // Posting after deinit must not dispatch to the deallocated delegate; + // a use-after-free would crash the test runner here. NotificationCenter.default.post(name: .welcomeCompleted, object: nil) - - XCTAssertTrue(true) } // MARK: - Integration Tests func testMultipleNotificationsInSequence() { - // Set up observers appDelegate.setupNotificationObservers() - // Post multiple notifications + // A burst of notifications must all be handled without mutating the + // status item (no status item exists in this test setup). NotificationCenter.default.post(name: .recordingStopped, object: nil) NotificationCenter.default.post(name: .restoreFocusToPreviousApp, object: nil) NotificationCenter.default.post(name: .welcomeCompleted, object: nil) - - // All should be handled without crash - XCTAssertTrue(true) + XCTAssertNil(appDelegate.statusItem) } func testNotificationWithPayload() { - // Set up observers appDelegate.setupNotificationObservers() - // Post notification with configuration payload + // A settings-changed notification carrying a configuration payload + // must reach the handler, which runs configureShortcutMonitors() and + // assigns a non-nil stored configuration. let config = PressAndHoldConfiguration(enabled: true, key: .rightCommand, mode: .hold) - NotificationCenter.default.post(name: .pressAndHoldSettingsChanged, object: config) - - // Should process without crash - XCTAssertTrue(true) + awaitNotificationProcessed(name: .pressAndHoldSettingsChanged, object: config) + XCTAssertEqual(appDelegate.pressAndHoldConfiguration, + PressAndHoldSettings.configuration()) } } diff --git a/Tests/AppDelegate/AppDelegateRecordingWindowTests.swift b/Tests/AppDelegate/AppDelegateRecordingWindowTests.swift index 87bf0d3..92de977 100644 --- a/Tests/AppDelegate/AppDelegateRecordingWindowTests.swift +++ b/Tests/AppDelegate/AppDelegateRecordingWindowTests.swift @@ -68,12 +68,11 @@ final class AppDelegateRecordingWindowTests: XCTestCase { } func testShowRecordingWindowForProcessingHidesDashboard() { - // This tests the behavior of hiding dashboard when showing recording window - // Dashboard visibility check happens in the method + // With no audio recorder configured, showRecordingWindowForProcessing + // must not create a recording window. + XCTAssertNil(appDelegate.audioRecorder) appDelegate.showRecordingWindowForProcessing() - - // Just verify no crash - XCTAssertTrue(true) + XCTAssertNil(appDelegate.recordingWindow) } // MARK: - createRecordingWindow Tests @@ -168,11 +167,11 @@ final class AppDelegateRecordingWindowTests: XCTestCase { // MARK: - restoreFocusToPreviousApp Tests func testRestoreFocusToPreviousAppCallsWindowController() { - // Just verify the method can be called + // restoreFocusToPreviousApp restores app focus; it must not create + // or mutate the recording window. + XCTAssertNil(appDelegate.recordingWindow) appDelegate.restoreFocusToPreviousApp() - - // No crash is success - XCTAssertTrue(true) + XCTAssertNil(appDelegate.recordingWindow) } // MARK: - Fallback Model Container Tests @@ -194,10 +193,18 @@ final class AppDelegateRecordingWindowTests: XCTestCase { // MARK: - ChromelessWindow Tests - func testChromelessWindowTypeExists() { - // Verify the ChromelessWindow type exists (compile-time check) - // Actual window creation/closing in tests can cause stability issues - XCTAssertTrue(true) + func testChromelessWindowOverridesResponderBehavior() { + // ChromelessWindow is borderless; it overrides NSWindow so it can + // still become key/main and accept first responder. + let window = ChromelessWindow( + contentRect: NSRect(x: 0, y: 0, width: 100, height: 100), + styleMask: [.borderless], + backing: .buffered, + defer: true + ) + XCTAssertTrue(window.canBecomeKey) + XCTAssertTrue(window.canBecomeMain) + XCTAssertTrue(window.acceptsFirstResponder) } // MARK: - Window State Tests diff --git a/Tests/AudioEngineRecorderTests.swift b/Tests/AudioEngineRecorderTests.swift index 576fb37..0a10630 100644 --- a/Tests/AudioEngineRecorderTests.swift +++ b/Tests/AudioEngineRecorderTests.swift @@ -44,23 +44,6 @@ final class AudioEngineRecorderTests: IsolatedXCTestCase { ) } - // MARK: - Protocol Conformance Tests - - func testConformsToAudioRecordingProtocol() { - recorder = makeRecorder() - - // Verify all required properties exist - _ = recorder.isRecording - _ = recorder.audioLevel - _ = recorder.waveformSamples - _ = recorder.frequencyBands - _ = recorder.currentSessionStart - _ = recorder.lastRecordingDuration - - // This test passes if it compiles - protocol conformance is verified at compile time - XCTAssertTrue(true) - } - // MARK: - Initial State Tests func testInitialState() { diff --git a/Tests/ErrorPresenterExpandedTests.swift b/Tests/ErrorPresenterExpandedTests.swift index b419928..a74a800 100644 --- a/Tests/ErrorPresenterExpandedTests.swift +++ b/Tests/ErrorPresenterExpandedTests.swift @@ -175,11 +175,17 @@ final class ErrorPresenterExpandedTests: XCTestCase { // Verify test environment is set XCTAssertTrue(ErrorPresenter.shared.isTestEnvironment) - // Show error - should not cause any UI issues in test mode + // An unclassified error (no api_key/microphone/connection/transcription + // keyword) must not post any retry notification in test mode. + let noRetry = expectation(forNotification: .retryRequested, object: nil) + noRetry.isInverted = true + let noRetryTranscription = expectation( + forNotification: .retryTranscriptionRequested, object: nil) + noRetryTranscription.isInverted = true + ErrorPresenter.shared.showError("Test error message") - // If we get here without crashing, the test passes - XCTAssertTrue(true) + wait(for: [noRetry, noRetryTranscription], timeout: 0.5) } func testIsTestEnvironmentIsThreadSafe() async { diff --git a/Tests/Integration/ErrorPropagationLayerTests.swift b/Tests/Integration/ErrorPropagationLayerTests.swift index 9d813a9..07aa6fb 100644 --- a/Tests/Integration/ErrorPropagationLayerTests.swift +++ b/Tests/Integration/ErrorPropagationLayerTests.swift @@ -261,21 +261,18 @@ final class ErrorPropagationLayerTests: XCTestCase { let errorMessage = "Whisper model 'large' not found" let error = TranscriptionError.from(errorMessage: errorMessage) - if case .modelNotFound = error { - XCTAssertTrue(true) - } else { - XCTFail("Expected modelNotFound for Whisper") + guard case .modelNotFound(let model) = error else { + return XCTFail("Expected modelNotFound for Whisper, got \(error)") } + XCTAssertEqual(model, "large") } func testParakeetErrorPropagation() { let errorMessage = "Parakeet transcription failed - Python not configured" let error = TranscriptionError.from(errorMessage: errorMessage) - if case .pythonConfigurationError = error { - XCTAssertTrue(true) - } else { - XCTFail("Expected pythonConfigurationError for Parakeet") + guard case .pythonConfigurationError = error else { + return XCTFail("Expected pythonConfigurationError for Parakeet, got \(error)") } } } diff --git a/Tests/Integration/ErrorRecoveryIntegrationTests.swift b/Tests/Integration/ErrorRecoveryIntegrationTests.swift index ece1ad5..785fad4 100644 --- a/Tests/Integration/ErrorRecoveryIntegrationTests.swift +++ b/Tests/Integration/ErrorRecoveryIntegrationTests.swift @@ -97,71 +97,91 @@ final class ErrorRecoveryIntegrationTests: XCTestCase { // Given - A permission denied error let permissionError = TranscriptionError.microphonePermissionDenied + // Then - A microphone error must NOT trigger a retry notification. + let noRetry = expectation(forNotification: .retryRequested, object: nil) + noRetry.isInverted = true + let noRetryTranscription = expectation( + forNotification: .retryTranscriptionRequested, object: nil) + noRetryTranscription.isInverted = true + // When - Error is presented ErrorPresenter.shared.showError(permissionError.userMessage) - await waitForAsyncOperation() - - // Then - System settings should be triggered - XCTAssertTrue(true) + await fulfillment(of: [noRetry, noRetryTranscription], timeout: 0.5) } func testRecoveryFromMicrophonePermissionRestricted() async throws { // Given - A restricted permission error let restrictedError = TranscriptionError.microphonePermissionRestricted + // Then - A microphone error must NOT trigger a retry notification. + let noRetry = expectation(forNotification: .retryRequested, object: nil) + noRetry.isInverted = true + let noRetryTranscription = expectation( + forNotification: .retryTranscriptionRequested, object: nil) + noRetryTranscription.isInverted = true + // When - Error is presented ErrorPresenter.shared.showError(restrictedError.userMessage) - await waitForAsyncOperation() - - // Then - System settings should be triggered - XCTAssertTrue(true) + await fulfillment(of: [noRetry, noRetryTranscription], timeout: 0.5) } // MARK: - Audio Processing Error Recovery Tests func testRecoveryFromAudioProcessingError() async throws { - // Given - An audio processing error + // Given - An audio processing error (not a retryable error type) let audioError = TranscriptionError.audioProcessingError + // Then - No retry notification should be posted for this error type. + let noRetry = expectation(forNotification: .retryRequested, object: nil) + noRetry.isInverted = true + let noRetryTranscription = expectation( + forNotification: .retryTranscriptionRequested, object: nil) + noRetryTranscription.isInverted = true + // When - Error is presented ErrorPresenter.shared.showError(audioError.userMessage) - await waitForAsyncOperation() - - // Then - User informed, no crash - XCTAssertTrue(true) + await fulfillment(of: [noRetry, noRetryTranscription], timeout: 0.5) } // MARK: - Model Error Recovery Tests func testRecoveryFromModelLoadFailure() async throws { - // Given - A model not found error + // Given - A model not found error (not a retryable error type) let modelError = TranscriptionError.modelNotFound(model: "large-v3") + // Then - No retry notification should be posted for this error type. + let noRetry = expectation(forNotification: .retryRequested, object: nil) + noRetry.isInverted = true + let noRetryTranscription = expectation( + forNotification: .retryTranscriptionRequested, object: nil) + noRetryTranscription.isInverted = true + // When - Error is presented ErrorPresenter.shared.showError(modelError.userMessage) - await waitForAsyncOperation() - - // Then - Dashboard should be shown for model download - XCTAssertTrue(true) + await fulfillment(of: [noRetry, noRetryTranscription], timeout: 0.5) } // MARK: - Python Configuration Error Recovery Tests func testRecoveryFromPythonConfigurationError() async throws { - // Given - A Python configuration error + // Given - A Python configuration error (not a retryable error type) let pythonError = TranscriptionError.pythonConfigurationError + // Then - No retry notification should be posted for this error type. + let noRetry = expectation(forNotification: .retryRequested, object: nil) + noRetry.isInverted = true + let noRetryTranscription = expectation( + forNotification: .retryTranscriptionRequested, object: nil) + noRetryTranscription.isInverted = true + // When - Error is presented ErrorPresenter.shared.showError(pythonError.userMessage) - await waitForAsyncOperation() - - // Then - Settings should be triggered for Python configuration - XCTAssertTrue(true) + await fulfillment(of: [noRetry, noRetryTranscription], timeout: 0.5) } // MARK: - Retry Mechanism Tests @@ -261,23 +281,26 @@ final class ErrorRecoveryIntegrationTests: XCTestCase { for testCase in testCases { let message = testCase.message - let expectsConnection = testCase.expectsConnection - let expectsTranscription = testCase.expectsTranscription let error = TranscriptionError.from(errorMessage: message) - if expectsConnection { - if case .networkConnectionError = error { - XCTAssertTrue(true) - } else if case .networkTimeout = error { - XCTAssertTrue(true) - } else { - // Check if it's actually a connection-related message + if testCase.expectsConnection { + let isConnectionError: Bool + switch error { + case .networkConnectionError, .networkTimeout: + isConnectionError = true + default: + isConnectionError = false } + XCTAssertTrue( + isConnectionError, + "Expected connection-class error for '\(message)', got \(error)" + ) } - if expectsTranscription { - if case .transcriptionFailed = error { - XCTAssertTrue(true) + if testCase.expectsTranscription { + guard case .transcriptionFailed = error else { + XCTFail("Expected .transcriptionFailed for '\(message)', got \(error)") + continue } } } @@ -286,30 +309,38 @@ final class ErrorRecoveryIntegrationTests: XCTestCase { // MARK: - Storage Error Tests func testRecoveryFromInsufficientStorageError() async throws { - // Given - A storage error + // Given - A storage error (not a retryable error type) let storageError = TranscriptionError.insufficientStorage + // Then - No retry notification should be posted for this error type. + let noRetry = expectation(forNotification: .retryRequested, object: nil) + noRetry.isInverted = true + let noRetryTranscription = expectation( + forNotification: .retryTranscriptionRequested, object: nil) + noRetryTranscription.isInverted = true + // When - Error is presented ErrorPresenter.shared.showError(storageError.userMessage) - await waitForAsyncOperation() - - // Then - User is informed about storage issue - XCTAssertTrue(true) + await fulfillment(of: [noRetry, noRetryTranscription], timeout: 0.5) } // MARK: - General Error Tests func testRecoveryFromGeneralError() async throws { - // Given - A general error + // Given - A general error with no retry/connection/transcription keyword let generalError = TranscriptionError.generalError(message: "Something unexpected happened") + // Then - No retry notification should be posted for an unclassified error. + let noRetry = expectation(forNotification: .retryRequested, object: nil) + noRetry.isInverted = true + let noRetryTranscription = expectation( + forNotification: .retryTranscriptionRequested, object: nil) + noRetryTranscription.isInverted = true + // When - Error is presented ErrorPresenter.shared.showError(generalError.userMessage) - await waitForAsyncOperation() - - // Then - Error is displayed without crash - XCTAssertTrue(true) + await fulfillment(of: [noRetry, noRetryTranscription], timeout: 0.5) } } diff --git a/Tests/SpeechToTextServiceTests.swift b/Tests/SpeechToTextServiceTests.swift index 377231c..29544ec 100644 --- a/Tests/SpeechToTextServiceTests.swift +++ b/Tests/SpeechToTextServiceTests.swift @@ -120,9 +120,19 @@ class SpeechToTextServiceTests: IsolatedXCTestCase { errorMessage.contains("corrupted") || errorMessage.contains("unreadable") XCTAssertTrue(hasExpectedError, "Error should indicate audio or Python issue: \(errorMessage)") + } catch let error as ParakeetError { + // Also acceptable: a Parakeet-specific failure (e.g. model not ready). + XCTAssertFalse( + error.localizedDescription.isEmpty, + "ParakeetError should carry a descriptive message" + ) } catch { - // Also acceptable - might be ParakeetError or other - XCTAssertTrue(true) + // Any other thrown error must still be a real, described error — + // an empty/placeholder error here would indicate a broken path. + XCTAssertFalse( + error.localizedDescription.isEmpty, + "Unexpected error type \(type(of: error)) should be descriptive" + ) } // Clean up diff --git a/Tests/TranscriptionPipelineTests.swift b/Tests/TranscriptionPipelineTests.swift index 9714f1c..b47780b 100644 --- a/Tests/TranscriptionPipelineTests.swift +++ b/Tests/TranscriptionPipelineTests.swift @@ -1,6 +1,31 @@ import XCTest @testable import AudioWhisper +/// Test-only stub that lets `TranscriptionPipeline` exercise a real success +/// path without hitting WhisperKit / Parakeet. `SpeechToTextService` is a +/// non-final class and `TranscriptionPipeline` injects it via its initializer, +/// so overriding `transcribeRaw(...)` is a legitimate seam. +private final class StubSpeechToTextService: SpeechToTextService, @unchecked Sendable { + /// Result returned by `transcribeRaw`. Defaults to a fixed transcript. + var rawResult: Result = .success("hello world") + private(set) var transcribeRawCallCount = 0 + private(set) var lastAudioURL: URL? + private(set) var lastProvider: TranscriptionProvider? + private(set) var lastModel: WhisperModel? + + override func transcribeRaw( + audioURL: URL, + provider: TranscriptionProvider, + model: WhisperModel? = nil + ) async throws -> String { + transcribeRawCallCount += 1 + lastAudioURL = audioURL + lastProvider = provider + lastModel = model + return try rawResult.get() + } +} + final class TranscriptionPipelineTests: XCTestCase { // MARK: - Configuration Tests @@ -84,6 +109,111 @@ final class TranscriptionPipelineTests: XCTestCase { XCTAssertEqual(receivedStep, "Transcribing...") } + // MARK: - Happy Path Tests (real success path via injected stub) + + /// Drives a full successful `transcribe(...)` with semantic correction + /// disabled and asserts the pipeline returns the stub provider's raw + /// transcript with a `nil` correction outcome. + @MainActor + func testTranscribeSuccessReturnsRawTextWhenCorrectionDisabled() async throws { + let stub = StubSpeechToTextService() + stub.rawResult = .success("the quick brown fox") + let pipeline = TranscriptionPipeline(speechService: stub) + let tempURL = createTemporaryAudioFile() + defer { try? FileManager.default.removeItem(at: tempURL) } + let config = TranscriptionPipelineConfig( + provider: .parakeet, + applySemanticCorrection: false + ) + + let result = try await pipeline.transcribe(audioURL: tempURL, config: config) + + XCTAssertEqual(result.text, "the quick brown fox") + XCTAssertNil(result.correctionOutcome, + "Correction outcome must be nil when correction is disabled") + XCTAssertEqual(stub.transcribeRawCallCount, 1) + XCTAssertEqual(stub.lastProvider, .parakeet) + XCTAssertEqual(stub.lastAudioURL, tempURL) + } + + /// Drives a full successful `transcribe(...)` with semantic correction + /// enabled but `semanticCorrectionMode == .off` so the real + /// `SemanticCorrectionService` deterministically returns `.skipped`. + /// Asserts both the final transcript text and the correction outcome. + @MainActor + func testTranscribeSuccessReturnsSkippedOutcomeWhenModeOff() async throws { + let previousMode = UserDefaults.standard.string(forKey: "semanticCorrectionMode") + UserDefaults.standard.set(SemanticCorrectionMode.off.rawValue, + forKey: "semanticCorrectionMode") + defer { + if let previousMode = previousMode { + UserDefaults.standard.set(previousMode, forKey: "semanticCorrectionMode") + } else { + UserDefaults.standard.removeObject(forKey: "semanticCorrectionMode") + } + } + + let stub = StubSpeechToTextService() + stub.rawResult = .success("transcribed text") + let pipeline = TranscriptionPipeline(speechService: stub) + let tempURL = createTemporaryAudioFile() + defer { try? FileManager.default.removeItem(at: tempURL) } + let config = TranscriptionPipelineConfig( + provider: .parakeet, + applySemanticCorrection: true + ) + + let result = try await pipeline.transcribe(audioURL: tempURL, config: config) + + XCTAssertEqual(result.text, "transcribed text") + guard case .skipped(let skippedText)? = result.correctionOutcome else { + return XCTFail("Expected .skipped outcome, got \(String(describing: result.correctionOutcome))") + } + XCTAssertEqual(skippedText, "transcribed text") + XCTAssertEqual(stub.transcribeRawCallCount, 1) + } + + /// `transcribeRaw(...)` convenience wrapper returns the provider transcript + /// unchanged and never attempts correction. + @MainActor + func testTranscribeRawSuccessReturnsProviderText() async throws { + let stub = StubSpeechToTextService() + stub.rawResult = .success("raw provider output") + let pipeline = TranscriptionPipeline(speechService: stub) + let tempURL = createTemporaryAudioFile() + defer { try? FileManager.default.removeItem(at: tempURL) } + + let text = try await pipeline.transcribeRaw(audioURL: tempURL, provider: .parakeet) + + XCTAssertEqual(text, "raw provider output") + XCTAssertEqual(stub.transcribeRawCallCount, 1) + XCTAssertEqual(stub.lastProvider, .parakeet) + } + + /// A provider failure must propagate out of the pipeline rather than being + /// silently swallowed. + @MainActor + func testTranscribePropagatesProviderError() async { + let stub = StubSpeechToTextService() + stub.rawResult = .failure(SpeechToTextError.transcriptionFailed("boom")) + let pipeline = TranscriptionPipeline(speechService: stub) + let tempURL = createTemporaryAudioFile() + defer { try? FileManager.default.removeItem(at: tempURL) } + let config = TranscriptionPipelineConfig(provider: .parakeet) + + do { + _ = try await pipeline.transcribe(audioURL: tempURL, config: config) + XCTFail("Expected provider error to propagate") + } catch let error as SpeechToTextError { + guard case .transcriptionFailed(let message) = error else { + return XCTFail("Expected .transcriptionFailed, got \(error)") + } + XCTAssertEqual(message, "boom") + } catch { + XCTFail("Expected SpeechToTextError, got \(error)") + } + } + // MARK: - Audio Validation Integration Tests @MainActor @@ -113,26 +243,25 @@ final class TranscriptionPipelineTests: XCTestCase { } } + /// `.local` with no `WhisperModel` must fail with the model-required error. + /// Using the real `SpeechToTextService` so the model guard is exercised. @MainActor - func testTranscribeRawLocalRequiresModel() async { + func testTranscribeRawLocalWithoutModelThrowsModelRequiredError() async { let pipeline = TranscriptionPipeline() let tempURL = createTemporaryAudioFile() defer { try? FileManager.default.removeItem(at: tempURL) } do { - // Calling transcribeRaw with .local but no model should fail _ = try await pipeline.transcribeRaw(audioURL: tempURL, provider: .local, model: nil) XCTFail("Expected failure when no model provided for local provider") } catch let error as SpeechToTextError { - if case .transcriptionFailed(let message) = error { - XCTAssertTrue(message.contains("model required"), "Error should mention model requirement") - } else { - // Other SpeechToTextError types are acceptable - XCTAssertTrue(true) + guard case .transcriptionFailed(let message) = error else { + return XCTFail("Expected .transcriptionFailed, got \(error)") } + XCTAssertTrue(message.contains("model required"), + "Error should mention model requirement, got: \(message)") } catch { - // Other error types are acceptable in test environment - XCTAssertTrue(true) + XCTFail("Expected SpeechToTextError, got \(error)") } } diff --git a/Tests/Utilities/LoggerTests.swift b/Tests/Utilities/LoggerTests.swift index 1d26499..43247ba 100644 --- a/Tests/Utilities/LoggerTests.swift +++ b/Tests/Utilities/LoggerTests.swift @@ -71,22 +71,19 @@ final class LoggerExtensionTests: XCTestCase { } func testLoggerCanLog() { - // This test verifies that logging doesn't crash - Logger.app.info("Test log message") - Logger.app.debug("Debug message") - Logger.app.error("Error message") - - // If we get here without crashing, the test passes - XCTAssertTrue(true) + // Logging at every level must complete without throwing or crashing. + XCTAssertNoThrow(Logger.app.info("Test log message")) + XCTAssertNoThrow(Logger.app.debug("Debug message")) + XCTAssertNoThrow(Logger.app.error("Error message")) } func testLoggerWithInterpolation() { let value = 42 let message = "Test value: \(value)" + XCTAssertEqual(message, "Test value: 42") - // This should not crash - Logger.app.info("\(message)") - XCTAssertTrue(true) + // Interpolated logging must complete without throwing or crashing. + XCTAssertNoThrow(Logger.app.info("\(message)")) } } diff --git a/Tests/Utilities/ResourceLocatorTests.swift b/Tests/Utilities/ResourceLocatorTests.swift index 487ada1..0e3f077 100644 --- a/Tests/Utilities/ResourceLocatorTests.swift +++ b/Tests/Utilities/ResourceLocatorTests.swift @@ -5,11 +5,9 @@ import XCTest final class ResourceLocatorTests: XCTestCase { func testURLForResourceWithValidName() { - // Test that method doesn't crash with valid inputs + // A non-existent resource with no dev fallback resolves to nil. let url = ResourceLocator.url(forResource: "test", withExtension: "txt") - // URL may be nil if resource doesn't exist, but should not crash - _ = url - XCTAssertTrue(true) + XCTAssertNil(url) } func testURLForResourceWithEmptyName() { @@ -19,21 +17,35 @@ final class ResourceLocatorTests: XCTestCase { } func testURLForResourceWithEmptyExtension() { + // A non-existent resource with an empty extension resolves to nil. let url = ResourceLocator.url(forResource: "test", withExtension: "") - // Should handle empty extension - _ = url - XCTAssertTrue(true) + XCTAssertNil(url) } - func testURLForResourceWithDevRelativePath() { + func testURLForResourceWithDevRelativePathMissingReturnsNil() { + // The dev fallback path does not exist, so resolution returns nil. let url = ResourceLocator.url( forResource: "test", withExtension: "txt", - devRelativePath: "Sources/test.txt" + devRelativePath: "Sources/definitely_missing_resource.txt" ) - // Should handle dev relative path - _ = url - XCTAssertTrue(true) + XCTAssertNil(url) + } + + func testURLForResourceWithDevRelativePathResolvesExistingFile() { + // The dev fallback resolves a file that exists relative to the cwd. + // Sources/parakeet_transcribe_pcm.py ships with the package. + let url = ResourceLocator.url( + forResource: "parakeet_transcribe_pcm", + withExtension: "py", + devRelativePath: "Sources/parakeet_transcribe_pcm.py" + ) + if let url = url { + XCTAssertEqual(url.lastPathComponent, "parakeet_transcribe_pcm.py") + XCTAssertTrue(FileManager.default.fileExists(atPath: url.path)) + } + // When run outside the package root the dev path is absent and the + // result is nil; either way no other resolution mode applies here. } func testPythonScriptURL() { @@ -54,9 +66,12 @@ final class ResourceLocatorTests: XCTestCase { } func testBundleMainResourceURLAccessible() { - // Resource URL may be nil but should not crash - _ = Bundle.main.resourceURL - XCTAssertTrue(true) + // When present, the main bundle resource URL is a file URL. + if let resourceURL = Bundle.main.resourceURL { + XCTAssertTrue(resourceURL.isFileURL) + } else { + XCTAssertNil(Bundle.main.resourceURL) + } } func testCurrentDirectoryAccessible() { @@ -101,19 +116,21 @@ final class ResourceLocatorPythonScriptTests: XCTestCase { ] for script in knownScripts { - // These may or may not exist depending on build type let url = ResourceLocator.pythonScriptURL(named: script) - // Just verify no crash - _ = url + // Resolution may fail when run outside the package root, but any + // URL returned must point at the correctly named .py script. + if let url = url { + XCTAssertEqual(url.pathExtension, "py") + XCTAssertEqual(url.deletingPathExtension().lastPathComponent, script) + } } - XCTAssertTrue(true) } - func testPythonScriptExtension() { - // Verify py extension is used - _ = ResourceLocator.pythonScriptURL(named: "test") - // URL format should include .py - XCTAssertTrue(true) + func testPythonScriptExtensionUsesPy() { + // A missing script with no dev fallback resolves to nil; the dev + // fallback path is always constructed with a .py extension. + let url = ResourceLocator.pythonScriptURL(named: "missing_script_name") + XCTAssertNil(url) } } @@ -121,13 +138,19 @@ final class ResourceLocatorPythonScriptTests: XCTestCase { final class ResourceLocatorBundleCandidatesTests: XCTestCase { func testBundleURLAccessible() { - _ = Bundle.main.bundleURL - XCTAssertTrue(true) + // bundleURL is always a non-empty file URL. + let bundleURL = Bundle.main.bundleURL + XCTAssertTrue(bundleURL.isFileURL) + XCTAssertFalse(bundleURL.path.isEmpty) } func testResourceURLAccessible() { - _ = Bundle.main.resourceURL - XCTAssertTrue(true) + // When present, resourceURL is a file URL. + if let resourceURL = Bundle.main.resourceURL { + XCTAssertTrue(resourceURL.isFileURL) + } else { + XCTAssertNil(Bundle.main.resourceURL) + } } func testAppendingPathComponent() { diff --git a/Tests/Utilities/WindowTitlesTests.swift b/Tests/Utilities/WindowTitlesTests.swift index 478f619..3ee2a42 100644 --- a/Tests/Utilities/WindowTitlesTests.swift +++ b/Tests/Utilities/WindowTitlesTests.swift @@ -51,12 +51,6 @@ final class ArchUtilityTests: XCTestCase { XCTAssertEqual(result1, result2) } - func testArchEnumExists() { - // Verify the Arch enum can be accessed - _ = Arch.self - XCTAssertTrue(true) - } - #if arch(arm64) func testIsAppleSiliconOnARM64() { // On ARM64, should return true diff --git a/Tests/Views/Components/InkRippleViewTests.swift b/Tests/Views/Components/InkRippleViewTests.swift index f7d734d..eebf510 100644 --- a/Tests/Views/Components/InkRippleViewTests.swift +++ b/Tests/Views/Components/InkRippleViewTests.swift @@ -18,8 +18,10 @@ final class InkRippleViewTests: XCTestCase { audioLevel: 0.5, isActive: true ) + // Evaluating body must not crash; the view retains its inputs. _ = view.body - XCTAssertTrue(true) + XCTAssertEqual(view.audioLevel, 0.5) + XCTAssertTrue(view.isActive) } func testInkRippleViewInactiveState() { @@ -65,8 +67,9 @@ final class InkRippleRecordingViewTests: XCTestCase { audioLevel: 0, onTap: {} ) + // Evaluating body must not crash; the view retains its inputs. _ = view.body - XCTAssertTrue(true) + XCTAssertEqual(view.audioLevel, 0) } func testRecordingViewWithRecordingStatus() { diff --git a/Tests/Views/Dashboard/DashboardTranscriptsViewTests.swift b/Tests/Views/Dashboard/DashboardTranscriptsViewTests.swift index e3d9ead..e851cf1 100644 --- a/Tests/Views/Dashboard/DashboardTranscriptsViewTests.swift +++ b/Tests/Views/Dashboard/DashboardTranscriptsViewTests.swift @@ -36,10 +36,21 @@ final class DataManagerContainerTests: XCTestCase { XCTAssertNotNil(dataManager) } - func testSharedModelContainerAccessDoesNotCrash() { - // Accessing the container should not crash even if history is disabled - _ = DataManager.shared.sharedModelContainer - XCTAssertTrue(true) + func testSharedModelContainerAccessIsConsistent() { + // The shared container accessor must be a stable, idempotent passthrough + // to the underlying container (it may be nil if not yet initialized). + let dataManager = DataManager.shared + let first = dataManager.sharedModelContainer + let second = dataManager.sharedModelContainer + switch (first, second) { + case (nil, nil): + break + case let (lhs?, rhs?): + XCTAssertTrue(lhs === rhs, + "Repeated container access must return the same instance") + default: + XCTFail("Container access must be consistent across calls") + } } func testIsHistoryEnabledProperty() { diff --git a/Tests/Views/Dashboard/MLXModelManagementViewTests.swift b/Tests/Views/Dashboard/MLXModelManagementViewTests.swift index fa03f77..e78d6ce 100644 --- a/Tests/Views/Dashboard/MLXModelManagementViewTests.swift +++ b/Tests/Views/Dashboard/MLXModelManagementViewTests.swift @@ -187,8 +187,16 @@ final class MLXRefreshStateTests: XCTestCase { func testRefreshModelListAsync() async { let manager = MLXModelManager.shared await manager.refreshModelList() - // Should complete without crash - XCTAssertTrue(true) + + // After a refresh, the model registry must be internally consistent: + // every downloaded model has a recorded size, and the cache size is + // non-negative. + XCTAssertEqual(manager.downloadedModels.count, manager.modelSizes.count) + XCTAssertGreaterThanOrEqual(manager.totalCacheSize, 0) + for model in manager.downloadedModels { + XCTAssertNotNil(manager.modelSizes[model], + "Downloaded model \(model) must have a recorded size") + } } } diff --git a/Tests/Views/Dashboard/UsageDashboardViewTests.swift b/Tests/Views/Dashboard/UsageDashboardViewTests.swift index ee65d5a..b26e7cb 100644 --- a/Tests/Views/Dashboard/UsageDashboardViewTests.swift +++ b/Tests/Views/Dashboard/UsageDashboardViewTests.swift @@ -61,8 +61,8 @@ final class UsageMetricsStoreViewTests: XCTestCase { func testResetMethod() { let store = UsageMetricsStore.shared store.reset() - // Should complete without crash - XCTAssertTrue(true) + // After reset, the snapshot must equal the empty snapshot. + XCTAssertEqual(store.snapshot, .empty) } } @@ -332,9 +332,11 @@ final class RebuildFromHistoryTests: XCTestCase { func testUsageMetricsStoreRebuild() { let store = UsageMetricsStore.shared - // Rebuild with empty records should not crash + // Rebuilding from no records yields a zeroed snapshot. store.rebuild(using: []) - XCTAssertTrue(true) + XCTAssertEqual(store.snapshot.totalWords, 0) + XCTAssertEqual(store.snapshot.totalSessions, 0) + XCTAssertEqual(store.snapshot.totalDuration, 0) } } @@ -350,6 +352,7 @@ final class SourceUsageStoreUsageTests: XCTestCase { func testSourceUsageStoreReset() { let store = SourceUsageStore.shared store.reset() - XCTAssertTrue(true) + // After reset, no ordered stats remain. + XCTAssertTrue(store.orderedStats.isEmpty) } } diff --git a/Tests/Views/Transcription/TranscriptionRecordRowTests.swift b/Tests/Views/Transcription/TranscriptionRecordRowTests.swift index 3d274c3..1a38629 100644 --- a/Tests/Views/Transcription/TranscriptionRecordRowTests.swift +++ b/Tests/Views/Transcription/TranscriptionRecordRowTests.swift @@ -123,10 +123,12 @@ final class TranscriptionRecordDisplayTests: XCTestCase { provider: .parakeet, duration: 0.0 ) - // Zero duration might return nil or a valid string - // Just verify it doesn't crash - _ = record.formattedDuration - XCTAssertTrue(true) + // A non-nil 0.0 duration is under a minute, so it is formatted as a + // seconds string (locale-formatted number followed by "s"). + let formatted = record.formattedDuration + XCTAssertNotNil(formatted) + XCTAssertTrue(formatted?.hasSuffix("s") ?? false, + "Sub-minute duration should be formatted in seconds, got \(formatted ?? "nil")") } func testTranscriptionProviderProperty() { diff --git a/Tests/Views/WelcomeViewTests.swift b/Tests/Views/WelcomeViewTests.swift index 21bb88a..088aa69 100644 --- a/Tests/Views/WelcomeViewTests.swift +++ b/Tests/Views/WelcomeViewTests.swift @@ -11,10 +11,11 @@ final class WelcomeViewTests: XCTestCase { XCTAssertNotNil(view) } - func testWelcomeViewBodyDoesNotCrash() { + func testWelcomeViewBodyProducesAView() { let view = WelcomeView() - _ = view.body - XCTAssertTrue(true) + // Evaluating body must not crash and must yield a concrete view value. + let body = view.body + XCTAssertFalse(String(describing: type(of: body)).isEmpty) } } diff --git a/Tests/Waveform/ParticleSystemTests.swift b/Tests/Waveform/ParticleSystemTests.swift index 8c1ba78..157649d 100644 --- a/Tests/Waveform/ParticleSystemTests.swift +++ b/Tests/Waveform/ParticleSystemTests.swift @@ -129,8 +129,10 @@ final class ParticleOverlayTests: XCTestCase { audioLevel: 0.7, isActive: true ) + // Evaluating body must not crash; the view retains its inputs. _ = view.body - XCTAssertTrue(true) + XCTAssertEqual(view.audioLevel, 0.7) + XCTAssertTrue(view.isActive) } func testParticleOverlayInactiveState() { diff --git a/Tests/WindowControllerTests.swift b/Tests/WindowControllerTests.swift index 3bd5f34..31e1dc0 100644 --- a/Tests/WindowControllerTests.swift +++ b/Tests/WindowControllerTests.swift @@ -32,12 +32,9 @@ final class WindowControllerTests: IsolatedXCTestCase { func testToggleRecordWindowBlockedDuringWelcome() { UserDefaults.standard.set(false, forKey: "hasCompletedWelcome") - - // Should not show window during welcome + + // During welcome, toggling the record window must be a safe no-op. XCTAssertNoThrow(windowController.toggleRecordWindow()) - - // Just verify no crash occurs - XCTAssertTrue(true) } func testToggleRecordWindowAllowedAfterWelcome() {