`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>
159 lines
6.8 KiB
Kotlin
159 lines
6.8 KiB
Kotlin
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.junit4.v2.createComposeRule
|
|
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.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 {
|
|
|
|
@get:Rule
|
|
val composeRule = createComposeRule()
|
|
|
|
/**
|
|
* `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"
|
|
}
|
|
}
|