Files
LibreMediaConverter/app/src/test/java/org/libremediaconverter/convert/FileCardTest.kt
T
JMR-devandClaude Opus 5 da8d53851b Render the container row for a video nothing could name (#199)
ConverterScreen.kt:668's null arm -- DetailRow("Container", probe.container?.label ?:
"Unknown") in the VIDEO branch -- had never rendered. Every video case in FileCardTest uses
VIDEO_PROBE, which carries container = MP4.

The argument for adding it is the asymmetry, not the coverage. FileCard renders that exact
expression twice, once in AUDIO_ONLY (:660) and once in VIDEO (:668), and "an audio-only
file nothing else could describe degrades one row at a time" drives only the first. Same
expression, same fallback, one kind covered and one not -- which is the same argument
CLAUDE.md records for including ContainerCapabilities:94.

Nor is null an edge case here. InputProbe.container's own KDoc says MediaExtractor cannot
report a container at all, so it comes from FFprobe alone: any run where FFprobe did not
answer produces exactly this shape -- real codec, real dimensions, real duration, no
container. An empty value in its place would read as a rendering bug rather than as a
probe that got half its sources.

The other three rows are asserted alongside, which is what keeps this from being a copy of
the audio-only case. There, everything is unknown at once; here one field is missing from
a probe that is otherwise complete, and the rest have to be unaffected by it.

Mutation: `?: "Unknown"` -> `?: ""` at :668 only, run and restored. The AUDIO_ONLY twin at
:660 is a separate expression, and mutating that one would redden the existing test instead
-- which would prove nothing about this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 19:36:30 -05:00

