Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
32ab54da3c | ||
|
|
e7caeeac43 | ||
|
|
ec2cae256f | ||
|
|
4e88de3045 | ||
|
|
2db0dc65d3 |
@@ -411,3 +411,24 @@ 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,6 +10,9 @@ 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
|
||||
@@ -24,6 +27,7 @@ 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.
|
||||
@@ -251,6 +255,21 @@ 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.
|
||||
*
|
||||
@@ -270,6 +289,7 @@ class SafPickerRoundTripTest {
|
||||
*/
|
||||
@After
|
||||
fun restoreOrientation() {
|
||||
ActivityLifecycleMonitorRegistry.getInstance().removeLifecycleCallback(recreationWatcher)
|
||||
if (!rotated) return
|
||||
device.setOrientationNatural()
|
||||
device.unfreezeRotation()
|
||||
@@ -303,9 +323,11 @@ 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
|
||||
@@ -675,6 +697,36 @@ 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()
|
||||
@@ -703,6 +755,15 @@ 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,6 +3,7 @@ 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
|
||||
@@ -17,14 +18,36 @@ 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.
|
||||
*/
|
||||
class LibreMediaConverterApp : Application() {
|
||||
open 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.
|
||||
*/
|
||||
private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
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
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
@@ -53,6 +76,6 @@ 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.
|
||||
appScope.launch { OutputPublisher(this@LibreMediaConverterApp).sweepStaging() }
|
||||
startupSweep = sweepScope.launch { OutputPublisher(this@LibreMediaConverterApp).sweepStaging() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,13 +673,23 @@ class ConversionViewModel @JvmOverloads constructor(
|
||||
else -> null
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
/**
|
||||
* 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 companion object {
|
||||
/**
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
@@ -66,16 +65,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 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(),
|
||||
),
|
||||
)
|
||||
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))
|
||||
}
|
||||
}
|
||||
cont.invokeOnCancellation { FFmpegKit.cancel(session.getSessionId()) }
|
||||
|
||||
@@ -4,7 +4,6 @@ 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
|
||||
@@ -51,19 +50,16 @@ class FFmpegEngine : SoftwareTranscoder {
|
||||
val session = FFmpegKit.executeWithArgumentsAsync(
|
||||
args.toTypedArray(),
|
||||
{ completed ->
|
||||
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()
|
||||
},
|
||||
),
|
||||
)
|
||||
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))
|
||||
}
|
||||
},
|
||||
{ log -> Log.d(TAG, log.message.trimEnd()) },
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
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 org.junit.Assert.assertEquals
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
@@ -10,7 +10,6 @@ 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.
|
||||
@@ -23,8 +22,19 @@ import java.util.concurrent.TimeUnit
|
||||
* 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, the scope it launches on is already there, and the first test
|
||||
* below is what pins that the framework calls it on *this* class.
|
||||
* 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.
|
||||
*/
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class AppStartSweepTest {
|
||||
@@ -34,17 +44,35 @@ 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 application the manifest starts is the one that sweeps`() {
|
||||
assertEquals(LibreMediaConverterApp::class.java, RuntimeEnvironment.getApplication().javaClass)
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -64,35 +92,23 @@ class AppStartSweepTest {
|
||||
|
||||
app.onCreate()
|
||||
|
||||
awaitGone(abandoned)
|
||||
// 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())
|
||||
// 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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)
|
||||
}
|
||||
@@ -51,10 +51,13 @@ 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.** 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.
|
||||
* - ~~**`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.
|
||||
* - **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
|
||||
|
||||
@@ -122,24 +122,25 @@ 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 loses a race that CI
|
||||
* caught and this machine does not reproduce.
|
||||
* 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.
|
||||
*
|
||||
* `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.
|
||||
* **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.
|
||||
*
|
||||
* 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.
|
||||
* 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*.
|
||||
*/
|
||||
private fun stagingPathAsRegularFile(): File {
|
||||
val stagingPath = File(cacheDir, "conversions")
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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 })
|
||||
}
|
||||
@@ -10,3 +10,9 @@
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user