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>
135 lines
5.2 KiB
Kotlin
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()
|
|
}
|
|
}
|