Hold the Advanced panel's gate, and the error card outside it #71

Merged
JMR-dev merged 2 commits from test/r38-4-advanced-picker into main 2026-08-24 22:01:33 +00:00
JMR-dev commented 2026-08-24 21:06:43 +00:00 (Migrated from github.com)

Closes #60. Child 4 of 8 decomposing #52. Two new JVM test files, no production change — the diff is app/src/test only.

What was untested

AdvancedPicker is the one leaf on the converter screen that is not stateless: expanded is its own rememberSaveable, and the Container/Video/Audio chip rows live inside AnimatedVisibility(visible = expanded). ValidationError is invoked after that block, so an invalid spec explains itself and offers one-tap fixes while the section is collapsed. That is the only route out of an invalid spec for a user who never opened Advanced — and since the only way to reach an invalid spec is through Advanced, hiding the way out behind the same toggle looks locally sensible. Folding the two if blocks into one compiles, and until now nothing went red.

AdvancedPickerTest — 9 tests

  • the gate in both directions: collapsed → the panel and all three row tags absent; expanded → all present; collapsed again → absent (the exit transition outlives the click, so the second absence is waited for rather than asserted straight away)
  • the toggle's label swaps Advanced / Hide advanced
  • each of the four colliding chip labels clicked through its own row tag: Copy in the video row and Copy in the audio row reach different callbacks, and MP3 in the container row does not report as an audio codec
  • the error card, every assertion made with the toggle untouched, and asserting the panel is absent in the same test so a future expanded = true default cannot quietly satisfy it
  • a suggestion chip applies its own spec, clicked from the collapsed section — index 1 of 2, with the count pinned first so a shrunken list says so instead of producing an opaque matcher error
  • a valid spec renders no error card, open or closed
  • open and collapsed both survive StateRestorationTester.emulateSavedInstanceStateRestore()

The invalid specs come from ContainerCapabilities.validate(spec, probe) rather than a hand-built Validation.Invalid, so the messages and the suggestions are the real pairing — "This would produce an empty file — keep at least one track." and "WebM cannot hold H.264 video." are asserted as literals against what validate produced.

AdvancedPanelSavedStateTest — 2 tests, and a finding

This is the DestinationSaverTest split that #60 assigned to this child: StateRestorationTester saves into an in-memory map, so it discriminates rememberSaveable from remember and stops there.

Driving a real SaveableStateRegistry through the composition turned up something the ticket's framing did not expect. The saved representation is not the Boolean. rememberSaveable { mutableStateOf(false) } passes no stateSaver, so autoSaver saves the MutableState itself — the registry hands back an androidx.compose.runtime.ParcelableSnapshotMutableState. That survives a rotation only because mutableStateOf on Android returns a Parcelable implementation; the same call on a plain JVM does not. So the thing standing between an open panel and a rotation that closes it is a platform-specific detail of a factory function nothing in this repo names directly, and an in-memory map cannot tell the two apart. The test writes what the registry produced into a real Bundle, round-trips it through a real Parcel, and asserts the panel comes back open.

canBeSaved = { true } is passed to the registry on purpose: a predicate mirroring Bundle acceptance would be a hand-written copy of the thing under test, and a false from it drops the entry silently — the same symptom as the remember regression. The type is checked on the way out instead.

Mutations — both run, output verbatim

1. ValidationError call moved inside the AnimatedVisibility block (ConverterScreen.kt:365)

AdvancedPickerTest > a codec the container cannot hold explains itself while the section is collapsed FAILED
java.lang.AssertionError: Assert failed: The component with Text + InputText + EditableText contains 'WebM cannot hold H.264 video.' (ignoreCase: false) is not displayed!

AdvancedPickerTest > a suggestion chip applies its own spec without the section ever being opened FAILED
java.lang.AssertionError: Failed to assert the following: (Text + EditableText = [MP4 · Copy + Opus])
Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'converter.validationError.suggestion:1')

AdvancedPickerTest > an empty output explains itself while the section is collapsed FAILED
java.lang.AssertionError: Failed: assertExists.
Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'converter.validationError')

ConverterLeafTagsTest stays green under this mutation, as it should — it calls ValidationError directly rather than through the picker, so it cannot see where the call site sits.

2. rememberSaveable -> remember on expanded (ConverterScreen.kt:300) — this one reddens both classes:

AdvancedPickerTest > an open panel is still open after recreation FAILED
java.lang.AssertionError: Failed: assertExists.
Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'converter.advanced.panel')

AdvancedPanelSavedStateTest > the open panel survives a real Parcel, not just an in-memory map FAILED
java.lang.AssertionError: the panel should register exactly one saved value expected:<1> but was:<0>

AdvancedPanelSavedStateTest > the panel registers its open state with the registry, and nothing else FAILED
java.lang.AssertionError: expected:<1> but was:<0>

Both mutations were reverted and the gate re-run green afterwards.

Not covered, so it is a decision

  • The chips' selected state. Which chip reads as selected for a given spec is asserted nowhere here; the tests click chips and check the callback. A selection-rendering test wants the FilterChip semantics and belongs with the other pickers rather than split across two files.
  • describe in its own right. It is exercised through the suggestion chip labels only. Its NONE-drops-the-track branches have no direct test.
  • The Container.entries / VideoCodec.entries / AudioCodec.entries completeness — that every enum constant gets a chip. The row tags make it reachable; nothing asserts the counts.
  • Anything needing a ConversionViewModel or a ConversionState. That is #61 and #62.

Gate

./gradlew :app:assembleDebug :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:ktlintCheck :app:detekt :app:lintDebug --continue — BUILD SUCCESSFUL.

🤖 Generated with Claude Code

