Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eded47d666 | ||
|
|
b41341a1cb | ||
|
|
0f842243b5 | ||
|
|
2fbc957119 | ||
|
|
a645442acc |
@@ -24,9 +24,11 @@ import androidx.compose.runtime.saveable.Saver
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import org.libremediaconverter.convert.ConverterScreen
|
||||
import org.libremediaconverter.join.JoinScreen
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.libremediaconverter.ui.theme.LibreMediaConverterTheme
|
||||
|
||||
/**
|
||||
@@ -114,7 +116,7 @@ internal fun AppRoot(
|
||||
|
||||
if (useRail) {
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
NavigationRail {
|
||||
NavigationRail(modifier = Modifier.testTag(TestTags.Shell.NAVIGATION_RAIL)) {
|
||||
Destination.entries.forEach { item ->
|
||||
NavigationRailItem(
|
||||
selected = destination == item,
|
||||
@@ -132,7 +134,7 @@ internal fun AppRoot(
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
NavigationBar(modifier = Modifier.testTag(TestTags.Shell.NAVIGATION_BAR)) {
|
||||
Destination.entries.forEach { item ->
|
||||
NavigationBarItem(
|
||||
selected = destination == item,
|
||||
|
||||
@@ -56,6 +56,20 @@ object TestTags {
|
||||
*/
|
||||
const val RETRY_SAVE: String = "action.retrySave"
|
||||
|
||||
/**
|
||||
* The adaptive shell around both screens -- `AppRoot`'s two layouts.
|
||||
*
|
||||
* Named because there is no other way to tell them apart from a test. Both render the same two
|
||||
* destinations with the same labels and the same selection state, so every assertion that could
|
||||
* be written without these tags is satisfied by either layout, and transposing the two bodies
|
||||
* passed the whole suite. Exactly one of the two exists at a time, which is what makes
|
||||
* `assertExists` / `assertDoesNotExist` on this pair a statement about the width class.
|
||||
*/
|
||||
object Shell {
|
||||
const val NAVIGATION_RAIL: String = "shell.navigationRail"
|
||||
const val NAVIGATION_BAR: String = "shell.navigationBar"
|
||||
}
|
||||
|
||||
/** `ConverterScreen`. */
|
||||
object Converter {
|
||||
const val CHOOSE_FILE: String = "converter.chooseFile"
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package org.libremediaconverter
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithTag
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import org.junit.After
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.ConversionDependencies
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.ui.TestTags
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
|
||||
/**
|
||||
* Which navigation affordance the shell actually renders, and which screen it actually shows.
|
||||
*
|
||||
* Assertion gaps rather than coverage gaps, both of them, and that is why they lasted.
|
||||
* `AppRootRestorationTest` already drives `AppRoot` at `Compact` and `Expanded`, so JaCoCo is green
|
||||
* on `useRail` -- but it asserts only that the *selected tab* survives recreation, through a stub
|
||||
* `content` composable. Nothing anywhere queried for a rail or a bar, and nothing rendered the real
|
||||
* screens. Two consequences, both measured before this file existed:
|
||||
*
|
||||
* - **Transposing the `NavigationRail` and `NavigationBar` bodies passed the entire suite.**
|
||||
* - **Transposing `Content`'s two arms passed it too** -- a tablet showing the phone chrome, or the
|
||||
* Convert tab opening the Join screen, and 546 tests with nothing to say about either.
|
||||
*
|
||||
* `AppRoot`'s own KDoc is why this matters more than it looks: from targetSdk 37 the app is resized
|
||||
* and rotated whether or not it is ready, so the width class is not a preference, it is whatever
|
||||
* the system hands over.
|
||||
*
|
||||
* ## Two things this needed that the rest of the suite does not
|
||||
*
|
||||
* **`createAndroidComposeRule`, not `createComposeRule`.** Rendering `AppRoot` with its *default*
|
||||
* content reaches `ConverterScreen`'s `viewModel = viewModel()`, which needs a
|
||||
* `ViewModelStoreOwner`; the plain rule supplies none. It works because both ViewModels are
|
||||
* `@JvmOverloads constructor(app: Application, …)`, so `AndroidViewModelFactory` can build them,
|
||||
* and because `app/build.gradle.kts` already puts `ui-test-manifest`'s `ComponentActivity` in the
|
||||
* merged manifest the unit tests build against -- which that file says in terms.
|
||||
*
|
||||
* **Tags on the two bars.** They are in `TestTags`, applied inside `main`, for the reason that
|
||||
* file's KDoc gives: a tag the test hands down proves only that the test set it.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AdaptiveShellTest {
|
||||
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val app = RuntimeEnvironment.getApplication()
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
// The real screens are composed here, so their ViewModels are real too. Neither test is
|
||||
// about probing or publishing; left alone they would reach the FFprobe loader and this
|
||||
// machine's codec list, and decide things no assertion mentions.
|
||||
ConversionDependencies.probe = { _, _ -> InputProbe() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a phone gets the bottom bar and a tablet gets the rail`() {
|
||||
setShell(WindowWidthSizeClass.Compact)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_BAR).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_RAIL).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an expanded window gets the rail`() {
|
||||
setShell(WindowWidthSizeClass.Expanded)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_RAIL).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_BAR).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The width class no test had ever passed.
|
||||
*
|
||||
* `useRail` is `!= Compact`, so Medium takes the rail with Expanded. Narrowing it to
|
||||
* `== Expanded` is a one-character change that breaks every tablet and unfolded foldable and
|
||||
* nothing else -- and until this test, nothing in either source set used `Medium` at all.
|
||||
*/
|
||||
@Test
|
||||
fun `a medium window is a rail window, not a phone`() {
|
||||
setShell(WindowWidthSizeClass.Medium)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_RAIL).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Shell.NAVIGATION_BAR).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/**
|
||||
* The mapping every other test stubs out: which screen each destination actually opens.
|
||||
*
|
||||
* Matched on each screen's own "choose a file" affordance rather than on a title, because those
|
||||
* tags are applied by the screens themselves -- so this fails if the destinations are
|
||||
* transposed, and it fails for the right reason.
|
||||
*/
|
||||
@Test
|
||||
fun `Convert opens the converter and Join opens the join screen`() {
|
||||
setShell(WindowWidthSizeClass.Compact)
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).assertDoesNotExist()
|
||||
|
||||
composeRule.onNodeWithText(Destination.JOIN.label).performClick()
|
||||
|
||||
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).assertExists()
|
||||
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).assertDoesNotExist()
|
||||
}
|
||||
|
||||
/** [AppRoot] with its real content, which is the half nothing else composes. */
|
||||
private fun setShell(width: WindowWidthSizeClass) {
|
||||
composeRule.setContent { AppRoot(width) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.AudioPlan
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.CopyPlanner
|
||||
import org.libremediaconverter.model.InputProbe
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.model.VideoPlan
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
/**
|
||||
* A job that reached Media3 with a container Media3 cannot mux.
|
||||
*
|
||||
* [Media3Muxers]' own KDoc names the defect this guards: *"the router claimed five containers while
|
||||
* the engine silently wrote MP4 for all of them."* `factoryFor` answers null for fourteen of the
|
||||
* app's containers, and `buildTransformer` turns that null into a failed job rather than letting
|
||||
* `Transformer` fall back to its default muxer.
|
||||
*
|
||||
* The guard had never fired. `Media3Engine$buildTransformer$3` -- the `requireNotNull` message
|
||||
* lambda -- was four lines and four branches at 0%, which is to say the entire repair for a defect
|
||||
* the codebase went to the trouble of writing down was untested. Weakening it would restore that
|
||||
* bug silently, because the wrong output is a *playable file with the wrong container*, not a crash.
|
||||
*
|
||||
* Same harness and same two disciplines as [Media3EngineEmptyCompositionTest]: assert the plan
|
||||
* really is the one the test needs before driving the engine, and rule out
|
||||
* `CancellationException` so an unresumed continuation cannot read as a pass.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class Media3MuxerGuardTest {
|
||||
|
||||
@Test
|
||||
fun `a container Media3 cannot mux fails the job rather than silently writing MP4`() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val engine = Media3Engine(context)
|
||||
val request = ConversionRequest(
|
||||
spec = OutputSpec(Container.WEBM, VideoCodec.VP9, AudioCodec.OPUS),
|
||||
probe = InputProbe(videoCodec = "h264", audioCodec = "aac", container = Container.MP4),
|
||||
)
|
||||
|
||||
// The premise, asserted rather than assumed -- three separate ways this test could pass
|
||||
// over a path it never entered.
|
||||
val plan = CopyPlanner.plan(request.spec, request.probe)
|
||||
assertEquals("the plan has to still be WebM by the time the engine sees it", Container.WEBM, plan.container)
|
||||
assertNull("...and Media3 really has no muxer for it", Media3Muxers.factoryFor(plan.container))
|
||||
// Not the empty-composition refusal, which fires earlier and is a different test's subject.
|
||||
assertNotEquals(VideoPlan.Drop, plan.video)
|
||||
assertNotEquals(AudioPlan.Drop, plan.audio)
|
||||
|
||||
val failure = try {
|
||||
runCatching {
|
||||
runBlocking {
|
||||
withTimeout(TIMEOUT_MS) {
|
||||
engine.transcode(Uri.parse("file:///dev/null"), File(context.cacheDir, "guard.webm"), request) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}.exceptionOrNull()
|
||||
} finally {
|
||||
engine.close()
|
||||
}
|
||||
|
||||
assertFalse(
|
||||
"the continuation was never resumed -- the refusal escaped instead of failing the job: $failure",
|
||||
failure is CancellationException,
|
||||
)
|
||||
// Type *and* message, and the message half is the load-bearing one. Replacing the
|
||||
// requireNotNull with a fallback factory does not make the export succeed here: it lets it
|
||||
// run on and fail some other way, which a bare type assertion would happily accept.
|
||||
assertTrue("expected the muxer guard to refuse the job, got $failure", failure is IllegalArgumentException)
|
||||
assertTrue(
|
||||
"the refusal has to name the container it could not mux, got: ${failure?.message}",
|
||||
failure?.message.orEmpty().contains("cannot mux") &&
|
||||
failure?.message.orEmpty().contains(Container.WEBM.name),
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Nothing is decoded or muxed on this path -- the guard refuses before any of that. */
|
||||
const val TIMEOUT_MS = 10_000L
|
||||
}
|
||||
}
|
||||
@@ -166,6 +166,30 @@ class FFmpegCommandBuilderTest {
|
||||
assertPair(cmd(OutputFormat.OPUS), "-c:a", "libopus")
|
||||
}
|
||||
|
||||
/**
|
||||
* The arm most conversions actually take, and the only one in `audioArgs` with no test.
|
||||
*
|
||||
* `flac wav and opus select the right encoders` above covers the three named arms; MP3 has its
|
||||
* own. AAC arrives through the `else`, so nothing named it and nothing pinned either half of
|
||||
* what it emits -- neither `aac` nor `192k` appeared anywhere in this file. Both are shipped
|
||||
* defaults: MP4 and M4A are the formats the picker offers first, so this is the audio
|
||||
* every ordinary conversion gets.
|
||||
*
|
||||
* The bitrate is asserted as well as the encoder because it is the half a refactor is likelier
|
||||
* to lose. An `-b:a` that quietly changed would not fail anything, would not look wrong in a
|
||||
* command line, and would show up only as files that sound different from the ones the app
|
||||
* produced last month.
|
||||
*/
|
||||
@Test
|
||||
fun `aac is the default encoder, at the bitrate the app ships`() {
|
||||
assertPair(cmd(OutputFormat.MP4_H264), "-c:a", "aac")
|
||||
assertPair(cmd(OutputFormat.MP4_H264), "-b:a", "192k")
|
||||
// Through the `else` rather than through a named arm, so an AAC branch added above it later
|
||||
// has to keep answering the same way.
|
||||
assertPair(cmd(OutputFormat.M4A_AAC), "-c:a", "aac")
|
||||
assertPair(cmd(OutputFormat.M4A_AAC), "-b:a", "192k")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `audio only formats never carry a video encoder`() {
|
||||
listOf(OutputFormat.MP3, OutputFormat.FLAC, OutputFormat.WAV, OutputFormat.OPUS)
|
||||
|
||||
@@ -28,6 +28,7 @@ class TagTableUniquenessTest {
|
||||
fun `every tag constant has its own value`() {
|
||||
val tags = tagsIn(
|
||||
TestTags::class.java,
|
||||
TestTags.Shell::class.java,
|
||||
TestTags.Converter::class.java,
|
||||
TestTags.Join::class.java,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
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 kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertThrows
|
||||
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.HardwareTranscoder
|
||||
import org.libremediaconverter.convert.SoftwareTranscoder
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.libremediaconverter.model.Container
|
||||
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.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* What happens when the hardware engine does not finish the job.
|
||||
*
|
||||
* `runMedia3OrFallBack` was eleven lines at 0% on the JVM and `isCancellation` had never been
|
||||
* called by any unit test at all. Its own KDoc calls the fallback the protection against vendor
|
||||
* hardware encoders that "cannot be tested for correctness", so it is the branch most likely to
|
||||
* matter on a device nobody here owns — and it was reachable the whole time through
|
||||
* `ConversionDependencies.hardware`, which no unit test had ever used.
|
||||
*
|
||||
* The sharp one is cancellation. `runMedia3OrFallBack` catches `Throwable`, so without the
|
||||
* `isCancellation` re-throw a user cancelling a hardware transcode would have the app quietly
|
||||
* start a *second* conversion in software — the one thing cancelling is supposed to prevent.
|
||||
*
|
||||
* `ForcedFailureTest` covers the failure half on a device. It does not cover the cancellation half,
|
||||
* and this host cannot run it either way.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class HardwareFallbackTest {
|
||||
|
||||
private lateinit var app: Application
|
||||
private lateinit var hardware: RecordingHardwareTranscoder
|
||||
private lateinit var software: RecordingSoftwareTranscoder
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
app = RuntimeEnvironment.getApplication()
|
||||
hardware = RecordingHardwareTranscoder()
|
||||
software = RecordingSoftwareTranscoder()
|
||||
ConversionDependencies.publisher = { AlwaysRoomPublisher(app) }
|
||||
ConversionDependencies.hardware = { hardware }
|
||||
ConversionDependencies.software = { software }
|
||||
// A probe with real codecs, not the default: `InputProbe()` reports UNPARSEABLE, which
|
||||
// PERMISSIVE.canDecode refuses, and the router would send every job here straight to
|
||||
// FFmpeg without any of these tests mentioning why.
|
||||
ConversionDependencies.probe = { _, _ -> H264_SOURCE }
|
||||
ConversionDependencies.deviceCodecs = { DeviceCodecs.PERMISSIVE }
|
||||
installTestWorkManager(app, Data.EMPTY)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ConversionDependencies.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hardware failure runs the job again in software, on a clean staging file`() {
|
||||
hardware.failWith = { error("the vendor encoder produced nothing usable") }
|
||||
|
||||
val result = runBlocking { worker().doWork() }
|
||||
|
||||
assertTrue("the job should still succeed, got $result", result is ListenableWorker.Result.Success)
|
||||
assertEquals("the hardware engine gets exactly one attempt", 1, hardware.attempts)
|
||||
assertEquals("and the job then goes to software", 1, software.attempts)
|
||||
// The `staged.delete()` between the two, asserted where it is observable: FFmpeg must not
|
||||
// find a half-written hardware output sitting at the path it is about to write.
|
||||
assertFalse(
|
||||
"the partial hardware output must be gone before FFmpeg starts",
|
||||
software.outputExistedOnEntry,
|
||||
)
|
||||
assertEquals("the hardware engine is closed either way", 1, hardware.closes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cancelled hardware transcode is not quietly retried in software`() {
|
||||
hardware.failWith = { throw CancellationException("the user pressed Cancel") }
|
||||
|
||||
assertThrows(CancellationException::class.java) { runBlocking { worker().doWork() } }
|
||||
|
||||
assertEquals("the hardware engine ran", 1, hardware.attempts)
|
||||
assertEquals(
|
||||
"cancelling must not start a second conversion -- that is the whole point of cancelling",
|
||||
0,
|
||||
software.attempts,
|
||||
)
|
||||
assertEquals("and the engine is still closed on the way out", 1, hardware.closes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a hardware transcode that works never reaches the software engine`() {
|
||||
val result = runBlocking { worker().doWork() }
|
||||
|
||||
assertTrue("got $result", result is ListenableWorker.Result.Success)
|
||||
assertEquals(1, hardware.attempts)
|
||||
assertEquals("the fallback is a fallback, not a second pass", 0, software.attempts)
|
||||
assertEquals(1, hardware.closes)
|
||||
}
|
||||
|
||||
/**
|
||||
* #169: the display-name fallback, which reaches further than the notification title.
|
||||
*
|
||||
* `inputData.getString(KEY_DISPLAY_NAME) ?: "input"` had never taken its right-hand side. The
|
||||
* value is not only the foreground notification's title: it feeds `outputNameFor`, so it is
|
||||
* also the filename offered in the user's save dialog. A job enqueued by an older build, or
|
||||
* built by hand, carries no such key.
|
||||
*/
|
||||
@Test
|
||||
fun `a job that names no input file still suggests an output name`() {
|
||||
val result = runBlocking { worker(displayName = null).doWork() }
|
||||
|
||||
assertTrue("got $result", result is ListenableWorker.Result.Success)
|
||||
val suggested = (result as ListenableWorker.Result.Success)
|
||||
.outputData.getString(ConversionWorker.KEY_SUGGESTED_NAME)
|
||||
assertTrue(
|
||||
"expected a name built from the fallback, got $suggested",
|
||||
suggested.orEmpty().startsWith("input"),
|
||||
)
|
||||
}
|
||||
|
||||
private fun worker(displayName: String? = DISPLAY_NAME): ConversionWorker {
|
||||
val spec = OutputFormat.MP4_H265.spec
|
||||
val entries = buildMap<String, Any> {
|
||||
put(ConversionWorker.KEY_INPUT_URI, INPUT.toString())
|
||||
displayName?.let { put(ConversionWorker.KEY_DISPLAY_NAME, it) }
|
||||
put(ConversionWorker.KEY_SIZE_BYTES, INPUT_BYTES)
|
||||
put(ConversionWorker.KEY_CONTAINER, spec.container.name)
|
||||
put(ConversionWorker.KEY_VIDEO_CODEC, spec.videoCodec.name)
|
||||
put(ConversionWorker.KEY_AUDIO_CODEC, spec.audioCodec.name)
|
||||
// AUTO rather than FORCE_SOFTWARE, which is what every other worker test uses and is
|
||||
// exactly why this path had no coverage: forcing software never enters the function.
|
||||
put(ConversionWorker.KEY_ENGINE_PREFERENCE, EnginePreference.AUTO.name)
|
||||
}
|
||||
return TestListenableWorkerBuilder<ConversionWorker>(
|
||||
context = app,
|
||||
inputData = Data.Builder().putAll(entries).build(),
|
||||
runAttemptCount = 0,
|
||||
).setId(JOB_ID).build()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val INPUT: Uri = Uri.parse("file:///tmp/holiday.mp4")
|
||||
const val DISPLAY_NAME = "holiday.mp4"
|
||||
const val INPUT_BYTES = 1024L
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000009")
|
||||
val H264_SOURCE = InputProbe(
|
||||
videoCodec = "h264",
|
||||
audioCodec = "aac",
|
||||
container = Container.MP4,
|
||||
durationMs = 1_000,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A hardware engine that writes something before it fails, and remembers being closed.
|
||||
*
|
||||
* Writing first is the point, exactly as it is for `PartialThenFailingTranscoder`: an engine that
|
||||
* only threw would let a missing `staged.delete()` pass unnoticed.
|
||||
*/
|
||||
@UnstableApi
|
||||
private class RecordingHardwareTranscoder : HardwareTranscoder {
|
||||
|
||||
var attempts = 0
|
||||
var closes = 0
|
||||
var failWith: (() -> Unit)? = null
|
||||
|
||||
override suspend fun transcode(input: Uri, output: File, request: ConversionRequest, onProgress: (Int) -> Unit) {
|
||||
attempts++
|
||||
output.writeBytes(ByteArray(PARTIAL_BYTES))
|
||||
failWith?.invoke()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closes++
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PARTIAL_BYTES = 2048
|
||||
}
|
||||
}
|
||||
|
||||
/** The software engine, recording whether the hardware attempt's leftovers were cleared first. */
|
||||
private class RecordingSoftwareTranscoder : SoftwareTranscoder {
|
||||
|
||||
var attempts = 0
|
||||
var outputExistedOnEntry = false
|
||||
|
||||
override suspend fun run(
|
||||
request: ConversionRequest,
|
||||
inputPath: String,
|
||||
output: File,
|
||||
durationMs: Long,
|
||||
onProgress: (Int) -> Unit,
|
||||
) {
|
||||
attempts++
|
||||
outputExistedOnEntry = output.exists()
|
||||
output.writeBytes(ByteArray(OUTPUT_BYTES))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val OUTPUT_BYTES = 512
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.app.Notification
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.work.Data
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.convert.installTestWorkManager
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import java.util.UUID
|
||||
|
||||
/**
|
||||
* The two things a progress notification can say, and that they are not the same thing.
|
||||
*
|
||||
* An assertion gap rather than a coverage one, and the distinction is the reason this file exists.
|
||||
* JaCoCo is green on `build`'s `if (indeterminate)`, because `ProgressNotificationTest` drives it
|
||||
* through a real worker -- but that test reads only the notification id and
|
||||
* `Notification.EXTRA_PROGRESS`. **Nothing had ever read the text.** Swapping the two branches, or
|
||||
* collapsing them into one string, passed the entire suite.
|
||||
*
|
||||
* What it costs to get wrong is small and constant: a conversion that has been running for four
|
||||
* minutes still saying "Preparing", or one that has not started reporting yet claiming 0%. Neither
|
||||
* is a crash, and neither would be found by anything else here -- which is exactly the kind of
|
||||
* thing that survives for a long time.
|
||||
*
|
||||
* Nothing else in the suite constructs [ConversionNotifications] directly.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class NotificationProgressTextTest {
|
||||
|
||||
/**
|
||||
* `build` reaches `WorkManager.getInstance` for the Cancel action's PendingIntent, so the
|
||||
* notification cannot be built at all without one. That coupling is why nothing had ever
|
||||
* constructed this class directly and read what it produced.
|
||||
*/
|
||||
@Before
|
||||
fun setUp() {
|
||||
installTestWorkManager(RuntimeEnvironment.getApplication(), Data.EMPTY)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an indeterminate notification says something different from a measured one`() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
val notifications = ConversionNotifications(context)
|
||||
|
||||
val preparing = notifications.build(JOB_ID, TITLE, percent = 0, indeterminate = true).text()
|
||||
val measured = notifications.build(JOB_ID, TITLE, percent = 42, indeterminate = false).text()
|
||||
|
||||
assertNotEquals(
|
||||
"the two states have to read differently, or the text says nothing at all",
|
||||
preparing,
|
||||
measured,
|
||||
)
|
||||
assertTrue(
|
||||
"a measured notification has to carry its percentage, got \"$measured\"",
|
||||
measured.contains("42"),
|
||||
)
|
||||
assertTrue(
|
||||
"an indeterminate one must not invent one, got \"$preparing\"",
|
||||
!preparing.contains("42") && !preparing.contains("0"),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The title is the caller's, not the builder's -- it is the file the user picked, and it is what
|
||||
* tells two simultaneous conversions apart in the shade.
|
||||
*/
|
||||
@Test
|
||||
fun `the notification is titled with the file it is converting`() {
|
||||
val context = RuntimeEnvironment.getApplication()
|
||||
|
||||
val built = ConversionNotifications(context).build(JOB_ID, TITLE, percent = 10)
|
||||
|
||||
assertEquals(TITLE, built.extras.getString(Notification.EXTRA_TITLE))
|
||||
}
|
||||
|
||||
private fun Notification.text(): String = extras.getString(Notification.EXTRA_TEXT).orEmpty()
|
||||
|
||||
private companion object {
|
||||
const val TITLE = "holiday.mp4"
|
||||
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-00000000000a")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user