Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0412329ff | ||
|
|
54932e97c6 | ||
|
|
ba16f5a89b | ||
|
|
bffcff92c7 |
@@ -12,11 +12,17 @@ import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.codec.AndroidDeviceCodecs
|
||||
import org.libremediaconverter.model.ConversionRequest
|
||||
import org.libremediaconverter.model.ConversionRouter
|
||||
import org.libremediaconverter.model.Engine
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
import org.libremediaconverter.work.ConversionWorker
|
||||
import java.io.File
|
||||
|
||||
@@ -34,6 +40,47 @@ import java.io.File
|
||||
* hand — a regression test that silently skips is worse than no test, because the count
|
||||
* still reads as coverage.
|
||||
*
|
||||
* ## Why this skips on emulators, and why that is the honest answer (#223)
|
||||
*
|
||||
* **This test used to pass everywhere while proving nothing.** Two independent facts stop the
|
||||
* fallback happening on an emulator, and both were measured rather than reasoned:
|
||||
*
|
||||
* 1. **The router never sends the job to Media3.** A Fast MP4/H.265 job goes to the hardware path
|
||||
* only when `device.canEncode(H265)`, and emulators expose no hardware encoder — every leg of
|
||||
* run `34004304566` logged
|
||||
* `Routing sample_h264_444.mp4 -> ... via FFMPEG (NO_HARDWARE_ENCODER)`. The whole test
|
||||
* finished in 448 ms, which is not long enough to fail an export and then re-encode.
|
||||
* 2. **Forcing it to Media3 does not help either, which is the part that settles it.** Pinning
|
||||
* `ConversionDependencies.deviceCodecs` to [DeviceCodecs.PERMISSIVE] — the trick
|
||||
* [ForcedFailureTest] uses — makes the router choose Media3, and the export then *succeeds*.
|
||||
* Measured on a local API 34 emulator: `MediaCodecInfo` logs
|
||||
* `NoSupport [codec.profileLevel, avc1.F4000C, video/avc]` for **both**
|
||||
* `c2.goldfish.h264.decoder` and `c2.android.avc.decoder`, and ExoPlayer allocates the
|
||||
* goldfish decoder anyway, which decodes the file regardless of the profile it declares.
|
||||
* `c2.android.hevc.encoder` then encodes the result and the job reports `MEDIA3`.
|
||||
*
|
||||
* So the class KDoc above — "Media3 fails partway through the export on every device" — **is not
|
||||
* true of the emulator images**, and no amount of routing pressure makes this fixture force a
|
||||
* fallback there. The emulator cannot answer this question, so the test says so out loud instead
|
||||
* of passing.
|
||||
*
|
||||
* That is why the gate is [assumeTrue] on the *production* premise (`canEncode(H265)`) rather than
|
||||
* a pinned profile: pinning would also swap in software codecs, which is not the path a real
|
||||
* device takes and is what made the forced run succeed. **This is now the third permanent skip**;
|
||||
* the other two are [org.libremediaconverter.bench.RealMediaBenchmark]'s.
|
||||
*
|
||||
* `ForcedFailureTest.hardwareFailureFallsBackToSoftware` still covers the fallback *wiring* on
|
||||
* every leg, with an `ExplodingHardware` double. What only a device with a real hardware encoder
|
||||
* can show is two real engines disagreeing about a real file, and that is what this is for.
|
||||
*
|
||||
* ## Why the assertion is a pair
|
||||
*
|
||||
* `KEY_ENGINE_USED` is `FFMPEG` whether the fallback fired **or** the router went straight there,
|
||||
* so asserting it alone would not have caught any of the above. The premise is asserted
|
||||
* separately: [ConversionRouter.route] chooses `MEDIA3` for this request on this device. Static
|
||||
* routing wanted hardware, the runtime result was software — together, and only together, that is
|
||||
* the fallback.
|
||||
*
|
||||
* The fixture was produced with x264, which the host toolchain cannot do (Fedora's
|
||||
* ffmpeg ships openh264, which is Constrained Baseline only):
|
||||
*
|
||||
@@ -66,6 +113,15 @@ class HardwareFallbackTest {
|
||||
|
||||
@Test
|
||||
fun aFileMedia3CannotDecodeStillConvertsViaFfmpeg(): Unit = runBlocking {
|
||||
// See "Why this skips on emulators" on the class. Without a real hardware encoder the
|
||||
// router never chooses Media3, and forcing it makes the export succeed instead of fail --
|
||||
// so there is no fallback to observe and a green run would mean nothing.
|
||||
assumeTrue(
|
||||
"no hardware HEVC encoder, so the router cannot choose Media3 and there is no " +
|
||||
"fallback to exercise",
|
||||
AndroidDeviceCodecs.get().canEncode(VideoCodec.H265),
|
||||
)
|
||||
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = SAMPLE,
|
||||
@@ -75,6 +131,19 @@ class HardwareFallbackTest {
|
||||
// the tier where the fallback has to rescue the conversion.
|
||||
quality = QualityTier.FAST,
|
||||
)
|
||||
// The premise, asserted rather than assumed: this request is one the router wants to send
|
||||
// to hardware on this device. Without it the test is green whether the fallback fired or
|
||||
// the job never went near Media3, which is exactly how #223 stayed invisible.
|
||||
val decision = ConversionRouter.route(
|
||||
ConversionRequest(OutputFormat.MP4_H265.spec, quality = QualityTier.FAST),
|
||||
AndroidDeviceCodecs.get(),
|
||||
)
|
||||
assertEquals(
|
||||
"this test only means something if the router sends this job to Media3",
|
||||
Engine.MEDIA3,
|
||||
decision.engine,
|
||||
)
|
||||
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
@@ -88,6 +157,14 @@ class HardwareFallbackTest {
|
||||
terminal?.state,
|
||||
)
|
||||
|
||||
// The outcome. Paired with the routing assertion above this is the fallback and nothing
|
||||
// else: hardware was chosen, software is what ran.
|
||||
assertEquals(
|
||||
"the router chose Media3, so a successful job must have fallen back to FFmpeg",
|
||||
Engine.FFMPEG.name,
|
||||
terminal?.outputData?.getString(ConversionWorker.KEY_ENGINE_USED),
|
||||
)
|
||||
|
||||
val out = File(terminal!!.outputData.getString(ConversionWorker.KEY_OUTPUT_PATH)!!)
|
||||
assertTrue("no output produced", out.exists() && out.length() > 0)
|
||||
out.delete()
|
||||
|
||||
@@ -207,6 +207,8 @@ import java.util.concurrent.atomic.AtomicInteger
|
||||
* driven there at all. That is why this gap survived as long as it did.
|
||||
* `tools/local-emulator/run-e2e.sh` runs API 33-36 on the development host, and both tests pass
|
||||
* there: **59 / 0 / 0 / 2 at API 33 and again at API 36**, whole suite, 2026-08-24.
|
||||
* (Since #223 the skip column reads 3 on an emulator — `HardwareFallbackTest` now announces
|
||||
* that it cannot run without a hardware HEVC encoder rather than passing vacuously.)
|
||||
*
|
||||
* ### Why only the rotation test carries [FailsOnEmulatorApi37]
|
||||
*
|
||||
|
||||
+129
@@ -0,0 +1,129 @@
|
||||
package org.libremediaconverter.work
|
||||
|
||||
import android.net.Uri
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import androidx.work.OneTimeWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.libremediaconverter.model.OutputFormat
|
||||
import org.libremediaconverter.model.QualityTier
|
||||
import java.io.File
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/**
|
||||
* The Cancel button in the notification shade actually cancels the job.
|
||||
*
|
||||
* `ConversionNotifications.build` attaches one action, wired to
|
||||
* `WorkManager.createCancelPendingIntent(id)`. Before this test `createCancelPendingIntent` had
|
||||
* **no references anywhere outside its own declaration** — no JVM test, no instrumented test
|
||||
* (#227).
|
||||
*
|
||||
* That matters more than an ordinary uncovered line. A conversion runs in a foreground service and
|
||||
* the user is invited to leave the app; once they do, this action is the only way to stop it. If
|
||||
* the `PendingIntent` carries the wrong id, the button does nothing, the notification stays, and
|
||||
* the job runs to completion — with no error, no log, and no screen to look at.
|
||||
*
|
||||
* ## Why this fires the intent rather than reading the shade
|
||||
*
|
||||
* The obvious version asks `NotificationManager.getActiveNotifications()` for id 1001 and taps what
|
||||
* it finds. That was rejected: the instrumented suite grants no runtime permissions, so
|
||||
* `POST_NOTIFICATIONS` is denied throughout, and whether a suppressed foreground-service
|
||||
* notification is returned there is a platform detail that varies — the test would be asserting
|
||||
* something about notification *visibility* rather than about cancellation.
|
||||
*
|
||||
* The `PendingIntent` is the subject; where it is read from is incidental. Building the
|
||||
* notification for a real, live work id and firing its action exercises exactly the thing that can
|
||||
* be wrong — a real `PendingIntent` dispatch reaching real `WorkManager` — and does it the same way
|
||||
* on every API level.
|
||||
*
|
||||
* ## Why the job is delayed rather than running
|
||||
*
|
||||
* A conversion of the committed 3 s fixture finishes in well under a second on an emulator
|
||||
* (`HardwareFallbackTest` completed one in 448 ms), so racing a cancel against a running job would
|
||||
* be flaky in the direction that fails. An initial delay keeps the job reliably `ENQUEUED`, which
|
||||
* is a state `cancelWorkById` acts on identically — what is under test is whether firing the action
|
||||
* reaches WorkManager with the right id, not which state it interrupts.
|
||||
*
|
||||
* *Mutation:* build the `PendingIntent` from `UUID.randomUUID()` instead of the request's id. The
|
||||
* notification looks identical and the job is never cancelled.
|
||||
*/
|
||||
@UnstableApi
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class NotificationCancelActionTest {
|
||||
|
||||
private val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
private val workManager = WorkManager.getInstance(context)
|
||||
private lateinit var input: File
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
input = File(context.cacheDir, "cancel_action_sample.mp4")
|
||||
InstrumentationRegistry.getInstrumentation().context.assets
|
||||
.open("sample_h264.mp4")
|
||||
.use { asset -> input.outputStream().use { asset.copyTo(it) } }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
input.delete()
|
||||
File(context.cacheDir, "conversions").listFiles()?.forEach { it.delete() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theNotificationsCancelActionCancelsThatJob(): Unit = runBlocking {
|
||||
val request = ConversionWorker.request(
|
||||
inputUri = Uri.fromFile(input),
|
||||
displayName = input.name,
|
||||
sizeBytes = input.length(),
|
||||
spec = OutputFormat.MP4_H264.spec,
|
||||
quality = QualityTier.FAST,
|
||||
).let { base ->
|
||||
// Rebuild with a delay so the job stays ENQUEUED for the whole test. See the KDoc.
|
||||
OneTimeWorkRequestBuilder<ConversionWorker>()
|
||||
.setInputData(base.workSpec.input)
|
||||
.setInitialDelay(1, TimeUnit.HOURS)
|
||||
.build()
|
||||
}
|
||||
workManager.enqueue(request).result.get()
|
||||
|
||||
// The job is queued and waiting, which is the state the cancel has to interrupt.
|
||||
assertEquals(
|
||||
WorkInfo.State.ENQUEUED,
|
||||
withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null }
|
||||
}?.state,
|
||||
)
|
||||
|
||||
val notification = ConversionNotifications(context)
|
||||
.build(request.id, title = input.name, percent = 0, indeterminate = true)
|
||||
val action = notification.actions?.firstOrNull()
|
||||
assertNotNull("the progress notification carries no action to cancel with", action)
|
||||
|
||||
// The whole point: fire it the way the shade would, and see the job stop.
|
||||
action!!.actionIntent.send()
|
||||
|
||||
val terminal = withTimeout(TIMEOUT_MS) {
|
||||
workManager.getWorkInfoByIdFlow(request.id).first { it != null && it.state.isFinished }
|
||||
}
|
||||
assertEquals(
|
||||
"firing the notification's Cancel action must cancel the job it was built for",
|
||||
WorkInfo.State.CANCELLED,
|
||||
terminal?.state,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TIMEOUT_MS = 30_000L
|
||||
}
|
||||
}
|
||||
@@ -312,6 +312,18 @@ on sample media that is deliberately not committed. Its third test,
|
||||
`reportDeviceEncoderCapabilities`, has no such guard and runs. A level reporting 0 skipped
|
||||
would mean someone had staged sample files, not that something improved.
|
||||
|
||||
**Since #223 there is a third, and it is the interesting one.**
|
||||
`HardwareFallbackTest.aFileMedia3CannotDecodeStillConvertsViaFfmpeg` is `assumeTrue`-guarded on
|
||||
`AndroidDeviceCodecs.get().canEncode(H265)`, which is false on every emulator image — so it now
|
||||
skips here and runs only on the Pixel. It used to *pass* on emulators without ever attempting the
|
||||
hardware path, which is worse. **Expect `skipped="3"` locally**, and note the guard is a property
|
||||
of the machine rather than of staged files: a level reporting 2 would mean an emulator image had
|
||||
gained a hardware HEVC encoder, which is worth knowing.
|
||||
|
||||
That test's KDoc carries the measurement, including the part that decides it: forcing the route to
|
||||
Media3 anyway does *not* produce a fallback, because the goldfish decoder decodes the High 4:4:4
|
||||
fixture despite declaring `NoSupport` for its profile.
|
||||
|
||||
### What the sweep adds, and what it does not
|
||||
|
||||
**The renderer rule held four more times.** No boot log contains the string
|
||||
|
||||
Reference in New Issue
Block a user