Skip to content

refactor(app): extract AppState ViewModel + BadgeKind.compute pure function#49

Merged
pasrom merged 4 commits intomainfrom
refactor/app-state-viewmodel
Mar 20, 2026
Merged

refactor(app): extract AppState ViewModel + BadgeKind.compute pure function#49
pasrom merged 4 commits intomainfrom
refactor/app-state-viewmodel

Conversation

@pasrom
Copy link
Copy Markdown
Owner

@pasrom pasrom commented Mar 20, 2026

Summary

  • Extracts all business state and badge logic out of the untestable @main App struct into AppState (@Observable @MainActor class) and BadgeKind.compute(...) (pure static function)
  • MeetingTranscriberApp shrinks from ~420 to ~150 lines (UI shell only: SwiftUI scenes, NSOpenPanel, NSWorkspace)
  • AppNotifying protocol keeps AppKit out of AppState — enables future CLI reuse
  • Expands AppStateTests from 6 to 40 tests covering the full public API

Changes

AppState.swift (new) — ViewModel owning watchLoop, pipelineQueue, updateChecker, settings, whisperKit + all business logic methods

MenuBarIcon.swift — adds BadgeKind.compute(watchLoopActive:watchLoopState:transcriberState:activeJobState:updateAvailable:) — pure function, testable without driving WatchLoop into states

MeetingTranscriberApp.swift — slimmed to UI shell

NotificationManager.swift — conforms to AppNotifying

AppStateTests.swift — 40 tests: isWatching, currentStateLabel, currentStatus, currentBadge, toggleWatching, startManualRecording, stopManualRecording, enqueueFiles, ensurePipelineQueue, makePipelineQueue, makeProtocolGenerator, configurePipelineCallbacks

BadgeKindComputeTests.swift (new) — 13 tests covering all branches of the pure compute function

TestHelpers.swift — adds makeSilentDetector(), makeTestWatchLoop(), RecordingNotifier

Docsarchitecture-macos.md, swift-architecture.md, CLAUDE.md updated

Test plan

  • swift test — all 36 suites pass
  • ./scripts/lint.sh — 0 violations
  • App läuft: ./scripts/run_app.sh — badge animiert, watching/recording funktioniert

pasrom added 4 commits March 20, 2026 08:02
…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.
@github-actions github-actions bot added the chore Maintenance or non-functional changes label Mar 20, 2026
@pasrom pasrom merged commit b661dbe into main Mar 20, 2026
10 checks passed
@pasrom pasrom deleted the refactor/app-state-viewmodel branch March 20, 2026 16:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

chore Maintenance or non-functional changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant