Files
LibreMediaConverter/app/src/test/java/org/libremediaconverter/convert/ConversionViewModelCleanupTest.kt
T
JMR-devandClaude Opus 5 c887af0d83 Offer the file again after a failed save, rather than only offering to delete it
save()'s onFailure keeps the staged file on purpose -- it can be the only copy
of an hour of transcoding, and the destination did not receive it -- and then
handed the screen a Failed carrying a message and nothing else. That branch
rendered exactly one control: "Start over", wired to reset(), which discards
precisely the file the comment above it goes out of its way to keep. The intent
was already written down in main; the UI did not honour it, and the only rescue
was process death followed by reattach -- unadvertised, and bounded by a sweep
that collects anything a day old.

Failed now carries a PendingSave, and only where the failure came from save().
A transcode that died staged nothing and must not sprout a save button, so the
handle is nullable and the observe() arm leaves it null; so does a save that
found the file already gone. The branch renders "Try saving again" above "Start
over", opening the same CreateDocument flow with the same name and type the
first attempt used. A retry that fails again lands back on a carrying Failed
rather than a bare one, so the second failure cannot eat what the first kept.

Start over still deletes from there, and that is a decision rather than an
inheritance: deletion is the user's choice only once the alternative has been
offered. pendingStaged remains the single owner of the delete, so the carried
handle is a view of it rather than a second owner and no path out of the state
can drop a file the old shape could not.

pendingSave() exists so save() and each screen's CreateDocument registration
answer "what would a save target" once instead of twice -- the entry points
cast to Converted/Joined, which answered null for a Failed and fell back to the
current pickers, wrong for any spec edited since the job ran and for every
reattached job.

Both tabs, since JoinViewModel and JoinScreen have the same shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-25 21:16:02 -05:00

123 lines
5.1 KiB
Kotlin

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)
// The handle is a ViewModel field rather than something read back out of the state
// machine, and stays one now that a save-failed `Failed` also carries a `PendingSave`:
// that is a view for the screen to offer a retry through, never a second owner of the
// file. This delete goes through the field, which is what keeps a state that is dropped
// rather than read from taking the only reference with it. What the state carries, and
// what the screen then does with it, are `FailedSaveRetryTest`'s.
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")
}
}