Files
LibreMediaConverter/app/src/test/java/org/libremediaconverter/convert/ReattachedCleanupTest.kt
T
JMR-devandClaude Opus 5 8bc2a337d0 Merge the staging-cleanup fix, and pin the seam the two changes share
The two changes meet at one line. `pendingStaged` is recorded in the `SUCCEEDED` branch of
each ViewModel's `observe()` collector, and reattachment reaches `Converted`/`Joined`
through that same collector rather than by building the state itself -- so a job picked up
from a previous process arrives with its cleanup handle already set, and "Start over" on it
deletes the staged file exactly as it does for a conversion run in this process. Nothing had
to be added for that; it falls out of routing reattachment through `observe()`.

Which is precisely why it needed a test. The claim is structural -- one assignment, in one
function, that both changes assume -- and the conflict here was `JoinViewModel`, where the
cleanup change edits a collector body that the reattachment change had moved out of `join()`
into a private `observe()`. Resolving that by putting the assignment back in `join()` would
compile, pass every test either branch brought, and silently leak a full-size file on the one
path both changes were written for. `ReattachedCleanupTest` fails if it lands anywhere else.

Verified the way the cleanup commit verified its own wiring: deleting `pendingStaged = staged`
from `ConversionViewModel.observe()` fails "start over on a reattached conversion deletes the
staged file" alongside the two `ConversionViewModelCleanupTest` cases that share the line.
Restored, all 183 JVM tests pass.

The reattachment path also gains JVM coverage it could not have had before this merge, since
Robolectric and the `ConversionDependencies` seams arrived with it: a ViewModel constructed
after a job has already finished, with nothing left that observed it, now demonstrably reaches
`Converted` with the display name recovered from the job's tags -- on a machine where no
instrumented test can run.

Conflict resolution: both sides kept in `JoinViewModel`, with the cleanup handle declared
alongside the other fields and the reattachment `init` after them. Nothing else conflicted;
`ConversionViewModel` merged clean because the reattachment change touches the `CANCELLED` and
`FAILED` branches while the cleanup change touches `SUCCEEDED`. No build file, manifest or
`OutputPublisher` line in this merge is mine -- they arrive from the cleanup commit verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-22 18:51:42 -05:00

135 lines
5.2 KiB
Kotlin

package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkManager
import androidx.work.workDataOf
import kotlinx.coroutines.Dispatchers
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
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.InputProbe
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
/**
* Where the two staging fixes meet: a result nobody started is still a result somebody owns.
*
* Reattachment hands the user back a job this ViewModel did not start, which means the
* ViewModel inherits the staged file along with it — and "Start over" on that file has to
* delete it exactly as it would for a conversion run in this process. Neither fix implies
* the other: cleanup only reaches a file whose handle was recorded, and reattachment only
* records one because it goes through the same `observe()` the normal success path does.
* That is a structural claim about one function, which is precisely the kind that a merge
* resolving the two changes into different places would quietly break. Hence a test rather
* than a comment.
*
* These run a real `WorkManager` and a real `OutputPublisher` against a real `cacheDir`, so
* what is asserted at the end is the filesystem.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class ReattachedCleanupTest {
private lateinit var app: Application
private lateinit var publisher: RecordingPublisher
private lateinit var workManager: WorkManager
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
publisher = RecordingPublisher(app)
ConversionDependencies.publisher = { publisher }
ConversionDependencies.probe = { _, _ -> InputProbe() }
staged = publisher.createStagingFile("holiday_converted.mp4").apply { writeBytes(ByteArray(4096)) }
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath))
workManager = WorkManager.getInstance(app)
}
@After
fun tearDown() {
ConversionDependencies.reset()
}
/**
* The defect end to end, on the JVM: work that finished with no ViewModel left to see it,
* and a ViewModel created afterwards that finds it anyway.
*/
@Test
fun `a conversion that finished before this ViewModel existed is picked up`() {
finishAConversionWithNobodyWatching()
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
val converted = awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
converted as ConversionState.Converted
assertEquals(staged.absolutePath, converted.staged.absolutePath)
// The name came back through the job's tags — the only channel WorkManager returns,
// since WorkInfo never carries the Data a request was enqueued with.
assertEquals("holiday.mp4", converted.input.displayName)
assertEquals(4_096L, converted.input.sizeBytes)
}
@Test
fun `start over on a reattached conversion deletes the staged file`() {
finishAConversionWithNobodyWatching()
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
assertTrue("the reattached result should still be on disk", staged.exists())
viewModel.reset()
assertEquals(ConversionState.Idle, viewModel.state.value)
assertEquals(
"a reattached result is still this ViewModel's to discard",
listOf(staged),
publisher.discarded,
)
assertFalse("Start over must not leave a full-size copy in cache", staged.exists())
}
@Test
fun `start over on a reattached join deletes the staged file`() {
workManager.enqueue(
ConcatWorker.request(
inputs = listOf(Uri.parse("content://test/one.mp4"), Uri.parse("content://test/two.mp4")),
totalBytes = 8_192L,
),
).result.get()
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
awaitState(viewModel.state, "Joined") { it is JoinState.Joined }
viewModel.reset()
assertEquals(listOf(staged), publisher.discarded)
assertFalse(staged.exists())
}
/**
* A job from a process that is gone: enqueued and finished before any ViewModel exists, so
* nothing observed it and nothing recorded its output.
*/
private fun finishAConversionWithNobodyWatching() {
workManager.enqueue(
ConversionWorker.request(
inputUri = Uri.parse("content://test/holiday.mp4"),
displayName = "holiday.mp4",
sizeBytes = 4_096L,
),
).result.get()
}
}