Closes #60. Child 4 of 8 decomposing #52. Two new JVM test files, **no production change** — the diff is `app/src/test` only. ## What was untested `AdvancedPicker` is the one leaf on the converter screen that is not stateless: `expanded` is its own `rememberSaveable`, and the Container/Video/Audio chip rows live inside `AnimatedVisibility(visible = expanded)`. `ValidationError` is invoked **after** that block, so an invalid spec explains itself and offers one-tap fixes *while the section is collapsed*. That is the only route out of an invalid spec for a user who never opened Advanced — and since the only way to reach an invalid spec is through Advanced, hiding the way out behind the same toggle looks locally sensible. Folding the two `if` blocks into one compiles, and until now nothing went red. ## `AdvancedPickerTest` — 9 tests - the gate in both directions: collapsed → the panel and all three row tags absent; expanded → all present; collapsed again → absent (the exit transition outlives the click, so the second absence is waited for rather than asserted straight away) - the toggle's label swaps `Advanced` / `Hide advanced` - each of the four colliding chip labels clicked through its own row tag: `Copy` in the video row and `Copy` in the audio row reach different callbacks, and `MP3` in the container row does not report as an audio codec - the error card, **every assertion made with the toggle untouched**, and asserting the panel is absent in the same test so a future `expanded = true` default cannot quietly satisfy it - a suggestion chip applies its own spec, clicked from the collapsed section — index 1 of 2, with the count pinned first so a shrunken list says so instead of producing an opaque matcher error - a valid spec renders no error card, open or closed - open and collapsed both survive `StateRestorationTester.emulateSavedInstanceStateRestore()` The invalid specs come from `ContainerCapabilities.validate(spec, probe)` rather than a hand-built `Validation.Invalid`, so the messages and the suggestions are the real pairing — `"This would produce an empty file — keep at least one track."` and `"WebM cannot hold H.264 video."` are asserted as literals against what `validate` produced. ## `AdvancedPanelSavedStateTest` — 2 tests, and a finding This is the `DestinationSaverTest` split that #60 assigned to this child: `StateRestorationTester` saves into an in-memory map, so it discriminates `rememberSaveable` from `remember` and stops there. Driving a real `SaveableStateRegistry` through the composition turned up something the ticket's framing did not expect. **The saved representation is not the `Boolean`.** `rememberSaveable { mutableStateOf(false) }` passes no `stateSaver`, so `autoSaver` saves the `MutableState` *itself* — the registry hands back an `androidx.compose.runtime.ParcelableSnapshotMutableState`. That survives a rotation only because `mutableStateOf` on Android returns a `Parcelable` implementation; the same call on a plain JVM does not. So the thing standing between an open panel and a rotation that closes it is a platform-specific detail of a factory function nothing in this repo names directly, and an in-memory map cannot tell the two apart. The test writes what the registry produced into a real `Bundle`, round-trips it through a real `Parcel`, and asserts the panel comes back open. `canBeSaved = { true }` is passed to the registry on purpose: a predicate mirroring `Bundle` acceptance would be a hand-written copy of the thing under test, and a `false` from it *drops* the entry silently — the same symptom as the `remember` regression. The type is checked on the way out instead. ## Mutations — both run, output verbatim **1. `ValidationError` call moved inside the `AnimatedVisibility` block** (`ConverterScreen.kt:365`) ``` AdvancedPickerTest > a codec the container cannot hold explains itself while the section is collapsed FAILED java.lang.AssertionError: Assert failed: The component with Text + InputText + EditableText contains 'WebM cannot hold H.264 video.' (ignoreCase: false) is not displayed! AdvancedPickerTest > a suggestion chip applies its own spec without the section ever being opened FAILED java.lang.AssertionError: Failed to assert the following: (Text + EditableText = [MP4 · Copy + Opus]) Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'converter.validationError.suggestion:1') AdvancedPickerTest > an empty output explains itself while the section is collapsed FAILED java.lang.AssertionError: Failed: assertExists. Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'converter.validationError') ``` `ConverterLeafTagsTest` stays green under this mutation, as it should — it calls `ValidationError` directly rather than through the picker, so it cannot see where the call site sits. **2. `rememberSaveable` -> `remember` on `expanded`** (`ConverterScreen.kt:300`) — this one reddens **both** classes: ``` AdvancedPickerTest > an open panel is still open after recreation FAILED java.lang.AssertionError: Failed: assertExists. Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'converter.advanced.panel') AdvancedPanelSavedStateTest > the open panel survives a real Parcel, not just an in-memory map FAILED java.lang.AssertionError: the panel should register exactly one saved value expected:<1> but was:<0> AdvancedPanelSavedStateTest > the panel registers its open state with the registry, and nothing else FAILED java.lang.AssertionError: expected:<1> but was:<0> ``` Both mutations were reverted and the gate re-run green afterwards. ## Not covered, so it is a decision - **The chips' `selected` state.** Which chip reads as selected for a given `spec` is asserted nowhere here; the tests click chips and check the callback. A selection-rendering test wants the `FilterChip` semantics and belongs with the other pickers rather than split across two files. - **`describe` in its own right.** It is exercised through the suggestion chip labels only. Its `NONE`-drops-the-track branches have no direct test. - **The `Container.entries` / `VideoCodec.entries` / `AudioCodec.entries` completeness** — that every enum constant gets a chip. The row tags make it reachable; nothing asserts the counts. - Anything needing a `ConversionViewModel` or a `ConversionState`. That is #61 and #62. ## Gate `./gradlew :app:assembleDebug :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:ktlintCheck :app:detekt :app:lintDebug --continue` — BUILD SUCCESSFUL. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.