Files
LibreMediaConverter/app/src/androidTest/java/org/libremediaconverter/convert/ReattachOnLaunchTest.kt
T
JMR-devandClaude Opus 5 6992f0e783 Reattach to a conversion that is still running (#230)
Reattachment.rank gives RUNNING the highest rank of all -- "live work
outranks a finished result because a running job is holding a foreground
service" -- and no test on either source set had ever produced one.
ReattachOnLaunchTest covers a job that finished, one whose staged file is
gone, an ambiguous pair, one still queued, and one the user cancelled.
ReattachmentTest exercises the ranking as a pure function over fabricated
snapshots. What was missing is a ViewModel meeting a real running job,
which is also the likeliest reattachment there is: the user starts a
conversion, leaves, and comes back while it is still going.

The engine is a fake, deliberately. The job has to still be running when
the ViewModel is built, and every real conversion in this suite finishes in
about a second -- racing that is what made the cancellation tests flaky
enough to need retries. A SoftwareTranscoder that blocks until released
removes the race outright. Nothing about reattachment depends on which
engine is transcoding: the tag query, Reattachment.choose over live
WorkManager state, and observe's mapping to Converting all run identically
whatever is doing the work.

This is what #230 can actually deliver, and the ticket asked for the answer
either way. Process death itself stays device-manual. D3/D13 already record
that am kill refuses a process holding a foreground service, and there is a
more basic obstacle underneath it: instrumentation runs in the app's own
process, so any route that really killed it would take the test runner with
it and leave nothing to assert with. Observing a relaunch needs two
instrumentation runs, which the runner does not provide. So the closest
observable analogue is a fresh ViewModel, with no memory of the work,
meeting a job that is genuinely mid-flight.

The teardown now resets ConversionDependencies. The suite runs without
Android Test Orchestrator, so a BlockingTranscoder left in place would hang
the next class that converts anything.

Verified on a local API 34 emulator: 67 tests, 0 failures, 3 skipped; and
making RUNNING unreattachable in Reattachment.rank fails this test and
nothing else -- which is also the evidence that the JVM ranking test was
not already covering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-06 01:58:24 -05:00

428 lines
18 KiB
Kotlin

package org.libremediaconverter.convert
import android.app.Application
import android.content.Context
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.work.Data
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.Worker
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinState
import org.libremediaconverter.join.JoinViewModel
import org.libremediaconverter.model.ConcatStrategy
import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.Engine
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.model.QualityTier
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.libremediaconverter.work.JobTags
import java.io.File
import java.util.UUID
import java.util.concurrent.TimeUnit
/**
* Stands in for a worker whose job is already over.
*
* Reattachment is defined entirely by what WorkManager can hand back — the worker class name
* as a tag, the tags the request carried, and the output `Data` — so a job with that shape is
* all the ViewModel needs to see. Producing one by running a real transcode would take minutes
* and would test the engines, which have their own suites. This echoes its input as its result,
* which lets a test state any finished job in a line.
*/
class EchoWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
override fun doWork(): Result = Result.success(inputData)
}
/**
* The defect: a conversion that outlives the process becomes unreachable.
*
* WorkManager's queue survives process death — that is why the app uses it — but the ViewModel
* held its job id in a plain field, so the next launch started at Idle while the finished output
* sat in `cacheDir` with no route to it from the UI. The realistic window is after the transcode
* finishes and before the user taps Save: the foreground service is gone and the process is an
* ordinary background one that may be reclaimed hours before the user comes back.
*
* A ViewModel constructed here *is* that next launch: it is a fresh instance with no memory of
* the work, exactly as after `am kill`. The real [WorkManager] is used rather than
* `WorkManagerTestInitHelper`, whose `setDelegate` replaces the singleton for the whole process
* and would silently turn `ConversionWorkerTest` — which exists to exercise the real WorkManager
* path, foreground service included — into a synchronous test double, depending on class order.
*/
@UnstableApi
/**
* A [SoftwareTranscoder] that holds the worker in [WorkInfo.State.RUNNING] until released.
*
* Declared here rather than in `FakeFailures` because it is the only test that needs a job to stay
* live on demand, and the shape is specific to that: the others fake a *failure*, this fakes
* *duration*.
*/
private class BlockingTranscoder(private val released: CompletableDeferred<Unit>) : SoftwareTranscoder {
override suspend fun run(
request: ConversionRequest,
inputPath: String,
output: File,
durationMs: Long,
onProgress: (Int) -> Unit,
) {
released.await()
output.writeBytes(ByteArray(1_024))
}
}
@RunWith(AndroidJUnit4::class)
class ReattachOnLaunchTest {
private val app = InstrumentationRegistry.getInstrumentation()
.targetContext.applicationContext as Application
private val workManager = WorkManager.getInstance(app)
@Before
fun clearTheQueue() = emptyQueueAndStaging()
@After
fun leaveNothingBehind() {
// The suite runs without Android Test Orchestrator, so every class shares one process and
// a swapped seam outlives the class that set it. Only one test here swaps one, but a
// BlockingTranscoder left in place would hang the next class that converts anything.
ConversionDependencies.reset()
emptyQueueAndStaging()
}
/**
* The claim the whole fix rests on, checked against the production request builder rather
* than assumed: `WorkRequest.Builder` seeds every request's tags with its worker class name,
* so the app's own work is findable with nothing persisted anywhere.
*/
@Test
fun aConversionRequestIsFindableByItsWorkerClassName() {
val request = ConversionWorker.request(
// A file that does not exist, so the job fails within seconds instead of transcoding.
// What is under test is the request's tags, which are written when it is enqueued.
inputUri = Uri.fromFile(File(app.cacheDir, "no_such_input.mp4")),
displayName = "holiday.mp4",
sizeBytes = 4_096L,
)
workManager.enqueue(request).result.get()
workManager.cancelWorkById(request.id).result.get()
val info = awaitFinished(request.id)
assertTrue(
"no worker class name in ${info.tags}",
info.tags.contains(ConversionWorker::class.java.name),
)
assertEquals("holiday.mp4", JobTags.displayNameOf(info.tags))
assertEquals(4_096L, JobTags.sizeBytesOf(info.tags))
}
@Test
fun reattachesToAConversionThatFinishedWhileTheViewModelWasGone() {
val staged = stage("holiday_converted.mp4")
finishedJob(
tags = listOf(
ConversionWorker::class.java.name,
JobTags.displayName("holiday.mp4"),
JobTags.sizeBytes(4_096L),
),
output = workDataOf(
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConversionWorker.KEY_ENGINE_USED to Engine.FFMPEG.name,
ConversionWorker.KEY_ROUTE_REASON to "test route",
),
)
val converted = awaitConversion<ConversionState.Converted>()
assertEquals(staged.absolutePath, converted.staged.absolutePath)
assertEquals("holiday.mp4", converted.input.displayName)
assertEquals(4_096L, converted.input.sizeBytes)
assertEquals(Engine.FFMPEG.name, converted.engineUsed)
}
/**
* The Save button has to be reachable *and* mean something. A staged file the OS reclaimed
* out of the cache — or one a previous save already published and deleted — would otherwise
* be offered and fail on tap.
*/
@Test
fun ignoresAFinishedConversionWhoseStagedFileIsGone() {
val missing = File(File(app.cacheDir, "conversions"), "vanished_converted.mp4")
missing.delete()
finishedJob(
tags = listOf(ConversionWorker::class.java.name, JobTags.displayName("vanished.mp4")),
output = workDataOf(ConversionWorker.KEY_OUTPUT_PATH to missing.absolutePath),
)
assertStaysIdle(conversionViewModel())
}
/**
* The shape a device actually produced: two SUCCEEDED jobs reporting the same output path,
* with one file on disk, because the staging name is derived from the input's display name.
* The file has to stay reachable — losing it is the defect — while the card must not claim
* an input that may belong to the other job.
*/
@Test
fun offersAFileTwoJobsClaimWithoutAttributingItToEither() {
val staged = stage("input_converted.mp4")
val output = workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath)
finishedJob(
tags = listOf(ConversionWorker::class.java.name, JobTags.displayName("input.mp4")),
output = output,
)
finishedJob(
tags = listOf(ConversionWorker::class.java.name, JobTags.displayName("input.mkv")),
output = output,
)
val converted = awaitConversion<ConversionState.Converted>()
assertEquals(staged.absolutePath, converted.staged.absolutePath)
assertTrue(
"attributed an aliased file to one of the jobs: ${converted.input.displayName}",
converted.input.displayName !in setOf("input.mp4", "input.mkv"),
)
}
@Test
fun reattachesToAConversionStillWaitingInTheQueue() {
queuedJob(
tags = listOf(
ConversionWorker::class.java.name,
JobTags.displayName("queued.mp4"),
JobTags.sizeBytes(2_048L),
),
)
val converting = awaitConversion<ConversionState.Converting>()
assertEquals("queued.mp4", converting.input.displayName)
assertEquals(2_048L, converting.input.sizeBytes)
}
/**
* The pair with the test above: same job, same tags, and the only difference is that the
* user cancelled it. Reattaching to it would undo their decision.
*/
@Test
fun doesNotResurrectAConversionTheUserCancelled() {
val id = queuedJob(
tags = listOf(ConversionWorker::class.java.name, JobTags.displayName("queued.mp4")),
)
workManager.cancelWorkById(id).result.get()
assertEquals(WorkInfo.State.CANCELLED, awaitFinished(id).state)
assertStaysIdle(conversionViewModel())
}
/** A pick the user has already made owns the screen; a job found afterwards must not take it. */
@Test
fun doesNotOverwriteAPickTheUserHasAlreadyMade() {
val staged = stage("holiday_converted.mp4")
finishedJob(
tags = listOf(ConversionWorker::class.java.name, JobTags.displayName("holiday.mp4")),
output = workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath),
)
val picked = Uri.fromFile(stage("picked.mp4"))
val viewModel = conversionViewModel()
onMainThread { viewModel.onInputPicked(picked) }
runBlocking {
val ready = withTimeout(TIMEOUT_MS) {
viewModel.state.first { it is ConversionState.Ready }
} as ConversionState.Ready
assertEquals(picked, ready.input.uri)
// And it stays the user's pick rather than being replaced a moment later.
val stolen = withTimeoutOrNull(SETTLE_MS) {
viewModel.state.first { it !is ConversionState.Ready }
}
assertNull("reattachment took the screen from the user: $stolen", stolen)
}
}
@Test
fun reattachesToAJoinThatFinishedWhileTheViewModelWasGone() {
val staged = stage("joined.mp4")
finishedJob(
tags = listOf(ConcatWorker::class.java.name, JobTags.inputCount(3)),
output = workDataOf(
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConcatWorker.KEY_STRATEGY to ConcatStrategy.STREAM_COPY.name,
),
)
val viewModel = joinViewModel()
val joined = runBlocking {
withTimeout(TIMEOUT_MS) { viewModel.state.first { it is JoinState.Joined } }
} as JoinState.Joined
assertEquals(staged.absolutePath, joined.staged.absolutePath)
assertEquals(ConcatStrategy.STREAM_COPY, joined.strategy)
}
// --- staging the situation --------------------------------------------------------------
/** Enqueues a job that runs immediately and finishes with [output] as its result. */
private fun finishedJob(tags: List<String>, output: Data): UUID {
val builder = OneTimeWorkRequestBuilder<EchoWorker>().setInputData(output)
tags.forEach(builder::addTag)
val request = builder.build()
workManager.enqueue(request).result.get()
assertEquals(WorkInfo.State.SUCCEEDED, awaitFinished(request.id).state)
return request.id
}
/**
* Reattaching to a conversion that is **running right now**, which nothing had ever driven.
*
* This class covers a job that finished, one whose staged file is gone, an ambiguous pair, one
* still queued, and one the user cancelled. [Reattachment.rank] gives
* [WorkInfo.State.RUNNING] the **highest** rank of all — "live work outranks a finished result
* because a running job is holding a foreground service" — and no test on either source set
* ever produced one. `ReattachmentTest` exercises the ranking as a pure function over
* fabricated snapshots; what was missing is a ViewModel meeting a real running job.
*
* It is also the likeliest reattachment there is: the user starts a conversion, leaves, and
* comes back while it is still going.
*
* ## Why the engine is a fake here, and why that is not a weakening
*
* The job has to still be running when the ViewModel is built, and every real conversion in
* this suite finishes in about a second — racing that is what made the cancellation tests flaky
* enough to need retries (#224). A [SoftwareTranscoder] that blocks until released removes the
* race outright: the job is `RUNNING` for exactly as long as the test wants.
*
* Nothing about reattachment depends on which engine is transcoding. What is under test is the
* tag query, [Reattachment.choose] over live WorkManager state, and `observe` mapping it to
* [ConversionState.Converting] — all of which run identically whatever is doing the work.
*
* ## What this does not do, and cannot (#230)
*
* It does not kill the process. `docs/defect-audit.md` D3/D13 record that `am kill` refuses a
* process holding a foreground service, and there is a more basic obstacle: **instrumentation
* runs in the app's own process**, so any route that really killed it would take the test
* runner with it and there would be nothing left to assert with. A relaunch-and-observe test
* needs two instrumentation runs, which the runner does not provide.
*
* So process death stays device-manual, and this is the closest observable analogue: a fresh
* ViewModel, with no memory of the work, meeting a job that is genuinely mid-flight.
*/
@Test
fun reattachesToAConversionThatIsStillRunning(): Unit = runBlocking {
val released = CompletableDeferred<Unit>()
ConversionDependencies.software = { BlockingTranscoder(released) }
val request = ConversionWorker.request(
inputUri = Uri.fromFile(stage("running_input.mp3")),
displayName = RUNNING_NAME,
sizeBytes = RUNNING_SIZE,
spec = OutputFormat.MP3.spec,
quality = QualityTier.FAST,
)
workManager.enqueue(request).result.get()
// Deterministic: the worker cannot finish until this test lets it.
withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(request.id).first { it?.state == WorkInfo.State.RUNNING }
}
val reattached = awaitConversion<ConversionState.Converting>()
assertEquals(RUNNING_NAME, reattached.input.displayName)
assertEquals(RUNNING_SIZE, reattached.input.sizeBytes)
released.complete(Unit)
workManager.cancelWorkById(request.id).result.get()
}
/**
* Enqueues a job that stays [WorkInfo.State.ENQUEUED]. The delay is what holds it there: it
* is long enough that nothing can run it during a test, and it is cancelled either way.
*/
private fun queuedJob(tags: List<String>): UUID {
val builder = OneTimeWorkRequestBuilder<EchoWorker>()
.setInitialDelay(1, TimeUnit.HOURS)
tags.forEach(builder::addTag)
val request = builder.build()
workManager.enqueue(request).result.get()
return request.id
}
private fun stage(name: String): File {
val dir = File(app.cacheDir, "conversions").apply { mkdirs() }
return File(dir, name).apply { writeBytes(ByteArray(1_024)) }
}
private fun emptyQueueAndStaging() {
workManager.cancelAllWorkByTag(ConversionWorker::class.java.name).result.get()
workManager.cancelAllWorkByTag(ConcatWorker::class.java.name).result.get()
workManager.cancelAllWorkByTag(EchoWorker::class.java.name).result.get()
workManager.pruneWork().result.get()
File(app.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
}
// --- reading the result -----------------------------------------------------------------
/** A ViewModel built now is the next launch: no memory of the work, only what it can query. */
private fun conversionViewModel(): ConversionViewModel = onMainThread { ConversionViewModel(app) }
private fun joinViewModel(): JoinViewModel = onMainThread { JoinViewModel(app) }
private inline fun <reified T : ConversionState> awaitConversion(): T = runBlocking {
val viewModel = conversionViewModel()
withTimeout(TIMEOUT_MS) { viewModel.state.first { it is T } } as T
}
private fun assertStaysIdle(viewModel: ConversionViewModel) = runBlocking {
val moved = withTimeoutOrNull(SETTLE_MS) {
viewModel.state.first { it !is ConversionState.Idle }
}
assertNull("reattached to work it should have left alone: $moved", moved)
}
private fun awaitFinished(id: UUID): WorkInfo = runBlocking {
withTimeout(TIMEOUT_MS) {
workManager.getWorkInfoByIdFlow(id).first { it != null && it.state.isFinished }
}!!
}
private fun <T : Any> onMainThread(block: () -> T): T {
lateinit var result: T
InstrumentationRegistry.getInstrumentation().runOnMainSync { result = block() }
return result
}
private companion object {
const val TIMEOUT_MS = 30_000L
/**
* How long "nothing happened" is given to happen. Reattachment is one indexed query
* against WorkManager's database, so this is generous rather than tuned.
*/
const val SETTLE_MS = 5_000L
/** Read back off the job's tags by the reattaching ViewModel, so both have to survive. */
const val RUNNING_NAME = "still_running.mp3"
const val RUNNING_SIZE = 4_242L
}
}