Write automated tests for the fully local macOS dictation app in this repo. Treat the acceptance criteria below as the spec. Use XCTest through `swift test` for everything in `FlowCore`, with fakes for `AudioSource`, `Transcriber`, `Cleaner`, `TextInserter`, `FrontmostApp`, and `Clock`. The real Parakeet model and the real cleaners are only touched by tests that skip when they're missing. Add a small XCUITest target for the flows that need a real app, and gate those behind `FLOW_INTEGRATION=1` since they need Accessibility and Microphone permissions that CI can't grant.

## Acceptance criteria to cover

1. First launch with no permissions shows the onboarding window, and the app registers a status item with `NSApp.activationPolicy() == .accessory` (integration).
2. `ModelChecksum.compute(folder:)` over `fixtures/fake_model/` (three small files) returns the value stored in its `manifest.json`, is stable across two runs, and changes when one byte of one file changes. `ModelImporter.load(from:)` on a copy of that folder writes a manifest with `source = "folder"` and the same checksum.
3. A full pipeline run with a fake transcriber that emits two interims and one final, a fake cleaner, and a fake inserter ends in `idle`, calls `insert` once with the cleaned text plus a trailing space, and writes one `dictations` row with `insert_method = ax` and `cleanup_backend` equal to the fake cleaner's backend.
4. Overlay panel: `styleMask` contains `.nonactivatingPanel`, `canBecomeKey` is false, `level == .floating`, `hidesOnDeactivate` is false (unit test on the panel subclass). Interim events from the fake transcriber reach the overlay view model in order.
5. `ParakeetTranscriber` with `modelPath` pointing at a downloaded model (skip with `XCTSkip` if the folder is missing) turns `fixtures/hello_world.wav` into a transcript containing `hello world`, and `fixtures/silence.wav` into an empty string. Compare the `hello_world` result against `fixtures/parakeet/hello_world.json` and print both if they differ. `SilenceTrimmer` on a buffer of 1 s silence, 2 s tone, 1 s silence returns a span between 1.8 s and 2.4 s long. `GainNormalizer` on a buffer peaking at 0.1 returns a peak within 0.01 of -3 dBFS and never amplifies a buffer peaking at 0.001 by more than 20 dB.
6. Prompt fixture: raw `um so send it uh Tuesday no Wednesday` through `RulesCleaner` produces exactly `So send it Wednesday.` The same input through `OllamaCleaner` against a stubbed `URLProtocol` that returns that string produces it too, and the request body has `temperature: 0`, `stream: false`, and a system message that starts with `You clean up dictated speech`. A `<think>…</think>` block in the stubbed response is stripped.
7. With dictionary entry `Wolchonok` / `wall chunk`: the built prompt contains `Wolchonok (sounds like: wall chunk)`, and `RulesCleaner` turns `call wall chunk back` into `Call Wolchonok back.` With an empty dictionary the prompt has no `DICTIONARY` line.
8. `PromptBuilder` with a `FrontmostApp` fake returning `com.tinyspeck.slackmacgap` yields `TONE: casual`; `com.apple.mail` yields `TONE: formal`; an unknown bundle id falls back to the `*` row; `com.apple.Notes` yields `FORMAT: markdown`, and `RulesCleaner` with that context turns `three things first milk second eggs third bread` into three `- ` lines.
9. Cleaner failures: a cleaner that throws, a cleaner that never returns (advance the manual clock past 4 seconds), and a cleaner that returns a 3-word response for a 20-word input each result in the raw text being inserted and `error` set to `cleanup_error`, `cleanup_timeout`, or `cleanup_guard`. A cleaner that throws `CleanerError.unavailable` causes exactly one call to `RulesCleaner` and a row with `cleanup_backend = rules`. The suite must not actually wait 4 seconds.
10. Network isolation: install a `URLProtocol` that fails every request, run the full pipeline with the real `RulesCleaner` and the real `ParakeetTranscriber` (skip if the model is missing), and assert the protocol was never hit. Separately, a test reads every file under `Sources/` and fails if any file other than `ModelDownloader.swift` and `OllamaCleaner.swift` contains `URLSession`, `NWConnection`, `CFStream`, or `import Network`.
11. `OllamaCleaner` refuses a base URL of `http://example.com:11434` and `http://10.0.0.5:11434` with `CleanerError.notLoopback`, and accepts `http://127.0.0.1:11434`, `http://localhost:11434`, and `http://[::1]:11434`. The availability check against a stubbed `/api/tags` that lists only `gemma3:4b` reports `ollamaModel = qwen3:4b` as missing with a hint that names `qwen3:4b`.
12. Clipboard restore: put a string and a PNG on `NSPasteboard.general`, run the paste path against a fake key poster, and assert both items are back within 500 ms (integration, since it touches the real pasteboard).
13. Inserter with a fake AX layer reporting no focused element returns `.clipboardOnly`, posts no Cmd+V, and leaves the text on the pasteboard.
14. Escape during `listening` moves the pipeline to `idle`, calls `stop()` on the audio source, calls `insert` zero times, and writes a row with `insert_method = cancelled` and null text columns. A final transcript of `"   "` results in no `insert` call and no history row. `HistoryStore`: insert three rows, the view model returns `raw_text` when `historyShowsRaw` is true and `cleaned_text` otherwise, `search("wednesday")` matches on either column, and `delete(id)` removes exactly one row.
15. Snippets: trigger `my calendar link` expands inside `send them my calendar link please` case-insensitively and doesn't expand `my calendar links`.
16. Hold mode accidental tap: key down then key up 200 ms later (manual clock) cancels with no insert and no history row. Key up at 300 ms proceeds normally.
17. A second hotkey press while the pipeline is in `cleaning` is ignored: the state doesn't change and no second audio stream starts.
18. Interim scheduling: with a fake clock and a fake model that takes 1,500 ms per pass, ticks at 0, 1,000, 2,000, and 3,000 ms produce exactly two inference calls (the 1,000 ms tick is skipped).
19. Window sizes: in `FlowTests`, open each window through the same code path the app uses and assert `contentRect` is within 2 pt of the table in the build prompt (Onboarding 560×480, History 820×560, Dictionary 520×440, Settings 560×520, overlay 420×44 idle), that `minSize` matches, that the frame is inside `NSScreen.main!.visibleFrame`, and that the clipped-view count from the debug store is 0. Also run `Flow --check-windows` as an integration step and assert exit code 0.

