Add a seam for forcing failure paths, and force thirteen of them

Error handling was the least-tested code in the app. It only runs when
something goes wrong, which is exactly what a healthy test run avoids, so the
branches a user meets on a bad day were the ones that had never executed. An
audit put it at 4 of 18 fallback branches actually forced by a test.

There was also no mechanism to do better: ConversionWorker constructed
Media3Engine and FFmpegEngine directly, so no test could make either of them
fail. Media3Engine and FFmpegEngine now implement HardwareTranscoder and
SoftwareTranscoder, and ConversionDependencies holds the factories. Workers
are built by WorkManager and the app deliberately carries no DI framework, so
a small settable holder is the least machinery that does the job.

The foreground-service timeout needed different treatment. Its trigger is the
six-hour-per-day budget expiring, which no test can reach, so the decision
moved out of the worker into FailureOutcome. The rule is now verified on the
JVM across every WorkInfo stop reason, including that only the timeout earns a
retry -- retrying a user cancellation would ignore the user, and retrying a
constraint failure would spin.

Thirteen branches are now forced, including both free-space prechecks, both
engines failing, a missing input, and the router bypassing hardware entirely
for MP3. The dynamic fallback is covered twice over: once by injection, and
once by HardwareFallbackTest driving it with genuinely undecodable 4:4:4
footage. Injection alone would prove the plumbing without proving the
condition ever arises in reality.

Five remain unforced and are listed in ConversionDependencies' documentation.
They need a SAF grant to be revoked mid-job, which is not something a test can
arrange.

Media3EngineTest now asserts against the muxed file rather than the engine's
own ExportResult, which is a stronger check anyway: a result object can report
success for a file that will not play.

35 instrumented tests on a Pixel 10 Pro XL and 66 unit tests, 0 failures.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-20 09:54:06 -05:00
co-authored by Claude Opus 5
parent 356c04d137
commit 83fb55515b
12 changed files with 474 additions and 44 deletions
@@ -0,0 +1,58 @@
package dev.jasonmross.mediaconverter.work
import androidx.work.WorkInfo
import org.junit.Assert.assertEquals
import org.junit.Test
/**
* The retry-versus-fail rule.
*
* Isolated from the worker precisely so it can be tested: the condition that triggers
* a retry in production is the foreground-service budget running out, six hours per
* twenty-four, which no test can reach. Extracting the decision means the rule is still
* verified even though its trigger cannot be reproduced.
*/
class FailureOutcomeTest {
@Test
fun `a foreground service timeout is a retry, not a failure`() {
// The work is still valid; there is simply no budget right now. Telling the
// user their conversion failed would be wrong.
assertEquals(
FailureOutcome.RETRY,
FailureOutcome.forStopReason(WorkInfo.STOP_REASON_FOREGROUND_SERVICE_TIMEOUT),
)
}
@Test
fun `an ordinary failure is reported as a failure`() {
assertEquals(
FailureOutcome.FAIL,
FailureOutcome.forStopReason(WorkInfo.STOP_REASON_NOT_STOPPED),
)
}
@Test
fun `every other stop reason fails rather than retrying forever`() {
// Retrying on, say, a user cancellation or a battery constraint would either
// ignore the user or spin. Only the timeout earns a retry.
val others = listOf(
WorkInfo.STOP_REASON_CANCELLED_BY_APP,
WorkInfo.STOP_REASON_USER,
WorkInfo.STOP_REASON_CONSTRAINT_BATTERY_NOT_LOW,
WorkInfo.STOP_REASON_CONSTRAINT_CHARGING,
WorkInfo.STOP_REASON_CONSTRAINT_CONNECTIVITY,
WorkInfo.STOP_REASON_CONSTRAINT_DEVICE_IDLE,
WorkInfo.STOP_REASON_CONSTRAINT_STORAGE_NOT_LOW,
WorkInfo.STOP_REASON_DEVICE_STATE,
WorkInfo.STOP_REASON_QUOTA,
WorkInfo.STOP_REASON_BACKGROUND_RESTRICTION,
WorkInfo.STOP_REASON_APP_STANDBY,
WorkInfo.STOP_REASON_TIMEOUT,
WorkInfo.STOP_REASON_UNKNOWN,
)
others.forEach {
assertEquals("stop reason $it should fail", FailureOutcome.FAIL, FailureOutcome.forStopReason(it))
}
}
}