Each of these is a site where JaCoCo reported mi > 0 -- a concrete instruction no test
runs -- rather than a partial branch on a compound condition, which is how the group was
filtered in the first place. Six closed, one moved to the exclusions.
AndroidDeviceCodecs:35 the UNPARSEABLE sentinel, refused where an unknown name is not
ContainerCapabilities:94 accepts(container, VideoCodec.NONE, mode) -- the audio twin has
had a test since #136; the asymmetry is the argument
ContainerCapabilities:323 repairVideo's keep-the-requested-codec arm
ContainerCapabilities:348 firstContainerHolding's fallback container
OutputPublisher:230 the resolver call that throws -- the third case the KDoc names
and the one the shape list was missing
JobSnapshots:31 a job that recorded no output path at all
Every one was mutated and confirmed red, then restored. Two are worth stating because
they did not go red first time or would not have:
**The firstContainerHolding test was vacuous on its first draft.** It asserted the
refusal still offered *something*, and deleting the fallback left it green: the source
container is a candidate in its own right, so the list stays non-empty and only its
contents change. Rewritten around AVI, which has no mapping for H.265, and asserting the
codec survives -- without the fallback the app silently offers H.264 instead, which is
the actual loss. This is the failure mode CLAUDE.md records from the mutation review, met
head on rather than in the abstract.
**OutputPublisher:230 needed one line.** `a destination whose size cannot be determined is
never deleted` already walked three RowShapes; QUERY_THROWS was the fourth case its own
KDoc names -- "a resolver call that throws" -- and the only one that reaches `?: false`
through runCatching rather than through a cursor answer.
ContainerCapabilities:282's `.filter { it != exclude }` is **not** closed here and is not
a gap: nothing can make it drop anything. On the shared container `repair` always changes
at least one codec, because a codec it left alone is one validate would not have refused;
every other candidate differs by container; and the single call site passing a non-default
exclude (validateVideo:186) excludes a spec carrying VideoCodec.COPY while every repaired
candidate carries NONE. F4-shaped -- recorded rather than covered, and 550 tests agree.
550 -> 555 JVM tests, 0 failures. Five new tests rather than six: OutputPublisher:230
is one line inside a test that already existed. No production code changed.
Line 2087/2348 -> 2090/2348; branch 1011/1340 -> 1022/1340.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
207 lines
9.7 KiB
Kotlin
207 lines
9.7 KiB
Kotlin
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.InputProbe
|
|
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.
|
|
*/
|
|
/**
|
|
* The sentinel is not just another unknown name, and the difference is the whole guard.
|
|
*
|
|
* `canDecode` ends `?: true` -- a name neither table knows keeps the permissive answer, because
|
|
* the app would rather try than refuse a file it might handle. `InputProbe.UNPARSEABLE` has to
|
|
* be the exception: the platform has *already* failed to parse the input, so there is nothing
|
|
* for a decoder to be permissive about, and waving it through spends a Media3 attempt on a job
|
|
* that cannot start.
|
|
*
|
|
* The `cinepak` line is what makes the sentinel line mean something. Without it, deleting the
|
|
* early return leaves this test green -- both names would fall through to the same `?: true`.
|
|
* The pair is the assertion.
|
|
*
|
|
* `DeviceCodecs.PERMISSIVE` carries the same rule and `ConversionRouterTest` pins its routing
|
|
* consequence. This is the implementation that runs on a device.
|
|
*/
|
|
@Test
|
|
fun `the unparseable sentinel is refused even where an unknown name is waved through`() {
|
|
val everything = AndroidDeviceCodecs.forTesting(
|
|
encoders = emptySet(),
|
|
decoders = setOf("video/avc", "video/hevc"),
|
|
)
|
|
assertFalse(
|
|
"the platform could not parse this input, so there is nothing to decode with",
|
|
everything.canDecode(InputProbe.UNPARSEABLE),
|
|
)
|
|
assertTrue(
|
|
"a merely unknown name still keeps the permissive answer",
|
|
everything.canDecode("cinepak"),
|
|
)
|
|
}
|
|
|
|
@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"))
|
|
}
|
|
|
|
/**
|
|
* The other half of the null policy, at the seam it exists for — #86.
|
|
*
|
|
* `mimeFor`'s `COPY, NONE -> null` arm carries its consequence in a comment: "Returning null
|
|
* makes canEncode answer true, which is the right answer: a copied or absent track places no
|
|
* demand on the hardware." That is a product decision, and until this test nothing held it. A
|
|
* MIME appearing in that arm would make a device with no matching encoder refuse a stream copy
|
|
* — a job that never encodes anything — and the router would send it to FFmpeg to re-mux what
|
|
* Media3 could have re-muxed.
|
|
*
|
|
* The `H264` line is what makes the other two mean something: without it, a `canEncode` that
|
|
* simply returned `true` would satisfy this test. `NONE` is asserted separately from `COPY`
|
|
* because they are one arm today and two answers, and splitting the arm must not silently
|
|
* halve the coverage.
|
|
*/
|
|
@Test
|
|
fun `a device with no video encoder at all still permits a copied or absent track`() {
|
|
val noEncoders = AndroidDeviceCodecs.forTesting(encoders = emptySet(), decoders = setOf("video/avc"))
|
|
assertTrue(
|
|
"a copied track is re-muxed, not encoded, so no encoder is required",
|
|
noEncoders.canEncode(VideoCodec.COPY),
|
|
)
|
|
assertTrue("an absent track places no demand on the hardware", noEncoders.canEncode(VideoCodec.NONE))
|
|
assertFalse(
|
|
"this device has no AVC encoder, so an H.264 target has to be refused — without this, " +
|
|
"a canEncode that always answered true would satisfy the two assertions above",
|
|
noEncoders.canEncode(VideoCodec.H264),
|
|
)
|
|
}
|
|
}
|