Files
LibreMediaConverter/app/src/test/java/org/libremediaconverter/convert/ConverterPickerSelectionTest.kt
T
JMR-devandClaude Opus 5 4aba3bbd2e Stop swallowing coroutine errors nobody asserted on
`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>
2026-08-24 23:10:04 -05:00

170 lines
7.0 KiB
Kotlin

package org.libremediaconverter.convert
import androidx.compose.ui.test.SemanticsNodeInteraction
import androidx.compose.ui.test.assertIsNotSelected
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.hasAnyAncestor
import androidx.compose.ui.test.hasTestTag
import androidx.compose.ui.test.hasText
import androidx.compose.ui.test.junit4.v2.createComposeRule
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.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.EnginePreference
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
/**
* Each picker lights the chip it was handed and reports the constant that was pressed.
*
* The defect this bites on is a picker that renders perfectly and answers wrongly. All three are
* the same dozen lines with a different enum substituted, so the failure mode is a copy-paste that
* survives review: an `onClick` that closes over the picker's `selected` parameter instead of the
* chip's own entry hands back one constant no matter which chip was tapped, and an inverted
* `entry == selected` lights every chip except the right one. Neither throws, neither changes the
* set of labels on screen, and a test that only asserted "the callback ran" would pass over both.
*
* Clicking every chip in turn and comparing the whole recorded list against `entries` is what makes
* the constant load-bearing rather than the click count -- a hardcoded `onSelect` fires the same
* number of times as a correct one. Selection is asserted over every chip for the same reason: the
* one that should be lit proves nothing on its own, because `!=` lights it too whenever the enum
* has exactly one entry, and lights all its siblings whenever it has more.
*
* Labels come from `OutputFormat.label` and `QualityTier.label`; [label], which the screen owns
* because `EnginePreference` carries no label of its own, supplies the third set. Retyping any of
* them here would turn a rename into a red test that named the wrong cause.
*
* Not covered, deliberately: the `"Output format"`, `"Quality"` and `"Engine"` headings, which are
* untagged `Text` calls with no enum behind them and no behaviour to bite on.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ConverterPickerSelectionTest {
@get:Rule
val composeRule = createComposeRule()
/**
* The chip carrying [label] inside the row tagged [rowTag].
*
* By ancestor rather than by direct child: how many semantics nodes Material 3 puts between a
* `FlowRow` and its chips is that library's business, and a matcher that assumed "one" would
* break on an upgrade that changed nothing this test is about.
*/
private fun chipIn(rowTag: String, label: String): SemanticsNodeInteraction =
composeRule.onNode(hasAnyAncestor(hasTestTag(rowTag)) and hasText(label))
private fun assertOnlySelected(rowTag: String, labels: List<String>, selected: String?) {
labels.forEach { label ->
val chip = chipIn(rowTag, label)
if (label == selected) chip.assertIsSelected() else chip.assertIsNotSelected()
}
}
@Test
fun `the format picker lights the selected format and no other`() {
composeRule.setContent { FormatPicker(OutputFormat.WEBM_VP9) {} }
assertOnlySelected(
rowTag = TestTags.Converter.FORMAT_CHIPS,
labels = OutputFormat.entries.map { it.label },
selected = OutputFormat.WEBM_VP9.label,
)
}
/** A spec no preset can express lights nothing, which is what the custom line stands in for. */
@Test
fun `the format picker lights nothing when the spec is custom`() {
composeRule.setContent { FormatPicker(null) {} }
assertOnlySelected(
rowTag = TestTags.Converter.FORMAT_CHIPS,
labels = OutputFormat.entries.map { it.label },
selected = null,
)
composeRule.onNodeWithText(CUSTOM_SPEC_NOTE).assertExists()
}
@Test
fun `a selected format hides the custom line`() {
composeRule.setContent { FormatPicker(OutputFormat.MP3) {} }
composeRule.onNodeWithText(CUSTOM_SPEC_NOTE).assertDoesNotExist()
}
@Test
fun `clicking a format chip reports that format`() {
val picked = mutableListOf<OutputFormat>()
composeRule.setContent { FormatPicker(null) { picked += it } }
OutputFormat.entries.forEach { chipIn(TestTags.Converter.FORMAT_CHIPS, it.label).performClick() }
assertEquals(OutputFormat.entries.toList(), picked)
}
@Test
fun `the quality picker lights the selected tier and no other`() {
composeRule.setContent { QualityPicker(QualityTier.BEST) {} }
assertOnlySelected(
rowTag = TestTags.Converter.QUALITY_CHIPS,
labels = QualityTier.entries.map { it.label },
selected = QualityTier.BEST.label,
)
}
/** The line under the chips describes what was chosen, not whichever tier was written first. */
@Test
fun `the quality picker explains the tier that is selected`() {
composeRule.setContent { QualityPicker(QualityTier.BEST) {} }
composeRule.onNodeWithText(QualityTier.BEST.description).assertExists()
composeRule.onNodeWithText(QualityTier.FAST.description).assertDoesNotExist()
}
@Test
fun `clicking a quality chip reports that tier`() {
val picked = mutableListOf<QualityTier>()
composeRule.setContent { QualityPicker(QualityTier.FAST) { picked += it } }
QualityTier.entries.forEach { chipIn(TestTags.Converter.QUALITY_CHIPS, it.label).performClick() }
assertEquals(QualityTier.entries.toList(), picked)
}
@Test
fun `the engine picker lights the selected preference and no other`() {
composeRule.setContent { EnginePicker(EnginePreference.FORCE_SOFTWARE) {} }
assertOnlySelected(
rowTag = TestTags.Converter.ENGINE_CHIPS,
labels = EnginePreference.entries.map { it.label() },
selected = EnginePreference.FORCE_SOFTWARE.label(),
)
}
@Test
fun `clicking an engine chip reports that preference`() {
val picked = mutableListOf<EnginePreference>()
composeRule.setContent { EnginePicker(EnginePreference.AUTO) { picked += it } }
EnginePreference.entries.forEach { chipIn(TestTags.Converter.ENGINE_CHIPS, it.label()).performClick() }
assertEquals(EnginePreference.entries.toList(), picked)
}
private companion object {
/**
* Copied byte for byte out of `ConverterScreen.kt` -- it holds a U+2014 em dash, which
* retyped as ASCII would match nothing and fail as "no node found" rather than as a reword.
*/
const val CUSTOM_SPEC_NOTE: String = "Custom — set below."
}
}