W3: the screen wiring, and a narrower hazard than the ticket claimed #165

Merged
JMR-dev merged 1 commits from test/screen-wiring into main 2026-08-29 16:13:35 +00:00
JMR-dev commented 2026-08-29 15:56:46 +00:00 (Migrated from github.com)

Closes #156. Last of wave 2 (#153).

The gap

The stateful outer composables hand the content composables a list of viewModel:: references. No test in the suite had ever seen that list — ConverterScreenContentTest, ConverterStateAffordancesTest and JoinScreenContentTest all build their own ConverterActions and drive the stateless inner.

The ticket's premise was half wrong, and checking beat assuming

#156 claims a transposition of any two of seventeen bindings survives the suite. Measured rather than trusted:

swap result
onVideoCodec ↔ onAudioCodec rejected — Inapplicable candidate(s): fun setAudioCodec(codec: AudioCodec)
onCancel ↔ onReset compiles

Every typed binding — container, both codecs, preset, suggestion, quality, engine preference — takes a distinct parameter type, so the compiler is already the test. Writing assertions against those transpositions would be theatre, and this PR says so rather than padding the file with them.

I got this wrong twice before getting it right: an early check reported the onCancel/onReset swap as rejected, from a grep-and-exit-code test that misread a stale build. Re-running it properly printed BUILD SUCCESSFUL with the swap in place.

What is actually at risk

The () -> Unit bindings, which are interchangeable to the compiler:

  • converter: onCancel, onReset
  • join: onJoin, onCancel, onReset — three of them

A Cancel button that discards the finished file, a Start-over that leaves it on screen, or a Join button that silently does nothing. Each is one wrong word, and each ships.

The seam

converterActions(viewModel, onPickInput, onConvert, onSave) and joinActions(viewModel, onPickInputs, onSave). The launcher-backed actions stay parameters — they need an ActivityResultLauncher, which is the part that genuinely needs a composition, so keeping them out means the rest needs none.

They are told apart by effect, not by a recording double: reset() sets state to Idle; cancel() with no active job leaves it alone, which SettingsEditsTest (#164) pins.

Mutations

mutation red
converter onCancel ↔ onReset 2 tests
join onJoin ↔ onCancel 2 tests
join onReset ↔ onCancel 2 tests
a typed binding dropped to {} 1 test
a launcher action rerouted at the ViewModel 1 test

The two-test symmetry is deliberate. One direction alone passes against a wiring with both actions bound to the same method — which is exactly what a copy-pasted line produces.

A note on the guard assertions

Three of these tests assert their own premise (the fixture needs a non-Idle state). That earned its place immediately: picks land through an injected dispatcher, and without ParkedPickDispatcher.runAll() all three sat on Idle. They failed loudly rather than passing quietly over a state that made the real assertion meaningless.

@UnstableApi on both builders per CLAUDE.md; lint caught their absence, as in W1.

525 → 537 tests, 88.0% → 88.9% line, 70.5% → 75.4% branch. Gate green: assembleDebug, testDebugUnitTest, compileDebugAndroidTestKotlin, ktlintCheck, detekt, lintDebug.

Closes #156. Last of wave 2 (#153). ## The gap The stateful outer composables hand the content composables a list of `viewModel::` references. **No test in the suite had ever seen that list** — `ConverterScreenContentTest`, `ConverterStateAffordancesTest` and `JoinScreenContentTest` all build their own `ConverterActions` and drive the stateless inner. ## The ticket's premise was half wrong, and checking beat assuming #156 claims a transposition of any two of seventeen bindings survives the suite. Measured rather than trusted: | swap | result | |---|---| | `onVideoCodec` ↔ `onAudioCodec` | **rejected** — `Inapplicable candidate(s): fun setAudioCodec(codec: AudioCodec)` | | `onCancel` ↔ `onReset` | **compiles** | Every typed binding — container, both codecs, preset, suggestion, quality, engine preference — takes a distinct parameter type, so **the compiler is already the test**. Writing assertions against those transpositions would be theatre, and this PR says so rather than padding the file with them. I got this wrong twice before getting it right: an early check reported the `onCancel`/`onReset` swap as *rejected*, from a grep-and-exit-code test that misread a stale build. Re-running it properly printed `BUILD SUCCESSFUL` with the swap in place. ## What is actually at risk The `() -> Unit` bindings, which are interchangeable to the compiler: - **converter**: `onCancel`, `onReset` - **join**: `onJoin`, `onCancel`, `onReset` — three of them A Cancel button that discards the finished file, a Start-over that leaves it on screen, or a Join button that silently does nothing. Each is one wrong word, and each ships. ## The seam `converterActions(viewModel, onPickInput, onConvert, onSave)` and `joinActions(viewModel, onPickInputs, onSave)`. The launcher-backed actions stay parameters — they need an `ActivityResultLauncher`, which is the part that genuinely needs a composition, so keeping them out means the rest needs none. They are told apart **by effect, not by a recording double**: `reset()` sets state to `Idle`; `cancel()` with no active job leaves it alone, which `SettingsEditsTest` (#164) pins. ## Mutations | mutation | red | |---|---| | converter `onCancel` ↔ `onReset` | 2 tests | | join `onJoin` ↔ `onCancel` | 2 tests | | join `onReset` ↔ `onCancel` | 2 tests | | a typed binding dropped to `{}` | 1 test | | a launcher action rerouted at the ViewModel | 1 test | **The two-test symmetry is deliberate.** One direction alone passes against a wiring with *both* actions bound to the same method — which is exactly what a copy-pasted line produces. ## A note on the guard assertions Three of these tests assert their own premise (`the fixture needs a non-Idle state`). That earned its place immediately: picks land through an injected dispatcher, and without `ParkedPickDispatcher.runAll()` all three sat on `Idle`. They failed loudly rather than passing quietly over a state that made the real assertion meaningless. `@UnstableApi` on both builders per `CLAUDE.md`; lint caught their absence, as in W1. **525 → 537 tests, 88.0% → 88.9% line, 70.5% → 75.4% branch.** Gate green: `assembleDebug`, `testDebugUnitTest`, `compileDebugAndroidTestKotlin`, `ktlintCheck`, `detekt`, `lintDebug`.
Sign in to join this conversation.