`drainEscapedCoroutineErrors()` cleared the collector at rule-construction time
with `runCatching { runTest {} }`, and discarding what it found was the whole
mechanism: it could not tell the one known deposit from an escaped error nobody
had asserted on. That traded a loud, misleading failure for a silent one, which
was acceptable only while exactly one depositor existed and the seam to remove it
did not.
The seam exists now, so the depositor is gone: the OOM is consumed by the test
that raises it. Every Compose class takes the v2 `createComposeRule()` directly,
and a future escaped error fails a test again instead of disappearing.
The two findings the drain's KDoc carried that outlive it: the v2 rule and the
non-v2 `StateRestorationTester` do interoperate -- the note now sits at the two
declarations that pair them -- and a drain could never have been a `@Before`
(the rule's `runTest` wraps it) or a `@BeforeClass` (Robolectric runs that
outside the sandbox classloader, where the collector is a different object).
Full JVM suite run twice in a row with the drain deleted: 373 tests, 0 failures
both times.
Closes #66
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
324 lines
13 KiB
Kotlin
324 lines
13 KiB
Kotlin
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.junit4.v2.createComposeRule
|
|
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.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 {
|
|
|
|
// The rule is the **v2** one (`androidx.compose.ui.test.junit4.v2`) while
|
|
// [StateRestorationTester], which takes it below, is not. The mismatched imports are
|
|
// deliberate: the v2 package has no tester of its own and the two do interoperate.
|
|
@get:Rule
|
|
val composeRule = createComposeRule()
|
|
|
|
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,
|
|
)
|
|
}
|
|
}
|