Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
92bcff8656 | ||
|
|
a847d3a81d |
@@ -0,0 +1,213 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.app.Application
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.workDataOf
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
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.ConversionRequest
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.work.ConcatWorker
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* A failure that says nothing still has to say something.
|
||||
*
|
||||
* Three sites, all `ci == 0` before this file, and all the same rule:
|
||||
*
|
||||
* ```
|
||||
* work/ConversionWorker.kt:316 cause.message ?: GENERIC_FAILURE_MESSAGE
|
||||
* convert/ConversionViewModel.kt:631 e.message ?: SAVE_FAILED_MESSAGE
|
||||
* join/JoinViewModel.kt:416 e.message ?: SAVE_FAILED_MESSAGE
|
||||
* ```
|
||||
*
|
||||
* Every existing test throws *with* a message, so the right-hand side had never been evaluated
|
||||
* anywhere in the suite. A `Throwable` carrying none is not exotic — `RuntimeException()`,
|
||||
* `IOException()` and most platform exceptions raised without an argument all have a null message.
|
||||
*
|
||||
* ## Held in one class, against the ticket's suggestion
|
||||
*
|
||||
* #193 proposed putting each case beside the behaviour it neighbours. They are together instead,
|
||||
* because they are one rule at three layers and because the trap below has to be explained once
|
||||
* rather than three times. `FailedSaveRetryTest` sets the precedent for both ViewModels in one
|
||||
* file; this extends it by one worker.
|
||||
*
|
||||
* ## The trap, which is why the worker case asserts what it does
|
||||
*
|
||||
* `ConversionStateMappingTest`'s *"a failure with nothing said still says something"* looks like it
|
||||
* already covers the worker site. It does not: it drives the **read** side, `map(FAILED, Data.EMPTY)`,
|
||||
* and that side has a fallback of its own (`ConversionViewModel.kt:147-149`):
|
||||
*
|
||||
* ```kotlin
|
||||
* update.outputData.getString(ConversionWorker.KEY_ERROR)
|
||||
* ?.takeIf { it.isNotBlank() }
|
||||
* ?: ConversionWorker.GENERIC_FAILURE_MESSAGE
|
||||
* ```
|
||||
*
|
||||
* So mutating the worker's fallback to `.orEmpty()` writes `KEY_ERROR to ""`, and the ViewModel
|
||||
* turns that straight back into the same constant. **A test asserting on the resulting `Failed`
|
||||
* state stays green under the mutation**, which is most likely why the write-side fallback survived
|
||||
* three waves of test work. The worker case therefore reads `KEY_ERROR` off the worker's own
|
||||
* `Result`, before anything downstream can repair it.
|
||||
*
|
||||
* The two save cases have no such second line: both write `_state.value` directly, so the state is
|
||||
* the right thing to assert there.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class MessagelessFailureTest {
|
||||
|
||||
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 }
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
/**
|
||||
* The engine gives up without saying why, which is what a native crash looks like from here.
|
||||
*
|
||||
* Asserted on the worker's own output `Data` rather than on a screen — see the class KDoc.
|
||||
*/
|
||||
@Test
|
||||
fun `a conversion that fails without a message still reports one`() {
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
ConversionDependencies.software = { MessagelessTranscoder }
|
||||
|
||||
val result = runBlocking { failingWorker().doWork() }
|
||||
|
||||
assertTrue("the job must fail rather than retry, got $result", result is ListenableWorker.Result.Failure)
|
||||
assertEquals(
|
||||
"a failure with no message must still put something on screen",
|
||||
ConversionWorker.GENERIC_FAILURE_MESSAGE,
|
||||
(result as ListenableWorker.Result.Failure).outputData.getString(ConversionWorker.KEY_ERROR),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a save that fails without a message still reports one`() {
|
||||
installTestWorkManager(app, conversionOutput())
|
||||
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
|
||||
viewModel.onInputPicked(Uri.parse("content://test/holiday.mkv"))
|
||||
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
|
||||
viewModel.convert()
|
||||
awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
|
||||
|
||||
publisher.publishFailure = RuntimeException()
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val failed = awaitState(viewModel.state, "Failed") { it is ConversionState.Failed } as ConversionState.Failed
|
||||
assertEquals(SAVE_FAILED_MESSAGE, failed.message)
|
||||
// The handle travels even on the wordless path. Without this, a fallback that also dropped
|
||||
// `pending` would pass -- and the file would be unreachable from the screen that just said
|
||||
// the save failed.
|
||||
assertNotNull("a wordless failure must still offer the file again", failed.retry)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join save that fails without a message still reports one`() {
|
||||
installTestWorkManager(app, joinOutput())
|
||||
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 }
|
||||
|
||||
publisher.publishFailure = RuntimeException()
|
||||
viewModel.save(DESTINATION)
|
||||
|
||||
val failed = awaitState(viewModel.state, "Failed") { it is JoinState.Failed } as JoinState.Failed
|
||||
assertEquals(SAVE_FAILED_MESSAGE, failed.message)
|
||||
assertNotNull("a wordless failure must still offer the file again", failed.retry)
|
||||
}
|
||||
|
||||
/**
|
||||
* `FORCE_SOFTWARE` so the failure comes straight out of `runFFmpeg`.
|
||||
*
|
||||
* `AUTO` would enter `runMedia3OrFallBack`, whose catch runs the job a second time in software
|
||||
* — the same exception would arrive, but through a path this test is not about and which
|
||||
* `HardwareFallbackTest` already owns.
|
||||
*/
|
||||
private fun failingWorker(): ConversionWorker {
|
||||
val spec = OutputFormat.MP4_H265.spec
|
||||
return TestListenableWorkerBuilder<ConversionWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConversionWorker.KEY_INPUT_URI to "file:///tmp/holiday.mp4",
|
||||
ConversionWorker.KEY_DISPLAY_NAME to "holiday.mp4",
|
||||
ConversionWorker.KEY_CONTAINER to spec.container.name,
|
||||
ConversionWorker.KEY_VIDEO_CODEC to spec.videoCodec.name,
|
||||
ConversionWorker.KEY_AUDIO_CODEC to spec.audioCodec.name,
|
||||
ConversionWorker.KEY_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
).setId(JOB_ID).build()
|
||||
}
|
||||
|
||||
private fun conversionOutput() = workDataOf(
|
||||
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConversionWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
|
||||
ConversionWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
|
||||
)
|
||||
|
||||
private fun joinOutput() = workDataOf(
|
||||
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
||||
ConcatWorker.KEY_SUGGESTED_NAME to SUGGESTED_NAME,
|
||||
ConcatWorker.KEY_MIME_TYPE to JOB_MIME_TYPE,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-00000000019a")
|
||||
const val SUGGESTED_NAME = "holiday.mp4"
|
||||
const val JOB_MIME_TYPE = "video/mp4"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An engine that gives up without saying why.
|
||||
*
|
||||
* `RuntimeException()` rather than a subclass with a blank message: `Throwable.message` is *null*
|
||||
* here, which is the case the elvis exists for. A blank-but-present message takes the left-hand
|
||||
* side and is a different path — `ConversionStateMappingTest` covers that one, on the read side.
|
||||
*/
|
||||
@UnstableApi
|
||||
private object MessagelessTranscoder : SoftwareTranscoder {
|
||||
override suspend fun run(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
output: File,
|
||||
durationMs: Long,
|
||||
onProgress: (Int) -> Unit,
|
||||
): Unit = throw RuntimeException()
|
||||
}
|
||||
Reference in New Issue
Block a user