Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
795b456d94 | ||
|
|
2c0bc4a583 | ||
|
|
7a47285f37 | ||
|
|
8a2bc86cac | ||
|
|
0e2525195b | ||
|
|
713d813a65 | ||
|
|
c360e82a10 | ||
|
|
44493d9943 | ||
|
|
de6d9526ba | ||
|
|
bb3358f209 | ||
|
|
04850a0415 |
@@ -358,4 +358,120 @@ class ContainerCapabilitiesTest {
|
||||
assertEquals(emptyList<VideoCodec>(), ContainerCapabilities.encodableVideo(container))
|
||||
}
|
||||
}
|
||||
|
||||
// --- the audio axis -----------------------------------------------------
|
||||
//
|
||||
// Every rule below has a video twin already tested above. The two halves of `validate` were
|
||||
// written together and only one of them was ever checked, so these are deliberately shaped like
|
||||
// their twins rather than as a fresh idea about what to assert.
|
||||
|
||||
@Test
|
||||
fun `an unidentifiable source audio codec cannot be copied`() {
|
||||
// The audio twin of `an unidentifiable source codec cannot be copied`. Never guess: a copy
|
||||
// of an unidentified codec is how you ship a file that does not play.
|
||||
val unknownAudio = InputProbe(videoCodec = "h264", audioCodec = null, container = Container.MP4)
|
||||
val spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.COPY)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, unknownAudio) as? Validation.Invalid
|
||||
?: throw AssertionError("copying an unidentified audio codec must be refused")
|
||||
|
||||
assertTrue(invalid.message, invalid.message.contains("could not be identified"))
|
||||
assertEverySuggestionValid(invalid, unknownAudio)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying an audio codec the container cannot hold is refused`() {
|
||||
// MP4 carries AAC, MP3, Opus and FLAC. Vorbis lives in Ogg and Matroska, so a stream copy
|
||||
// out of a Vorbis source into MP4 has nowhere to put the track.
|
||||
val vorbisAudio = InputProbe(videoCodec = "h264", audioCodec = "vorbis", container = Container.MKV)
|
||||
val spec = OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.COPY)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, vorbisAudio) as? Validation.Invalid
|
||||
?: throw AssertionError("Vorbis copied into MP4 must be refused")
|
||||
|
||||
assertEquals("MP4 cannot hold Vorbis audio.", invalid.message)
|
||||
assertEverySuggestionValid(invalid, vorbisAudio)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an audio codec the container cannot hold is refused on the encode path too`() {
|
||||
// WAV carries PCM and nothing else. The twin is `H265 in AVI is refused`.
|
||||
val spec = OutputSpec(Container.WAV, VideoCodec.NONE, AudioCodec.AAC)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, mp3Source) as? Validation.Invalid
|
||||
?: throw AssertionError("AAC in WAV must be refused")
|
||||
|
||||
assertEquals("WAV cannot hold AAC audio.", invalid.message)
|
||||
assertEverySuggestionValid(invalid, mp3Source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an audio codec this app cannot encode is refused, and copying is offered instead`() {
|
||||
// Matroska carries Vorbis; nothing here encodes it. The refusal has to say so *and* say
|
||||
// what would work, which is the audio twin of `copying is offered as the fix when the codec
|
||||
// is right but unencodable`.
|
||||
val spec = OutputSpec(Container.MKV, VideoCodec.H264, AudioCodec.VORBIS)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, h264Source) as? Validation.Invalid
|
||||
?: throw AssertionError("encoding Vorbis must be refused")
|
||||
|
||||
assertEquals(
|
||||
"This app cannot encode Vorbis audio. It can still be copied from a Vorbis source.",
|
||||
invalid.message,
|
||||
)
|
||||
assertEverySuggestionValid(invalid, h264Source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `copying a video codec the container cannot hold is refused`() {
|
||||
// Not the audio axis, but the one video refusal with no test: AVI predates H.265, so a
|
||||
// stream copy out of an HEVC source into AVI has nowhere to put the track. `H265 in AVI is
|
||||
// refused` covers the matrix; this covers what validate() does with it.
|
||||
val h265Source = InputProbe(videoCodec = "hevc", audioCodec = "mp3", container = Container.MP4)
|
||||
val spec = OutputSpec(Container.AVI, VideoCodec.COPY, AudioCodec.MP3)
|
||||
|
||||
val invalid = ContainerCapabilities.validate(spec, h265Source) as? Validation.Invalid
|
||||
?: throw AssertionError("H.265 copied into AVI must be refused")
|
||||
|
||||
assertEquals("AVI cannot hold H.265 video.", invalid.message)
|
||||
assertEverySuggestionValid(invalid, h265Source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `no audio track is accepted by every container in both modes`() {
|
||||
// The audio twin of VideoCodec.NONE -> true. A container that refused "no audio" would make
|
||||
// every video-only output invalid.
|
||||
Container.entries.forEach { container ->
|
||||
listOf(CodecMode.COPY, CodecMode.ENCODE).forEach { mode ->
|
||||
assertTrue(
|
||||
"$container should accept no audio track ($mode)",
|
||||
ContainerCapabilities.accepts(container, AudioCodec.NONE, mode),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resolving audio COPY before asking the matrix is required`() {
|
||||
// The audio twin of `resolving COPY before asking the matrix is required`, and the reason is
|
||||
// identical: silently answering "false" would refuse a perfectly good remux.
|
||||
runCatching { ContainerCapabilities.accepts(Container.MP4, AudioCodec.COPY, CodecMode.COPY) }
|
||||
.onSuccess { throw AssertionError("expected audio COPY to be rejected by the matrix") }
|
||||
}
|
||||
|
||||
/**
|
||||
* Every alternative a refusal offers has to be one the same input could actually take.
|
||||
*
|
||||
* `Validation.Invalid` promises exactly this and names this class as the proof. The global
|
||||
* property test walks the presets; these paths reach `suggestions()` through `validateAudio`,
|
||||
* which no preset does.
|
||||
*/
|
||||
private fun assertEverySuggestionValid(invalid: Validation.Invalid, probe: InputProbe) {
|
||||
invalid.suggestions.forEach {
|
||||
assertTrue(
|
||||
"suggestion $it is itself invalid, so the chip leads to a second error",
|
||||
ContainerCapabilities.validate(it, probe).isValid,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,9 +27,6 @@ import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* That a refused foreground-service start does not end the job.
|
||||
@@ -125,6 +122,40 @@ class DeniedForegroundStartTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join denied past the attempt bound fails with a message the user can act on`() {
|
||||
// The join twin of the conversion case above. ConcatWorker reaches the same FailureOutcome
|
||||
// through its own `when`, and that arm was the only one of its three with no test -- so a
|
||||
// join that gave up silently, or gave up with an empty Data, would have looked identical to
|
||||
// one that retried.
|
||||
val worker = concatWorker(runAttemptCount = FailureOutcome.MAX_FOREGROUND_START_ATTEMPTS)
|
||||
|
||||
val result = runBlocking { worker.doWork() }
|
||||
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(
|
||||
workDataOf(ConcatWorker.KEY_ERROR to FailureOutcome.FOREGROUND_DENIED_MESSAGE),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join that gives up collects the partial it had already staged`() {
|
||||
// The delete lives on ConcatWorker's `catch (e: Throwable)` path, which every give-up goes
|
||||
// through. Written first so a missing delete cannot pass by asking whether a file nobody
|
||||
// wrote is absent.
|
||||
concatStagedFile().writeBytes(ByteArray(PARTIAL_BYTES))
|
||||
|
||||
runBlocking { concatWorker(runAttemptCount = FailureOutcome.MAX_FOREGROUND_START_ATTEMPTS).doWork() }
|
||||
|
||||
assertEquals(
|
||||
"a join that gave up must not orphan what it staged",
|
||||
emptyList<String>(),
|
||||
stagedNames(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun conversionWorker(runAttemptCount: Int = 0): ConversionWorker =
|
||||
TestListenableWorkerBuilder<ConversionWorker>(
|
||||
context = app,
|
||||
@@ -141,18 +172,22 @@ class DeniedForegroundStartTest {
|
||||
.setForegroundUpdater(DenyingForegroundUpdater)
|
||||
.build()
|
||||
|
||||
private fun concatWorker(): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
private fun concatWorker(runAttemptCount: Int = 0): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "content://test/second.mp4"),
|
||||
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
|
||||
ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name,
|
||||
ConcatWorker.KEY_FORMAT to CONCAT_FORMAT.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
runAttemptCount = runAttemptCount,
|
||||
).setId(CONCAT_ID)
|
||||
.setForegroundUpdater(DenyingForegroundUpdater)
|
||||
.build()
|
||||
|
||||
/** The staging path the join will compute, asked for rather than spelled out here. */
|
||||
private fun concatStagedFile(): File =
|
||||
publisher.createStagingFile(StagingNames.forJob(CONCAT_ID, CONCAT_FORMAT.extension))
|
||||
|
||||
/** The staging path the worker will compute, asked for rather than spelled out here. */
|
||||
private fun stagedFile(): File = publisher.createStagingFile(StagingNames.forJob(CONVERSION_ID, SPEC.extension))
|
||||
|
||||
@@ -164,6 +199,7 @@ class DeniedForegroundStartTest {
|
||||
const val INPUT_BYTES = 1024L
|
||||
const val PARTIAL_BYTES = 2048
|
||||
val SPEC = OutputFormat.MP4_H265.spec
|
||||
val CONCAT_FORMAT = OutputFormat.MP4_H264
|
||||
val CONVERSION_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000001")
|
||||
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000002")
|
||||
}
|
||||
@@ -182,18 +218,3 @@ private object DenyingForegroundUpdater : ForegroundUpdater {
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* An already-failed future, written out rather than pulled from a futures library.
|
||||
*
|
||||
* `await()` takes the `isDone` fast path and unwraps the `ExecutionException`, which is what puts
|
||||
* the platform's own exception in front of the worker's catch rather than a wrapper.
|
||||
*/
|
||||
private class FailedFuture(private val failure: Throwable) : ListenableFuture<Void> {
|
||||
override fun addListener(listener: Runnable, executor: Executor): Unit = executor.execute(listener)
|
||||
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false
|
||||
override fun isCancelled(): Boolean = false
|
||||
override fun isDone(): Boolean = true
|
||||
override fun get(): Void = throw ExecutionException(failure)
|
||||
override fun get(timeout: Long, unit: TimeUnit): Void = throw ExecutionException(failure)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
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.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.ContainerCapabilities
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.Validation
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* Jobs the worker refuses before it converts anything, and what it says about them.
|
||||
*
|
||||
* Two exits, both cold before this file, and both reachable for the same underlying reason: **a job
|
||||
* does not have to come from the picker.** WorkManager keeps queued and finished work for about a
|
||||
* week, so a downgrade or a rollback hands this build a job enqueued by another one — the premise
|
||||
* `WorkerEnumFallbackTest` and `JobTags` are both written on — and `ConversionWorker.request(...)`
|
||||
* is callable directly.
|
||||
*
|
||||
* What makes these worth their own file rather than another case in an existing one is that both
|
||||
* are about the *message*. A refusal that fails with empty output `Data` renders the UI's generic
|
||||
* "Conversion failed." with nothing else to say, which is the defect shape `DeniedForegroundStartTest`
|
||||
* records from the device pass. Asserting the verdict alone would pass against exactly that.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class RefusedJobTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var publisher: OutputPublisher
|
||||
private lateinit var engine: RefusingTranscoder
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
publisher = AlwaysRoomPublisher(app)
|
||||
engine = RefusingTranscoder()
|
||||
ConversionDependencies.publisher = { publisher }
|
||||
ConversionDependencies.software = { engine }
|
||||
// Neither test is about probing or about this machine's codecs; both would otherwise decide
|
||||
// the outcome for reasons no assertion mentions. See WorkerCancellationTest's setUp.
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a job with no input URI fails with a message rather than a bare failure`() {
|
||||
val result = runBlocking { workerWithout(ConversionWorker.KEY_INPUT_URI).doWork() }
|
||||
|
||||
// `Failure.equals` compares output data, so this pins the message and the verdict together.
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(workDataOf(ConversionWorker.KEY_ERROR to "No input file.")),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a job with no input URI stages nothing`() {
|
||||
// The URI read is the first thing doWork does -- above the space check, above the staging
|
||||
// name, above the try. A refusal there must not have reserved anything.
|
||||
runBlocking { workerWithout(ConversionWorker.KEY_INPUT_URI).doWork() }
|
||||
|
||||
assertEquals("a job refused for having no input must not stage a file", emptyList<String>(), stagedNames())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a spec the picker would never have allowed is refused with the reason`() {
|
||||
// WAV carries PCM and nothing else. The picker cannot produce this combination today, which
|
||||
// is exactly why the worker checks: the job can arrive from a queue written before the
|
||||
// settings changed, or from a direct request(...) call.
|
||||
val expected = ContainerCapabilities.validate(REFUSED_SPEC, InputProbe()) as? Validation.Invalid
|
||||
?: throw AssertionError("the fixture spec is supposed to be invalid; ContainerCapabilities disagrees")
|
||||
|
||||
val result = runBlocking { worker(REFUSED_SPEC).doWork() }
|
||||
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(workDataOf(ConversionWorker.KEY_ERROR to expected.message)),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a refused spec never reaches an engine`() {
|
||||
// The half that says it failed *before* converting rather than during. Without this, a
|
||||
// worker that ran the job and then reported the validation message would pass the test
|
||||
// above -- and would have spent the user's battery on a file it was going to refuse.
|
||||
runBlocking { worker(REFUSED_SPEC).doWork() }
|
||||
|
||||
assertTrue("a refused spec must be refused before any engine runs", engine.invocations.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a valid spec is not refused`() {
|
||||
// The control. Every assertion above is about a refusal, so without this they would all
|
||||
// still pass against a worker that refused everything.
|
||||
val result = runBlocking { worker(OutputFormat.MP4_H265.spec).doWork() }
|
||||
|
||||
assertEquals(ListenableWorker.Result.success(), stripOutput(result))
|
||||
assertEquals(listOf(OutputFormat.MP4_H265.spec), engine.invocations)
|
||||
}
|
||||
|
||||
// --- the same refusal, on the join side ----------------------------------
|
||||
|
||||
@Test
|
||||
fun `a join of a single file is refused with a message rather than joined`() {
|
||||
// The arm beside it -- a job with no URI array at all -- is covered on the device by
|
||||
// `UnopenableUriTest.aJoinWithNoInputArrayFailsWithAMessage`. This one was covered by
|
||||
// nothing in either source set, which a coverage report cannot say because it cannot see
|
||||
// androidTest: the two arms are adjacent lines and only one of them had a test.
|
||||
//
|
||||
// Reachable for the reason this file's header gives, plus one of its own: `request(...)`
|
||||
// takes a `List<Uri>` and checks nothing about its length, so a single-item join is a
|
||||
// well-formed call, not a corrupted queue entry.
|
||||
val result = runBlocking { joinWorker(INPUT).doWork() }
|
||||
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(
|
||||
workDataOf(ConcatWorker.KEY_ERROR to "Pick at least two files to join."),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a join of two files is not refused for its count`() {
|
||||
// The control, and the half that makes the test above bite on the boundary rather than on
|
||||
// the message: without it, `uris.size < 3` passes everything here.
|
||||
//
|
||||
// It refuses the space instead of letting the job run, because the next thing past the
|
||||
// count guard is `ConcatEngine`, which is native -- `NamingPublisher`'s KDoc records that
|
||||
// no JVM test gets past it. A refusal with the *space* message is proof that execution
|
||||
// reached line 57, which is proof it got past line 42, and it costs no engine to say so.
|
||||
val noRoom = NamingPublisher(app).apply { refuseSpace = true }
|
||||
ConversionDependencies.publisher = { noRoom }
|
||||
|
||||
val result = runBlocking { joinWorker(INPUT, SECOND_INPUT).doWork() }
|
||||
|
||||
assertEquals(
|
||||
ListenableWorker.Result.failure(
|
||||
workDataOf(ConcatWorker.KEY_ERROR to "Not enough free space to join these files."),
|
||||
),
|
||||
result,
|
||||
)
|
||||
}
|
||||
|
||||
/** [ListenableWorker.Result.Success] compares its output data, which these tests do not pin. */
|
||||
private fun stripOutput(result: ListenableWorker.Result): ListenableWorker.Result =
|
||||
if (result is ListenableWorker.Result.Success) ListenableWorker.Result.success() else result
|
||||
|
||||
private fun worker(spec: OutputSpec): ConversionWorker = build(
|
||||
workDataOf(
|
||||
ConversionWorker.KEY_INPUT_URI to INPUT.toString(),
|
||||
ConversionWorker.KEY_DISPLAY_NAME to DISPLAY_NAME,
|
||||
ConversionWorker.KEY_SIZE_BYTES to INPUT_BYTES,
|
||||
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,
|
||||
),
|
||||
)
|
||||
|
||||
/**
|
||||
* The ordinary input `Data`, less one key.
|
||||
*
|
||||
* Built by removal rather than by spelling out a shorter map, so the test cannot drift into
|
||||
* omitting something else as well and passing for a reason it does not name.
|
||||
*/
|
||||
private fun workerWithout(key: String): ConversionWorker {
|
||||
val full = OutputFormat.MP4_H265.spec
|
||||
val entries = mapOf(
|
||||
ConversionWorker.KEY_INPUT_URI to INPUT.toString(),
|
||||
ConversionWorker.KEY_DISPLAY_NAME to DISPLAY_NAME,
|
||||
ConversionWorker.KEY_SIZE_BYTES to INPUT_BYTES,
|
||||
ConversionWorker.KEY_CONTAINER to full.container.name,
|
||||
ConversionWorker.KEY_VIDEO_CODEC to full.videoCodec.name,
|
||||
ConversionWorker.KEY_AUDIO_CODEC to full.audioCodec.name,
|
||||
ConversionWorker.KEY_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
|
||||
) - key
|
||||
return build(Data.Builder().putAll(entries).build())
|
||||
}
|
||||
|
||||
private fun build(data: Data): ConversionWorker =
|
||||
TestListenableWorkerBuilder<ConversionWorker>(context = app, inputData = data, runAttemptCount = 0)
|
||||
.setId(JOB_ID)
|
||||
.build()
|
||||
|
||||
/**
|
||||
* A join job carrying [inputs], a declared total, and a format.
|
||||
*
|
||||
* The total is declared so `hasRoomFor` takes its `hasSpaceFor` branch: the other branch is
|
||||
* `hasSpaceForUnknownSize`, which `NamingPublisher` does not override and which would measure
|
||||
* this machine's real disk.
|
||||
*/
|
||||
private fun joinWorker(vararg inputs: Uri): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConcatWorker.KEY_INPUT_URIS to inputs.map(Uri::toString).toTypedArray(),
|
||||
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES * inputs.size,
|
||||
ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
).setId(JOB_ID).build()
|
||||
|
||||
private fun stagedNames(): List<String> =
|
||||
publisher.createStagingFile("anything").parentFile?.listFiles().orEmpty().map { it.name }.sorted()
|
||||
|
||||
private companion object {
|
||||
val INPUT: Uri = Uri.parse("file:///tmp/holiday.mp4")
|
||||
const val DISPLAY_NAME = "holiday.mp4"
|
||||
const val INPUT_BYTES = 1024L
|
||||
|
||||
/** A join needs two, and "two" is the boundary the count guard is about. */
|
||||
val SECOND_INPUT: Uri = Uri.parse("file:///tmp/holiday-2.mp4")
|
||||
|
||||
/** WAV carries PCM and nothing else, so AAC in WAV has nowhere to go. */
|
||||
val REFUSED_SPEC = OutputSpec(
|
||||
org.libremediaconverter.model.Container.WAV,
|
||||
VideoCodec.NONE,
|
||||
AudioCodec.AAC,
|
||||
)
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000005")
|
||||
}
|
||||
}
|
||||
|
||||
/** An engine that records what it was asked for and writes an output, so a success is a success. */
|
||||
private class RefusingTranscoder : SoftwareTranscoder {
|
||||
|
||||
/** Every spec that actually reached an engine. Empty is the assertion for a refused job. */
|
||||
val invocations = mutableListOf<OutputSpec>()
|
||||
|
||||
override suspend fun run(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
output: File,
|
||||
durationMs: Long,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
invocations += request.spec
|
||||
output.writeBytes(ByteArray(OUTPUT_BYTES))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val OUTPUT_BYTES = 512
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,16 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import androidx.work.ForegroundInfo
|
||||
import androidx.work.ForegroundUpdater
|
||||
import androidx.work.ListenableWorker
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.workDataOf
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
@@ -18,6 +22,7 @@ import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.convert.StagingNames
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.DeviceCodecs
|
||||
@@ -108,6 +113,54 @@ class WorkerCancellationTest {
|
||||
assertEquals("a failed attempt must not leave its partial behind", emptyList<String>(), stagedNames())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled join propagates instead of being turned into a Result`() {
|
||||
val thrown = runCatching { runBlocking { concatWorker().doWork() } }.exceptionOrNull()
|
||||
|
||||
assertTrue(
|
||||
"cancellation must leave doWork as cancellation, not as a Result; got $thrown",
|
||||
thrown is CancellationException,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled join still deletes the partial it had already staged`() {
|
||||
// Written first, so a missing delete cannot pass by asking whether a file nobody wrote is
|
||||
// absent -- the same reason PartialThenFailingTranscoder writes before it throws.
|
||||
concatStagedFile().writeBytes(ByteArray(PARTIAL_STAGED_BYTES))
|
||||
|
||||
runCatching { runBlocking { concatWorker().doWork() } }
|
||||
|
||||
assertEquals("a cancelled join must not leave its partial behind", emptyList<String>(), stagedNames())
|
||||
}
|
||||
|
||||
/**
|
||||
* A join whose foreground start is cancelled rather than denied.
|
||||
*
|
||||
* The conversion twin cancels *inside the engine*, which is the honest shape there because
|
||||
* `ConversionDependencies` has a seam for it. `ConcatWorker` calls `ConcatEngine` directly and
|
||||
* has no such seam -- it is native, and nothing here gets past it -- so the cancellation is
|
||||
* injected at the only other point inside the `try`: `setForeground`. That is not a contrivance.
|
||||
* A job cancelled while WorkManager is promoting it to the foreground is precisely when the
|
||||
* window is open, and what is being tested is the `catch` arm, which cannot tell where in the
|
||||
* `try` the cancellation came from.
|
||||
*/
|
||||
private fun concatWorker(): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
|
||||
context = app,
|
||||
inputData = workDataOf(
|
||||
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "file:///tmp/second.mp4"),
|
||||
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
|
||||
ConcatWorker.KEY_FORMAT to CONCAT_FORMAT.name,
|
||||
),
|
||||
runAttemptCount = 0,
|
||||
).setId(CONCAT_ID)
|
||||
.setForegroundUpdater(CancellingForegroundUpdater)
|
||||
.build()
|
||||
|
||||
/** The staging path the join will compute, asked for rather than spelled out here. */
|
||||
private fun concatStagedFile(): File =
|
||||
publisher.createStagingFile(StagingNames.forJob(CONCAT_ID, CONCAT_FORMAT.extension))
|
||||
|
||||
/**
|
||||
* A worker routed to the software engine, which is [failure] and nothing else.
|
||||
*
|
||||
@@ -142,7 +195,10 @@ class WorkerCancellationTest {
|
||||
const val DISPLAY_NAME = "holiday.mp4"
|
||||
const val INPUT_BYTES = 1024L
|
||||
val SPEC = OutputFormat.MP4_H265.spec
|
||||
val CONCAT_FORMAT = OutputFormat.MP4_H264
|
||||
const val PARTIAL_STAGED_BYTES = 2048
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000003")
|
||||
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000004")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,3 +223,19 @@ private class PartialThenFailingTranscoder(private val failure: () -> Nothing) :
|
||||
const val PARTIAL_BYTES = 2048
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for a job cancelled while WorkManager is promoting it to the foreground.
|
||||
*
|
||||
* The mechanism `DeniedForegroundStartTest` documents, carrying a different exception:
|
||||
* `WorkForegroundUpdater` propagates whatever the future failed with, and
|
||||
* `ListenableFuture.await()` unwraps the `ExecutionException`, so the worker meets a bare
|
||||
* `CancellationException` exactly where a real cancellation would put one.
|
||||
*/
|
||||
private object CancellingForegroundUpdater : ForegroundUpdater {
|
||||
override fun setForegroundAsync(
|
||||
context: Context,
|
||||
id: UUID,
|
||||
foregroundInfo: ForegroundInfo,
|
||||
): ListenableFuture<Void> = FailedFuture(CancellationException("cancelled while going foreground"))
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.content.Context
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import org.libremediaconverter.convert.OutputPublisher
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import java.io.File
|
||||
import java.util.concurrent.ExecutionException
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* Scaffolding more than one worker test needs.
|
||||
@@ -68,3 +72,25 @@ object WritingTranscoder : SoftwareTranscoder {
|
||||
|
||||
private const val OUTPUT_BYTES = 512
|
||||
}
|
||||
|
||||
/**
|
||||
* An already-failed future, written out rather than pulled from a futures library.
|
||||
*
|
||||
* `await()` takes the `isDone` fast path and unwraps the `ExecutionException`, which is what puts
|
||||
* the original exception in front of the worker's `catch` rather than a wrapper. That is the whole
|
||||
* mechanism behind driving a `ForegroundUpdater` to fail: `WorkForegroundUpdater` propagates
|
||||
* whatever the future failed with rather than swallowing it, so `setForeground()` throws exactly
|
||||
* what is handed here.
|
||||
*
|
||||
* Shared because two tests inject two different failures through it -- a denied foreground start
|
||||
* and a cancellation -- and Kotlin will not take two file-private top-level classes of one name in
|
||||
* one package.
|
||||
*/
|
||||
internal class FailedFuture(private val failure: Throwable) : ListenableFuture<Void> {
|
||||
override fun addListener(listener: Runnable, executor: Executor): Unit = executor.execute(listener)
|
||||
override fun cancel(mayInterruptIfRunning: Boolean): Boolean = false
|
||||
override fun isCancelled(): Boolean = false
|
||||
override fun isDone(): Boolean = true
|
||||
override fun get(): Void = throw ExecutionException(failure)
|
||||
override fun get(timeout: Long, unit: TimeUnit): Void = throw ExecutionException(failure)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user