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>
111 lines
5.0 KiB
Kotlin
111 lines
5.0 KiB
Kotlin
package org.libremediaconverter.model
|
|
|
|
import org.junit.Assert.assertEquals
|
|
import org.junit.Assert.assertFalse
|
|
import org.junit.Assert.assertNull
|
|
import org.junit.Test
|
|
|
|
/**
|
|
* The bridge between what probing reports and what the app's enums mean.
|
|
*
|
|
* 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 {
|
|
|
|
@Test
|
|
fun `the aliases both probes emit resolve to the same codec`() {
|
|
listOf("h264", "avc", "avc1", "AVC1").forEach {
|
|
assertEquals("$it should be H.264", VideoCodec.H264, CodecNames.videoFromName(it))
|
|
}
|
|
listOf("hevc", "h265", "hvc1", "hev1").forEach {
|
|
assertEquals("$it should be H.265", VideoCodec.H265, CodecNames.videoFromName(it))
|
|
}
|
|
assertEquals(VideoCodec.AV1, CodecNames.videoFromName("av01"))
|
|
assertEquals(AudioCodec.AAC, CodecNames.audioFromName("mp4a"))
|
|
assertEquals(AudioCodec.PCM, CodecNames.audioFromName("pcm_s16le"))
|
|
}
|
|
|
|
/** The sentinel must never look like a codec — that is the whole point of it. */
|
|
@Test
|
|
fun `the unparseable sentinel resolves to nothing`() {
|
|
assertNull(CodecNames.videoFromName(InputProbe.UNPARSEABLE))
|
|
}
|
|
|
|
@Test
|
|
fun `an unknown name resolves to nothing rather than a default`() {
|
|
assertNull(CodecNames.videoFromName("cinepak"))
|
|
assertNull(CodecNames.audioFromName("qdm2"))
|
|
assertNull(CodecNames.videoFromName(null))
|
|
}
|
|
|
|
/** The source-info card shows these, so they must never be blank or a raw sentinel. */
|
|
@Test
|
|
fun `descriptions stay readable for unknown and missing codecs`() {
|
|
assertEquals("H.264", CodecNames.describeVideo("h264"))
|
|
assertEquals("Unknown", CodecNames.describeVideo(null))
|
|
assertEquals("Unrecognised", CodecNames.describeVideo(InputProbe.UNPARSEABLE))
|
|
// 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))
|
|
}
|
|
}
|