Files
LibreMediaConverter/app/src/test/java/org/libremediaconverter/convert/ConversionViewModelProbeFailureTest.kt
T
JMR-devandClaude Opus 5 dbba213c51 Give the pick a dispatcher, so an escaped error fails the test that caused it
`onInputPicked` hops to a hard-coded `Dispatchers.IO` inside a `launch` with no
exception handler -- deliberate, because a real OutOfMemoryError should reach the
thread's default handler and take the process down. On the JVM there is no such
handler: kotlinx-coroutines-test installs a process-wide collector, once per
classloader and never removed, which keeps the error and rethrows it at whichever
`runTest` starts next. Every Compose rule is a `runTest`, so the OOM raised by
`ConversionViewModelProbeFailureTest` failed some *other* Compose class, and which
one moved between runs of identical, green code.

Naming the dispatcher gives the throw somewhere to land. With the pick inline
inside a `runTest`, the collector's callback belongs to the test that caused the
error, so it is handed over and consumed rather than stored for a stranger.

Both hops of a pick rather than only the probe, which is where this differs from
the seam issue #66 sketched: leaving the metadata query on a real IO thread makes
the coroutine resume on a main looper Robolectric leaves paused, and that bounce
is exactly the asynchrony that made delivery unpredictable.

That buys the assertion the test could not make before -- the real OutOfMemoryError
instance, not an inference from a card that never filled in, which is also what a
probe returning null looks like. Reverting the hop to `Dispatchers.IO` turns it
red: "expected java.lang.OutOfMemoryError to be thrown, but nothing was thrown".

Refs #66

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 23:07:38 -05:00

154 lines
7.1 KiB
Kotlin

package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
/**
* That a probe which throws leaves a screen the user can act on, not a dead coroutine.
*
* [MediaProbeNativeLoadTest] covers the boundary itself. This covers the other half of the
* same defect: `onInputPicked` runs inside `viewModelScope.launch`, so anything the probe
* throws and does not handle leaves the launch with no result at all — the file card never
* fills in, and on a device the default handler takes the process down.
*
* The seam is what makes that testable. Injecting a prober that throws reproduces the
* condition exactly, without depending on which types FFmpegKit happens to throw today.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ConversionViewModelProbeFailureTest {
private lateinit var app: Application
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to "/dev/null"))
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
/**
* The exact observed failure: FFmpegKit's loader rethrows a bare [Error] whose cause is
* the `UnsatisfiedLinkError` `System.loadLibrary` raised.
*/
@Test
fun `a native load failure during the probe reports an unreadable file`() {
ConversionDependencies.probe = { _, _ ->
throw Error(
"FFmpegKit failed to start on brand: robolectric.",
UnsatisfiedLinkError("dlopen failed: library \"libffmpegkit.so\" not found"),
)
}
val probe = pickedProbe()
assertNotNull("the pick must finish; a thrown Error used to abandon the launch", probe)
assertEquals(InputKind.UNPARSEABLE, probe?.kind)
assertEquals(InputProbe.UNPARSEABLE, probe?.videoCodec)
}
/** Every touch after the first throws this instead, so the guard has to cover it too. */
@Test
fun `a NoClassDefFoundError from a poisoned class reports an unreadable file`() {
ConversionDependencies.probe = { _, _ ->
throw NoClassDefFoundError("Could not initialize class com.arthenica.ffmpegkit.FFmpegKitConfig")
}
assertEquals(InputKind.UNPARSEABLE, pickedProbe()?.kind)
}
/**
* The line the guard must not cross.
*
* `MediaProbe` spawns a native process, so an [OutOfMemoryError] raised in it is a real
* one about this JVM, not a report about the file. Swallowing it would turn "the device
* is out of memory" into "this video looks unreadable" and let the app carry on in a
* state it cannot honour — which is the regression a blanket `catch (Throwable)` would
* have introduced, and the reason this defect was left open rather than fixed carelessly.
*
* **The error itself is what is asserted here, and that is what the `pickDispatcher` seam
* bought.** With the hop hard-coded to `Dispatchers.IO` this was impossible: the throw
* happened on a pool thread some time after this method had returned, so all a test could do
* was infer it from a card that never filled in — which is also what a probe returning null
* would look like. Worse, the escaped error went into kotlinx-coroutines-test's process-wide
* collector and was rethrown at whichever `runTest` started next, which is a *different*
* Compose class between runs of identical code. Putting the pick on [Dispatchers.Unconfined]
* runs it inline, inside a `runTest` whose scope owns the collector's callback: the error is
* handed to this test and consumed, rather than stored for a stranger.
*
* Note where it surfaces — at the end of `runTest`, not inside `onInputPicked`. `launch`
* gives an escaped error to the handler chain and never to its caller, so nothing can catch
* it at the call itself. This is as close as the coroutine machinery allows, and unlike the
* old assertion it is the real [OutOfMemoryError] instance.
*/
@Test
fun `an OutOfMemoryError is not swallowed`() {
ConversionDependencies.probe = { _, _ -> throw OutOfMemoryError("Failed to allocate 512 MB") }
// Unconfined for the pick, so the whole of onInputPicked runs inline on this thread and
// has thrown before runTest can leave the scope that has to receive the error.
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined, Dispatchers.Unconfined)
val escaped = assertThrows(OutOfMemoryError::class.java) { runTest { viewModel.onInputPicked(INPUT) } }
assertEquals("Failed to allocate 512 MB", escaped.message)
// The other half of the contract, unchanged: an OOM is about the process, so the card is
// left as it was rather than filled in with a verdict the app would then act on.
val settled = viewModel.state.value
// `sizeBytes = null`, not `0L`: no provider is registered for this authority, so the
// metadata query returns nothing and the descriptor cannot be opened either. That is the
// unknown, and it stopped being spelled the same way as "empty" -- see [InputQuery].
assertEquals(ConversionState.Ready(InputFile(INPUT, "input", sizeBytes = null)), settled)
assertNull("an OOM must not be reported as a probe result", (settled as ConversionState.Ready).input.probe)
}
/** A working probe is untouched by any of this. */
@Test
fun `a probe that succeeds still fills the card in`() {
ConversionDependencies.probe = { _, _ -> InputProbe(videoCodec = "h264", kind = InputKind.VIDEO) }
assertEquals("h264", pickedProbe()?.videoCodec)
}
/**
* Drives a real pick and returns the probe the card ended up with.
*
* Asserting on the probe rather than merely on `Ready` is deliberate: `onInputPicked`
* sets `Ready` *before* it probes, so a test that only checked the state would have
* passed against the unguarded code.
*/
private fun pickedProbe(): InputProbe? {
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(INPUT)
val ready = awaitState(viewModel.state, "Ready with a probe") {
it is ConversionState.Ready && it.input.probe != null
}
assertNull("nothing here should reach a terminal failure", (ready as? ConversionState.Failed))
return (ready as ConversionState.Ready).input.probe
}
private companion object {
val INPUT: Uri = Uri.parse("content://test/holiday.mp4")
}
}