283 lines
12 KiB
Kotlin
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package org.libremediaconverter.convert
import android.net.Uri
import androidx.compose.ui.test.assertCountEquals
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onChildren
import androidx.compose.ui.test.onNodeWithTag
import androidx.media3.common.util.UnstableApi
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremediaconverter.model.AudioCodec
import org.libremediaconverter.model.Container
import org.libremediaconverter.model.InputKind
import org.libremediaconverter.model.InputProbe
import org.libremediaconverter.model.VideoCodec
import org.libremediaconverter.ui.TestTags
import org.robolectric.RobolectricTestRunner
/**
* What the source-info card says when it does not know something.
*
* The defect is a card that invents an answer instead of admitting it has none. Two of them are
* live here and neither had a test before this file:
*
* - **`InputFile.sizeBytes` is nullable and the card is the reader that has to say so in words.**
* `sizeBytes` used to be `0L` for "nobody told me", and [UnknownInputSizeTest] records what that
* cost at the space check. The card is the other reader, and its failure mode is the mirror
* image: hand the null to `formatBytes` and it renders `"0 B"` -- a measurement, shown to the
* user, that no provider ever made. It renders **independently of the probe**, which is why the
* same assertion appears twice below, with the probe present and absent. That independence is
* the contract; a test covering only the probed case would leave the branch a user actually hits
* first -- the card is on screen before the probe finishes -- unguarded.
* - **The codec rows degrade in words too.** `CodecNames.describeVideo`/`describeAudio` answer
* `"Unknown"` for a codec nothing named, the `VIDEO` branch answers `"No audio track"` for a file
* with no audio, and the two `> 0` guards drop the dimension and length rows rather than printing
* `0` and `0:00`. Each of those has a case below on **both** sides of the guard, because a test
* of the present side alone stays green with the guard deleted.
*
* ### What cannot be asserted here, so that it is a decision rather than an omission
*
* The `probe == null` branch exits before `HorizontalDivider`, and **the divider's absence is not
* observable from a test**: Material 3 renders it as a `Box` with no semantics modifier, so it
* contributes no node to the semantics tree at all. What is asserted instead is everything the
* divider precedes -- no detail row for any label the four kind branches can emit -- plus the
* card's child count, which pins "these three texts and nothing else" without having to enumerate.
*
* The early exit itself is enforced by the compiler rather than by this file, which the PR body
* records: deleting `return@Column` un-smart-casts `probe`, and the `probe.kind` below it stops
* compiling. The mutation that reddens the test here is the compilable form of that regression --
* defaulting the null away with `?: InputProbe()` and letting the kind rows render.
*/
@UnstableApi
@RunWith(RobolectricTestRunner::class)
class FileCardTest {
@get:Rule
val composeRule = createComposeRule()
@Test
fun `a file no provider could measure says so in words rather than showing a zero`() {
setFileCard(input(sizeBytes = null, probe = VIDEO_PROBE))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_BYTES)
.assertTextEquals("Size unknown")
}
/**
* The same line, with no probe at all. Separate from the case above rather than folded into
* it because `setContent` may only be called once per rule, and because two independent reds
* are the evidence that the size line does not depend on the probe.
*/
@Test
fun `the size line says the same thing while the probe is still running`() {
setFileCard(input(sizeBytes = null, probe = null))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_BYTES)
.assertTextEquals("Size unknown")
}
@Test
fun `a size that was reported is formatted rather than replaced by the unknown line`() {
setFileCard(input(sizeBytes = 12_345_678L, probe = VIDEO_PROBE))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NAME).assertTextEquals("clip.mkv")
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_BYTES).assertTextEquals("12.3 MB")
}
/**
* The note and the emptiness are one behaviour, so they are one test: a regression that keeps
* the note but renders the rows anyway would leave a note-only test green.
*/
@Test
fun `while the probe is still running the card shows the reading note and nothing else`() {
setFileCard(input(probe = null))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NOTE)
.assertTextEquals("Reading…")
assertNoDetailRows()
// Name, size, note. Catches a row whose label is not in EVERY_ROW_LABEL as well.
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD).onChildren().assertCountEquals(3)
}
@Test
fun `a file nothing could read gets the explanatory line instead of unknown codecs`() {
setFileCard(input(probe = InputProbe(kind = InputKind.UNPARSEABLE)))
composeRule.onNodeWithTag(TestTags.Converter.FILE_CARD_NOTE)
.assertTextEquals("Could not identify this file. It will be converted with FFmpeg.")
assertNoDetailRows()
}
@Test
fun `an image gets its type and its pixel dimensions`() {
setFileCard(input(probe = InputProbe(kind = InputKind.IMAGE, width = 1920, height = 1080)))
assertRow("Type", "Image")
assertRow("Size", "1920×1080")
}
/** The `width > 0` guard, from the side that would print `0×0` if it were dropped. */
@Test
fun `an image whose dimensions nothing reported gets the type row alone`() {
setFileCard(input(probe = InputProbe(kind = InputKind.IMAGE)))
assertRow("Type", "Image")
assertNoRow("Size")
}
@Test
fun `an audio-only file says it has no video track rather than leaving the row blank`() {
setFileCard(
input(
probe = InputProbe(
audioCodec = "aac",
hasVideo = false,
durationMs = 90_000,
kind = InputKind.AUDIO_ONLY,
container = Container.MP3,
),
),
)
assertRow("Container", Container.MP3.label)
assertRow("Video", "No video track")
assertRow("Audio", AudioCodec.AAC.label)
assertRow("Length", "1:30")
assertNoRow("Type")
assertNoRow("Size")
}
/**
* Everything the audio branch can fail to know, at once: no container, no codec name, no
* duration. Each degrades in its own words, and the length row disappears rather than
* claiming `0:00`.
*/
@Test
fun `an audio-only file nothing else could describe degrades one row at a time`() {
setFileCard(input(probe = InputProbe(hasVideo = false, kind = InputKind.AUDIO_ONLY)))
assertRow("Container", "Unknown")
assertRow("Video", "No video track")
assertRow("Audio", "Unknown")
assertNoRow("Length")
}
@Test
fun `a video file composes its codec with its dimensions on one row`() {
setFileCard(input(probe = VIDEO_PROBE))
assertRow("Container", Container.MP4.label)
assertRow("Video", "${VideoCodec.H264.label} · 1920×1080")
assertRow("Audio", AudioCodec.AAC.label)
assertRow("Length", "1:30")
}
/**
* `"No audio track"` rather than `describeAudio(null)`'s `"Unknown"`. The video branch knows
* the difference between a track it could not name and a track that is not there; the audio
* branch above cannot, because a file with no audio is not audio-only.
*/
@Test
fun `a video file with no audio track says so instead of naming an unknown codec`() {
setFileCard(input(probe = VIDEO_PROBE.copy(audioCodec = null)))
assertRow("Audio", "No audio track")
}
/** Both `> 0` guards on the video branch, plus the codec name nothing supplied. */
@Test
fun `a video file missing its codec, dimensions and duration omits them rather than faking them`() {
setFileCard(
input(
probe = VIDEO_PROBE.copy(
videoCodec = null,
width = 0,
height = 0,
durationMs = 0,
),
),
)
assertRow("Video", "Unknown")
assertNoRow("Length")
}
/**
* A video the app knows a great deal about and cannot name the container of.
*
* Not an edge case. `InputProbe.container`'s own KDoc says `MediaExtractor` cannot report a
* container at all -- it comes from FFprobe -- so any run where FFprobe did not answer produces
* exactly this: real codec, real dimensions, real duration, `container = null`.
*
* **The twin was already tested and this one was not**, which is the argument for adding it.
* `FileCard` renders `probe.container?.label ?: "Unknown"` twice, once in the `AUDIO_ONLY`
* branch (`ConverterScreen.kt:660`) and once in the `VIDEO` branch (`:668`), and
* `an audio-only file nothing else could describe degrades one row at a time` drives only the
* first. Same expression, same fallback, one kind covered. That asymmetry is the same one
* `CLAUDE.md` records for including `ContainerCapabilities:94`.
*
* The other rows are asserted alongside so this is not a copy of the audio-only case: there,
* everything is unknown at once; here, one field is missing from a probe that is otherwise
* complete, and the rest must be unaffected by it.
*/
@Test
fun `a video file whose container nothing identified says so and keeps its other rows`() {
setFileCard(input(probe = VIDEO_PROBE.copy(container = null)))
assertRow("Container", "Unknown")
assertRow("Video", "${VideoCodec.H264.label} · 1920×1080")
assertRow("Audio", AudioCodec.AAC.label)
assertRow("Length", "1:30")
}
/**
* The row is one node, not a label node beside a value node. A test matching on `"Container"`
* alone would pass against either shape.
*/
@Test
fun `a detail row renders its label and its value as a single node`() {
composeRule.setContent { DetailRow("Container", "Matroska") }
composeRule.onNodeWithTag(TestTags.Converter.detailRow("Container"))
.assertTextEquals("Container: Matroska")
}
private fun setFileCard(input: InputFile) = composeRule.setContent { FileCard(input) }
private fun input(sizeBytes: Long? = 12_345_678L, probe: InputProbe? = VIDEO_PROBE) = InputFile(
uri = Uri.parse("content://test/clip.mkv"),
displayName = "clip.mkv",
sizeBytes = sizeBytes,
probe = probe,
)
private fun assertRow(label: String, value: String) {
composeRule.onNodeWithTag(TestTags.Converter.detailRow(label))
.assertTextEquals("$label: $value")
}
private fun assertNoRow(label: String) {
composeRule.onNodeWithTag(TestTags.Converter.detailRow(label)).assertDoesNotExist()
}
private fun assertNoDetailRows() = EVERY_ROW_LABEL.forEach(::assertNoRow)
private companion object {
/** Every label the four kind branches can emit, so absence can be asserted exhaustively. */
val EVERY_ROW_LABEL = listOf("Container", "Video", "Audio", "Length", "Type", "Size")
val VIDEO_PROBE = InputProbe(
videoCodec = "h264",
audioCodec = "aac",
durationMs = 90_000,
kind = InputKind.VIDEO,
container = Container.MP4,
width = 1920,
height = 1080,
)
}
}