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>
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
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 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.model.InputProbe
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* That `reset()` actually deletes — the wiring, not the tool.
|
||||
*
|
||||
* D2 was never that `OutputPublisher` could not delete a file. It was that "Start over"
|
||||
* dropped the reference without calling anything. So this drives the real ViewModel through
|
||||
* a real `WorkManager` to `Converted` and then asserts on the filesystem.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ConversionViewModelCleanupTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: RecordingPublisher
|
||||
private lateinit var staged: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = RecordingPublisher(app)
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
// MediaProbe spawns FFprobe, whose loader throws a bare java.lang.Error with no
|
||||
// native library present. Without this the pick dies before the test starts.
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
|
||||
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
installTestWorkManager(app, workDataOf(ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start over on a finished conversion deletes the staged file`() {
|
||||
val viewModel = convertedViewModel()
|
||||
assertTrue("the conversion should have produced a staged file", staged.exists())
|
||||
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(ConversionState.Idle, viewModel.state.value)
|
||||
assertEquals("reset() should have discarded exactly the staged file", listOf(staged), publisher.discarded)
|
||||
assertFalse("Start over must not leave a full-size copy in cache", staged.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset after a successful save does not try to delete again`() {
|
||||
val viewModel = convertedViewModel()
|
||||
viewModel.save(DESTINATION)
|
||||
awaitState(viewModel.state, "Saved") { it is ConversionState.Saved }
|
||||
|
||||
// save() deletes the staged file itself, through File.delete() rather than through
|
||||
// the publisher, so assert the disappearance as well as the absent second discard.
|
||||
assertFalse("a successful save should have removed the staged file", staged.exists())
|
||||
|
||||
viewModel.reset()
|
||||
|
||||
// Discarding again would be a delete aimed at a path this ViewModel no longer owns.
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
assertFalse(staged.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed save keeps the staged file, and a later reset collects it`() {
|
||||
val viewModel = convertedViewModel()
|
||||
publisher.publishFailure = IllegalStateException("destination volume full")
|
||||
|
||||
viewModel.save(DESTINATION)
|
||||
awaitState(viewModel.state, "Failed") { it is ConversionState.Failed }
|
||||
|
||||
// The deliberate decision, pinned: the staged file may be the only copy of an hour
|
||||
// of transcoding, and the destination did not receive it.
|
||||
assertTrue("a failed save must not destroy the only copy", staged.exists())
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
|
||||
// Failed carries no file reference at all, so this only works because the handle is
|
||||
// a ViewModel field rather than something read back out of the state machine.
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(listOf(staged), publisher.discarded)
|
||||
assertFalse(staged.exists())
|
||||
}
|
||||
|
||||
/** A ViewModel driven all the way to [ConversionState.Converted]. */
|
||||
private fun convertedViewModel(): ConversionViewModel {
|
||||
// Unconfined so reset()'s delete runs inline instead of on a real IO thread.
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(Uri.parse("content://test/holiday.mp4"))
|
||||
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
|
||||
viewModel.convert()
|
||||
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
|
||||
return viewModel
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
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.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* What a pure function cannot say: the file is really gone.
|
||||
*
|
||||
* [StagingSweepTest] pins the rule; this pins the effect. Robolectric gives each test a
|
||||
* real, empty `cacheDir` on a temp path, so this drives the actual [OutputPublisher] over
|
||||
* the actual filesystem — the same calls `reset()` makes, without needing a ViewModel (both
|
||||
* of those construct a `WorkManager`, which is not initialised on the JVM classpath).
|
||||
*
|
||||
* The instrumented suite cannot run on the development host, so this is the only place the
|
||||
* "Start over leaks a full-size copy" defect can be caught before CI.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class OutputPublisherStagingTest {
|
||||
|
||||
private lateinit var cacheDir: File
|
||||
private lateinit var publisher: OutputPublisher
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
cacheDir = context.cacheDir
|
||||
publisher = OutputPublisher(context)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discarding a staged output actually removes it`() {
|
||||
// This is the leak in D2: convert, decline to save, tap "Start over".
|
||||
val staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
assertTrue("the staged file should exist to begin with", staged.exists())
|
||||
|
||||
assertTrue("discard should report that it deleted the file", publisher.discardStaged(staged))
|
||||
assertFalse("the staged file should be gone after the reset path runs", staged.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discarding a file that is already gone is not an error`() {
|
||||
// reset() after a successful save, or two resets in a row. Neither should throw.
|
||||
val staged = publisher.createStagingFile("already_published.mp4")
|
||||
assertFalse(publisher.discardStaged(staged))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file outside the staging directory is refused`() {
|
||||
// The handle can originate in WorkInfo.outputData, which is a string the ViewModel
|
||||
// turns straight into a File. Nothing else checks where it points.
|
||||
val outsider = File(cacheDir, "someone_elses.bin").apply { writeBytes(ByteArray(16)) }
|
||||
|
||||
assertFalse(publisher.discardStaged(outsider))
|
||||
assertTrue("a file outside staging must survive", outsider.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a path that climbs out of the staging directory is refused`() {
|
||||
// The naive parent check -- comparing path strings -- passes this one.
|
||||
val outsider = File(cacheDir, "climbed_to.bin").apply { writeBytes(ByteArray(16)) }
|
||||
val escaping = File(cacheDir, "conversions/../climbed_to.bin")
|
||||
|
||||
assertFalse(publisher.discardStaged(escaping))
|
||||
assertTrue("a traversal must not delete outside staging", outsider.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sweep collects an orphan and leaves a live job alone`() {
|
||||
val orphan = publisher.createStagingFile("orphan.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
val liveOutput = publisher.createStagingFile("joined.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
val liveList = publisher.createStagingFile("concat_list.txt").apply { writeText("file 'a.mp4'\n") }
|
||||
assertTrue(orphan.setLastModified(System.currentTimeMillis() - StagingSweep.GRACE_PERIOD_MS - 60_000))
|
||||
|
||||
publisher.sweepStaging()
|
||||
|
||||
assertFalse("an abandoned output should be collected", orphan.exists())
|
||||
assertTrue("a live job's output must survive", liveOutput.exists())
|
||||
assertTrue("a live join's list file must survive", liveList.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the sweep tolerates a staging directory that does not exist yet`() {
|
||||
File(cacheDir, "conversions").deleteRecursively()
|
||||
|
||||
publisher.sweepStaging()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.Data
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerFactory
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.SynchronousExecutor
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Shared scaffolding for the two ViewModel cleanup tests.
|
||||
*
|
||||
* These tests exist because [StagingSweepTest] and [OutputPublisherStagingTest] both prove
|
||||
* the *tool* works while saying nothing about whether anything calls it — and the wiring is
|
||||
* where D2 actually lived. Deleting the `discardStaged` line from either `reset()` left all
|
||||
* of the earlier tests green.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A real [OutputPublisher] that records what it was asked to discard.
|
||||
*
|
||||
* It still really deletes, so the assertions are about the filesystem rather than about a
|
||||
* mock's memory. [publish] is stubbed because the SAF destination is not what these tests
|
||||
* are about, and because making it throw is the only way to reach the failed-save branch
|
||||
* deterministically.
|
||||
*/
|
||||
open class RecordingPublisher(context: Context) : OutputPublisher(context) {
|
||||
|
||||
val discarded = mutableListOf<File>()
|
||||
|
||||
/** When set, [publish] throws it — the failed-save path. */
|
||||
var publishFailure: Throwable? = null
|
||||
|
||||
override fun publish(staged: File, destination: Uri) {
|
||||
publishFailure?.let { throw it }
|
||||
}
|
||||
|
||||
override fun discardStaged(staged: File): Boolean {
|
||||
discarded += staged
|
||||
return super.discardStaged(staged)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for whichever worker is enqueued and succeeds immediately with [outputData].
|
||||
*
|
||||
* The real workers cannot run here: both drive FFmpeg or Media3 through native libraries
|
||||
* that do not exist on the JVM. What the ViewModel actually needs from them is one
|
||||
* `SUCCEEDED` `WorkInfo` carrying an output path, and that is exactly what this produces —
|
||||
* through a real `WorkManager`, so the ViewModel's own observer, its `SUCCEEDED` branch and
|
||||
* its cleanup handle are all the production ones.
|
||||
*/
|
||||
class SucceedingWorkerFactory(private val outputData: Data) : WorkerFactory() {
|
||||
override fun createWorker(
|
||||
appContext: Context,
|
||||
workerClassName: String,
|
||||
workerParameters: WorkerParameters,
|
||||
): ListenableWorker = object : Worker(appContext, workerParameters) {
|
||||
override fun doWork(): Result = Result.success(outputData)
|
||||
}
|
||||
}
|
||||
|
||||
/** Installs a synchronous test WorkManager whose workers succeed with [outputData]. */
|
||||
fun installTestWorkManager(context: Context, outputData: Data) {
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(
|
||||
context,
|
||||
Configuration.Builder()
|
||||
.setMinimumLoggingLevel(Log.ASSERT)
|
||||
.setExecutor(SynchronousExecutor())
|
||||
.setTaskExecutor(SynchronousExecutor())
|
||||
.setWorkerFactory(SucceedingWorkerFactory(outputData))
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for [predicate] to hold, pumping the main looper as it goes.
|
||||
*
|
||||
* Both ViewModels hop to a real `Dispatchers.IO` for file metadata and resume on the main
|
||||
* looper, which Robolectric leaves paused. So neither a bare read of `state.value` nor a
|
||||
* single `idle()` is enough, and the timeout is generous because it only has to be longer
|
||||
* than a few file stats — in practice this converges in milliseconds.
|
||||
*/
|
||||
fun <T> awaitState(state: StateFlow<T>, description: String, predicate: (T) -> Boolean): T {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(AWAIT_TIMEOUT_SECONDS)
|
||||
while (System.nanoTime() < deadline) {
|
||||
shadowOf(Looper.getMainLooper()).idle()
|
||||
val current = state.value
|
||||
if (predicate(current)) return current
|
||||
Thread.sleep(POLL_INTERVAL_MS)
|
||||
}
|
||||
throw AssertionError("Timed out waiting for $description; state was ${state.value}")
|
||||
}
|
||||
|
||||
private const val AWAIT_TIMEOUT_SECONDS = 10L
|
||||
private const val POLL_INTERVAL_MS = 5L
|
||||
@@ -0,0 +1,77 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* The orphan-collection rule.
|
||||
*
|
||||
* Timestamps are passed in as values rather than read off real files on purpose. The
|
||||
* interesting part of this decision is clock arithmetic — the boundary, and a clock that
|
||||
* has moved backwards — and a test that created real files would be measuring the
|
||||
* filesystem's mtime granularity instead of the rule.
|
||||
*/
|
||||
class StagingSweepTest {
|
||||
|
||||
private val now = 1_700_000_000_000L
|
||||
private val grace = StagingSweep.GRACE_PERIOD_MS
|
||||
|
||||
@Test
|
||||
fun `an orphan older than the grace period is collectable`() {
|
||||
// Left behind by a process that died, or by a "Start over" whose delete never ran.
|
||||
val entries = listOf(StagingSweep.Entry("orphan.mp4", now - grace - 1))
|
||||
assertEquals(listOf("orphan.mp4"), StagingSweep.collectable(entries, now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file written moments ago is left alone`() {
|
||||
// The in-flight guard. A live job's output has its mtime refreshed by every write,
|
||||
// so a running conversion always looks young; deleting it would destroy the job.
|
||||
val entries = listOf(StagingSweep.Entry("in_progress.mp4", now - 1_000))
|
||||
assertEquals(emptyList<String>(), StagingSweep.collectable(entries, now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the grace boundary itself collects`() {
|
||||
// Pins the comparison: age >= grace collects, age one millisecond short does not.
|
||||
assertTrue(StagingSweep.isCollectable(lastModifiedMs = now - grace, nowMs = now))
|
||||
assertFalse(StagingSweep.isCollectable(lastModifiedMs = now - grace + 1, nowMs = now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a file dated in the future is left alone`() {
|
||||
// The clock moved backwards — an RTC correction, or the user setting the date. The
|
||||
// age is negative, which says nothing about whether the file is still in use, so
|
||||
// the safe answer is to keep it and let a later sweep decide.
|
||||
val entries = listOf(StagingSweep.Entry("tomorrow.mp4", now + grace))
|
||||
assertEquals(emptyList<String>(), StagingSweep.collectable(entries, now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an empty directory yields nothing`() {
|
||||
assertEquals(emptyList<String>(), StagingSweep.collectable(emptyList(), now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a mixed directory names only the orphans`() {
|
||||
// The whole point of narrowing the old clearStaging(): a sweep that runs while a
|
||||
// join is live must not take the list file out from under it.
|
||||
val entries = listOf(
|
||||
StagingSweep.Entry("orphan.mp4", now - grace - 1),
|
||||
StagingSweep.Entry("concat_list.txt", now - 5_000),
|
||||
StagingSweep.Entry("joined.mp4", now - 5_000),
|
||||
StagingSweep.Entry("older_orphan.webm", now - grace * 7),
|
||||
)
|
||||
assertEquals(listOf("orphan.mp4", "older_orphan.webm"), StagingSweep.collectable(entries, now))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a shorter grace period can be asked for explicitly`() {
|
||||
// The caller owns the period; the constant is only a default.
|
||||
val entries = listOf(StagingSweep.Entry("recent.mp4", now - 60_000))
|
||||
assertEquals(emptyList<String>(), StagingSweep.collectable(entries, now))
|
||||
assertEquals(listOf("recent.mp4"), StagingSweep.collectable(entries, now, gracePeriodMs = 30_000))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package org.libremediaconverter.join
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
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.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.RecordingPublisher
|
||||
import org.libremediaconverter.convert.awaitState
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* The join tab's half of the same defect.
|
||||
*
|
||||
* Carried separately rather than parameterised with the convert tab, because the two are
|
||||
* independent ViewModels that can each hold a staged file at the same time — the reason
|
||||
* `clearStaging()` could not simply be wired up.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class JoinViewModelCleanupTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: RecordingPublisher
|
||||
private lateinit var staged: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = RecordingPublisher(app)
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
|
||||
staged = publisher.createStagingFile("joined.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
installTestWorkManager(app, workDataOf(ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath))
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `start over on a finished join deletes the staged file`() {
|
||||
val viewModel = joinedViewModel()
|
||||
assertTrue("the join should have produced a staged file", staged.exists())
|
||||
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(JoinState.Idle, viewModel.state.value)
|
||||
assertEquals(listOf(staged), publisher.discarded)
|
||||
assertFalse("Start over must not leave a full-size copy in cache", staged.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reset after a successful save does not try to delete again`() {
|
||||
val viewModel = joinedViewModel()
|
||||
viewModel.save(DESTINATION)
|
||||
awaitState(viewModel.state, "Saved") { it is JoinState.Saved }
|
||||
|
||||
// save() deletes the staged file itself, through File.delete() rather than through
|
||||
// the publisher, so assert the disappearance as well as the absent second discard.
|
||||
assertFalse("a successful save should have removed the staged file", staged.exists())
|
||||
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
assertFalse(staged.exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a failed save keeps the staged file, and a later reset collects it`() {
|
||||
val viewModel = joinedViewModel()
|
||||
publisher.publishFailure = IllegalStateException("destination volume full")
|
||||
|
||||
viewModel.save(DESTINATION)
|
||||
awaitState(viewModel.state, "Failed") { it is JoinState.Failed }
|
||||
|
||||
assertTrue("a failed save must not destroy the only copy", staged.exists())
|
||||
assertEquals(emptyList<File>(), publisher.discarded)
|
||||
|
||||
viewModel.reset()
|
||||
|
||||
assertEquals(listOf(staged), publisher.discarded)
|
||||
assertFalse(staged.exists())
|
||||
}
|
||||
|
||||
/** A ViewModel driven all the way to [JoinState.Joined]. */
|
||||
private fun joinedViewModel(): JoinViewModel {
|
||||
// Unconfined so reset()'s delete runs inline instead of on a real IO thread.
|
||||
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mp4"), Uri.parse("content://test/b.mp4")))
|
||||
awaitState(viewModel.state, "Ready") { it is JoinState.Ready }
|
||||
viewModel.join()
|
||||
awaitState(viewModel.state, "Joined") { it is JoinState.Joined }
|
||||
return viewModel
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user