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>
123 lines
5.1 KiB
Kotlin
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")
|
|
}
|
|
}
|