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.
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`.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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,ConverterStateAffordancesTestandJoinScreenContentTestall build their ownConverterActionsand 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:
onVideoCodec↔onAudioCodecInapplicable candidate(s): fun setAudioCodec(codec: AudioCodec)onCancel↔onResetEvery 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/onResetswap as rejected, from a grep-and-exit-code test that misread a stale build. Re-running it properly printedBUILD SUCCESSFULwith the swap in place.What is actually at risk
The
() -> Unitbindings, which are interchangeable to the compiler:onCancel,onResetonJoin,onCancel,onReset— three of themA 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)andjoinActions(viewModel, onPickInputs, onSave). The launcher-backed actions stay parameters — they need anActivityResultLauncher, 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 toIdle;cancel()with no active job leaves it alone, whichSettingsEditsTest(#164) pins.Mutations
onCancel↔onResetonJoin↔onCancelonReset↔onCancel{}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 withoutParkedPickDispatcher.runAll()all three sat onIdle. They failed loudly rather than passing quietly over a state that made the real assertion meaningless.@UnstableApion both builders perCLAUDE.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.