Compare commits

..
Author SHA1 Message Date
JMR-dev 4ea5afefe1 Merge branch 'main' into test/r38-4-advanced-picker 2026-08-24 16:54:10 -05:00
JMR-devandClaude Opus 5 7c69d0699a Hold the Advanced panel's gate, and the error card outside it
`AdvancedPicker` is the one leaf on the converter screen that carries its own
state, and `ValidationError` is deliberately invoked after the
`AnimatedVisibility` that gates the chip rows -- 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, it was
completely untested, and folding the two `if` blocks into one is a plausible
tidy-up that compiles.

`AdvancedPickerTest` covers the gate in both directions, clicks each of the
four colliding chip labels through its own row tag, and does every assertion
about the error card with the toggle untouched.

`AdvancedPanelSavedStateTest` is the `DestinationSaverTest` split for
`expanded`: `StateRestorationTester` saves into an in-memory map, so it proves
`rememberSaveable` is in use and nothing about the representation. Driving a
real `SaveableStateRegistry` shows the picker saves the `MutableState` itself
rather than the `Boolean`, which only survives a rotation because
`mutableStateOf` on Android returns a `Parcelable` one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 16:06:00 -05:00
2 changed files with 480 additions and 0 deletions
@@ -0,0 +1,159 @@
package org.libremediaconverter.convert
import android.os.Bundle
import android.os.Parcel
import android.os.Parcelable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.saveable.LocalSaveableStateRegistry
import androidx.compose.runtime.saveable.SaveableStateRegistry
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.media3.common.util.UnstableApi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.createDrainedComposeRule
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
/**
* What `AdvancedPickerTest`'s restoration test cannot see.
*
* `StateRestorationTester` saves into an **in-memory map**, never a `Bundle`. That is enough to
* discriminate `rememberSaveable` from `remember`, and it is where it stops: the map holds object
* references, so a value the platform could never parcel goes in and comes back out looking green.
* `AppRootRestorationTest` has the same blind spot and `DestinationSaverTest` is the split it
* prompted; this is that split for `expanded`, the only `rememberSaveable` on either screen's
* leaves.
*
* ### The saved representation is not the Boolean
*
* `var expanded by rememberSaveable { mutableStateOf(false) }` passes no `stateSaver`, so
* `autoSaver` saves **the `MutableState` itself**, not the `false` inside it. That works only
* because `mutableStateOf` on Android returns a `Parcelable` implementation -- the same call on a
* plain JVM returns one that is not. So what stands between an open panel and a rotation that
* closes it is a platform-specific detail of a factory function nothing here names directly, and
* an in-memory map cannot tell the two apart.
*
* Pinning it is the move `DestinationSaverTest` makes about names versus ordinals. Passing an
* explicit `stateSaver` would save a bare `Boolean` instead and is a perfectly reasonable edit --
* it is just not the one in the tree, and it should be made on purpose rather than discovered
* after a rotation.
*
* ### Shared bite, stated rather than implied
*
* `rememberSaveable` -> `remember` empties the registry, so it reddens this file *and* the
* restoration test in `AdvancedPickerTest`. Both failures belong in any report of that mutation.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class AdvancedPanelSavedStateTest {
// Not `createComposeRule()` directly: see [drainEscapedCoroutineErrors].
@get:Rule
val composeRule = createDrainedComposeRule()
/**
* `canBeSaved = { true }` deliberately.
*
* A predicate mirroring what a `Bundle` accepts would be a hand-written copy of the thing
* under test, and a `false` from it *drops* the entry silently -- so the test would fail by
* finding nothing saved, which is also how a `remember` regression fails. Two causes, one
* symptom, is not a test. The type is checked on the way out instead.
*/
private val registry = SaveableStateRegistry(restoredValues = null, canBeSaved = { true })
@Test
fun `the panel registers its open state with the registry, and nothing else`() {
setPicker()
// Collapsed is a saved value, not an absent one: `rememberSaveable` registers its provider
// on first composition, whatever the state happens to be. Exactly one, because `expanded`
// is the only saveable in the subtree -- a second would mean something else began saving.
assertEquals(1, savedValues().size)
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
val saved = theOneSavedValue()
assertTrue("saved as ${saved?.javaClass?.name}", saved is MutableState<*>)
assertEquals(true, (saved as MutableState<*>).value)
}
@Test
fun `the open panel survives a real Parcel, not just an in-memory map`() {
setPicker()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
val saved = theOneSavedValue()
// The claim the restoration test cannot make. A `MutableState` that was not `Parcelable`
// would satisfy `StateRestorationTester` and then be dropped by the platform.
assertTrue("saved as ${saved?.javaClass?.name}", saved is Parcelable)
val restored = throughARealBundle(saved as Parcelable)
assertTrue("restored as ${restored.javaClass.name}", restored is MutableState<*>)
assertEquals(true, (restored as MutableState<*>).value)
}
private fun setPicker() {
composeRule.setContent {
CompositionLocalProvider(LocalSaveableStateRegistry provides registry) {
AdvancedPicker(
spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC),
validation = Validation.Valid,
onContainer = {},
onVideoCodec = {},
onAudioCodec = {},
onSuggestion = {},
)
}
}
}
/** Every value the picker hands the host to persist, keys dropped -- they are positional. */
private fun savedValues(): List<Any?> = composeRule.runOnIdle { registry.performSave().values.flatten() }
/**
* The single saved value, asserted rather than assumed.
*
* `single()` on an empty list throws `NoSuchElementException: List is empty`, which names
* neither the panel nor the registry -- and an empty registry is exactly how the
* `rememberSaveable` -> `remember` regression shows up here.
*/
private fun theOneSavedValue(): Any? {
val values = savedValues()
assertEquals("the panel should register exactly one saved value", 1, values.size)
return values.first()
}
/** A write and a read through a real `Parcel`, which is what the tester's map stands in for. */
private fun throughARealBundle(value: Parcelable): Parcelable {
val bundle = Bundle().apply { putParcelable(KEY, value) }
val parcel = Parcel.obtain()
return try {
parcel.writeBundle(bundle)
parcel.setDataPosition(0)
val restored = requireNotNull(parcel.readBundle(javaClass.classLoader)) {
"the Bundle did not survive the Parcel"
}
requireNotNull(restored.getParcelable(KEY, Parcelable::class.java)) {
"the saved state did not survive the Parcel"
}
} finally {
parcel.recycle()
}
}
private companion object {
const val KEY = "expanded"
}
}
@@ -0,0 +1,321 @@
package org.libremediaconverter.convert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.StateRestorationTester
import androidx.compose.ui.test.onAllNodesWithTag
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.media3.common.util.UnstableApi
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.createDrainedComposeRule
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.ContainerCapabilities
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputSpec
import org.libremediaconverter.model.Validation
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
/**
* The gate over the Advanced chips, and the error card that deliberately sits outside it.
*
* Two defects, and they pull in opposite directions.
*
* The first is the chips escaping the gate, or never being reachable through it. `AdvancedPicker`
* is the one leaf on this screen that is not stateless -- `expanded` is its own `rememberSaveable`
* -- and Container, Video and Audio live inside `AnimatedVisibility(visible = expanded)`. Nothing
* else on the screen hides anything, so a refactor that flattened the panel, or wired the toggle to
* a state nobody reads, would render an app that looks reasonable in a screenshot and is wrong.
*
* The second is the opposite mistake, and it is the one this file exists for: **moving the
* `ValidationError` call inside the `AnimatedVisibility`**. It 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 and is a trap. Tidying the two `if` blocks into one is a plausible edit, it
* compiles, and until this file existed nothing went red. Every assertion about the error card here
* therefore runs with the toggle untouched, and asserts the panel is absent in the same test, so a
* future `expanded = true` default cannot quietly satisfy it either.
*
* The invalid specs come from [ContainerCapabilities.validate] rather than from a hand-built
* [Validation.Invalid], so the messages and the suggestions are the real pairing. A hand-built one
* would keep passing after `validate` stopped producing anything like it.
*
* Node location is by the three separate chip-row tags, never by text. `"Copy"` and `"None"` are
* each both a [VideoCodec] and an [AudioCodec], and `"MP3"` and `"FLAC"` are each both a
* [Container] and an [AudioCodec], so a text matcher over the open panel is ambiguous for four
* chips -- which is what the separate tags are for.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class AdvancedPickerTest {
// Not `createComposeRule()` directly: see [drainEscapedCoroutineErrors].
@get:Rule
val composeRule = createDrainedComposeRule()
private val restoration = StateRestorationTester(composeRule)
private val containers = mutableListOf<Container>()
private val videoCodecs = mutableListOf<VideoCodec>()
private val audioCodecs = mutableListOf<AudioCodec>()
private val applied = mutableListOf<OutputSpec>()
// --- the expand gate ----------------------------------------------------
@Test
fun `the three chip rows appear only while the panel is expanded`() {
setPicker()
assertPanelHidden()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertExists()
ROW_TAGS.forEach { composeRule.onNodeWithTag(it).assertExists() }
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
// The exit transition outlives the click, so absence has to be waited for rather than
// asserted straight away -- unlike the initial collapsed state, which has no animation
// in flight.
composeRule.waitUntil { nodeCount(TestTags.Converter.ADVANCED_PANEL) == 0 }
assertPanelHidden()
}
/** The toggle is the only affordance the collapsed picker offers, so it has to say so. */
@Test
fun `the toggle names the direction it will move in`() {
setPicker()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).assertTextEquals("Advanced")
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE)
.assertTextEquals("Hide advanced")
}
/**
* The four colliding labels, one per row.
*
* `"Copy"` is a video codec *and* an audio codec; `"MP3"` is a container *and* an audio codec.
* Clicking each through its own row is what proves the rows are wired to different callbacks
* -- a picker that handed every chip to `onAudioCodec` would look identical on screen.
*/
@Test
fun `each chip row reports to its own callback, including the labels that collide`() {
setPicker()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
chipIn(TestTags.Converter.ADVANCED_VIDEO_CHIPS, "Copy").performClick()
assertEquals(listOf(VideoCodec.COPY), videoCodecs)
assertEquals(emptyList<AudioCodec>(), audioCodecs)
chipIn(TestTags.Converter.ADVANCED_AUDIO_CHIPS, "Copy").performClick()
assertEquals(listOf(AudioCodec.COPY), audioCodecs)
chipIn(TestTags.Converter.ADVANCED_CONTAINER_CHIPS, "MP3").performClick()
assertEquals(listOf(Container.MP3), containers)
// Still only the one audio click. `MP3` is an AudioCodec label too, and the container row
// must not be reporting through that callback.
assertEquals(listOf(AudioCodec.COPY), audioCodecs)
}
// --- the error card, which is outside the gate --------------------------
/**
* The headline case. Dropping both tracks is reachable from the collapsed screen -- the
* `None`/`None` pair is set inside Advanced, but the user can close it again -- and the
* explanation has to still be there.
*/
@Test
fun `an empty output explains itself while the section is collapsed`() {
val spec = OutputSpec(Container.MP4, VideoCodec.NONE, AudioCodec.NONE)
val invalid = invalidFor(spec)
assertEquals("This would produce an empty file — keep at least one track.", invalid.message)
setPicker(spec, invalid)
assertPanelHidden()
composeRule.onNodeWithTag(TestTags.Converter.VALIDATION_ERROR).assertExists()
composeRule.onNodeWithText(invalid.message).assertIsDisplayed()
}
@Test
fun `a codec the container cannot hold explains itself while the section is collapsed`() {
val spec = OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.OPUS)
val invalid = invalidFor(spec)
assertEquals("WebM cannot hold H.264 video.", invalid.message)
setPicker(spec, invalid)
assertPanelHidden()
composeRule.onNodeWithText(invalid.message).assertIsDisplayed()
}
/**
* Clicking a suggestion, with the toggle never touched.
*
* The second suggestion rather than the first, and its count pinned first: with one suggestion
* a picker that handed every chip `suggestions[0]` would pass, and `onNodeWithTag` on a
* suggestion index that no longer exists reports an unhelpful matcher failure rather than
* saying the list shrank.
*/
@Test
fun `a suggestion chip applies its own spec without the section ever being opened`() {
val spec = OutputSpec(Container.WEBM, VideoCodec.H264, AudioCodec.OPUS)
val invalid = invalidFor(spec)
assertEquals(2, invalid.suggestions.size)
val second = invalid.suggestions[1]
setPicker(spec, invalid)
assertPanelHidden()
composeRule.onNodeWithTag(TestTags.Converter.suggestion(1)).assertTextEquals(describe(second))
composeRule.onNodeWithTag(TestTags.Converter.suggestion(1)).performClick()
assertEquals(listOf(second), applied)
// What the chips offer is what `validate` said would work, not a repair of the test's own.
assertTrue(
"suggestion $second should itself validate",
ContainerCapabilities.validate(second, PROBE).isValid,
)
}
/** A valid spec has nothing to say, collapsed or not. */
@Test
fun `a valid spec renders no error card`() {
setPicker()
composeRule.onNodeWithTag(TestTags.Converter.VALIDATION_ERROR).assertDoesNotExist()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
composeRule.onNodeWithTag(TestTags.Converter.VALIDATION_ERROR).assertDoesNotExist()
}
// --- recreation ---------------------------------------------------------
/**
* `expanded` is the only `rememberSaveable` on either screen's leaves.
*
* `MainActivity` declares no `configChanges`, so a rotation destroys and rebuilds the whole
* composition. A panel the user opened, set three chips in, and left open must not close
* itself on the way back. `remember` would.
*
* What this cannot see is the saved *representation* -- `StateRestorationTester` saves into an
* in-memory map rather than a `Bundle`. `AdvancedPanelSavedStateTest` covers that half.
*/
@Test
fun `an open panel is still open after recreation`() {
restoration.setContent {
AdvancedPicker(
spec = VALID_SPEC,
validation = Validation.Valid,
onContainer = {},
onVideoCodec = {},
onAudioCodec = {},
onSuggestion = {},
)
}
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE).performClick()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertExists()
restoration.emulateSavedInstanceStateRestore()
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertExists()
ROW_TAGS.forEach { composeRule.onNodeWithTag(it).assertExists() }
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_TOGGLE)
.assertTextEquals("Hide advanced")
}
/** The default has to survive too, or the panel would spring open on every rotation. */
@Test
fun `a collapsed panel is still collapsed after recreation`() {
restoration.setContent {
AdvancedPicker(
spec = VALID_SPEC,
validation = Validation.Valid,
onContainer = {},
onVideoCodec = {},
onAudioCodec = {},
onSuggestion = {},
)
}
assertPanelHidden()
restoration.emulateSavedInstanceStateRestore()
assertPanelHidden()
}
// --- helpers ------------------------------------------------------------
private fun setPicker(spec: OutputSpec = VALID_SPEC, validation: Validation = Validation.Valid) {
composeRule.setContent {
AdvancedPicker(
spec = spec,
validation = validation,
onContainer = { containers += it },
onVideoCodec = { videoCodecs += it },
onAudioCodec = { audioCodecs += it },
onSuggestion = { applied += it },
)
}
}
/** The whole panel, by every tag it owns, so a partial escape counts as a failure. */
private fun assertPanelHidden() {
composeRule.onNodeWithTag(TestTags.Converter.ADVANCED_PANEL).assertDoesNotExist()
ROW_TAGS.forEach { composeRule.onNodeWithTag(it).assertDoesNotExist() }
}
private fun nodeCount(tag: String) = composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().size
private fun chipIn(rowTag: String, label: String) =
composeRule.onNode(hasText(label) and hasAnyAncestor(hasTestTag(rowTag)))
private fun invalidFor(spec: OutputSpec): Validation.Invalid {
val validation = ContainerCapabilities.validate(spec, PROBE)
return validation as? Validation.Invalid
?: throw AssertionError("$spec was expected to be invalid, but validate said $validation")
}
private companion object {
val ROW_TAGS = listOf(
TestTags.Converter.ADVANCED_CONTAINER_CHIPS,
TestTags.Converter.ADVANCED_VIDEO_CHIPS,
TestTags.Converter.ADVANCED_AUDIO_CHIPS,
)
val VALID_SPEC = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC)
/** An ordinary H.264/AAC MP4, so the suggestions have a real source to repair towards. */
val PROBE = InputProbe(
videoCodec = "h264",
audioCodec = "aac",
durationMs = 90_000,
container = Container.MP4,
)
}
}