Compare commits

..
Author SHA1 Message Date
Jason Ross e90f5a801c Merge branch 'main' into test/audio-drop-arm 2026-09-05 19:49:49 -05:00
Jason Ross 68015b3374 Merge pull request #207 from JMR-dev/test/null-message-fallbacks
Make a failure that says nothing still say something
2026-09-05 19:48:11 -05:00
23 changed files with 100 additions and 1540 deletions
-21
View File
@@ -411,24 +411,3 @@ Because versions float, a build can change without a commit. `./gradlew :app:dep
`app/build.gradle.kts`, neither of which moves a thread. `HangBoundTest` guards both numbers,
and **a timed-out run writes no XML for the class that hung** — the dump is its only
attribution, so do not delete the watchdog as stray config.
- **The JVM suite does not run `LibreMediaConverterApp`.** `app/src/test/resources/robolectric.properties`
names `TestLibreMediaConverterApp` for every test, and it differs from the real class in exactly
one thing: `sweepScope` is `Dispatchers.Unconfined`, so the startup staging sweep finishes before
`onCreate()` returns instead of running on `Dispatchers.IO`.
**That line is load-bearing — do not delete it as stray config.** Robolectric builds an
`Application` per test class that asks for one, and each `onCreate` launched a sweep over the
shared `<cacheDir>/conversions/` that nothing joined. So a test asserting about a staged file was
racing every sweep the classes before it had left in flight (#159). It was CI-only until wave 4
added ten Robolectric classes, at which point `OutputPublisherStagingTest` failed on roughly one
local run in six. Per-test opt-in was measured and rejected: **27 of the 58 Robolectric classes
touch that directory**. The `SupervisorJob` is kept in the test scope so a throwing sweep is
swallowed there exactly as in production — the dispatcher is the only intended difference.
**It cost one assertion, knowingly.** `AppStartSweepTest` used to open by asserting that the
manifest's `android:name` is what Robolectric instantiated, so the sweep is code that actually
runs. An `application=` override *replaces* the manifest rather than being checked against it, and
`applicationInfo.className` reports the override too — measured — so that claim is not merely
unasserted on the JVM now, it is unobservable, and a rewritten version would assert the override
against itself. **The manifest link is device-only.** What remains is the `as LibreMediaConverterApp`
cast in that class's `setUp`, which catches only the test app ceasing to extend the real one.
@@ -10,9 +10,6 @@ import androidx.compose.ui.test.performClick
import androidx.media3.common.util.UnstableApi
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.runner.lifecycle.ActivityLifecycleCallback
import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry
import androidx.test.runner.lifecycle.Stage
import androidx.test.uiautomator.By
import androidx.test.uiautomator.BySelector
import androidx.test.uiautomator.Configurator
@@ -27,7 +24,6 @@ import org.junit.runner.RunWith
import org.libremediaconverter.FailsOnEmulatorApi37
import org.libremediaconverter.MainActivity
import org.libremediaconverter.ui.TestTags
import java.util.concurrent.atomic.AtomicInteger
/**
* Choosing a file, through the real system picker, and still having it after a rotation.
@@ -255,21 +251,6 @@ class SafPickerRoundTripTest {
/** Set by the one test that rotates, read by [restoreOrientation]. See its KDoc. */
private var rotated = false
/** Counts [MainActivity] creations from the moment [watchForRecreation] is called. */
private val recreations = AtomicInteger()
/**
* Counts a rotation's recreation without asking the Activity anything.
*
* Deliberately not `composeRule.activity`, which resolves through `scenario.onActivity` and so
* blocks on the main thread. Polling *that* across a recreation is a plausible reading of the
* 20-minute wedges in #122, which would make the obvious barrier the bug it is meant to fix.
* The runner's lifecycle monitor is a callback: reading the counter touches no looper.
*/
private val recreationWatcher = ActivityLifecycleCallback { activity, stage ->
if (activity is MainActivity && stage == Stage.CREATED) recreations.incrementAndGet()
}
/**
* Leave the device the way it was found — and only if this test moved it.
*
@@ -289,7 +270,6 @@ class SafPickerRoundTripTest {
*/
@After
fun restoreOrientation() {
ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(recreationWatcher)
if (!rotated) return
device.setOrientationNatural()
device.unfreezeRotation()
@@ -323,11 +303,9 @@ class SafPickerRoundTripTest {
// The identity hash rather than the Activity itself, so nothing here keeps a destroyed
// Activity reachable across the recreation it is being used to detect.
val before = System.identityHashCode(composeRule.activity)
watchForRecreation()
device.setOrientationLandscape()
rotated = true
awaitRecreation()
composeRule.waitForIdle()
// Two guards before the assertion that matters, because both of the ways this test could
@@ -697,36 +675,6 @@ class SafPickerRoundTripTest {
* `Condition still not satisfied after 30000 ms` — which names neither the node nor the test.
* With the description it says which affordance never arrived, which is the whole finding.
*/
/** Starts counting [MainActivity] creations, so [awaitRecreation] can wait for the next one. */
private fun watchForRecreation() {
recreations.set(0)
ActivityLifecycleMonitorRegistry.getInstance().addLifecycleCallback(recreationWatcher)
}
/**
* Waits for the rotation to actually rebuild [MainActivity], which `waitForIdle` does not.
*
* **This is #122.** `waitForIdle()` waits for the compose hierarchy to settle. Immediately
* after a rotation the window manager has accepted but not yet delivered as a configuration
* change, the *old* Activity's composition is already idle — so it returns, `composeRule
* .activity` still resolves to the old instance, and the guard below reads an unchanged
* identity hash. That is the clean `AssertionError` seen on the API 33 gating leg of #217, and
* the wedges on #122 are the same race taken the other way: land while the composition is
* being torn down and there is nothing coherent for `waitForIdle` to settle on.
*
* A bounded wait is worth having even if that second half is wrong. It turns a 20-minute
* `WEDGE_TIMEOUT` — which costs the leg and names no test — into a fast failure that says which
* test and what it was waiting for.
*/
private fun awaitRecreation() {
composeRule.waitUntil(
"the rotation did not recreate MainActivity within $RECREATION_TIMEOUT_MS ms",
RECREATION_TIMEOUT_MS,
) {
recreations.get() > 0
}
}
private fun awaitNode(tag: String) {
composeRule.waitUntil("a node tagged $tag exists", APP_TIMEOUT_MS) {
composeRule.onAllNodesWithTag(tag).fetchSemanticsNodes().isNotEmpty()
@@ -755,15 +703,6 @@ class SafPickerRoundTripTest {
*/
const val REOPENED_TIMEOUT_MS = 10_000L
/**
* How long a rotation is given to destroy and rebuild the Activity.
*
* Generous against the API 33 and 34 emulators #122 was measured on, where the rotation is
* slow enough for the gap this bound exists to cover to be observable at all — and still
* two orders of magnitude inside the 1200 s `WEDGE_TIMEOUT` it replaces.
*/
const val RECREATION_TIMEOUT_MS = 15_000L
/**
* How long the app is given to take the window focus back after a back press.
*
@@ -3,7 +3,6 @@ package org.libremediaconverter
import android.app.Application
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import org.libremediaconverter.convert.OutputPublisher
@@ -18,36 +17,14 @@ import org.libremediaconverter.convert.OutputPublisher
* ever becomes a `Converted` state, or a `reset()`'s delete is cancelled along with the
* Activity. Process start is the one moment those leftovers are reliably observable.
*/
open class LibreMediaConverterApp : Application() {
class LibreMediaConverterApp : Application() {
/**
* Deliberately process-lifetime and never cancelled: the work it carries is a single
* short task that should outlive nothing in particular and be interrupted by nothing.
* A `SupervisorJob` so a failure here could never take a sibling down with it.
*
* **`protected open` for #159.** Robolectric builds an `Application` for every test that asks
* for one, so on the JVM this is not one background sweep but one *per test* — all of them on
* `Dispatchers.IO`, all touching the same `cacheDir`, none of them joined by anything. That is
* a race against any test asserting about a file under `conversions/`, and it grew with the
* suite: wave 4 added ten Robolectric classes and took it from CI-only to roughly one local run
* in six. The JVM suite substitutes a scope that runs the sweep inline — see
* `app/src/test/resources/robolectric.properties` and `TestLibreMediaConverterApp`.
*
* A constructor parameter would be the ordinary way to inject this and is not available: the
* framework builds this class, so the seam has to be a member.
*/
protected open val sweepScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
/**
* The sweep [onCreate] last started, so a caller that needs it finished can wait for it.
*
* Nothing in production reads this — process start does not wait for its own housekeeping. It
* exists because the alternative for a test is a timed poll, and a poll cannot tell "the sweep
* has not run yet" from "the sweep ran and did nothing".
*/
@Volatile
var startupSweep: Job? = null
private set
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
override fun onCreate() {
super.onCreate()
@@ -76,6 +53,6 @@ open class LibreMediaConverterApp : Application() {
//
// sweepStaging() also re-reads each timestamp immediately before deleting, which
// closes the window between listing the directory and acting on the listing.
startupSweep = sweepScope.launch { OutputPublisher(this@LibreMediaConverterApp).sweepStaging() }
appScope.launch { OutputPublisher(this@LibreMediaConverterApp).sweepStaging() }
}
}
@@ -21,11 +21,6 @@ import org.libremediaconverter.model.VideoCodec
* words, "cannot be tested for correctness". It is a hint, not a guarantee, which is
* why the router treats a failed hardware export as a signal to fall back rather
* than trusting this up front.
* - **An enumeration that fails answers no to everything**, which sends every job to
* FFmpeg. Empty sets are not a permissive default: `canEncode` looks a MIME type up in
* [hardwareEncodeMimes] and finds nothing there. That is the intended answer — FFmpeg
* can do whatever Media3 can, only slower — but it is the opposite of what this class
* said until #194, so it is written down rather than left to be re-derived.
*/
class AndroidDeviceCodecs private constructor(
private val hardwareEncodeMimes: Set<String>,
@@ -51,62 +46,22 @@ class AndroidDeviceCodecs private constructor(
fun get(): AndroidDeviceCodecs = cached ?: synchronized(this) { cached ?: probe().also { cached = it } }
/**
* One entry of the platform's codec list, reduced to what the rules below read.
*
* The five booleans and the type list are the whole of what [capabilitiesFrom] needs, and
* none of them can be set on a `MediaCodecInfo` from a test: Robolectric ships
* `MediaCodecInfoBuilder`, but it has no `setIsAlias` and no `setCanonicalName`, which is
* exactly the objection #133 raised against reaching this code through
* `ShadowMediaCodecList`. That objection is about the shadow. It does not apply to a
* function that takes its own entry type, which is why this exists.
*/
internal data class CodecEntry(
val canonicalName: String,
val isAlias: Boolean,
val isEncoder: Boolean,
val isHardwareAccelerated: Boolean,
val isSoftwareOnly: Boolean,
val supportedTypes: List<String>,
)
/**
* The enumeration rules, over entries a caller chooses.
*
* [probe] is the only production caller and supplies the real codec list; a test supplies
* its own, which is the point — the two rules this class's KDoc calls out as easy to get
* wrong, the alias skip and the canonical-name dedup, are unreachable any other way.
*
* **`enumerate` returns a `Sequence`, deliberately.** The `runCatching` has to wrap the
* *iteration* rather than a list built before it, because a `MediaCodecInfo` whose
* properties throw does so partway through — and when that happens the codecs already read
* are kept. Taking a `List` here would move that throw outside the loop and silently turn a
* partial answer into an empty one. That behaviour predates this seam; a `List` parameter
* would have changed it as a side effect of a refactor.
*
* **An enumeration that fails answers restrictively, and that is deliberate.** The sets
* come back empty, and `"video/avc" in emptySet()` is `false`, so [canEncode] and
* [canDecode] both answer no and every job routes to FFmpeg. FFmpeg can do everything
* Media3 can, only slower, so refusing the hardware path is the safe reading of "we could
* not find out what this device supports". This used to log "assuming permissive", which
* described the opposite of what the code does.
*/
internal fun capabilitiesFrom(enumerate: () -> Sequence<CodecEntry>): AndroidDeviceCodecs {
private fun probe(): AndroidDeviceCodecs {
val encoders = mutableSetOf<String>()
val decoders = mutableSetOf<String>()
val seen = mutableSetOf<String>()
runCatching {
enumerate().forEach { entry ->
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.forEach { info ->
// Aliases point at the same underlying codec; counting both would
// double-count capabilities.
if (entry.isAlias) return@forEach
if (!seen.add(entry.canonicalName)) return@forEach
if (info.isAlias) return@forEach
if (!seen.add(info.canonicalName)) return@forEach
entry.supportedTypes.forEach { mime ->
info.supportedTypes.forEach { mime ->
if (!mime.startsWith("video/")) return@forEach
if (entry.isEncoder) {
if (entry.isHardwareAccelerated && !entry.isSoftwareOnly) {
if (info.isEncoder) {
if (info.isHardwareAccelerated && !info.isSoftwareOnly) {
encoders += mime
}
} else {
@@ -114,32 +69,12 @@ class AndroidDeviceCodecs private constructor(
}
}
}
}.onFailure { Log.w(TAG, "Codec enumeration failed; routing everything to FFmpeg.", it) }
}.onFailure { Log.w(TAG, "Codec enumeration failed; assuming permissive.", it) }
Log.i(TAG, "Hardware video encoders: $encoders")
return AndroidDeviceCodecs(encoders, decoders)
}
/**
* The thin edge: the real codec list, mapped onto [CodecEntry] one at a time.
*
* Lazily, so a property that throws does it inside [capabilitiesFrom]'s `runCatching` and
* on the entry that caused it — see that function's note on why the parameter is a
* `Sequence`.
*/
private fun probe(): AndroidDeviceCodecs = capabilitiesFrom {
MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.asSequence().map { info ->
CodecEntry(
canonicalName = info.canonicalName,
isAlias = info.isAlias,
isEncoder = info.isEncoder,
isHardwareAccelerated = info.isHardwareAccelerated,
isSoftwareOnly = info.isSoftwareOnly,
supportedTypes = info.supportedTypes.toList(),
)
}
}
/**
* `internal` rather than `private` so the cross-check test can ask what a [VideoCodec]
* means here and compare it with what [NAME_TO_MIME] says the same codec's names mean.
@@ -673,23 +673,13 @@ class ConversionViewModel @JvmOverloads constructor(
else -> null
}
/**
* The input `convert()` may act on, which is only ever the one on a `Ready` screen.
*
* This used to answer for `Converting`, `Waiting` and `Converted` as well. Those arms were not
* reachable by tapping Convert -- the button renders only in the `Ready` branch -- but they
* were reachable through the POST_NOTIFICATIONS **result**, which `ConverterScreen.kt:91` wires
* to `convert()` rather than to the button. Reaching one of them enqueued a *second* job over a
* live one: `activeWorkId` was overwritten, and the first job kept running with its foreground
* notification orphaned and nothing left holding its id to cancel it.
*
* Narrowed under #202 rather than tested as it stood, because a test written against the old
* shape would have frozen the double-enqueue as intended behaviour -- the F1/F5 failure mode.
*
* `JoinViewModel.join()` has been `(_state.value as? JoinState.Ready)?.inputs ?: return` all
* along. The two screens are the same shape and only one of them was over-general.
*/
private fun currentInput(): InputFile? = (_state.value as? ConversionState.Ready)?.input
private fun currentInput(): InputFile? = when (val s = _state.value) {
is ConversionState.Ready -> s.input
is ConversionState.Converting -> s.input
is ConversionState.Waiting -> s.input
is ConversionState.Converted -> s.input
else -> null
}
private companion object {
/**
@@ -221,39 +221,12 @@ object MediaProbe {
null
}
/**
* The thin edge: spawn FFprobe, hand what it said to [ffprobeInfoFrom].
*
* Everything device-bound is on this line and the null check under it. What FFprobe *said* is a
* `MediaInformation`, which is an ordinary object over a `JSONObject` — so the reading of it is
* a decision a test can choose the inputs for, and it lives below rather than here.
*/
private fun readMediaInformation(path: String): FFprobeInfo? =
FFprobeKit.getMediaInformation(path).getMediaInformation()?.let(::ffprobeInfoFrom)
private fun readMediaInformation(path: String): FFprobeInfo? {
// ffmpeg-kit-next is compiled from Kotlin with private backing fields, so these have to go
// through the Java getters rather than property syntax.
val info: MediaInformation = FFprobeKit.getMediaInformation(path).getMediaInformation()
?: return null
/**
* What FFprobe's answer means, as a function of the answer alone.
*
* `internal` for the same reason [Extracted] and [FFprobeInfo] are: a test cannot name it
* otherwise, and the JVM test source set is a friend of `main`.
*
* **JVM-safe, verified rather than assumed.** `javap` over the committed AAR's runtime jar:
* `MediaInformation(JSONObject, List<StreamInformation>, List<Chapter>)` and
* `StreamInformation(JSONObject)` are plain public constructors, and neither class's `<clinit>`
* touches the native library — so a test builds its own without `libffmpegkit` being present.
* That is the whole reason this split is worth making: `readMediaInformation` was 114 missed
* instructions and 24 missed branches, of which exactly one line needed a device.
*
* The subtle part is the **second argument to [containerFrom]**. `matroska,webm` is reported
* for both MKV and WebM — they share a demuxer — so the video codec is the only thing that
* separates them, and dropping it silently turns every VP9 WebM into an MKV. `containerFrom`
* has thirty-three covered branches of its own and none of them can notice that, because the
* mistake is at the call rather than in the callee.
*
* ffmpeg-kit-next is compiled from Kotlin with private backing fields, so these go through the
* Java getters rather than property syntax.
*/
internal fun ffprobeInfoFrom(info: MediaInformation): FFprobeInfo {
val streams = info.getStreams().orEmpty()
val video = streams.firstOrNull { it.getType() == "video" }
val audio = streams.firstOrNull { it.getType() == "audio" }
@@ -5,6 +5,7 @@ import android.net.Uri
import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.suspendCancellableCoroutine
import org.libremediaconverter.convert.ConcatJoiner
import org.libremediaconverter.convert.MediaProbe
@@ -65,16 +66,16 @@ class ConcatEngine(private val context: Context) : ConcatJoiner {
private suspend fun execute(args: List<String>) = suspendCancellableCoroutine { cont ->
Log.i(TAG, "ffmpeg ${args.joinToString(" ")}")
val session = FFmpegKit.executeWithArgumentsAsync(args.toTypedArray()) { completed ->
val outcome = sessionOutcome(
rc = completed.getReturnCode(),
prefix = "Joining",
failStackTrace = { completed.getFailStackTrace() },
logTail = { completed.getAllLogsAsString(LOG_TAIL_LIMIT) },
)
when (outcome) {
SessionOutcome.Success -> cont.resume(Unit)
SessionOutcome.Cancelled -> cont.cancel()
is SessionOutcome.Failed -> cont.resumeWithException(FFmpegEngine.FFmpegException(outcome.message))
val rc = completed.getReturnCode()
when {
ReturnCode.isSuccess(rc) -> cont.resume(Unit)
ReturnCode.isCancel(rc) -> cont.cancel()
else -> cont.resumeWithException(
FFmpegEngine.FFmpegException(
"Joining failed (${rc?.value}): " +
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty(),
),
)
}
}
cont.invokeOnCancellation { FFmpegKit.cancel(session.getSessionId()) }
@@ -4,6 +4,7 @@ import android.util.Log
import com.arthenica.ffmpegkit.FFmpegKit
import com.arthenica.ffmpegkit.FFmpegKitConfig
import com.arthenica.ffmpegkit.Level
import com.arthenica.ffmpegkit.ReturnCode
import kotlinx.coroutines.suspendCancellableCoroutine
import org.libremediaconverter.convert.SoftwareTranscoder
import org.libremediaconverter.model.ConversionRequest
@@ -50,16 +51,19 @@ class FFmpegEngine : SoftwareTranscoder {
val session = FFmpegKit.executeWithArgumentsAsync(
args.toTypedArray(),
{ completed ->
val outcome = sessionOutcome(
rc = completed.getReturnCode(),
prefix = "FFmpeg",
failStackTrace = { completed.getFailStackTrace() },
logTail = { completed.getAllLogsAsString(LOG_TAIL_LIMIT) },
)
when (outcome) {
SessionOutcome.Success -> cont.resume(Unit)
SessionOutcome.Cancelled -> cont.cancel()
is SessionOutcome.Failed -> cont.resumeWithException(FFmpegException(outcome.message))
val rc = completed.getReturnCode()
when {
ReturnCode.isSuccess(rc) -> cont.resume(Unit)
ReturnCode.isCancel(rc) ->
cont.cancel()
else -> cont.resumeWithException(
FFmpegException(
"FFmpeg failed (${rc?.value}): " +
completed.getFailStackTrace().orEmpty().ifBlank {
completed.getAllLogsAsString(LOG_TAIL_LIMIT).orEmpty()
},
),
)
}
},
{ log -> Log.d(TAG, log.message.trimEnd()) },
@@ -1,54 +0,0 @@
package org.libremediaconverter.ffmpeg
import com.arthenica.ffmpegkit.ReturnCode
/**
* What a finished FFmpegKit session means, as a function of its return code.
*
* Both engines had their own copy of this `when`, twelve lines apart in two files, and the copies
* had drifted: [FFmpegEngine] preferred the fail stack trace and fell back to the log tail, while
* [ConcatEngine] only ever read the log tail. Neither was tested — both live inside a callback
* handed to `FFmpegKit`, which does not run on the JVM — so the divergence was invisible.
*
* #203 decided to unify on the stack trace, so a join failure now carries the diagnostics a
* conversion failure always did. The *prefix* stays per-engine: unifying the strategy must not
* unify the sentence, since "FFmpeg failed" and "Joining failed" describe different jobs.
*/
internal sealed interface SessionOutcome {
/** rc 0. The suspension resumes normally. */
data object Success : SessionOutcome
/** rc 255. The suspension is cancelled rather than failed — the user asked for this. */
data object Cancelled : SessionOutcome
/** Anything else, with the sentence the user is shown. */
data class Failed(val message: String) : SessionOutcome
}
/**
* Maps a return code onto the outcome, and builds the failure sentence when there is one.
*
* **The two message parts arrive as lambdas, deliberately.** `getAllLogsAsString` and
* `getFailStackTrace` are calls onto a native session, and only the failure arm needs either. Taking
* them by value would put both on the happy path of every successful conversion, which is a cost the
* shape this replaced did not have — the old code read them inside the `else` branch. That is the
* same reason [org.libremediaconverter.codec.AndroidDeviceCodecs.capabilitiesFrom] takes a
* `Sequence`: a seam should not change what runs when.
*
* A null [rc] is a real input rather than a defensive one — `getReturnCode()` is nullable, and a
* session killed before it reported anything has none. It is neither success nor cancellation, so
* it fails, and the sentence says `null` where the number would be.
*/
internal fun sessionOutcome(
rc: ReturnCode?,
prefix: String,
failStackTrace: () -> String?,
logTail: () -> String?,
): SessionOutcome = when {
ReturnCode.isSuccess(rc) -> SessionOutcome.Success
ReturnCode.isCancel(rc) -> SessionOutcome.Cancelled
else -> SessionOutcome.Failed(
"$prefix failed (${rc?.value}): " + failStackTrace().orEmpty().ifBlank { logTail().orEmpty() },
)
}
@@ -1,8 +1,8 @@
package org.libremediaconverter
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assert.fail
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
@@ -10,6 +10,7 @@ import org.libremediaconverter.convert.StagingSweep
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import java.io.File
import java.util.concurrent.TimeUnit
/**
* That process start actually sweeps.
@@ -22,19 +23,8 @@ import java.io.File
* output ever became a `Converted` state, a `reset()` whose delete was cancelled with the Activity.
*
* `onCreate()` is called again rather than a second Application being built: it is what the
* framework calls at process start, and the scope it launches on is already there.
*
* **What this class stopped covering in #159, deliberately.** It used to open by asserting that
* `RuntimeEnvironment.getApplication()` is a [LibreMediaConverterApp] — that the manifest's
* `android:name` points here, so the sweep is code that actually runs. That assertion cannot exist
* on the JVM any more: `robolectric.properties` now names [TestLibreMediaConverterApp] for the
* whole suite, and an `application=` override replaces the manifest rather than being checked
* against it — `applicationInfo.className` reports the override too, measured. So the manifest is
* not merely unasserted here, it is unobservable from this source set, and a rewritten version of
* that test would have asserted the override against itself. **The manifest link is a device-only
* guarantee now**, and it was traded knowingly for the race that override fixes. The cast in
* [setUp] still fails if [TestLibreMediaConverterApp] stops extending the real class, which is a
* smaller claim than the one withdrawn.
* framework calls at process start, the scope it launches on is already there, and the first test
* below is what pins that the framework calls it on *this* class.
*/
@RunWith(RobolectricTestRunner::class)
class AppStartSweepTest {
@@ -44,35 +34,17 @@ class AppStartSweepTest {
@Before
fun setUp() {
// The cast is an assertion in itself: Robolectric builds the Application named in the
// merged manifest, so this fails if `android:name` ever stops pointing here -- in which
// case the sweep below would be perfectly correct code that never runs.
app = RuntimeEnvironment.getApplication() as LibreMediaConverterApp
stagingDir = File(app.cacheDir, "conversions").apply { mkdirs() }
stagingDir.listFiles()?.forEach { it.delete() }
}
/**
* The property the whole substitution exists for, asserted directly rather than waited on.
*
* #159 is not "the sweep is slow", it is "the sweep is still running while some later test
* reads the directory". [TestLibreMediaConverterApp] answers that by finishing the sweep before
* `onCreate()` returns, and this is the only place that claim is checked -- every other test in
* the suite benefits from it silently and would go back to racing without saying why.
*
* Deterministic in the direction that matters: `Dispatchers.Unconfined` runs a `launch` whose
* body never suspends to completion inline, so this cannot flake green-to-red. Putting the test
* app back on `Dispatchers.IO` makes it a race that the assertion loses essentially every time,
* which is what a six-run suite comparison could not show -- at the rate #159 was observed at,
* a clean six-run arm is a coin flip.
*/
@Test
fun `the sweep is finished before onCreate returns`() {
app.onCreate()
val sweep = app.startupSweep
assertNotNull("onCreate() started no sweep", sweep)
assertTrue(
"the JVM suite's sweep outlived onCreate(), so it is in flight during test bodies again",
sweep?.isCompleted == true,
)
fun `the application the manifest starts is the one that sweeps`() {
assertEquals(LibreMediaConverterApp::class.java, RuntimeEnvironment.getApplication().javaClass)
}
@Test
@@ -92,23 +64,35 @@ class AppStartSweepTest {
app.onCreate()
// Joined rather than polled. `onCreate` publishes the sweep it started, so this waits for
// that exact sweep -- where a timed poll could not tell "swept" from "not started yet", and
// answered the second case by failing after ten seconds.
val sweep = app.startupSweep
assertNotNull("onCreate() started no sweep to wait for", sweep)
runBlocking { sweep?.join() }
assertTrue("process start left ${abandoned.name} in staging; nothing swept it", !abandoned.exists())
awaitGone(abandoned)
// The other half, and the one that says the sweep is a sweep rather than a
// `clearStaging()`: the directory is shared by the convert tab, the join tab and
// ConcatEngine's list file, so deleting everything could take a file from a running job.
assertTrue("a file written moments ago belongs to a live job", live.exists())
}
/**
* Waits for [file] to be deleted.
*
* The sweep runs on `Dispatchers.IO`, deliberately: it lists a directory and stats every entry
* on the path that decides how long the launcher icon stays unresponsive. So there is nothing
* to join, and the wait is a bounded poll — long enough for a directory listing, short enough
* that a sweep which never happens fails rather than hangs.
*/
private fun awaitGone(file: File) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(AWAIT_TIMEOUT_SECONDS)
while (System.nanoTime() < deadline) {
if (!file.exists()) return
Thread.sleep(POLL_INTERVAL_MS)
}
fail("process start left ${file.name} in staging; nothing swept it")
}
private fun stagedFile(name: String): File = File(stagingDir, name).apply { writeBytes(ByteArray(4096)) }
private companion object {
const val ONE_MINUTE_MS = 60L * 1000
const val AWAIT_TIMEOUT_SECONDS = 10L
const val POLL_INTERVAL_MS = 5L
}
}
@@ -1,28 +0,0 @@
package org.libremediaconverter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
/**
* The [LibreMediaConverterApp] the JVM suite runs, differing from it in exactly one thing: the
* startup sweep runs inline on the thread that builds the Application instead of on
* `Dispatchers.Unconfined`.
*
* **This is #159.** Robolectric builds an `Application` per test class that asks for one, and each
* one launches a sweep over the shared `<cacheDir>/conversions/`. Nothing joins them, so a test
* asserting about a staged file is racing however many sweeps the classes before it left in
* flight — `OutputPublisherStagingTest` being the one that lost, at roughly one local run in six
* once wave 4 added ten more Robolectric classes. Making the sweep finish before `onCreate()`
* returns removes the race for every test at once rather than asking each to opt in; 27 of the
* suite's 58 Robolectric classes touch that directory, so opting in was not a real option.
*
* `Dispatchers.Unconfined` is what makes it inline: `sweepStaging()` is a plain function, so an
* `Unconfined` `launch` runs it to completion before returning. The `SupervisorJob` is kept so this
* differs from production in the dispatcher alone — a sweep that throws is logged and swallowed
* here exactly as it is there, rather than taking Application construction down with it and failing
* every test in the class for an unrelated reason.
*/
class TestLibreMediaConverterApp : LibreMediaConverterApp() {
override val sweepScope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Unconfined)
}
@@ -1,201 +0,0 @@
package org.libremediaconverter.codec
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.VideoCodec
import org.robolectric.RobolectricTestRunner
/**
* The rules `AndroidDeviceCodecs.probe()` applies to the platform's codec list.
*
* ## Why this is not a third run of the #86/#133 spike
*
* #86 closed `probe()` as device-bound. #133 re-opened the question with
* `ShadowMediaCodecList` in hand and closed it again, for a reason that was right about what it
* was answering: `MediaCodecInfoBuilder` "has no `setIsAlias` and no `setCanonicalName`, so the
* alias skip and the canonical-name dedup — the two things the class's KDoc calls out as easy to
* get wrong — are not reachable through it."
*
* **That objection is about the shadow.** It does not apply to a function that takes its own entry
* type, which is what `capabilitiesFrom` now does. The half #133 named as unreachable is the half
* this file spends most of its cases on.
*
* ## What made the seam worth cutting, which is not coverage
*
* The `runCatching` fallback logged *"assuming permissive"* and returned empty sets — and empty
* sets are **restrictive**: `"video/avc" in emptySet()` is `false`, so `canEncode` and `canDecode`
* both answer no and every job routes to FFmpeg. The code was right and the message described the
* opposite of it. That is pinned below, so whichever reading a future change takes, it has to say
* so out loud.
*
* Robolectric only because `capabilitiesFrom` logs what it found; the rules themselves are pure.
*/
@RunWith(RobolectricTestRunner::class)
class CodecEnumerationTest {
/**
* The alias skip, in the one arrangement where it is observable — and finding that arrangement
* is the whole of this test.
*
* A first attempt listed the alias *after* the codec it aliases and passed with the skip
* deleted, because `canonicalName` is shared and the dedup below catches the second entry
* either way. The two rules overlap, so a fixture that does not separate them tests neither.
*
* What separates them is **order**. `MediaCodecInfo.getCanonicalName()` on an alias returns the
* underlying codec's name, so an alias arriving first claims that name in `seen` and has its
* own `supportedTypes` credited — and then the real codec is dropped by the dedup. Without the
* alias skip the device is described by whichever entry the platform happened to list first.
*
* That also says what the rule is worth. With a `Set` accumulator, an alias declaring the same
* types as its codec changes nothing whichever order they arrive in; the skip earns its place
* only when the two disagree, which is exactly when believing the wrong one matters.
*/
@Test
fun `an alias listed before the codec it aliases does not describe the device`() {
val codecs = capabilities(
entry("c2.qti.avc.encoder", encoder = true, types = listOf(HEVC), alias = true),
entry("c2.qti.avc.encoder", encoder = true, types = listOf(AVC)),
)
assertTrue("the real codec's types are the device's", codecs.canEncode(VideoCodec.H264))
assertFalse(
"an alias must not be credited with types the codec it aliases never claimed",
codecs.canEncode(VideoCodec.H265),
)
}
@Test
fun `two entries sharing a canonical name are read once`() {
val codecs = capabilities(
entry("c2.qti.avc.encoder", encoder = true, types = listOf(AVC)),
entry("c2.qti.avc.encoder", encoder = true, types = listOf(HEVC)),
)
assertEquals(setOf(AVC), codecs.hardwareEncoders())
}
/**
* Both halves of the hardware predicate, one arm at a time.
*
* A vendor may declare a codec hardware-accelerated *and* software-only; the class KDoc is
* explicit that the first flag "cannot be tested for correctness", so the second is what stops
* a mislabelled software encoder being treated as the fast path.
*/
@Test
fun `an encoder counts as hardware only when it is accelerated and not software-only`() {
assertEquals(
setOf(AVC),
capabilities(entry("hw", encoder = true, accelerated = true, types = listOf(AVC))).hardwareEncoders(),
)
assertEquals(
emptySet<String>(),
capabilities(entry("sw", encoder = true, accelerated = false, types = listOf(AVC))).hardwareEncoders(),
)
assertEquals(
"a codec claiming both must not be trusted as hardware",
emptySet<String>(),
capabilities(
entry("both", encoder = true, accelerated = true, softwareOnly = true, types = listOf(AVC)),
).hardwareEncoders(),
)
}
/**
* Decoders are collected regardless of the hardware flags, and that asymmetry is the design.
*
* `canDecode` asks whether the platform can read the input at all — a software decoder answers
* that as well as a hardware one. `canEncode` asks whether the *fast path* exists, which is a
* different question and why only encoders are filtered.
*/
@Test
fun `a software decoder still counts as something the platform can read`() {
val codecs = capabilities(
entry(
"c2.android.avc.decoder",
encoder = false,
accelerated = false,
softwareOnly = true,
types = listOf(AVC),
),
)
assertTrue(codecs.canDecode("h264"))
}
@Test
fun `audio types are ignored on both sides`() {
val codecs = capabilities(
entry("aac.encoder", encoder = true, accelerated = true, types = listOf("audio/mp4a-latm")),
entry("aac.decoder", encoder = false, types = listOf("audio/mp4a-latm")),
)
assertEquals(emptySet<String>(), codecs.hardwareEncoders())
// Not "the platform cannot decode AAC" -- `canDecode` is asked about *video* codec names,
// and an unknown name is answered permissively. The point is that nothing audio reached
// either set.
assertTrue("an unknown name stays permissive", codecs.canDecode("something-nobody-named"))
}
/**
* The failure fallback, pinned as the restrictive answer it actually is.
*
* #194 decided this rather than assuming it: the code stays, the message changes. If a later
* change wants the permissive reading its old log line described, this test is what makes that
* a decision instead of a drift.
*/
@Test
fun `an enumeration that fails sends every job to FFmpeg`() {
val codecs = AndroidDeviceCodecs.capabilitiesFrom { error("MediaCodecList exploded") }
assertFalse("a failed enumeration must not claim a hardware encoder", codecs.canEncode(VideoCodec.H264))
assertFalse(codecs.canDecode("h264"))
assertEquals(emptySet<String>(), codecs.hardwareEncoders())
}
/**
* A list that throws partway keeps what it already read.
*
* This predates the seam — `runCatching` has always wrapped the iteration rather than a list
* built before it — and it is asserted here because the seam is where it could quietly have
* been lost. Taking a `List` instead of a `Sequence` would move the throw outside the loop and
* turn this partial answer into an empty one, with no test to notice.
*/
@Test
fun `codecs read before a failing entry are kept`() {
val codecs = AndroidDeviceCodecs.capabilitiesFrom {
sequence {
yield(entry("good", encoder = true, accelerated = true, types = listOf(AVC)))
error("the sixth codec's properties threw")
}
}
assertEquals(setOf(AVC), codecs.hardwareEncoders())
}
private fun capabilities(vararg entries: AndroidDeviceCodecs.Companion.CodecEntry) =
AndroidDeviceCodecs.capabilitiesFrom { entries.asSequence() }
private fun entry(
canonicalName: String,
encoder: Boolean,
accelerated: Boolean = true,
softwareOnly: Boolean = false,
alias: Boolean = false,
types: List<String>,
) = AndroidDeviceCodecs.Companion.CodecEntry(
canonicalName = canonicalName,
isAlias = alias,
isEncoder = encoder,
isHardwareAccelerated = accelerated,
isSoftwareOnly = softwareOnly,
supportedTypes = types,
)
private companion object {
const val AVC = "video/avc"
const val HEVC = "video/hevc"
}
}
@@ -1,192 +0,0 @@
package org.libremediaconverter.convert
import com.arthenica.ffmpegkit.MediaInformation
import com.arthenica.ffmpegkit.StreamInformation
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.Container
import org.robolectric.RobolectricTestRunner
/**
* What FFprobe's answer means, read as a function of the answer alone.
*
* `readMediaInformation` was 114 missed instructions and 24 missed branches — the second-biggest
* block on the wave-4 report — of which **exactly one line needed a device**:
*
* ```kotlin
* FFprobeKit.getMediaInformation(path).getMediaInformation()
* ```
*
* Everything after it reads an ordinary object. `javap` over the committed AAR's runtime jar:
* `MediaInformation(JSONObject, List<StreamInformation>, List<Chapter>)` and
* `StreamInformation(JSONObject)` are plain public constructors, and neither class's `<clinit>`
* loads the native library — so the fixtures below are built without `libffmpegkit` present.
*
* ## The one that matters
*
* `containerFrom(formatName, video?.getCodec())`. FFprobe reports `matroska,webm` for **both** MKV
* and WebM, because they share a demuxer, so the video codec is the only thing separating them.
* `containerFrom` has thirty-three covered branches of its own and not one of them can notice the
* argument being dropped — the mistake would be at the call, not in the callee, and every existing
* `containerFrom` test would stay green while every VP9 WebM quietly became an MKV.
*
* Robolectric only for `org.json`, which is a stub in a plain JVM test.
*/
@RunWith(RobolectricTestRunner::class)
class FFprobeMappingTest {
@Test
fun `the video codec decides between matroska and webm`() {
assertEquals(
Container.WEBM,
MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("video", "vp9"))).container,
)
assertEquals(
Container.MKV,
MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("video", "h264"))).container,
)
}
/**
* The same format name with no video stream at all, which is what makes the case above about
* the *argument* rather than about the format string.
*/
@Test
fun `a matroska container with no video track cannot be told from webm and is not guessed`() {
val read = MediaProbe.ffprobeInfoFrom(info("matroska,webm", stream("audio", "opus")))
assertEquals(Container.MKV, read.container)
assertNull(read.videoCodec)
}
@Test
fun `the first stream of each type wins`() {
val read = MediaProbe.ffprobeInfoFrom(
info(
"mov,mp4,m4a,3gp,3g2,mj2",
stream("video", "h264", width = 1920, height = 1080),
stream("video", "hevc", width = 640, height = 480),
stream("audio", "aac"),
stream("audio", "mp3"),
),
)
assertEquals("h264", read.videoCodec)
assertEquals("aac", read.audioCodec)
assertEquals(1920, read.width)
assertEquals(1080, read.height)
}
/**
* Dimensions come from the stream the codec came from, not from whichever stream has some.
*
* The fixture is deliberately awkward: the chosen video stream carries **no** dimensions and a
* later one does. That is a real shape — FFprobe omits `width`/`height` for a stream it could
* not measure — and it is the only arrangement that separates the two readings.
*
* A first version of this file asserted the dimensions inside the case above, where the chosen
* stream was also the first one carrying any. Replacing `video?.getWidth()` with
* `streams.firstNotNullOfOrNull { it.getWidth() }` gave the same answer there and **the
* mutation survived**. It reddens here.
*/
@Test
fun `a video stream with no dimensions reports none rather than borrowing another stream's`() {
val read = MediaProbe.ffprobeInfoFrom(
info(
"mov,mp4,m4a,3gp,3g2,mj2",
stream("video", "h264"),
stream("video", "hevc", width = 640, height = 480),
),
)
assertEquals("h264", read.videoCodec)
assertEquals(0, read.width)
assertEquals(0, read.height)
}
/**
* Stream order is the file's, not a promise. An audio-first container must read the same as a
* video-first one.
*/
@Test
fun `an audio track listed first does not become the video track`() {
val read = MediaProbe.ffprobeInfoFrom(
info("mov,mp4,m4a,3gp,3g2,mj2", stream("audio", "aac"), stream("video", "h264")),
)
assertEquals("h264", read.videoCodec)
assertEquals("aac", read.audioCodec)
}
@Test
fun `a duration in seconds becomes milliseconds`() {
assertEquals(12_345L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = "12.345")).durationMs)
}
/**
* Both ways a duration can be absent, and neither may throw.
*
* FFprobe reports `"N/A"` for a stream it could not measure, and omits the key entirely for
* some containers. `toDoubleOrNull` is what keeps the second from being an exception on the
* file-pick path, where there is no user-visible failure to report it as.
*/
@Test
fun `a duration that is not a number is no duration rather than a crash`() {
assertEquals(0L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = "N/A")).durationMs)
assertEquals(0L, MediaProbe.ffprobeInfoFrom(info("mp4", duration = null)).durationMs)
}
@Test
fun `a file with no streams reports nothing rather than defaults that look measured`() {
val read = MediaProbe.ffprobeInfoFrom(info("mp4"))
assertNull(read.videoCodec)
assertNull(read.audioCodec)
assertEquals(0, read.width)
assertEquals(0, read.height)
}
@Test
fun `an image format is reported as one`() {
assertTrue(MediaProbe.ffprobeInfoFrom(info("png_pipe", stream("video", "png"))).isImage)
assertFalse(MediaProbe.ffprobeInfoFrom(info("mp4", stream("video", "h264"))).isImage)
}
private fun stream(type: String, codec: String, width: Int? = null, height: Int? = null) = StreamInformation(
JSONObject().apply {
put(StreamInformation.KEY_TYPE, type)
put(StreamInformation.KEY_CODEC, codec)
width?.let { put(StreamInformation.KEY_WIDTH, it) }
height?.let { put(StreamInformation.KEY_HEIGHT, it) }
},
)
/**
* The format properties are **nested** under `"format"`, which is how FFprobe reports them and
* what `MediaInformation` reads: `getFormat()` resolves through `getStringFormatProperty`, not
* off the top-level object. A first version of this helper put the keys at the top level and
* every format-dependent case failed with a null container, which is worth recording here so
* the next fixture does not have to rediscover it.
*
* Streams are the other half and are *not* nested — they come from the constructor argument.
*/
private fun info(formatName: String, vararg streams: StreamInformation, duration: String? = "1.0") =
MediaInformation(
JSONObject().apply {
put(
MediaInformation.KEY_FORMAT_PROPERTIES,
JSONObject().apply {
put(MediaInformation.KEY_FORMAT, formatName)
duration?.let { put(MediaInformation.KEY_DURATION, it) }
},
)
},
streams.toList(),
emptyList(),
)
}
@@ -51,13 +51,10 @@ import java.io.File
* here needs. `OutputPublisherPublishTest` owns what a real publish writes.
* - **The screen's two buttons.** `ConverterStateAffordancesTest` and `JoinStateAffordancesTest`
* own what each state renders; this file owns what each state carries.
* - ~~**`ConverterScreen`'s `destinationMime` line itself.**~~ **Withdrawn 2026-09-02 (#201).** The
* exemption read: "it lives in the entry point, above the `ScreenContent` seam, and reaching it
* needs a real ViewModel inside a composition". That was true when written and is no longer:
* `AdaptiveShellTest` (#173) established composing the real screens with real ViewModels, and
* #200 added the `ShadowActivity` mechanics for reading what a launcher launched. `RetrySaveMimeTest`
* now asserts the line directly. What this file still owns is the half below the seam -- what each
* state *carries* -- which is why `pendingSave()?.mimeType` is also asserted here.
* - **`ConverterScreen`'s `destinationMime` line itself.** It lives in the entry point, above the
* `ScreenContent` seam, and reaching it needs a real ViewModel inside a composition. What it
* reads -- `pendingSave()?.mimeType` -- is asserted directly instead, which is why that
* derivation was moved out of the entry point in the first place.
* - **Picking a new input while a `Failed` carries a file.** `onInputPicked` overwrites the state
* without discarding, from `Converted` exactly as much as from a carrying `Failed`, and neither
* branch renders a picker. It is a pre-existing path this change neither opens nor widens: the
@@ -205,34 +205,6 @@ class FileCardTest {
assertNoRow("Length")
}
/**
* A video the app knows a great deal about and cannot name the container of.
*
* Not an edge case. `InputProbe.container`'s own KDoc says `MediaExtractor` cannot report a
* container at all -- it comes from FFprobe -- so any run where FFprobe did not answer produces
* exactly this: real codec, real dimensions, real duration, `container = null`.
*
* **The twin was already tested and this one was not**, which is the argument for adding it.
* `FileCard` renders `probe.container?.label ?: "Unknown"` twice, once in the `AUDIO_ONLY`
* branch (`ConverterScreen.kt:660`) and once in the `VIDEO` branch (`:668`), and
* `an audio-only file nothing else could describe degrades one row at a time` drives only the
* first. Same expression, same fallback, one kind covered. That asymmetry is the same one
* `CLAUDE.md` records for including `ContainerCapabilities:94`.
*
* The other rows are asserted alongside so this is not a copy of the audio-only case: there,
* everything is unknown at once; here, one field is missing from a probe that is otherwise
* complete, and the rest must be unaffected by it.
*/
@Test
fun `a video file whose container nothing identified says so and keeps its other rows`() {
setFileCard(input(probe = VIDEO_PROBE.copy(container = null)))
assertRow("Container", "Unknown")
assertRow("Video", "${VideoCodec.H264.label} · 1920×1080")
assertRow("Audio", AudioCodec.AAC.label)
assertRow("Length", "1:30")
}
/**
* The row is one node, not a label node beside a value node. A test matching on `"Container"`
* alone would pass against either shape.
@@ -1,141 +0,0 @@
package org.libremediaconverter.convert
import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.media3.common.util.UnstableApi
import androidx.work.Data
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.join.JoinScreen
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import org.robolectric.shadows.ShadowActivity
/**
* The launcher layer above the `ScreenContent` seam — registered, and until now never resulted.
*
* ## The hazard this exists for
*
* `ConversionViewModel.onInputPicked(uri: Uri)` and `.save(destination: Uri)` are **both
* `(Uri) -> Unit`**, so swapping the two launcher callbacks at `ConverterScreen.kt:70` and `:83`
* compiles, renders, and passes the entire suite. Picking a file would attempt a save to it, and
* choosing a destination would load it as input.
*
* That is precisely the defect class `ScreenWiringTest` exists for, on the one pair it declines to
* cover: it drives `converterActions` directly and says the launcher-backed actions stay
* parameters. Correct for the `actions` seam, and it leaves the edge above that seam unpinned.
*
* Join's equivalents (`JoinScreen.kt:45`, `:55`) are `List<Uri>` and `Uri`, so they are **not**
* transposable and need no such test. The picker filter is a different matter and is covered below
* for both screens.
*
* ## The two mechanics, verified before the assertions were written
*
* Neither is used anywhere else in the suite, so both were spiked first:
*
* - **Reading what was launched** — `shadowOf(activity).nextStartedActivityForResult`, which returns
* the `Intent` with its `EXTRA_MIME_TYPES` intact.
* - **Delivering a result** — `shadowOf(activity).receiveResult(...)`, which reaches
* `ComponentActivity`'s `ActivityResultRegistry` and fires the `rememberLauncherForActivityResult`
* callback.
*
* `createAndroidComposeRule`, as `AdaptiveShellTest` uses and for the reason it gives: the screens
* compose real ViewModels through `viewModel()`, and the plain rule supplies no `ViewModelStoreOwner`.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class LauncherWiringTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
@Before
fun setUp() {
val app = RuntimeEnvironment.getApplication()
installTestWorkManager(app, Data.EMPTY)
// The real screen composes a real ViewModel; neither test here is about probing.
ConversionDependencies.probe = { _, _ -> InputProbe() }
}
@After
fun tearDown() = ConversionDependencies.reset()
/**
* The transposition guard. A picked file has to reach `onInputPicked`, which is observable as
* the screen arriving at `Ready` with the file card showing — `save()` from `Idle` returns at
* its own guard and leaves nothing behind.
*/
@Test
fun `a picked document is loaded as input rather than saved to`() {
composeRule.setContent { ConverterScreen() }
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
deliver(Uri.parse("content://test/holiday.mkv"))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME).assertIsDisplayed()
}
/**
* `ConverterScreen.kt:65-67` records why the all-types wildcard is load-bearing rather than lazy:
*
* > the picker is images and video only, offers no audio at all, and will not reliably surface
* > .mkv/.flac/.webm
*
* Narrowing it would make every audio conversion unreachable from the file picker, and nothing
* would have gone red. (The literal is spelled only in the assertion below: a KDoc cannot
* contain it, because the wildcard's second half closes the comment.)
*/
@Test
fun `the converter picker asks for every type, not just the ones a photo picker offers`() {
composeRule.setContent { ConverterScreen() }
composeRule.onNodeWithTag(TestTags.Converter.CHOOSE_FILE).performClick()
val intent = launched().intent
assertEquals(Intent.ACTION_OPEN_DOCUMENT, intent.action)
assertEquals(listOf("*/*"), intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList())
}
@Test
fun `the join picker asks for video and accepts more than one file`() {
composeRule.setContent { JoinScreen() }
composeRule.onNodeWithTag(TestTags.Join.CHOOSE_FILES).performClick()
val intent = launched().intent
assertEquals(Intent.ACTION_OPEN_DOCUMENT, intent.action)
assertEquals(listOf("video/*"), intent.getStringArrayExtra(Intent.EXTRA_MIME_TYPES)?.toList())
// A join of one file is not a join; the contract is what asks for several.
assertEquals(true, intent.getBooleanExtra(Intent.EXTRA_ALLOW_MULTIPLE, false))
}
private fun launched(): ShadowActivity.IntentForResult {
composeRule.waitForIdle()
return requireNotNull(shadowOf(composeRule.activity).nextStartedActivityForResult) {
"nothing was launched for a result"
}
}
private fun deliver(uri: Uri) {
val started = launched()
shadowOf(composeRule.activity).receiveResult(
started.intent,
Activity.RESULT_OK,
Intent().setData(uri),
)
composeRule.waitForIdle()
}
}
@@ -122,25 +122,24 @@ class OutputPublisherStagingTest {
/**
* Makes `cacheDir/conversions` a regular file, which is the whole precondition of the test
* above -- and does it in a loop, because a single delete-then-write once lost a race that CI
* caught and this machine did not reproduce.
* above -- and does it in a loop, because a single delete-then-write loses a race that CI
* caught and this machine does not reproduce.
*
* **That race is closed at the source as of #159, and the loop is kept anyway.**
* `LibreMediaConverterApp.onCreate` launched its staging sweep on `Dispatchers.IO`, and
* `sweepStaging` reads `stagingDir`, whose getter calls `mkdirs()`. Robolectric builds an
* application for every test class that asks for one, so that background `mkdirs()` was in
* flight across the whole suite, on a thread the paused main looper does not control. Between
* deleting this path and writing it there is a window where the path does not exist and that
* `mkdirs()` could win -- `FileNotFoundException: ... (Is a directory)` out of `writeBytes`,
* run 33069641674 on #149, once, against 468 tests that passed here. The JVM suite now runs
* `TestLibreMediaConverterApp`, whose sweep finishes before `onCreate()` returns, so nothing is
* sweeping while a test body runs.
* `LibreMediaConverterApp.onCreate` ends with
* `appScope.launch { OutputPublisher(...).sweepStaging() }` on `Dispatchers.IO`, and
* `sweepStaging` reads `stagingDir`, whose getter calls `mkdirs()`. Robolectric instantiates
* the application for every test that asks for one, so that background `mkdirs()` is in flight
* across the whole suite, on a thread the paused main looper does not control. Between deleting
* this path and writing it there is a window where the path does not exist and that `mkdirs()`
* can win, which is `FileNotFoundException: ... (Is a directory)` out of `writeBytes` -- run
* 33069641674 on #149, once, against 468 tests that pass here.
*
* The loop stays because it is what would catch that substitution being undone. Without it the
* regression returns as this one class failing rarely on CI -- the exact shape that took #159
* from a single run on #149 to a wave-4 flake before anyone chased it. Retrying closes the
* window rather than narrowing it, because the race is not symmetric: `mkdirs()` fails on an
* existing regular file, so the invariant only has to survive being *established*.
* Retrying closes it rather than narrowing it, because the race is not symmetric: `mkdirs()`
* fails on an existing regular file, so the invariant only has to survive being *established*.
* Once a write lands, nothing in the suite can turn this back into a directory.
*
* The wider problem -- application-scope IO work racing every Robolectric test that shares
* `cacheDir` -- is #159, and is deliberately not fixed here.
*/
private fun stagingPathAsRegularFile(): File {
val stagingPath = File(cacheDir, "conversions")
@@ -1,136 +0,0 @@
package org.libremediaconverter.convert
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkManager
import androidx.work.workDataOf
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.OutputFormat
import org.libremediaconverter.ui.TestTags
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows.shadowOf
import java.io.File
/**
* The save dialog opens with the type the *job* produced, not the type the picker is showing now.
*
* `ConverterScreen.kt:80` — `state.pendingSave()?.mimeType ?: settings.spec.mimeType` — had never
* taken its left-hand side. Its comment records what the line is for:
*
* > a retry offered after a failed save opens the dialog with the type its first attempt used —
* > the cast answered null for a `Failed`, and the fallback below is the current picker, which a
* > reattached job never set.
*
* So the untested half is the fix, and the tested half is the fallback it was added to stop being
* used.
*
* ## This revises a named exemption, deliberately
*
* `FailedSaveRetryTest`'s KDoc lists this line under "Not asserted here, so each is a decision
* rather than an omission":
*
* > It lives in the entry point, above the `ScreenContent` seam, and reaching it needs a real
* > ViewModel inside a composition.
*
* That was true when written. `AdaptiveShellTest` (#173) then established exactly that capability,
* and #200 added the two `ShadowActivity` mechanics that let a test read what a launcher launched.
* The reason the exemption gave no longer holds, so the exemption is withdrawn rather than left to
* be taken at face value — the same shape as #141 revising #84's boundary. That KDoc is corrected
* in this change.
*
* ## Why the job is reattached rather than run
*
* The screen composes its own ViewModel through `viewModel()`, so nothing can be injected into it.
* A job finished before the composition is the one route to a `Converted` state carrying output
* `Data` this test chose — and it is also the case the line exists for, since a reattached job's
* spec "was never in these settings at all".
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class RetrySaveMimeTest {
@get:Rule
val composeRule = createAndroidComposeRule<ComponentActivity>()
private lateinit var app: Application
private lateinit var staged: File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
ConversionDependencies.probe = { _, _ -> InputProbe() }
staged = OutputPublisher(app).createStagingFile("holiday.mkv").apply { writeBytes(ByteArray(4096)) }
}
@After
fun tearDown() = ConversionDependencies.reset()
@Test
fun `the save dialog offers the type the job produced, not the one the picker is showing`() {
finishAJobProducing(JOB_MIME_TYPE)
composeRule.setContent { ConverterScreen() }
composeRule.waitForIdle()
composeRule.onNodeWithTag(TestTags.SAVE_FILE).performScrollTo().performClick()
composeRule.waitForIdle()
val intent = requireNotNull(shadowOf(composeRule.activity).nextStartedActivityForResult) {
"the save dialog was never launched"
}.intent
assertEquals(Intent.ACTION_CREATE_DOCUMENT, intent.action)
assertEquals(JOB_MIME_TYPE, intent.type)
// The fixture is only meaningful while the two differ; without this the assertion above
// would pass just as well against the fallback.
assertNotEquals(
"the picker's own type must differ, or this test proves nothing",
JOB_MIME_TYPE,
OutputFormat.MP4_H265.spec.mimeType,
)
}
/**
* A conversion that finished while nothing was watching, which is what `reattach()` picks up.
*
* `SucceedingWorkerFactory` reports this output `Data` for whatever is enqueued, so the job
* lands `SUCCEEDED` carrying a staged path that exists — the two things `Reattachment.choose`
* requires of a finished job.
*/
private fun finishAJobProducing(mimeType: String) {
installTestWorkManager(
app,
workDataOf(
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConversionWorker.KEY_SUGGESTED_NAME to "holiday.mkv",
ConversionWorker.KEY_MIME_TYPE to mimeType,
),
)
WorkManager.getInstance(app).enqueue(
ConversionWorker.request(
inputUri = Uri.parse("content://test/holiday.mkv"),
displayName = "holiday.mkv",
sizeBytes = 4_096L,
),
).result.get()
}
private companion object {
/** Matroska, against the MP4 the picker defaults to. */
const val JOB_MIME_TYPE = "video/x-matroska"
}
}
@@ -1,147 +0,0 @@
package org.libremediaconverter.convert
import android.app.Application
import android.net.Uri
import androidx.media3.common.util.UnstableApi
import androidx.work.WorkManager
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.join.JoinState
import org.libremediaconverter.join.JoinViewModel
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.work.ConcatWorker
import org.libremediaconverter.work.ConversionWorker
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
/**
* An answer that arrives after the screen has moved on does nothing.
*
* Four refusal arms, cold before this file:
*
* ```
* convert/ConversionViewModel.kt:513 currentInput() ?: return
* convert/ConversionViewModel.kt:600 pendingSave() ?: return
* join/JoinViewModel.kt:316 (as? Ready)?.inputs ?: return
* join/JoinViewModel.kt:390 pendingSave() ?: return
* ```
*
* They are not merely defensive. `ConverterScreen.kt:91` wires `convert()` to the
* **POST_NOTIFICATIONS result**, and `:83` wires `save()` to the CreateDocument result — so both
* are entered by a system callback rather than by a tap, and a result redelivered after process
* death arrives at a brand-new ViewModel sitting on `Idle`.
*
* ## The production change that came with this
*
* `currentInput()` used to answer for `Converting`, `Waiting` and `Converted` as well as `Ready`.
* Those arms were unreachable by tapping Convert but reachable through that permission callback,
* and reaching one enqueued a **second** job over a live one — `activeWorkId` overwritten, the
* first job still running with an orphaned notification and nothing holding its id.
*
* #202 decided to narrow rather than to test it as it stood, because a test written against the old
* shape would have frozen the double-enqueue as intended behaviour. `JoinViewModel.join()` has been
* `(_state.value as? JoinState.Ready)?.inputs ?: return` all along; the two screens are the same
* shape and only one was over-general.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class StaleLauncherResultTest {
private lateinit var app: Application
private lateinit var workManager: WorkManager
private lateinit var staged: java.io.File
@Before
fun setUp() {
app = RuntimeEnvironment.getApplication()
val publisher = RecordingPublisher(app)
ConversionDependencies.publisher = { publisher }
ConversionDependencies.probe = { _, _ -> InputProbe() }
// A real staged file, because a SUCCEEDED job with no output path maps to Failed rather
// than Converted -- and Converted is the state this file's second case has to reach.
staged = publisher.createStagingFile("holiday.mp4").apply { writeBytes(ByteArray(4096)) }
installTestWorkManager(
app,
workDataOf(
ConversionWorker.KEY_OUTPUT_PATH to staged.absolutePath,
ConversionWorker.KEY_SUGGESTED_NAME to "holiday.mp4",
ConversionWorker.KEY_MIME_TYPE to "video/mp4",
),
)
workManager = WorkManager.getInstance(app)
}
@After
fun tearDown() = ConversionDependencies.reset()
@Test
fun `a permission answer arriving on an empty screen enqueues nothing`() {
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
awaitState(viewModel.state, "Idle") { it is ConversionState.Idle }
viewModel.convert()
assertEquals(ConversionState.Idle, viewModel.state.value)
assertEquals("nothing may be enqueued for a file that is not there", 0, conversionJobs())
}
/**
* The narrowing itself: a permission answer that arrives while a conversion is already running
* must not start a second one.
*
* Reached by converting once — the synchronous test WorkManager finishes it inline, so the
* screen is `Converted`, which is one of the three arms `currentInput()` used to answer for.
* Calling `convert()` again from there is precisely what the permission callback can do.
*/
@Test
fun `a permission answer arriving after the job finished does not start a second one`() {
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
viewModel.onInputPicked(Uri.parse("content://test/holiday.mkv"))
awaitState(viewModel.state, "Ready") { it is ConversionState.Ready }
viewModel.convert()
val converted = awaitState(viewModel.state, "Converted") { it is ConversionState.Converted }
assertEquals("the fixture needs exactly one job to start with", 1, conversionJobs())
viewModel.convert()
assertEquals("a second job must not be enqueued over the first", 1, conversionJobs())
assertEquals("and the screen must not move", converted, viewModel.state.value)
}
@Test
fun `a save answer arriving on an empty screen does nothing`() {
val viewModel = ConversionViewModel(app, Dispatchers.Unconfined)
awaitState(viewModel.state, "Idle") { it is ConversionState.Idle }
viewModel.save(DESTINATION)
assertEquals(ConversionState.Idle, viewModel.state.value)
}
@Test
fun `a join answer arriving on an empty screen enqueues nothing`() {
val viewModel = JoinViewModel(app, Dispatchers.Unconfined)
awaitState(viewModel.state, "Idle") { it is JoinState.Idle }
viewModel.join()
viewModel.save(DESTINATION)
assertEquals(JoinState.Idle, viewModel.state.value)
assertEquals(0, joinJobs())
}
private fun conversionJobs() = jobsTagged(ConversionWorker::class.java.name)
private fun joinJobs() = jobsTagged(ConcatWorker::class.java.name)
private fun jobsTagged(tag: String) = workManager.getWorkInfosByTag(tag).get().size
private companion object {
val DESTINATION: Uri = Uri.parse("content://test/destination.mp4")
}
}
@@ -1,127 +0,0 @@
package org.libremediaconverter.ffmpeg
import com.arthenica.ffmpegkit.ReturnCode
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* What a finished FFmpegKit session means, for both engines at once.
*
* `FFmpegEngine` and `ConcatEngine` each carried their own copy of this `when`, and the copies had
* drifted: one preferred the fail stack trace and fell back to the log tail, the other only ever
* read the log tail. Neither was tested, because both live inside a callback handed to `FFmpegKit`,
* which does not run on the JVM — so nothing could see that the two disagreed.
*
* **JVM-safe, verified rather than assumed.** `javap` over the committed AAR's runtime jar shows
* `ReturnCode(int)` as a plain public constructor with `SUCCESS`/`CANCEL` int constants and pure
* static `isSuccess`/`isCancel`; its `<clinit>` is constant initialisation and loads no native
* library.
*
* The unification is #203's decision, so the tests pin it as one: a join failure now carries the
* stack trace a conversion failure always did, while the two prefixes stay distinct.
*/
class SessionOutcomeTest {
@Test
fun `a return code of zero is success`() {
assertEquals(SessionOutcome.Success, outcome(ReturnCode(ReturnCode.SUCCESS)))
}
/**
* Cancellation is a separate outcome from failure, and the distinction is the point: the engines
* resume the continuation *cancelled* rather than exceptionally, so a user who pressed Cancel
* does not get an error card.
*/
@Test
fun `a return code of 255 is a cancellation, not a failure`() {
assertEquals(SessionOutcome.Cancelled, outcome(ReturnCode(ReturnCode.CANCEL)))
}
@Test
fun `any other return code fails, and the sentence carries the number`() {
val failed = outcome(ReturnCode(1), stackTrace = "boom") as SessionOutcome.Failed
assertTrue("the code belongs in the message, got: ${failed.message}", failed.message.contains("(1)"))
}
/**
* The half that was different between the two engines before #203, now the same in both.
*/
@Test
fun `the stack trace is preferred over the log tail`() {
val failed = outcome(ReturnCode(1), stackTrace = "the real cause", logTail = "…noise…")
as SessionOutcome.Failed
assertTrue(failed.message.contains("the real cause"))
assertTrue("the log tail must not be appended as well", !failed.message.contains("noise"))
}
@Test
fun `a blank stack trace falls back to the log tail`() {
val blank = outcome(ReturnCode(1), stackTrace = " ", logTail = "the last few lines") as SessionOutcome.Failed
val absent = outcome(ReturnCode(1), stackTrace = null, logTail = "the last few lines") as SessionOutcome.Failed
assertTrue(blank.message.contains("the last few lines"))
assertTrue("a null stack trace is a blank one", absent.message.contains("the last few lines"))
}
/**
* Both sources empty still has to produce a sentence. A message ending in a dangling colon is
* thin, but it is what the user gets when FFmpeg said nothing at all, and it must not be an
* exception on the way to the screen.
*/
@Test
fun `a failure with nothing to say still names the code`() {
val failed = outcome(ReturnCode(1), stackTrace = null, logTail = null) as SessionOutcome.Failed
assertEquals("FFmpeg failed (1): ", failed.message)
}
/**
* `getReturnCode()` is nullable and a session killed before it reported anything has none.
* Neither success nor cancellation, so it fails — and the sentence says so rather than throwing.
*/
@Test
fun `a session with no return code at all fails`() {
val failed = outcome(null, logTail = "whatever was logged") as SessionOutcome.Failed
assertTrue("got: ${failed.message}", failed.message.startsWith("FFmpeg failed (null): "))
}
/**
* Unifying the *strategy* must not unify the *sentence*: the two engines describe different
* jobs, and a join that reports "FFmpeg failed" is a worse message than the one it replaced.
*/
@Test
fun `each engine keeps its own prefix`() {
val join = sessionOutcome(ReturnCode(1), "Joining", { "cause" }, { null }) as SessionOutcome.Failed
assertTrue(join.message.startsWith("Joining failed (1): "))
}
/**
* Neither message source is read unless the outcome is a failure.
*
* They are calls onto a native session, and reading them on the happy path is work every
* successful conversion would do for nothing — which the shape this replaced did not, since it
* read them inside the `else` branch. That is why the parameters are lambdas, and this is what
* would notice if they stopped being.
*/
@Test
fun `a session that succeeded reads neither the stack trace nor the log`() {
var reads = 0
fun counted(): String? {
reads++
return null
}
sessionOutcome(ReturnCode(ReturnCode.SUCCESS), "FFmpeg", ::counted, ::counted)
sessionOutcome(ReturnCode(ReturnCode.CANCEL), "FFmpeg", ::counted, ::counted)
assertEquals("neither source may be touched unless the session failed", 0, reads)
}
private fun outcome(rc: ReturnCode?, stackTrace: String? = null, logTail: String? = null) =
sessionOutcome(rc, "FFmpeg", { stackTrace }, { logTail })
}
@@ -1,89 +0,0 @@
package org.libremediaconverter.ui.theme
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.ui.test.junit4.v2.createComposeRule
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* The theme called the way the app calls it: with no arguments at all.
*
* [ThemeColorSchemeTest] resolves every branch of the `when` and always passes `darkTheme`
* explicitly, so the `$default` bridge is never entered and **`isSystemInDarkTheme()` is never
* called**. `MainActivity.kt:79` is its only default-argument caller and does not execute on the
* JVM, which left the app's actual call shape the one nothing exercised —
* `LibreMediaConverterTheme` reported `mi=21, mb=6, cb=12` at method level.
*
* ## Not #68
*
* #68 is about the two **unreachable** arms, `DarkColorScheme` and `LightColorScheme`, which cannot
* run because `dynamicColor` is always `true` and nothing can flip it. That is an open product
* decision. This is the reachable half — whether the default follows the system — and closing it
* does not close that.
*
* ## Why the assertion compares schemes rather than reading a number
*
* A luminance threshold would be a guess about the device palette. What is asserted instead is that
* the no-argument call resolves to **the same scheme** an explicit `darkTheme` of the matching
* value does, and a different one from its opposite. That holds whatever palette the platform
* hands back, and it is exactly the claim: the default reads the system rather than picking a side.
*
* Both schemes are resolved in one composition because `setContent` may be called once per test.
*/
@RunWith(RobolectricTestRunner::class)
class ThemeFollowsSystemTest {
@get:Rule
val composeRule = createComposeRule()
@Test
@Config(qualifiers = "+night")
fun `with no arguments the theme follows a system in dark mode`() {
val resolved = resolve()
assertEquals("the default must resolve what darkTheme = true does", resolved.dark, resolved.bare)
assertNotEquals(resolved.light, resolved.bare)
}
@Test
@Config(qualifiers = "+notnight")
fun `with no arguments the theme follows a system in light mode`() {
val resolved = resolve()
assertEquals("the default must resolve what darkTheme = false does", resolved.light, resolved.bare)
assertNotEquals(resolved.dark, resolved.bare)
}
/**
* The three colours are read together as one value, because any single one could coincide
* between the two schemes on some palette while the schemes themselves differ. Background is
* what dark mode is chiefly about; primary and surface are along to make a coincidence
* implausible rather than merely unlikely.
*/
private data class Fingerprint(val background: Long, val primary: Long, val surface: Long)
private fun ColorScheme.fingerprint() =
Fingerprint(background.value.toLong(), primary.value.toLong(), surface.value.toLong())
private class Resolved(val bare: Fingerprint, val dark: Fingerprint, val light: Fingerprint)
private fun resolve(): Resolved {
lateinit var bare: Fingerprint
lateinit var dark: Fingerprint
lateinit var light: Fingerprint
composeRule.setContent {
// No arguments — the call MainActivity makes, and the one nothing exercised.
LibreMediaConverterTheme { bare = MaterialTheme.colorScheme.fingerprint() }
LibreMediaConverterTheme(darkTheme = true) { dark = MaterialTheme.colorScheme.fingerprint() }
LibreMediaConverterTheme(darkTheme = false) { light = MaterialTheme.colorScheme.fingerprint() }
}
composeRule.waitForIdle()
return Resolved(bare, dark, light)
}
}
@@ -21,10 +21,8 @@ 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
@@ -131,40 +129,6 @@ class ProgressNotificationTest {
)
}
/**
* The same plumbing on the engine most conversions actually use, which had none.
*
* `ConversionWorker.kt:208-210` is a second `onProgress` lambda at a second call site — the one
* handed to `engine.transcode` — and it reported `ci == 0`. Every test above drives the FFmpeg
* path; `HardwareFallbackTest` reaches `runMedia3OrFallBack` but its recording transcoder
* records the call and never invokes the callback it was given. So the two engines' progress
* wiring was one tested and one not, and the untested one is the default: `ConversionRouter`
* sends everything it can to Media3.
*
* `AUTO` with a real H.264 probe, because `FORCE_SOFTWARE` is precisely what keeps the other
* tests out of this branch. The probe and the permissive codec profile are what let the router
* choose Media3 at all — `InputProbe()` reports `UNPARSEABLE`, which routes straight to FFmpeg.
*
* Asserted on the *percentage*, not merely on an update having happened: `publishProgress`
* takes a display name and a percent, and replacing the percent with a constant compiles.
*/
@Test
fun `progress from the hardware engine reaches WorkManager the same way FFmpeg's does`() {
ConversionDependencies.probe = { _, _ -> H264_SOURCE }
val reporting = ReportingHardwareTranscoder { onProgress -> onProgress(PERCENT) }
ConversionDependencies.hardware = { reporting }
runBlocking { workerReporting(EnginePreference.AUTO) { }.doWork() }
assertEquals("the job must have gone to the hardware engine", 1, reporting.attempts)
val progressUpdates = updater.infos.drop(1)
assertEquals("one throttled progress update expected", 1, progressUpdates.size)
assertEquals(
PERCENT,
progressUpdates.single().notification.extras.getInt(Notification.EXTRA_PROGRESS),
)
}
/**
* A worker routed to the software engine, whose engine is [report] and a written output.
*
@@ -173,10 +137,7 @@ class ProgressNotificationTest {
* bridge, which is native. [report] is handed the worker's own progress callback, and runs with
* the worker as its receiver so a test can stop it mid-transcode.
*/
private fun workerReporting(
enginePreference: EnginePreference = EnginePreference.FORCE_SOFTWARE,
report: ConversionWorker.((Int) -> Unit) -> Unit,
): ConversionWorker {
private fun workerReporting(report: ConversionWorker.((Int) -> Unit) -> Unit): ConversionWorker {
val worker = TestListenableWorkerBuilder<ConversionWorker>(
context = app,
inputData = workDataOf(
@@ -186,7 +147,7 @@ class ProgressNotificationTest {
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.name,
ConversionWorker.KEY_ENGINE_PREFERENCE to EnginePreference.FORCE_SOFTWARE.name,
),
runAttemptCount = 0,
).setId(JOB_ID)
@@ -210,17 +171,6 @@ class ProgressNotificationTest {
const val TICKS = 50
val SPEC = OutputFormat.MP4_H265.spec
val JOB_ID: UUID = UUID.fromString("00000000-0000-4000-8000-000000000021")
/**
* A probe the router can actually route. `InputProbe()` reports `UNPARSEABLE`, which
* `PERMISSIVE.canDecode` refuses, so every job would reach FFmpeg with no test saying why.
*/
val H264_SOURCE = InputProbe(
videoCodec = "h264",
audioCodec = "aac",
container = Container.MP4,
durationMs = 1_000,
)
}
}
@@ -261,22 +211,3 @@ private class ReportingTranscoder(private val report: ((Int) -> Unit) -> Unit) :
const val OUTPUT_BYTES = 512
}
}
/** A hardware engine that reports whatever [report] wants reported, then writes an output. */
@UnstableApi
private class ReportingHardwareTranscoder(private val report: ((Int) -> Unit) -> Unit) : HardwareTranscoder {
var attempts = 0
override suspend fun transcode(input: Uri, output: File, request: ConversionRequest, onProgress: (Int) -> Unit) {
attempts++
report(onProgress)
output.writeBytes(ByteArray(OUTPUT_BYTES))
}
override fun close() = Unit
private companion object {
const val OUTPUT_BYTES = 16
}
}
@@ -10,9 +10,3 @@
# Set here rather than in a @Config on each class so a later Robolectric test does not have
# to rediscover it. Remove it once Robolectric ships an android-all jar for 37.
sdk=36
# Every test gets TestLibreMediaConverterApp, whose only difference from the real one is that the
# startup sweep runs inline rather than on Dispatchers.IO. Set suite-wide because the race it fixes
# (#159) is suite-wide: any class that builds an Application leaves a sweep of the shared staging
# directory in flight for whatever runs next. TestLibreMediaConverterApp explains the choice.
application=org.libremediaconverter.TestLibreMediaConverterApp