Six places decided what a converted or joined file should be called, and not one of them asked
the job. `JoinViewModel` reported `JoinState.Saved("joined.mp4")` whatever the format;
`JoinScreen` opened `CreateDocument("video/mp4")` and launched it with `"joined.mp4"`. On the
convert side `save()` and `suggestedOutputName()` built the name from `_settings.value.spec`, and
the screen took the MIME type from the same place -- the picker as it stands *now*, which is not
the spec the job ran with.
All six are right today, and all six are right by accident. The join screen has no format picker,
so the three MP4 literals agree with `ConcatWorker.request`'s default. The conversion pickers are
drawn only in the `Ready` state, so the settings cannot move between enqueue and save. Neither
accident is load-bearing anywhere it is written down.
One of them has already stopped holding, quietly. A job picked up by `reattach()` ran with a spec
that was never in this ViewModel's settings, because those settings belong to a process that no
longer exists -- so a reattached MP3 conversion is offered `.mp4` and `video/mp4` today. The
previous commit made that path more reachable rather than less: `Reattachment` exists precisely
to find work this ViewModel did not start.
The fix is to ask the only thing that knows. The spec travels to the worker as input `Data` and
`WorkInfo` hands input `Data` back to nobody, so the worker is the single point at which the
input's name and the spec that ran are both in scope. Both workers now report the two derived
strings -- the name to suggest and the type to open the dialog with -- in their output `Data`,
and they ride on `ConversionState.Converted` and `JoinState.Joined` from there. `save()` reports
what it saved rather than recomputing it, and both screens read the name and the MIME type off
the state they already collect, remembering their `CreateDocument` contract against that type
instead of a literal. The MIME type is not cosmetic: some providers rewrite a document's
extension to match it, so an MP3 offered as `video/webm` can arrive with the wrong one.
`suggestedOutputName()` is deleted rather than repaired. With the answer on the state there is no
caller left for it, and an accessor recomputing the same string would only be a second place for
it to be wrong -- which is what it was.
`ConcatWorker` gains `DEFAULT_FORMAT` and `outputNameFor(format)`. Three copies of "MP4" is the
shape this entry is about, and the input-Data default, `request`'s parameter default and the
ViewModel's fallback for a job that predates this change are exactly three copies.
Work already in the queue carries neither string, and WorkManager keeps finished work for about a
week, so that is the ordinary case for a few days rather than a corner. Those fall back to the
old derivation, which is a guess -- but it is the same guess the app was already making, it is
confined to jobs enqueued before this commit, and for such a job there is genuinely nothing
better to hand. New work never reaches it. The join's fallback is not even a guess: the format
`ConcatWorker.request` has always defaulted to is the format such a job really used.
`reattach()`'s KDoc carried this as a known wart it was deliberately leaving alone. That
paragraph is now a description of the fix rather than of a defect.
Tested at both ends, since either alone would pass while the other was wrong.
`WorkerOutputNamingTest` runs the real worker on an MP3 job -- nothing like the default preset, so
a name built from the picker is visibly wrong rather than accidentally right -- and compares the
whole success `Result`, which is what makes a missing key fail rather than go unnoticed. The two
ViewModel tests drive a finished job and then move the picker, which is what a reattached job
amounts to from the ViewModel's point of view.
Restoring just the two name sources -- `save()`'s `outputNameFor(displayName, _settings.value.spec)`
and `JoinState.Saved("joined.mp4")` -- turns them red with
`expected:<holiday_converted.mp3> but was:<input_converted.webm>` and
`expected:<joined.mkv> but was:<joined.mp4>`. The convert side loses the extension *and* the name:
`_settings.value` had been moved to WebM, and the display name a reattached job cannot supply had
already fallen back to the placeholder.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
114 lines
4.1 KiB
Kotlin
114 lines
4.1 KiB
Kotlin
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.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.model.ConcatStrategy
|
|
import org.libremediaconverter.work.ConcatWorker
|
|
import org.robolectric.RobolectricTestRunner
|
|
import org.robolectric.RuntimeEnvironment
|
|
import java.io.File
|
|
|
|
/**
|
|
* That a join is named after the format it produced.
|
|
*
|
|
* `JoinState.Saved("joined.mp4")` was a literal, and so were the screen's `CreateDocument`
|
|
* MIME type and the name it launched with. All three agree with reality only because the join
|
|
* screen has no format picker and `ConcatWorker.request` defaults to MP4 — three copies of one
|
|
* assumption, none of which would notice the day a picker arrives.
|
|
*
|
|
* MKV throughout below, because it is the format the old literals get wrong.
|
|
*/
|
|
@UnstableApi
|
|
@RunWith(RobolectricTestRunner::class)
|
|
class JoinViewModelNamingTest {
|
|
|
|
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("staged-under-a-job-id.mkv").apply { writeBytes(ByteArray(4096)) }
|
|
installTestWorkManager(
|
|
app,
|
|
workDataOf(
|
|
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
|
ConcatWorker.KEY_STRATEGY to ConcatStrategy.STREAM_COPY.name,
|
|
ConcatWorker.KEY_SUGGESTED_NAME to "joined.mkv",
|
|
ConcatWorker.KEY_MIME_TYPE to "video/x-matroska",
|
|
),
|
|
)
|
|
}
|
|
|
|
@After
|
|
fun tearDown() {
|
|
ConversionDependencies.reset()
|
|
}
|
|
|
|
@Test
|
|
fun `a finished join carries the name and type its own format produced`() {
|
|
val joined = joinedViewModel().state.value as JoinState.Joined
|
|
|
|
assertEquals("joined.mkv", joined.suggestedName)
|
|
assertEquals("video/x-matroska", joined.mimeType)
|
|
}
|
|
|
|
@Test
|
|
fun `saving reports that name rather than a hardcoded one`() {
|
|
val viewModel = joinedViewModel()
|
|
|
|
viewModel.save(DESTINATION)
|
|
val saved = awaitState(viewModel.state, "Saved") { it is JoinState.Saved }
|
|
|
|
assertEquals("joined.mkv", (saved as JoinState.Saved).displayName)
|
|
}
|
|
|
|
@Test
|
|
fun `a join from before the worker reported its own name still gets one`() {
|
|
// Work enqueued by an earlier version carries neither string. The fallback is the format
|
|
// ConcatWorker.request has always defaulted to, which is what such a job really used.
|
|
installTestWorkManager(
|
|
app,
|
|
workDataOf(
|
|
ConcatWorker.KEY_OUTPUT_PATH to staged.absolutePath,
|
|
ConcatWorker.KEY_STRATEGY to ConcatStrategy.STREAM_COPY.name,
|
|
),
|
|
)
|
|
|
|
val joined = joinedViewModel().state.value as JoinState.Joined
|
|
|
|
assertEquals("joined.mp4", joined.suggestedName)
|
|
assertEquals("video/mp4", joined.mimeType)
|
|
}
|
|
|
|
/** A ViewModel driven all the way to [JoinState.Joined]. */
|
|
private fun joinedViewModel(): JoinViewModel {
|
|
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
|
|
viewModel.onInputsPicked(listOf(Uri.parse("content://test/a.mkv"), Uri.parse("content://test/b.mkv")))
|
|
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.mkv")
|
|
}
|
|
}
|