## Layout

- `Tests/FlowCoreTests/PipelineTests.swift`: 3, 9, 14, 16, 17, 18.
- `Tests/FlowCoreTests/PromptTests.swift`: 6, 7, 8.
- `Tests/FlowCoreTests/RulesCleanerTests.swift`: the rules halves of 6, 7, 8.
- `Tests/FlowCoreTests/OllamaCleanerTests.swift`: 6 (stubbed), 11.
- `Tests/FlowCoreTests/AudioTests.swift`: the trimmer and normalizer halves of 5.
- `Tests/FlowCoreTests/ModelTests.swift`: 2.
- `Tests/FlowCoreTests/ParakeetTests.swift`: 5. Skips without a model.
- `Tests/FlowCoreTests/NetworkIsolationTests.swift`: 10.
- `Tests/FlowCoreTests/HistoryStoreTests.swift`: 14, using an in-memory GRDB database.
- `Tests/FlowCoreTests/SnippetTests.swift`: 15.
- `Tests/FlowTests/` (needs AppKit): 4, 13.
- `Tests/FlowUITests/`: 1, 12. Gated on `FLOW_INTEGRATION=1`.

## Rules

- Name every test after its criterion: `func test_AC9_cleanupTimeoutFallsBackToRaw()`.
- Inject time. The pipeline takes the `Clock` protocol; tests use a manual clock so timeouts, the 250 ms tap threshold, and the 1 s interim tick fire instantly.
- Never open a real socket in tests. Every Ollama call goes through a `URLProtocol` stub. There is no cloud provider to mock, and a test that reaches for one is a bug in the test.
- Fakes live in `Tests/FlowCoreTests/Fakes/`, one file per protocol (`FakeTranscriber`, `FakeCleaner`, `FakeInserter`, and the rest), and record every call so tests can assert on call counts and arguments. `FakeCleaner` takes a `backend` so criterion 3 can check what gets written to the row.
- Add `make test` and a GitHub Actions workflow on `macos-15` that runs `swift build` and `swift test`. The model tests skip there because there's no model; the network isolation source scan runs everywhere. The UI tests don't run in CI; document how to run them locally.
- Run the suite. Fix the app where the app is wrong and the test where the test is wrong. Report per-criterion pass, fail, or skipped, and what changed.
