Compare commits

..
Author SHA1 Message Date
JMR-dev dab28d5f44 Merge branch 'main' into fix/codec-vocabulary-drift 2026-08-24 23:13:20 -05:00
Jason Ross aed4d83e70 Merge pull request #89 from JMR-dev/docs/robolectric-rationale-correction
Give the Robolectric choice a reason that is still true
2026-08-24 23:12:48 -05:00
JMR-dev 4ff44be1d7 Give the Robolectric choice a reason that is still true
Two test classes justified using Robolectric by asserting that the alternative does not
exist:

  AppRootRestorationTest       "The instrumented tests cannot run on the development
                                host at all (see CLAUDE.md)"
  OutputPublisherStagingTest   "The instrumented suite cannot run on the development
                                host, so this is the only place [it] can be caught"

Both were true when written and stopped being true on 2026-08-22, when the segfault was
traced to SwiftShader's Reactor JIT against SELinux execheap rather than to the machine.
tools/local-emulator/run-e2e.sh has run API 33-36 here since.

The first one cites CLAUDE.md as its authority, and PR #73 corrected CLAUDE.md to say the
opposite. So it was no longer merely stale: a reader who followed the reference found the
contradiction, with the citation making the wrong half look verified. That is the worst
version of this -- R14, R15, R20 and R25 were all the same defect, and this is the fifth.

The choice itself was never wrong, which is why the fix is not to move these tests. Both
belong on the JVM, and the honest reason is cost rather than impossibility: neither needs
anything a device supplies, and both run inside the same ./gradlew invocation as every
other unit test instead of booting an emulator. That argument survives the correction; the
premise did not.

The old line also has a second failure mode worth naming. "Nobody can execute this" invites
a reader to skip the local run and let CI decide, which is the opposite of what the
definition-of-done in #51 asks for.

Verified: the string appears nowhere in app/src now, and testDebugUnitTest, ktlintCheck and
detekt are green.

Closes #46.
2026-08-24 22:45:02 -05:00
JMR-devandClaude Opus 5 7f951baf8f Make the two codec tables answer for each other, and stop describeAudio printing a NUL
The FFprobe codec vocabulary is written out in at least four places and none of them
had a test. Two had already drifted apart. `x264`, `hev1`, `x265` and `vp09` resolved
in `CodecNames.videoFromName` and returned null from
`AndroidDeviceCodecs.mimeForCodecName`, so the app identified the codec for the source
card and for routing and then ran the device capability check blind on the same string;
`mpeg4` ran the other way and rendered as a raw name. Nothing could notice, and the
reason is structural: a `when` cannot be enumerated, so no test can ask one table what
the other one knows.

Both are maps now, for that reason alone, and `CodecVocabularyTest` walks the two key
sets. A name added to -- or removed from -- one side alone fails the build. The one
legitimate asymmetry is listed rather than implied: `mpeg4` is decodable input with no
`VideoCodec` to name it, so `CodecNames` is right not to carry it. That list is itself
checked, because otherwise it is an escape hatch -- any future divergence could be waved
through by adding the name to it, and adding `x265` to it now fails.

THIS CHANGES BEHAVIOUR for `x264`, `hev1`, `x265` and `vp09`. A null from
`mimeForCodecName` means "unknown to us: assume the platform can handle it and let a
failed export trigger the FFmpeg fallback", which is the right policy for a name nobody
recognises and the wrong one for a name recognised one file over. A device without the
matching decoder now sends those four to FFmpeg up front instead of spending a doomed
hardware attempt to discover it. No input loses hardware it could have used: each alias
resolves to the MIME its canonical spelling already resolved to, so a device that has
the decoder still answers true. `ConversionRouterTest` still passes and that is not
evidence either way -- every `canDecode` in it is a hand-written stub that never reaches
this table.

#74 is the same family one level down. `describeVideo` answered "Unrecognised" for
`InputProbe.UNPARSEABLE` and `describeAudio` had no such arm, so an unparseable audio
codec would have fallen through to `?: name` -- and the sentinel opens with a NUL, so
the source-info card would have rendered a `Text` beginning with U+0000. The two now
share one body, which is what stops the next arm being added to one side only.

