Compare commits

..
Author SHA1 Message Date
Jason Ross 2e879ce5e3 Merge pull request #146 from JMR-dev/test/container-capabilities-audio
C2: test the audio half of validate, and the one video refusal missing
2026-08-27 08:55:33 -05:00
3 changed files with 21 additions and 140 deletions
@@ -27,6 +27,9 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment import org.robolectric.RuntimeEnvironment
import java.io.File import java.io.File
import java.util.UUID 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. * That a refused foreground-service start does not end the job.
@@ -122,40 +125,6 @@ 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 = private fun conversionWorker(runAttemptCount: Int = 0): ConversionWorker =
TestListenableWorkerBuilder<ConversionWorker>( TestListenableWorkerBuilder<ConversionWorker>(
context = app, context = app,
@@ -172,22 +141,18 @@ class DeniedForegroundStartTest {
.setForegroundUpdater(DenyingForegroundUpdater) .setForegroundUpdater(DenyingForegroundUpdater)
.build() .build()
private fun concatWorker(runAttemptCount: Int = 0): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>( private fun concatWorker(): ConcatWorker = TestListenableWorkerBuilder<ConcatWorker>(
context = app, context = app,
inputData = workDataOf( inputData = workDataOf(
ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "content://test/second.mp4"), ConcatWorker.KEY_INPUT_URIS to arrayOf(INPUT.toString(), "content://test/second.mp4"),
ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES, ConcatWorker.KEY_TOTAL_BYTES to INPUT_BYTES,
ConcatWorker.KEY_FORMAT to CONCAT_FORMAT.name, ConcatWorker.KEY_FORMAT to OutputFormat.MP4_H264.name,
), ),
runAttemptCount = runAttemptCount, runAttemptCount = 0,
).setId(CONCAT_ID) ).setId(CONCAT_ID)
.setForegroundUpdater(DenyingForegroundUpdater) .setForegroundUpdater(DenyingForegroundUpdater)
.build() .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. */ /** 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)) private fun stagedFile(): File = publisher.createStagingFile(StagingNames.forJob(CONVERSION_ID, SPEC.extension))
@@ -199,7 +164,6 @@ class DeniedForegroundStartTest {
const val INPUT_BYTES = 1024L const val INPUT_BYTES = 1024L
const val PARTIAL_BYTES = 2048 const val PARTIAL_BYTES = 2048
val SPEC = OutputFormat.MP4_H265.spec val SPEC = OutputFormat.MP4_H265.spec
val CONCAT_FORMAT = OutputFormat.MP4_H264
val CONVERSION_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000001") val CONVERSION_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000001")
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000002") val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000002")
} }
@@ -218,3 +182,18 @@ 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)
}
@@ -1,16 +1,12 @@
package org.libremediaconverter.work package org.libremediaconverter.work
import android.app.Application import android.app.Application
import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.work.Data import androidx.work.Data
import androidx.work.ForegroundInfo
import androidx.work.ForegroundUpdater
import androidx.work.ListenableWorker import androidx.work.ListenableWorker
import androidx.work.testing.TestListenableWorkerBuilder import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf import androidx.work.workDataOf
import com.google.common.util.concurrent.ListenableFuture
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.After import org.junit.After
@@ -22,7 +18,6 @@ import org.junit.runner.RunWith
import org.libremediaconverter.convert.ConversionDependencies import org.libremediaconverter.convert.ConversionDependencies
import org.libremediaconverter.convert.OutputPublisher import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.convert.SoftwareTranscoder import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.convert.StagingNames
import org.libremediaconverter.convert.installTestWorkManager import org.libremediaconverter.convert.installTestWorkManager
import org.libremediaconverter.model.ConversionRequest import org.libremediaconverter.model.ConversionRequest
import org.libremediaconverter.model.DeviceCodecs import org.libremediaconverter.model.DeviceCodecs
@@ -113,54 +108,6 @@ class WorkerCancellationTest {
assertEquals("a failed attempt must not leave its partial behind", emptyList<String>(), stagedNames()) 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. * A worker routed to the software engine, which is [failure] and nothing else.
* *
@@ -195,10 +142,7 @@ class WorkerCancellationTest {
const val DISPLAY_NAME = "holiday.mp4" const val DISPLAY_NAME = "holiday.mp4"
const val INPUT_BYTES = 1024L const val INPUT_BYTES = 1024L
val SPEC = OutputFormat.MP4_H265.spec 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 JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000003")
val CONCAT_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000004")
} }
} }
@@ -223,19 +167,3 @@ private class PartialThenFailingTranscoder(private val failure: () -> Nothing) :
const val PARTIAL_BYTES = 2048 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,14 +1,10 @@
package org.libremediaconverter.work package org.libremediaconverter.work
import android.content.Context import android.content.Context
import com.google.common.util.concurrent.ListenableFuture
import org.libremediaconverter.convert.OutputPublisher import org.libremediaconverter.convert.OutputPublisher
import org.libremediaconverter.convert.SoftwareTranscoder import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.model.ConversionRequest import org.libremediaconverter.model.ConversionRequest
import java.io.File 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. * Scaffolding more than one worker test needs.
@@ -72,25 +68,3 @@ object WritingTranscoder : SoftwareTranscoder {
private const val OUTPUT_BYTES = 512 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)
}