refactor(app): extract AppState ViewModel + BadgeKind.compute pure function#49
Merged
refactor(app): extract AppState ViewModel + BadgeKind.compute pure function#49
Conversation
…function What: Move all business state and badge logic out of the @main App struct into testable units — AppState (@observable @mainactor class) and BadgeKind.compute (static pure function). Reasoning: - Problem: currentBadge and isWatching were private computed properties on MeetingTranscriberApp, which cannot be instantiated in unit tests. - Considered: Testing via UI tests (slow, brittle), making properties internal (leaks struct state, still can't test directly). - Decision: Two-layer fix — AppState ViewModel owns all business state, and BadgeKind.compute takes plain value inputs so any combination can be tested directly without driving WatchLoop into states. AppNotifying protocol keeps AppKit out of AppState, enabling future CLI reuse. SilentNotifier is the default; RecordingNotifier is available for tests. MeetingTranscriberApp shrinks from ~420 to ~150 lines (UI shell only).
What: Full coverage of AppState public API — all methods and derived properties. New tests cover: - isWatching (3): nil, active, manual recording - currentStateLabel (3): idle, watching, recording - currentStatus (6): nil, active, state/detail/meeting fields, manual recording info - currentBadge integration (2): recording, updateAvailable - toggleWatching stop-path (2): stops loop, no-op during manual recording - toggleWatching start-path (2, async): creates loop, loop is active - stopManualRecording (2): clears watchLoop, no-op when nil - enqueueFiles (6): single/multiple URLs, title extraction, appName, empty, nil paths - ensurePipelineQueue (2): replaces bare queue, idempotent - makePipelineQueue (4): whiskerKit, factories, outputDir all set - makeProtocolGenerator (2+1): OpenAI path, Claude CLI path (#if !APPSTORE) - configurePipelineCallbacks (3): done/error notifications, transcribing is silent Infrastructure added to TestHelpers.swift: - makeSilentDetector() — MeetingDetector with no patterns, never fires - makeTestWatchLoop() — WatchLoop with MockRecorder, no real audio needed - RecordingNotifier moved here from AppStateTests (shared across test files) Async tests use waitFor() bounded-loop pattern instead of Task.sleep. AVCaptureDevice.requestAccess is a real suspension point that requires multiple yields — single Task.yield() was insufficient.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
@mainApp struct intoAppState(@Observable @MainActor class) andBadgeKind.compute(...)(pure static function)MeetingTranscriberAppshrinks from ~420 to ~150 lines (UI shell only: SwiftUI scenes, NSOpenPanel, NSWorkspace)AppNotifyingprotocol keeps AppKit out ofAppState— enables future CLI reuseAppStateTestsfrom 6 to 40 tests covering the full public APIChanges
AppState.swift(new) — ViewModel owningwatchLoop,pipelineQueue,updateChecker,settings,whisperKit+ all business logic methodsMenuBarIcon.swift— addsBadgeKind.compute(watchLoopActive:watchLoopState:transcriberState:activeJobState:updateAvailable:)— pure function, testable without driving WatchLoop into statesMeetingTranscriberApp.swift— slimmed to UI shellNotificationManager.swift— conforms toAppNotifyingAppStateTests.swift— 40 tests: isWatching, currentStateLabel, currentStatus, currentBadge, toggleWatching, startManualRecording, stopManualRecording, enqueueFiles, ensurePipelineQueue, makePipelineQueue, makeProtocolGenerator, configurePipelineCallbacksBadgeKindComputeTests.swift(new) — 13 tests covering all branches of the pure compute functionTestHelpers.swift— addsmakeSilentDetector(),makeTestWatchLoop(),RecordingNotifierDocs —
architecture-macos.md,swift-architecture.md,CLAUDE.mdupdatedTest plan
swift test— all 36 suites pass./scripts/lint.sh— 0 violations./scripts/run_app.sh— badge animiert, watching/recording funktioniert