Two corrections to that ticket, taken from the file rather than from the ticket, since
it warns about exactly this:

  - It quotes `audioFromName` as opening with `null, InputProbe.UNPARSEABLE -> null`.
    It did not; it opened with `null -> null` and the sentinel reached `else`. Naming
    the sentinel in the shared lookup therefore changes no answer and is documentation,
    not the fix.
  - It says `describeVideo`'s arm has no test of its own. It did -- `descriptions stay
    readable for unknown and missing codecs` asserts it -- so deleting the shared arm
    now reddens three tests across both sides, not one.

Mutations run, each on the full 386-test suite:

  add "avc3" to CodecNames only    -> CodecVocabularyTest red on two counts,
                                      CodecNamesTest green: 8 tests, 0 failures, which
                                      is the ticket's point about per-table arm tests
  delete the UNPARSEABLE arm       -> CodecNamesTest red on three, one of them quoting
                                      the NUL back
  add "x265" to DECODE_ONLY_NAMES  -> CodecVocabularyTest red on the escape hatch
  delete "vp09" from the MIME map  -> CodecVocabularyTest red on three, which is the
                                      state this commit is fixing

Audio is not cross-checked, and that is a gap rather than a decision: the device
capability check is video-only, so this module has no second audio table to compare
`AUDIO_ALIASES` against. `Media3Engine.audioMimeTypeFor` is the other half and belongs
to #85. `MediaProbe.shortName` (#84) is the fourth table and is untouched here for the
same reason.

Closes #87.
Closes #74.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 22:44:55 -05:00
Jason Ross ad28293b72 Merge pull request #82 from JMR-dev/docs/api37-advisory-counts
Say three where a third test joined, and stop the name claiming to be exact
2026-08-24 22:06:10 -05:00
JMR-dev b9abe85580 Say three where a third test joined, and stop the name claiming to be exact
#80 added SafPickerRoundTripTest's rotation case to @FailsOnEmulatorApi37, because a
real rotation aborts the framework on android-37.0. Three tests carry the marker now --
two in Media3EngineTest, one in SafPickerRoundTripTest -- and five statements still
described two.

Four were counts, and wrong:

  status_check.yml  "notAnnotation removes the two tests that do not pass"
  status_check.yml  "The two API 37 tests the gating row above excludes"
  CLAUDE.md         "the gating leg runs the other 55"          (59 - 3 = 56)
  CLAUDE.md         "do not read a green run as evidence those two tests pass"

The fifth was worse, because it was not a count. The advisory job's header justified its
name with an invariant:

  "It is named for WHAT IT RUNS, deliberately. Both tests drive a full H.264 -> H.265
   hardware transcode through Media3Engine"

The rotation case drives no transcode. So the comment did not merely miscount -- it
asserted a property of the job's contents that had stopped being true, and that property
was the entire argument for the name.

The name is unchanged, deliberately, and the header now says so instead of implying the
question never arose. This is not a required context, it is red on every PR by design, and
it is one people have learned to look for; renaming a check costs more than the imprecision
does. What replaced the invariant is the honest rule: THE MARKER IS THE DEFINITION, NOT THE
NAME -- this job holds the tests that cannot pass on the API 37 emulator image, whatever
their subject.

Two things stay as they were because they are still true. "the two Media3EngineTest cases
that pass here" is correct: that class has four tests and two carry the marker. And the
decoder theory is still a claim about the Media3 pair alone, so it now says so rather than
being read as covering a rotation failure it has nothing to do with.

Nothing about the job's behaviour changes: same name, same continue-on-error, same marker,
same selection on both rows. Verified: yaml parses, five jobs, matrix still 33/34/35/36/37.

The check to re-run when a test next joins or leaves the marker, which is the event that
broke this twice:

  grep -rn "@FailsOnEmulatorApi37" app/src/androidTest --include='*.kt' | grep -v import | grep -c FailsOn

It must equal the number every corrected comment states. It is 3.

Closes #81.
2026-08-24 21:58:33 -05:00
Jason Ross 4375a377bc Merge pull request #80 from JMR-dev/test/r38-8-saf-e2e
Pick a file through the real system picker, then rotate the phone
2026-08-24 21:23:36 -05:00
8 changed files with 395 additions and 61 deletions
+21 -9
View File
@@ -266,7 +266,7 @@ jobs:
# api-level must be "37.0". A bare 37 is not an SDK package and fails
# during setup, which cost a run to discover.
#
# notAnnotation removes the two tests that do not pass on this image; they
# notAnnotation removes the three tests that do not pass on this image; they
# run in the advisory job below, off the same marker so they cannot end up
# in both or neither. docs/api-37-emulator-crash.md has the measurements.
- label: "37"
@@ -367,7 +367,7 @@ jobs:
if-no-files-found: ignore
# ---------------------------------------------------------------------------
# The two API 37 tests the gating row above excludes, run on their own so they
# The three API 37 tests the gating row above excludes, run on their own so they
# stay visible instead of disappearing behind a notAnnotation.
#
# continue-on-error: it reports, it never blocks. That is the whole reason it is
@@ -375,17 +375,29 @@ jobs:
# job's `E2E API <label>` name, and a check cannot be both required and advisory
# under one name.
#
# It is named for WHAT IT RUNS, deliberately. Both tests drive a full H.264 ->
# H.265 hardware transcode through Media3Engine -- which is exactly what
# separates them from the two Media3EngineTest cases that pass here, since those
# two never decode video. The current theory about why they fail is in the next
# paragraph, where it can be corrected without renaming a check that people have
# already learned to look for.
# It was named for WHAT IT RUNS, and that is now APPROXIMATE rather than exact.
# When this job was created it held two tests, both driving a full H.264 -> H.265
# hardware transcode through Media3Engine -- which is exactly what separated them
# from the two Media3EngineTest cases that pass here, since those two never decode
# video. Since 2026-08-25 it also holds SafPickerRoundTripTest's rotation case,
# which drives no transcode at all: a real rotation rebuilds every surface at once
# and takes the framework down on this image (INSTRUMENTATION_ABORTED), which is a
# different failure from the decoder one below.
#
# The name is kept anyway, and that is a decision rather than an oversight. This is
# not a required context, it is red on every PR by design, and it is one people
# have learned to look for -- renaming a check costs more than the imprecision
# does. **The marker is the definition, not the name:** what this job holds is the
# tests that cannot pass on the API 37 emulator image, whatever their subject. The
# theory about the Media3 pair is in the next paragraph, where it can be corrected
# without touching the name.
#
# THEORY, NOT SETTLED: the exception surfaces at `dequeueOutputBuffer` on
# `c2.goldfish.h264.decoder`, the emulator's own codec, which gets its frames out
# of a host-side colour buffer -- the same readback machinery that aborts
# surfaceflinger on this image. What is MEASURED is narrower: these two fail on
# surfaceflinger on this image. It is about the MEDIA3 PAIR only; the rotation
# case above fails for its own reason. What is MEASURED is narrower: those two
# fail on
# the API 37 emulator image; pass at API 36 on this runner under the same renderer
# AND the same SystemUI-disable path; pass at API 33-36 without that path at all,
# since nothing below 37 needs it; and pass on a physical Pixel 10 Pro XL at 37. That the decoder is the culprit rather than something else
+13 -7
View File
@@ -76,16 +76,22 @@ days. Read it as the current answer, and see the git history if you need the old
`angle_indirect` and `swangle_indirect` all boot, while `auto`, `off`, `guest` and
`swiftshader_indirect` do not. `docs/local-emulator.md` has the evidence and the per-API renderer
table.
- **CI runs API 37, and it gates.** The matrix is 33/34/35/36/37. Two Media3 hardware-transcode
tests fail inside the emulator's own `c2.goldfish.h264.decoder` rather than on anything this app
does; they carry `@FailsOnEmulatorApi37` and run in a separate `continue-on-error` job,
`E2E API 37 Media3 hardware transcode (advisory)`. The gating leg runs the other 55.
**That advisory job is red on every PR, by design** — do not read it as your change breaking
something, and do not read a green run as evidence those two tests pass.
- **CI runs API 37, and it gates.** The matrix is 33/34/35/36/37. **Three** of the 59 instrumented
tests cannot pass on that image, for two unrelated reasons: two Media3 hardware transcodes fail
inside the emulator's own `c2.goldfish.h264.decoder`, and one SAF test takes the framework down
when it rotates the display. All three carry `@FailsOnEmulatorApi37` and run in a separate
`continue-on-error` job; the gating leg runs the other 56.
That job is still called `E2E API 37 Media3 hardware transcode (advisory)`, which no longer
describes everything in it. The name is kept deliberately — it is not a required context and
people have learned to look for it — so **read the marker, not the name**, for what it holds.
**It is red on every PR, by design**: do not read it as your change breaking something, and do
not read a green run as evidence those three tests pass.
`docs/api-37-emulator-crash.md` has the measurements.
Still true, and the reason the advisory job is not simply deleted: **API 37 needs a manual check on
the Pixel 10 Pro XL before each release.** The advisory pair is the one thing CI cannot answer for.
the Pixel 10 Pro XL before each release.** Those three tests are the one thing CI cannot answer
for.
On a device or emulator, build only the ABI it can execute:
@@ -75,7 +75,13 @@ class AndroidDeviceCodecs private constructor(
return AndroidDeviceCodecs(encoders, decoders)
}
private fun mimeFor(codec: VideoCodec): String? = when (codec) {
/**
* `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.
* The JVM test source set is a friend of `main`, so this stays invisible outside the
* module — the precedent is `MainActivity`'s `Destination`.
*/
internal fun mimeFor(codec: VideoCodec): String? = when (codec) {
VideoCodec.H264 -> MediaFormat.MIMETYPE_VIDEO_AVC
VideoCodec.H265 -> MediaFormat.MIMETYPE_VIDEO_HEVC
VideoCodec.VP8 -> MediaFormat.MIMETYPE_VIDEO_VP8
@@ -87,20 +93,62 @@ class AndroidDeviceCodecs private constructor(
VideoCodec.COPY, VideoCodec.NONE -> null
}
/** Maps an FFprobe-style codec name onto a MediaFormat MIME type. */
private fun mimeForCodecName(name: String): String? = when (name.lowercase()) {
"h264", "avc", "avc1" -> MediaFormat.MIMETYPE_VIDEO_AVC
"hevc", "h265", "hvc1" -> MediaFormat.MIMETYPE_VIDEO_HEVC
"vp8" -> MediaFormat.MIMETYPE_VIDEO_VP8
"vp9" -> MediaFormat.MIMETYPE_VIDEO_VP9
"av1", "av01" -> MediaFormat.MIMETYPE_VIDEO_AV1
"mpeg4" -> MediaFormat.MIMETYPE_VIDEO_MPEG4
// Unknown to us: assume the platform can handle it and let a failed export
// trigger the FFmpeg fallback, rather than pre-emptively refusing hardware.
else -> null
}
/**
* FFprobe-style codec names, and the MediaFormat MIME type each one asks about.
*
* This is the same vocabulary `CodecNames.VIDEO_ALIASES` holds, written out a second time
* because this side has to answer in platform MIME types and `model` does not depend on
* Android. Two copies of one vocabulary drift, and these had: `x264`, `hev1`, `x265` and
* `vp09` resolved for display and routing and fell through to null here, so the app ran
* the capability check blind on inputs it had already identified (#87). They are listed
* now, which **changes behaviour** for those four names — see [mimeForCodecName].
*
* A map rather than a `when` because a `when` cannot be enumerated, and `CodecVocabularyTest`
* has to walk both key sets to notice the next divergence.
*/
internal val NAME_TO_MIME: Map<String, String> = mapOf(
"h264" to MediaFormat.MIMETYPE_VIDEO_AVC,
"avc" to MediaFormat.MIMETYPE_VIDEO_AVC,
"avc1" to MediaFormat.MIMETYPE_VIDEO_AVC,
"x264" to MediaFormat.MIMETYPE_VIDEO_AVC,
"hevc" to MediaFormat.MIMETYPE_VIDEO_HEVC,
"h265" to MediaFormat.MIMETYPE_VIDEO_HEVC,
"hvc1" to MediaFormat.MIMETYPE_VIDEO_HEVC,
"hev1" to MediaFormat.MIMETYPE_VIDEO_HEVC,
"x265" to MediaFormat.MIMETYPE_VIDEO_HEVC,
"vp8" to MediaFormat.MIMETYPE_VIDEO_VP8,
"vp9" to MediaFormat.MIMETYPE_VIDEO_VP9,
"vp09" to MediaFormat.MIMETYPE_VIDEO_VP9,
"av1" to MediaFormat.MIMETYPE_VIDEO_AV1,
"av01" to MediaFormat.MIMETYPE_VIDEO_AV1,
"mpeg4" to MediaFormat.MIMETYPE_VIDEO_MPEG4,
)
/** Test seam: lets instrumented tests build a probe from explicit sets. */
/**
* The names in [NAME_TO_MIME] that no [VideoCodec] member spells, and why.
*
* MPEG-4 Part 2 is decodable input the app never targets, so there is no enum for it and
* `CodecNames` is right not to carry it. That makes it the one place the two tables
* legitimately differ. It is listed rather than implied so the cross-check can tell a
* documented asymmetry from a fresh drift — and so the list itself is checked: a name here
* that `CodecNames` does resolve is a divergence being waved through, and the test fails on
* it.
*/
internal val DECODE_ONLY_NAMES: Set<String> = setOf("mpeg4")
/**
* Maps an FFprobe-style codec name onto a MediaFormat MIME type.
*
* Null keeps its documented meaning — unknown to us: assume the platform can handle it and
* let a failed export trigger the FFmpeg fallback, rather than pre-emptively refusing
* hardware. What changed with #87 is which names are unknown. Four that FFmpeg genuinely
* emits used to land here and be treated as unknown while the rest of the app knew exactly
* what they were; a device without the matching decoder now routes them to FFmpeg up front
* instead of spending a doomed hardware attempt to find out.
*/
internal fun mimeForCodecName(name: String): String? = NAME_TO_MIME[name.lowercase()]
/** Test seam: lets a test build a probe from explicit sets, on a device or on the JVM. */
fun forTesting(encoders: Set<String>, decoders: Set<String>) = AndroidDeviceCodecs(encoders, decoders)
}
}
@@ -15,36 +15,97 @@ package org.libremediaconverter.model
*/
object CodecNames {
fun videoFromName(name: String?): VideoCodec? = when (name?.lowercase()) {
null, InputProbe.UNPARSEABLE -> null
"h264", "avc", "avc1", "x264" -> VideoCodec.H264
"hevc", "h265", "hvc1", "hev1", "x265" -> VideoCodec.H265
"vp8" -> VideoCodec.VP8
"vp9", "vp09" -> VideoCodec.VP9
"av1", "av01" -> VideoCodec.AV1
else -> null
}
/**
* The video vocabulary, as data rather than a `when`.
*
* This is not the only place the app spells these names. `AndroidDeviceCodecs` reads the same
* FFprobe strings to decide what the device can decode, and answers in platform MIME types,
* which `model` cannot name without depending on Android. The two copies drifted apart:
* `x264`, `hev1`, `x265` and `vp09` resolved here and returned null there, so the app
* identified the codec for display and routing and then ran the device check blind, attempting
* a hardware path it had enough information to skip (#87).
*
* The reason this is a map is that **a `when` cannot be enumerated**, so nothing could compare
* the two tables. `CodecVocabularyTest` walks both key sets, so a name added to or removed
* from one side alone now fails the build rather than waiting for a wasted transcode to show
* it.
*
* Keys are lowercase; [videoFromName] lowercases before looking one up.
*/
internal val VIDEO_ALIASES: Map<String, VideoCodec> = mapOf(
"h264" to VideoCodec.H264,
"avc" to VideoCodec.H264,
"avc1" to VideoCodec.H264,
"x264" to VideoCodec.H264,
"hevc" to VideoCodec.H265,
"h265" to VideoCodec.H265,
"hvc1" to VideoCodec.H265,
"hev1" to VideoCodec.H265,
"x265" to VideoCodec.H265,
"vp8" to VideoCodec.VP8,
"vp9" to VideoCodec.VP9,
"vp09" to VideoCodec.VP9,
"av1" to VideoCodec.AV1,
"av01" to VideoCodec.AV1,
)
fun audioFromName(name: String?): AudioCodec? = when (name?.lowercase()) {
null -> null
"aac", "mp4a", "aac_latm" -> AudioCodec.AAC
"opus" -> AudioCodec.OPUS
"vorbis" -> AudioCodec.VORBIS
"mp3", "mp3float", "mpga" -> AudioCodec.MP3
"flac" -> AudioCodec.FLAC
"pcm", "raw", "pcm_s16le", "pcm_s24le", "pcm_f32le" -> AudioCodec.PCM
else -> null
}
/**
* The audio vocabulary, data for the same reason.
*
* Nothing cross-checks this one yet, and that is a gap rather than a decision: the device
* capability check is video-only, so this module holds no second audio table to compare it
* against. `Media3Engine.audioMimeTypeFor` is the other half, and #85 owns that file.
*/
internal val AUDIO_ALIASES: Map<String, AudioCodec> = mapOf(
"aac" to AudioCodec.AAC,
"mp4a" to AudioCodec.AAC,
"aac_latm" to AudioCodec.AAC,
"opus" to AudioCodec.OPUS,
"vorbis" to AudioCodec.VORBIS,
"mp3" to AudioCodec.MP3,
"mp3float" to AudioCodec.MP3,
"mpga" to AudioCodec.MP3,
"flac" to AudioCodec.FLAC,
"pcm" to AudioCodec.PCM,
"raw" to AudioCodec.PCM,
"pcm_s16le" to AudioCodec.PCM,
"pcm_s24le" to AudioCodec.PCM,
"pcm_f32le" to AudioCodec.PCM,
)
fun videoFromName(name: String?): VideoCodec? = asCodecName(name)?.let(VIDEO_ALIASES::get)
fun audioFromName(name: String?): AudioCodec? = asCodecName(name)?.let(AUDIO_ALIASES::get)
/** Human-readable name for the source-info card. Falls back to the raw probe string. */
fun describeVideo(name: String?): String = when {
fun describeVideo(name: String?): String = describe(name) { videoFromName(it)?.label }
fun describeAudio(name: String?): String = describe(name) { audioFromName(it)?.label }
/**
* Lowercases a probe string, and answers null for the two inputs that are not codec names at
* all: absent, and the [InputProbe.UNPARSEABLE] sentinel.
*
* The sentinel would miss every key anyway, so naming it changes no answer. Naming it is still
* the point: `videoFromName` excluded it explicitly and `audioFromName` did not, which read as
* though the two disagreed about what the sentinel means — the same asymmetry as #74 one
* function further up.
*/
private fun asCodecName(name: String?): String? =
if (name == null || name == InputProbe.UNPARSEABLE) null else name.lowercase()
/**
* The shared body of [describeVideo] and [describeAudio].
*
* They are one function apiece over one vocabulary, and they had stopped matching:
* `describeVideo` answered "Unrecognised" for [InputProbe.UNPARSEABLE] and `describeAudio` fell
* through to `?: name` instead. The sentinel opens with a NUL, so that fallback would have put
* a U+0000 into a `Text` on the source-info card (#74). Sharing the arms is what stops the next
* one being added to one side only.
*/
private fun describe(name: String?, label: (String) -> String?): String = when {
name == null -> "Unknown"
name == InputProbe.UNPARSEABLE -> "Unrecognised"
else -> videoFromName(name)?.label ?: name
}
fun describeAudio(name: String?): String = when {
name == null -> "Unknown"
else -> audioFromName(name)?.label ?: name
else -> label(name) ?: name
}
}
@@ -33,9 +33,12 @@ import org.robolectric.RobolectricTestRunner
* representation survives a `Bundle` round trip. A JVM round-trip test on the
* saver covers the representation.
*
* Robolectric rather than the instrumented suite, deliberately. The instrumented tests
* cannot run on the development host at all (see CLAUDE.md), and a red test nobody can
* execute is not a loop anyone can work in.
* Robolectric rather than the instrumented suite, deliberately -- but not because the
* instrumented suite is unavailable. It runs on this host for API 33-36
* (`tools/local-emulator/run-e2e.sh`), and CI runs 33-37. The reason is cost: this test
* needs a composition and a saved-state round trip, nothing a device supplies, and it runs
* in the same `./gradlew` invocation as every other JVM test instead of booting an
* emulator. A loop measured in seconds is a loop people stay inside.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
@@ -0,0 +1,143 @@
package org.libremediaconverter.codec
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.libremediaconverter.model.CodecNames
import org.libremediaconverter.model.VideoCodec
/**
* Bites on #87: two tables read one codec vocabulary and had stopped agreeing.
*
* `CodecNames.VIDEO_ALIASES` answers "which enum is this FFprobe name", for the source-info card
* and for routing. `AndroidDeviceCodecs.NAME_TO_MIME` answers "which MIME do I ask this device
* about", for the capability check. On `ad28293` five names lived in one and not the other: `x264`,
* `hev1`, `x265` and `vp09` were identified for display and then fell through the device check as
* unknown, so the app attempted a hardware path it had enough information to skip; `mpeg4` ran the
* other way and rendered as a raw name on the card.
*
* Per-table arm tests would have passed on both tables and encoded the disagreement, which is why
* these walk the key sets instead. A name added to — or removed from — one side alone fails here.
*/
class CodecVocabularyTest {
private val aliases = CodecNames.VIDEO_ALIASES
private val mimes = AndroidDeviceCodecs.NAME_TO_MIME
private val decodeOnly = AndroidDeviceCodecs.DECODE_ONLY_NAMES
@Test
fun `no video codec name resolves for display without also resolving for the device check`() {
assertEquals(
"resolve in CodecNames but return null from mimeForCodecName, so the device check runs blind",
emptySet<String>(),
aliases.keys - mimes.keys,
)
}
@Test
fun `no video codec name resolves for the device check without being a name the app can label`() {
assertEquals(
"resolve in AndroidDeviceCodecs but not in CodecNames, and are not listed as decode-only",
emptySet<String>(),
mimes.keys - aliases.keys - decodeOnly,
)
}
/**
* Membership is not enough: `"x265" to MIMETYPE_VIDEO_AVC` would satisfy both key sets and
* still ask the device about the wrong codec.
*/
@Test
fun `the two tables agree on what each name means, not merely that they know it`() {
aliases.forEach { (name, codec) ->
val expected = AndroidDeviceCodecs.mimeFor(codec)
assertNotNull("$name maps to $codec, which has no MIME to ask about", expected)
assertEquals("$name is $codec in CodecNames", expected, mimes[name])
}
}
/**
* The exception list is the escape hatch: any future divergence could be waved through by
* adding the name to it. Guard both directions so it cannot be.
*/
@Test
fun `the decode-only names are genuinely decode-only`() {
decodeOnly.forEach { name ->
assertNotNull("$name is listed as decode-only but the device check cannot resolve it", mimes[name])
assertNull(
"$name is listed as decode-only, but CodecNames does resolve it — that is a divergence " +
"being waved through rather than a documented exception",
CodecNames.videoFromName(name),
)
}
}
/**
* The five names #87 measured, pinned by name so the specific regression cannot come back
* quietly even if someone rewrites the tables above.
*/
@Test
fun `the names that used to resolve on one side only resolve on both`() {
mapOf(
"x264" to VideoCodec.H264,
"hev1" to VideoCodec.H265,
"x265" to VideoCodec.H265,
"vp09" to VideoCodec.VP9,
).forEach { (name, codec) ->
assertEquals("$name is a name FFmpeg emits", codec, CodecNames.videoFromName(name))
assertEquals(
"$name has to reach the device check too, or the app identifies it and then asks blind",
AndroidDeviceCodecs.mimeFor(codec),
AndroidDeviceCodecs.mimeForCodecName(name),
)
}
// The one that runs the other way: decodable input with no enum to name it.
assertNull("mpeg4 is not an output the app can target", CodecNames.videoFromName("mpeg4"))
assertNotNull("mpeg4 is still decodable input", AndroidDeviceCodecs.mimeForCodecName("mpeg4"))
}
/**
* Without this the agreement test above could pass on two nulls.
*
* `MediaFormat.MIMETYPE_VIDEO_AVC` is a Java compile-time constant, so it is inlined and the
* unit-test classpath's stubbed `android.jar` never has to supply it. If that ever stops being
* true, every MIME comparison here would be `null == null` and green — the vacuous-mutation
* failure this repo has counted before. Assert one literal so the stub fails loudly instead.
*/
@Test
fun `the MIME constants are real strings rather than stubs`() {
assertEquals("video/avc", AndroidDeviceCodecs.mimeForCodecName("h264"))
assertEquals("video/hevc", AndroidDeviceCodecs.mimeForCodecName("hevc"))
assertEquals("video/avc", AndroidDeviceCodecs.mimeFor(VideoCodec.H264))
}
@Test
fun `codec names are matched case-insensitively on both sides`() {
assertEquals(VideoCodec.H265, CodecNames.videoFromName("HEV1"))
assertEquals("video/hevc", AndroidDeviceCodecs.mimeForCodecName("HEV1"))
}
@Test
fun `a name neither table knows still resolves to nothing`() {
assertNull(CodecNames.videoFromName("cinepak"))
assertNull(AndroidDeviceCodecs.mimeForCodecName("cinepak"))
}
/**
* The behaviour #87 actually changes, at the seam that uses it.
*
* `canDecode` treats an unresolved name as "assume the platform copes". Before the alias
* landed, a device with no HEVC decoder answered true for `x265` and Media3 was handed a job it
* could not do; now the router sends it to FFmpeg without spending the attempt.
*/
@Test
fun `a device without the decoder now says so for the aliases it used to wave through`() {
val hevcOnly = AndroidDeviceCodecs.forTesting(encoders = emptySet(), decoders = setOf("video/hevc"))
assertTrue("x265 is HEVC by another name", hevcOnly.canDecode("x265"))
assertFalse("this device has no AVC decoder, and x264 is AVC", hevcOnly.canDecode("x264"))
assertTrue("a name nobody knows keeps the permissive answer", hevcOnly.canDecode("cinepak"))
}
}
@@ -18,8 +18,10 @@ import java.util.UUID
* the actual filesystem — the same calls `reset()` makes, without needing a ViewModel (both
* of those construct a `WorkManager`, which is not initialised on the JVM classpath).
*
* The instrumented suite cannot run on the development host, so this is the only place the
* "Start over leaks a full-size copy" defect can be caught before CI.
* The instrumented suite could also catch the "Start over leaks a full-size copy" defect --
* it runs on this host for API 33-36 (`tools/local-emulator/run-e2e.sh`) and on CI for
* 33-37. Here rather than there because a real `cacheDir` is all the defect needs, and
* finding it costs an emulator boot there and a few seconds here.
*/
@RunWith(RobolectricTestRunner::class)
class OutputPublisherStagingTest {
@@ -1,6 +1,7 @@
package org.libremediaconverter.model
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Test
@@ -10,6 +11,16 @@ import org.junit.Test
* Three vocabularies meet: `MediaExtractor` MIME types, FFprobe `codec_name` strings, and the
* enums. Stream copy depends on the round trip, so a missing alias here shows up as "we could not
* identify the source codec" and silently costs the user a re-encode.
*
* Also bites on #74: `describeVideo` and `describeAudio` are one function apiece over one
* vocabulary and had stopped matching. Only the video side special-cased
* [InputProbe.UNPARSEABLE]; the audio side fell through to the raw name, and that sentinel opens
* with a NUL, so the source-info card would have rendered a control character. The arms are shared
* now, and the tests below assert both sides so the symmetric bug cannot reappear on the other one.
*
* The tables these read are cross-checked against the device capability check by
* `CodecVocabularyTest` (#87). Deliberately not repeated here: this file is what each name means,
* that one is whether the app's two copies of the vocabulary still agree.
*/
class CodecNamesTest {
@@ -48,4 +59,52 @@ class CodecNamesTest {
// An unrecognised but real codec name is more useful shown than hidden.
assertEquals("cinepak", CodecNames.describeVideo("cinepak"))
}
/** The audio row of the same card, which had none of the above. */
@Test
fun `audio descriptions degrade exactly the way video ones do`() {
assertEquals("AAC", CodecNames.describeAudio("mp4a"))
assertEquals("Unknown", CodecNames.describeAudio(null))
assertEquals("Unrecognised", CodecNames.describeAudio(InputProbe.UNPARSEABLE))
assertEquals("qdm2", CodecNames.describeAudio("qdm2"))
}
/**
* #74's actual failure mode, stated as the thing the user would have seen.
*
* `InputProbe.UNPARSEABLE` is `"\u0000unparseable"`. Falling through to `?: name` does not
* mislabel the track, it puts U+0000 into a `Text`.
*/
@Test
fun `no description can put a control character on the card`() {
listOf(CodecNames.describeAudio(InputProbe.UNPARSEABLE), CodecNames.describeVideo(InputProbe.UNPARSEABLE))
.forEach { assertFalse("$it leaks the sentinel", it.contains('\u0000')) }
}
/**
* Every alias, pinned one at a time.
*
* The tables became maps so `CodecVocabularyTest` could enumerate them; this is what catches a
* key mistyped or a value pointing at the wrong enum while that rewrite happened.
*/
@Test
fun `every name in the tables resolves to the codec it spells`() {
CodecNames.VIDEO_ALIASES.forEach { (name, codec) ->
assertEquals(name, codec, CodecNames.videoFromName(name))
}
CodecNames.AUDIO_ALIASES.forEach { (name, codec) ->
assertEquals(name, codec, CodecNames.audioFromName(name))
}
assertEquals(VideoCodec.H264, CodecNames.videoFromName("x264"))
assertEquals(VideoCodec.VP9, CodecNames.videoFromName("vp09"))
assertEquals(AudioCodec.MP3, CodecNames.audioFromName("mpga"))
assertEquals(AudioCodec.OPUS, CodecNames.audioFromName("opus"))
}
/** The audio lookup reads the sentinel the same way the video one does. */
@Test
fun `the unparseable sentinel resolves to nothing on the audio side too`() {
assertNull(CodecNames.audioFromName(InputProbe.UNPARSEABLE))
assertNull(CodecNames.audioFromName(null))
}
}