Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ff5c4463c | ||
|
|
fea480b000 |
@@ -5,6 +5,7 @@ import android.net.Uri
|
|||||||
import androidx.lifecycle.AndroidViewModel
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.work.Data
|
||||||
import androidx.work.WorkInfo
|
import androidx.work.WorkInfo
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -30,6 +31,108 @@ import org.libremediaconverter.work.jobSnapshots
|
|||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One update about a running join, as WorkManager last reported it.
|
||||||
|
*
|
||||||
|
* The join-side twin of `ConversionUpdate`, and deliberately the same shape: this pair of seams is
|
||||||
|
* one refactor done twice, and letting them diverge would make the two flows harder to compare than
|
||||||
|
* the duplication costs. There is no `progressPercent` here because `ConcatWorker` publishes none —
|
||||||
|
* a join is indeterminate.
|
||||||
|
*/
|
||||||
|
internal data class JoinUpdate(val state: WorkInfo.State, val runAttemptCount: Int, val outputData: Data)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What the join screen should show, given what WorkManager last said about the job.
|
||||||
|
*
|
||||||
|
* The join-side twin of `conversionStateFrom`, extracted for the same reason and with the same two
|
||||||
|
* exclusions: the ownership check stays at the call site, and this takes no responsibility for the
|
||||||
|
* staged file. See that function's KDoc for the argument in full.
|
||||||
|
*
|
||||||
|
* Five arms had never been chosen by any test before this was cut out, because a real `ConcatWorker`
|
||||||
|
* only ever produces a terminal state with well-formed output.
|
||||||
|
*
|
||||||
|
* @param cancelled where a cancellation lands, which differs for a reattached job — see [observe].
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
internal fun joinStateFrom(update: JoinUpdate, inputs: List<InputFile>, cancelled: JoinState): JoinState =
|
||||||
|
when (update.state) {
|
||||||
|
// BLOCKED is a job waiting on a prerequisite, which the user has nothing to do about and
|
||||||
|
// nothing useful to be told about. It reads as "starting", like a fresh ENQUEUED.
|
||||||
|
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
|
||||||
|
|
||||||
|
// ENQUEUED after a run means a retry is pending -- the same rule, and the same reasoning, as
|
||||||
|
// the convert side. See `conversionStateFrom`.
|
||||||
|
WorkInfo.State.ENQUEUED ->
|
||||||
|
if (update.runAttemptCount > 0) {
|
||||||
|
JoinState.Waiting(inputs)
|
||||||
|
} else {
|
||||||
|
JoinState.Joining(inputs)
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkInfo.State.SUCCEEDED -> joinedFrom(update.outputData)
|
||||||
|
|
||||||
|
// A worker that dies before it can report anything leaves no output data at all, and an
|
||||||
|
// exception's message can be an empty string. Both would read as a failure with nothing said,
|
||||||
|
// so blank falls back like missing does.
|
||||||
|
WorkInfo.State.FAILED -> JoinState.Failed(
|
||||||
|
update.outputData.getString(ConcatWorker.KEY_ERROR)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: ConcatWorker.GENERIC_FAILURE_MESSAGE,
|
||||||
|
)
|
||||||
|
|
||||||
|
WorkInfo.State.CANCELLED -> cancelled
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `SUCCEEDED` arm. A join that reported success and named no file has nothing to offer.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
private fun joinedFrom(outputData: Data): JoinState {
|
||||||
|
val path = outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
|
||||||
|
?: return JoinState.Failed(JOINED_WITHOUT_A_FILE_MESSAGE)
|
||||||
|
return JoinState.Joined(
|
||||||
|
staged = File(path),
|
||||||
|
strategy = strategyFrom(outputData.getString(ConcatWorker.KEY_STRATEGY)),
|
||||||
|
// A join enqueued before the worker reported these carries neither, and the fallback is
|
||||||
|
// the format such a job really used -- ConcatWorker.request has always defaulted to it,
|
||||||
|
// and the join screen has never offered a choice.
|
||||||
|
suggestedName = outputData.getString(ConcatWorker.KEY_SUGGESTED_NAME)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT),
|
||||||
|
mimeType = outputData.getString(ConcatWorker.KEY_MIME_TYPE)
|
||||||
|
?.takeIf { it.isNotBlank() }
|
||||||
|
?: ConcatWorker.DEFAULT_FORMAT.mimeType,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The strategy a finished join reported, or [ConcatStrategy.REENCODE] when it named none.
|
||||||
|
*
|
||||||
|
* **Looked up rather than `valueOf`, and that is a fix rather than a style choice.** `valueOf`
|
||||||
|
* throws `IllegalArgumentException` on a name this build does not define, and this runs inside a
|
||||||
|
* `viewModelScope` collect with no handler -- so the throw does not become a `Failed` state, it
|
||||||
|
* takes the process down.
|
||||||
|
*
|
||||||
|
* Reachable for the reason `WorkerEnumFallbackTest` and `JobTags` are both written on: WorkManager
|
||||||
|
* keeps finished work for about a week, so a downgrade or a rollback hands this build a job
|
||||||
|
* enqueued by another one. `ConcatWorker` writes `result.strategy.name` into the output `Data`, so
|
||||||
|
* a build that added a third strategy would leave this one crashing on its own completed joins.
|
||||||
|
*
|
||||||
|
* `ConcatWorker.kt` already made exactly this change for `KEY_FORMAT`, and says why in as many
|
||||||
|
* words: *"Looked up rather than `valueOf` … a format name this build does not define used to throw
|
||||||
|
* past the catch."* The same read on this side had not been changed with it.
|
||||||
|
*
|
||||||
|
* REENCODE is the safe default rather than an arbitrary one: it is the answer for inputs that do
|
||||||
|
* not match, so a job whose strategy cannot be read is described as the more conservative of the
|
||||||
|
* two rather than being claimed as a lossless stream copy.
|
||||||
|
*/
|
||||||
|
private fun strategyFrom(name: String?): ConcatStrategy =
|
||||||
|
ConcatStrategy.entries.firstOrNull { it.name == name } ?: ConcatStrategy.REENCODE
|
||||||
|
|
||||||
|
/** A join that reported success and named no file. There is nothing to offer the user to save. */
|
||||||
|
internal const val JOINED_WITHOUT_A_FILE_MESSAGE: String =
|
||||||
|
"Joining reported success but produced no file."
|
||||||
|
|
||||||
sealed interface JoinState {
|
sealed interface JoinState {
|
||||||
data object Idle : JoinState
|
data object Idle : JoinState
|
||||||
data class Ready(val inputs: List<InputFile>) : JoinState
|
data class Ready(val inputs: List<InputFile>) : JoinState
|
||||||
@@ -251,56 +354,19 @@ class JoinViewModel @JvmOverloads constructor(
|
|||||||
// takes ownership of the staged file, and a superseded observation must not do
|
// takes ownership of the staged file, and a superseded observation must not do
|
||||||
// that either.
|
// that either.
|
||||||
if (!ownership.stillHeldBy(token)) return@collect
|
if (!ownership.stillHeldBy(token)) return@collect
|
||||||
_state.value = when (info.state) {
|
val next = joinStateFrom(
|
||||||
WorkInfo.State.RUNNING, WorkInfo.State.BLOCKED -> JoinState.Joining(inputs)
|
JoinUpdate(
|
||||||
WorkInfo.State.ENQUEUED ->
|
state = info.state,
|
||||||
if (info.runAttemptCount > 0) {
|
runAttemptCount = info.runAttemptCount,
|
||||||
JoinState.Waiting(inputs)
|
outputData = info.outputData,
|
||||||
} else {
|
),
|
||||||
JoinState.Joining(inputs)
|
inputs = inputs,
|
||||||
}
|
cancelled = cancelled,
|
||||||
|
)
|
||||||
WorkInfo.State.SUCCEEDED -> {
|
// Read off the result rather than assigned inside the mapping -- see the same
|
||||||
val path = info.outputData.getString(ConcatWorker.KEY_OUTPUT_PATH)
|
// three lines in ConversionViewModel for why that is the better half of the swap.
|
||||||
val strategy = info.outputData.getString(ConcatWorker.KEY_STRATEGY)
|
if (next is JoinState.Joined) pendingStaged = next.staged
|
||||||
?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE
|
_state.value = next
|
||||||
if (path == null) {
|
|
||||||
JoinState.Failed("Joining reported success but produced no file.")
|
|
||||||
} else {
|
|
||||||
val staged = File(path)
|
|
||||||
// Take responsibility for the file at the same moment the state
|
|
||||||
// starts referring to it, so the two cannot disagree.
|
|
||||||
pendingStaged = staged
|
|
||||||
JoinState.Joined(
|
|
||||||
staged = staged,
|
|
||||||
strategy = strategy,
|
|
||||||
// A join enqueued before the worker reported these carries
|
|
||||||
// neither, and the fallback is the format such a job really
|
|
||||||
// used -- ConcatWorker.request has always defaulted to it, and
|
|
||||||
// the join screen has never offered a choice.
|
|
||||||
suggestedName = info.outputData
|
|
||||||
.getString(ConcatWorker.KEY_SUGGESTED_NAME)
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?: ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT),
|
|
||||||
mimeType = info.outputData
|
|
||||||
.getString(ConcatWorker.KEY_MIME_TYPE)
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?: ConcatWorker.DEFAULT_FORMAT.mimeType,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// A worker that dies before it can report anything leaves no output data at
|
|
||||||
// all, and an exception's message can be an empty string. Both would read as
|
|
||||||
// a failure with nothing said, so blank falls back like missing does.
|
|
||||||
WorkInfo.State.FAILED -> JoinState.Failed(
|
|
||||||
info.outputData.getString(ConcatWorker.KEY_ERROR)
|
|
||||||
?.takeIf { it.isNotBlank() }
|
|
||||||
?: ConcatWorker.GENERIC_FAILURE_MESSAGE,
|
|
||||||
)
|
|
||||||
|
|
||||||
WorkInfo.State.CANCELLED -> cancelled
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
package org.libremediaconverter.convert
|
|
||||||
|
|
||||||
import android.app.Application
|
|
||||||
import androidx.media3.common.util.UnstableApi
|
|
||||||
import androidx.work.Data
|
|
||||||
import org.junit.After
|
|
||||||
import org.junit.Assert.assertEquals
|
|
||||||
import org.junit.Assert.assertNotEquals
|
|
||||||
import org.junit.Assert.assertNull
|
|
||||||
import org.junit.Before
|
|
||||||
import org.junit.Test
|
|
||||||
import org.junit.runner.RunWith
|
|
||||||
import org.libremediaconverter.model.AudioCodec
|
|
||||||
import org.libremediaconverter.model.Container
|
|
||||||
import org.libremediaconverter.model.EnginePreference
|
|
||||||
import org.libremediaconverter.model.OutputFormat
|
|
||||||
import org.libremediaconverter.model.QualityTier
|
|
||||||
import org.libremediaconverter.model.VideoCodec
|
|
||||||
import org.robolectric.RobolectricTestRunner
|
|
||||||
import org.robolectric.RuntimeEnvironment
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The seven one-line edits the settings sheet makes, and what each one leaves alone.
|
|
||||||
*
|
|
||||||
* ## Why these needed a file of their own
|
|
||||||
*
|
|
||||||
* `setPreset` was covered. The six beside it — `setContainer`, `setVideoCodec`, `setAudioCodec`,
|
|
||||||
* `applySuggestion`, `setQuality`, `setEnginePreference` — and `cancel()` had **no coverage at
|
|
||||||
* all**, which is the tell: they are reachable from the JVM suite by exactly the route `setPreset`
|
|
||||||
* already takes, and nothing had asked.
|
|
||||||
*
|
|
||||||
* ## What is actually being asserted
|
|
||||||
*
|
|
||||||
* Not "the setter sets something". Each of these copies into a nested `OutputSpec`, so the failure
|
|
||||||
* worth catching is **a setter that writes the right value into the wrong field, or that rebuilds
|
|
||||||
* the spec and silently discards the other two**. So every test here asserts the field it changed
|
|
||||||
* *and* that the rest of the spec survived — a `setContainer` implemented as
|
|
||||||
* `it.copy(spec = OutputFormat.MP4_H265.spec.copy(container = container))` would pass a test that
|
|
||||||
* only checked the container.
|
|
||||||
*
|
|
||||||
* `ConverterScreenContentTest` cannot cover this: it builds `ConverterActions` itself and never
|
|
||||||
* touches the ViewModel. That the *screen* calls these is #156's, and neither implies the other.
|
|
||||||
*/
|
|
||||||
@UnstableApi
|
|
||||||
@RunWith(RobolectricTestRunner::class)
|
|
||||||
class SettingsEditsTest {
|
|
||||||
|
|
||||||
private lateinit var app: Application
|
|
||||||
private lateinit var viewModel: ConversionViewModel
|
|
||||||
|
|
||||||
@Before
|
|
||||||
fun setUp() {
|
|
||||||
app = RuntimeEnvironment.getApplication()
|
|
||||||
installTestWorkManager(app, Data.EMPTY)
|
|
||||||
viewModel = ConversionViewModel(app)
|
|
||||||
}
|
|
||||||
|
|
||||||
@After
|
|
||||||
fun tearDown() {
|
|
||||||
ConversionDependencies.reset()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `choosing a preset replaces the whole spec`() {
|
|
||||||
viewModel.setPreset(OutputFormat.WEBM_VP9)
|
|
||||||
|
|
||||||
assertEquals(OutputFormat.WEBM_VP9.spec, viewModel.settings.value.spec)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `changing the container leaves both codecs alone`() {
|
|
||||||
// Moved off the default spec first, and that is load-bearing rather than tidiness. The
|
|
||||||
// default IS `OutputFormat.MP4_H265.spec`, so a `setContainer` that rebuilt the spec from
|
|
||||||
// that preset instead of from the current one produced an identical answer and the
|
|
||||||
// mutation went green. Editing the codecs away from the default first is what makes
|
|
||||||
// "the other two survived" an assertion rather than a coincidence.
|
|
||||||
viewModel.setPreset(OutputFormat.WEBM_VP9)
|
|
||||||
val before = viewModel.settings.value.spec
|
|
||||||
|
|
||||||
viewModel.setContainer(Container.MKV)
|
|
||||||
|
|
||||||
val after = viewModel.settings.value.spec
|
|
||||||
assertEquals(Container.MKV, after.container)
|
|
||||||
assertEquals("the video codec is not the container's to change", before.videoCodec, after.videoCodec)
|
|
||||||
assertEquals("the audio codec is not the container's to change", before.audioCodec, after.audioCodec)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `changing the video codec leaves the container and the audio codec alone`() {
|
|
||||||
// The transposition this guards against is real: setVideoCodec and setAudioCodec take
|
|
||||||
// different enum types, but a copy(...) naming the wrong field compiles wherever the types
|
|
||||||
// happen to line up, and the picker would silently set the other one.
|
|
||||||
val before = viewModel.settings.value.spec
|
|
||||||
|
|
||||||
viewModel.setVideoCodec(VideoCodec.VP9)
|
|
||||||
|
|
||||||
val after = viewModel.settings.value.spec
|
|
||||||
assertEquals(VideoCodec.VP9, after.videoCodec)
|
|
||||||
assertEquals(before.container, after.container)
|
|
||||||
assertEquals(before.audioCodec, after.audioCodec)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `changing the audio codec leaves the container and the video codec alone`() {
|
|
||||||
val before = viewModel.settings.value.spec
|
|
||||||
|
|
||||||
viewModel.setAudioCodec(AudioCodec.OPUS)
|
|
||||||
|
|
||||||
val after = viewModel.settings.value.spec
|
|
||||||
assertEquals(AudioCodec.OPUS, after.audioCodec)
|
|
||||||
assertEquals(before.container, after.container)
|
|
||||||
assertEquals(before.videoCodec, after.videoCodec)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `applying a suggestion replaces the spec without disturbing quality or engine`() {
|
|
||||||
// A suggestion comes from ContainerCapabilities when the current spec is invalid, so it is
|
|
||||||
// a whole spec by construction. What it must not do is reset the two settings beside it.
|
|
||||||
viewModel.setQuality(QualityTier.BEST)
|
|
||||||
viewModel.setEnginePreference(EnginePreference.FORCE_SOFTWARE)
|
|
||||||
|
|
||||||
viewModel.applySuggestion(OutputFormat.MKV_H264.spec)
|
|
||||||
|
|
||||||
val settings = viewModel.settings.value
|
|
||||||
assertEquals(OutputFormat.MKV_H264.spec, settings.spec)
|
|
||||||
assertEquals(QualityTier.BEST, settings.quality)
|
|
||||||
assertEquals(EnginePreference.FORCE_SOFTWARE, settings.enginePreference)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `changing the quality leaves the spec and the engine preference alone`() {
|
|
||||||
// Both neighbours are moved off their defaults first. Asserting against AUTO -- which is
|
|
||||||
// what `ConversionSettings` starts with -- let a `setQuality` that also reset the engine
|
|
||||||
// preference to AUTO pass, because the reset and the survival looked identical.
|
|
||||||
viewModel.setPreset(OutputFormat.WEBM_VP9)
|
|
||||||
viewModel.setEnginePreference(EnginePreference.FORCE_SOFTWARE)
|
|
||||||
val before = viewModel.settings.value.spec
|
|
||||||
|
|
||||||
viewModel.setQuality(QualityTier.BEST)
|
|
||||||
|
|
||||||
val settings = viewModel.settings.value
|
|
||||||
assertEquals(QualityTier.BEST, settings.quality)
|
|
||||||
assertEquals(before, settings.spec)
|
|
||||||
assertEquals(
|
|
||||||
"quality is not the engine preference's to change",
|
|
||||||
EnginePreference.FORCE_SOFTWARE,
|
|
||||||
settings.enginePreference,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `changing the engine preference leaves the spec and the quality alone`() {
|
|
||||||
// Off the defaults for the same reason as the test above: QualityTier.FAST is the starting
|
|
||||||
// value, so asserting it here would have been satisfied by a reset as readily as by a
|
|
||||||
// survival.
|
|
||||||
viewModel.setPreset(OutputFormat.WEBM_VP9)
|
|
||||||
viewModel.setQuality(QualityTier.BEST)
|
|
||||||
val before = viewModel.settings.value.spec
|
|
||||||
|
|
||||||
viewModel.setEnginePreference(EnginePreference.FORCE_SOFTWARE)
|
|
||||||
|
|
||||||
val settings = viewModel.settings.value
|
|
||||||
assertEquals(EnginePreference.FORCE_SOFTWARE, settings.enginePreference)
|
|
||||||
assertEquals(before, settings.spec)
|
|
||||||
assertEquals(
|
|
||||||
"the engine preference is not the quality's to change",
|
|
||||||
QualityTier.BEST,
|
|
||||||
settings.quality,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `editing past every preset leaves no matching preset`() {
|
|
||||||
// `matchingPreset` is what the settings sheet reads to decide whether to show a preset as
|
|
||||||
// selected or to say "Custom". Editing one field of a preset must drop it out of the list
|
|
||||||
// rather than leaving the old one highlighted.
|
|
||||||
viewModel.setPreset(OutputFormat.MP4_H265)
|
|
||||||
assertEquals(OutputFormat.MP4_H265, viewModel.settings.value.matchingPreset)
|
|
||||||
|
|
||||||
viewModel.setAudioCodec(AudioCodec.FLAC)
|
|
||||||
|
|
||||||
assertNull(
|
|
||||||
"an edited spec is no longer any preset, and the sheet says Custom",
|
|
||||||
viewModel.settings.value.matchingPreset,
|
|
||||||
)
|
|
||||||
assertNotEquals(OutputFormat.MP4_H265.spec, viewModel.settings.value.spec)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
fun `cancelling with no active job does nothing rather than throwing`() {
|
|
||||||
// `activeWorkId?.let(...)` -- the null side. A user can reach Cancel through a state that
|
|
||||||
// has already finished, and taking the app down for it would be worse than doing nothing.
|
|
||||||
viewModel.cancel()
|
|
||||||
|
|
||||||
assertEquals(ConversionState.Idle, viewModel.state.value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package org.libremediaconverter.join
|
||||||
|
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.work.Data
|
||||||
|
import androidx.work.WorkInfo
|
||||||
|
import androidx.work.workDataOf
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.libremediaconverter.convert.InputFile
|
||||||
|
import org.libremediaconverter.model.ConcatStrategy
|
||||||
|
import org.libremediaconverter.work.ConcatWorker
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every answer [joinStateFrom] can give, chosen rather than stumbled into.
|
||||||
|
*
|
||||||
|
* The join-side twin of `ConversionStateMappingTest`, and the argument is the same one: the mapping
|
||||||
|
* ran on every test that drove a real `ConcatWorker`, but a real worker only ever reaches a terminal
|
||||||
|
* state with well-formed output, so five arms had never been *chosen* by anything.
|
||||||
|
*
|
||||||
|
* ## The one that is not just coverage
|
||||||
|
*
|
||||||
|
* `an unknown strategy name is read as a re-encode rather than thrown` covers a real defect this
|
||||||
|
* seam exposed. The line it replaces was:
|
||||||
|
*
|
||||||
|
* ```kotlin
|
||||||
|
* .getString(ConcatWorker.KEY_STRATEGY)?.let(ConcatStrategy::valueOf) ?: ConcatStrategy.REENCODE
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* `valueOf` throws on a name this build does not define, and this runs inside a `viewModelScope`
|
||||||
|
* collect with no handler — so it does not become a `Failed` state, it takes the process down.
|
||||||
|
* `ConcatWorker.kt` had already made this exact change for `KEY_FORMAT` and written down why; the
|
||||||
|
* matching read on this side had not been changed with it.
|
||||||
|
*/
|
||||||
|
@UnstableApi
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class JoinStateMappingTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a running join is joining`() {
|
||||||
|
assertEquals(JoinState.Joining(INPUTS), map(WorkInfo.State.RUNNING))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a blocked join looks like one that is starting`() {
|
||||||
|
// Folded into the RUNNING arm deliberately: a job waiting on a prerequisite is nothing the
|
||||||
|
// user can act on, and a separate word for it would be noise.
|
||||||
|
assertEquals(JoinState.Joining(INPUTS), map(WorkInfo.State.BLOCKED))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an enqueued join that has already run is waiting to retry`() {
|
||||||
|
assertEquals(JoinState.Waiting(INPUTS), map(WorkInfo.State.ENQUEUED, runAttemptCount = 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an enqueued join that has never run is simply starting`() {
|
||||||
|
// The other side. Without it, a mapping that ignored runAttemptCount passes the test above.
|
||||||
|
assertEquals(JoinState.Joining(INPUTS), map(WorkInfo.State.ENQUEUED, runAttemptCount = 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a success that named no file is a failure, not an empty success`() {
|
||||||
|
assertEquals(
|
||||||
|
JoinState.Failed(JOINED_WITHOUT_A_FILE_MESSAGE),
|
||||||
|
map(WorkInfo.State.SUCCEEDED, data = Data.EMPTY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a success carries the strategy the worker actually used`() {
|
||||||
|
// Not cosmetic: the join screen tells the user whether their files were stream-copied or
|
||||||
|
// re-encoded, which is the difference between lossless and lossy.
|
||||||
|
val joined = map(
|
||||||
|
WorkInfo.State.SUCCEEDED,
|
||||||
|
data = workDataOf(
|
||||||
|
ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4",
|
||||||
|
ConcatWorker.KEY_STRATEGY to ConcatStrategy.STREAM_COPY.name,
|
||||||
|
),
|
||||||
|
) as JoinState.Joined
|
||||||
|
|
||||||
|
assertEquals(ConcatStrategy.STREAM_COPY, joined.strategy)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown strategy name is read as a re-encode rather than thrown`() {
|
||||||
|
// The defect. A build that added a third strategy leaves finished joins in the queue naming
|
||||||
|
// it, and WorkManager keeps those about a week -- the premise WorkerEnumFallbackTest and
|
||||||
|
// JobTags are both written on. With `valueOf` this throws IllegalArgumentException inside a
|
||||||
|
// viewModelScope collect that has no handler, so it is not a Failed state, it is a crash.
|
||||||
|
//
|
||||||
|
// REENCODE rather than STREAM_COPY because it is the conservative answer: describing an
|
||||||
|
// unknown join as lossless would be a claim the app cannot support.
|
||||||
|
val joined = map(
|
||||||
|
WorkInfo.State.SUCCEEDED,
|
||||||
|
data = workDataOf(
|
||||||
|
ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4",
|
||||||
|
ConcatWorker.KEY_STRATEGY to "SMART_CONCAT_V2",
|
||||||
|
),
|
||||||
|
) as JoinState.Joined
|
||||||
|
|
||||||
|
assertEquals(ConcatStrategy.REENCODE, joined.strategy)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a success with no strategy at all falls back the same way`() {
|
||||||
|
val joined = map(
|
||||||
|
WorkInfo.State.SUCCEEDED,
|
||||||
|
data = workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4"),
|
||||||
|
) as JoinState.Joined
|
||||||
|
|
||||||
|
assertEquals(ConcatStrategy.REENCODE, joined.strategy)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a success from older work falls back to the format such a job really used`() {
|
||||||
|
val joined = map(
|
||||||
|
WorkInfo.State.SUCCEEDED,
|
||||||
|
data = workDataOf(ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4"),
|
||||||
|
) as JoinState.Joined
|
||||||
|
|
||||||
|
assertEquals(ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT), joined.suggestedName)
|
||||||
|
assertEquals(ConcatWorker.DEFAULT_FORMAT.mimeType, joined.mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a blank name or type falls back the same way a missing one does`() {
|
||||||
|
val joined = map(
|
||||||
|
WorkInfo.State.SUCCEEDED,
|
||||||
|
data = workDataOf(
|
||||||
|
ConcatWorker.KEY_OUTPUT_PATH to "/cache/conversions/joined.mp4",
|
||||||
|
ConcatWorker.KEY_SUGGESTED_NAME to "",
|
||||||
|
ConcatWorker.KEY_MIME_TYPE to " ",
|
||||||
|
),
|
||||||
|
) as JoinState.Joined
|
||||||
|
|
||||||
|
assertEquals(ConcatWorker.outputNameFor(ConcatWorker.DEFAULT_FORMAT), joined.suggestedName)
|
||||||
|
assertEquals(ConcatWorker.DEFAULT_FORMAT.mimeType, joined.mimeType)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failure carries the reason the worker gave`() {
|
||||||
|
assertEquals(
|
||||||
|
JoinState.Failed("Not enough free space to join these files."),
|
||||||
|
map(
|
||||||
|
WorkInfo.State.FAILED,
|
||||||
|
data = workDataOf(
|
||||||
|
ConcatWorker.KEY_ERROR to "Not enough free space to join these files.",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failure with nothing said still says something`() {
|
||||||
|
assertEquals(
|
||||||
|
JoinState.Failed(ConcatWorker.GENERIC_FAILURE_MESSAGE),
|
||||||
|
map(WorkInfo.State.FAILED, data = Data.EMPTY),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a failure whose message is blank falls back like a missing one`() {
|
||||||
|
assertEquals(
|
||||||
|
JoinState.Failed(ConcatWorker.GENERIC_FAILURE_MESSAGE),
|
||||||
|
map(WorkInfo.State.FAILED, data = workDataOf(ConcatWorker.KEY_ERROR to " ")),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a cancellation lands wherever the caller said it should`() {
|
||||||
|
// A join started here goes back to Ready with the picked files; one picked up by reattach
|
||||||
|
// goes to Idle, because those URIs belong to a process that no longer exists.
|
||||||
|
assertEquals(
|
||||||
|
JoinState.Ready(INPUTS),
|
||||||
|
map(WorkInfo.State.CANCELLED, cancelled = JoinState.Ready(INPUTS)),
|
||||||
|
)
|
||||||
|
assertEquals(JoinState.Idle, map(WorkInfo.State.CANCELLED, cancelled = JoinState.Idle))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun map(
|
||||||
|
state: WorkInfo.State,
|
||||||
|
runAttemptCount: Int = 0,
|
||||||
|
data: Data = Data.EMPTY,
|
||||||
|
cancelled: JoinState = JoinState.Ready(INPUTS),
|
||||||
|
): JoinState = joinStateFrom(
|
||||||
|
JoinUpdate(state = state, runAttemptCount = runAttemptCount, outputData = data),
|
||||||
|
inputs = INPUTS,
|
||||||
|
cancelled = cancelled,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val INPUTS = listOf(
|
||||||
|
InputFile(Uri.parse("content://test/a.mp4"), "a.mp4", 1024L),
|
||||||
|
InputFile(Uri.parse("content://test/b.mp4"), "b.mp4", 2048L),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user