Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d55004286 | ||
|
|
e06b0826a0 | ||
|
|
7b578c1ccf | ||
|
|
9e7f80feaa | ||
|
|
3c5a37fd3c | ||
|
|
af13155c27 | ||
|
|
3d51fefeff | ||
|
|
fea88a281f |
@@ -64,16 +64,28 @@ of the first one that fails.
|
||||
on this machine (below), so without it an androidTest compile error is not discovered until CI.
|
||||
ktlint and detekt also cover the `test`/`androidTest` source sets that `lintDebug` skips.
|
||||
|
||||
## Instrumented tests do not run locally
|
||||
## Instrumented tests: where they actually run
|
||||
|
||||
Two independent reasons, so do not spend time on either:
|
||||
This section said the opposite until 2026-08-24, and both of its claims had been false for two
|
||||
days. Read it as the current answer, and see the git history if you need the old one.
|
||||
|
||||
- **Emulators segfault on this host.** qemu dies on every AVD. Instrumented tests run on CI or on
|
||||
the physical Pixel, never in a local emulator.
|
||||
- **The API 37 image is broken.** `android-37.0` crash-loops surfaceflinger inside its own gralloc
|
||||
mapper, so every test fails there regardless of this app. `docs/api-37-emulator-crash.md` records
|
||||
the evidence and the ruled-out fixes; CI's matrix therefore stops at API 36 even though targetSdk
|
||||
is 37. **API 37 needs a manual check on the Pixel 10 Pro XL before each release.**
|
||||
- **Local emulators work, for API 33-36.** `tools/local-emulator/run-e2e.sh` runs them on this
|
||||
host. The segfault that made this look impossible was not a broken machine: SwiftShader's Reactor
|
||||
JIT writes generated shader code onto the heap and executes it, Fedora's SELinux policy denies
|
||||
`execheap`, and qemu dies. Choosing a different renderer avoids it entirely — `-gpu host`,
|
||||
`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.
|
||||
`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.
|
||||
|
||||
On a device or emulator, build only the ABI it can execute:
|
||||
|
||||
@@ -96,12 +108,26 @@ install for code that can never run — and on API 37 the full APK does not fit
|
||||
- The `model` package is excluded from `ReturnCount` and `CyclomaticComplexMethod` only. It is the
|
||||
decision layer, where one branch is one documented user-visible outcome and the metric counts
|
||||
answers rather than complexity. Every other rule still applies there.
|
||||
- **Coverage is reported, not gated** — **29.8% of lines (629/2113), 28.7% of branches**, measured
|
||||
on `main` 2026-08-23 with `./gradlew :app:jacocoTestReport`. A floor needs a baseline that has
|
||||
settled first, and this one has not: the figure **fell** from the ~31% recorded earlier even
|
||||
though the JVM suite went from 11 test files to 43. Main source grew 4,114 -> 5,715 lines over
|
||||
the same period, so the denominator outran the numerator. Re-measure before quoting it; do not
|
||||
assume more tests means a higher percentage here.
|
||||
- **Coverage is reported, not gated** — **69.2% of lines (1519/2194), 53.2% of branches**,
|
||||
measured 2026-08-24 with `./gradlew :app:jacocoTestReport`.
|
||||
|
||||
**Every figure this file carried before that date was an artifact, roughly half the real one.**
|
||||
Robolectric loads classes through its own sandbox classloader with no source location, JaCoCo
|
||||
skips no-location classes by default, and nothing told it otherwise — so **not one Robolectric
|
||||
test counted**, and Robolectric is what exercises the framework edge here. The
|
||||
`isIncludeNoLocationClasses` block in `app/build.gradle.kts` is what fixes it; **do not delete
|
||||
it as stray config**, and re-run the numbers if you ever touch it. Same commit, same 335 tests:
|
||||
29.7% -> 69.2% with that block alone.
|
||||
|
||||
The old entry also explained the wrong thing. It said coverage **fell** as the suite grew from 11
|
||||
test files to 43 because "the denominator outran the numerator" on framework-edge code "the JVM
|
||||
cannot reach". The JVM reaches that code fine. What actually happened is that the new tests were
|
||||
disproportionately Robolectric, so each one added denominator and no numerator — the measurement
|
||||
was punishing exactly the tests that were hardest to write.
|
||||
|
||||
Two things still hold. A floor needs a baseline that has settled, and this one has now moved by
|
||||
39 points in a single build change, so it has not. And **re-measure before quoting** — that
|
||||
instruction is the only reason this was caught.
|
||||
- **Testable code is not done until it is tested.** If a piece is unit testable, it gets unit
|
||||
tests before it counts as done. If it is e2e testable, it gets e2e tests. Both clauses apply —
|
||||
a change that is both needs both.
|
||||
@@ -112,9 +138,9 @@ install for code that can never run — and on API 37 the full APK does not fit
|
||||
documents the reasoning — turns "needs a device" into "a pure function plus a thin edge".
|
||||
Robolectric is in the JVM source set, `compose-ui-test-junit4` with it, so Compose screens are
|
||||
unit testable too. Reach for the seam before concluding something cannot be unit tested.
|
||||
- **E2E is runnable locally now.** `tools/local-emulator/run-e2e.sh` runs API 33-36 on this
|
||||
machine; see `docs/local-emulator.md`. That was believed impossible until the SELinux/renderer
|
||||
cause was found, and it is what makes the e2e half of this norm enforceable.
|
||||
- **E2E is runnable locally**, API 33-36, via `tools/local-emulator/run-e2e.sh` — see
|
||||
"Instrumented tests: where they actually run" above. That was believed impossible until the
|
||||
SELinux/renderer cause was found, and it is what makes the e2e half of this norm enforceable.
|
||||
- **A test has to bite.** Revert the line it covers, confirm it goes red, restore. A review of
|
||||
this codebase ran 46 mutations against a 257-test suite and **9 were vacuous** — five of them
|
||||
passing the whole suite over a completely unguarded code path. Green is not evidence.
|
||||
|
||||
@@ -188,6 +188,30 @@ detekt {
|
||||
}
|
||||
|
||||
// Pin the coverage agent rather than inheriting whatever Gradle bundles.
|
||||
// Robolectric loads every class it touches through its own sandbox classloader, and those
|
||||
// classes arrive with no source location. JaCoCo skips no-location classes by default, so
|
||||
// without this block **not one Robolectric test counts** -- and Robolectric is what exercises
|
||||
// the framework edge here: the workers, the publisher, both ViewModels, every Compose screen.
|
||||
//
|
||||
// Measured on e06b082, same 335 tests, same 0 failures, only this block added:
|
||||
//
|
||||
// LINE 29.7% -> 69.2% OutputPublisher 0.0% -> 97.5%
|
||||
// BRANCH 29.8% -> 53.2% ConversionViewModel 0.0% -> 85.4%
|
||||
//
|
||||
// The discriminator, if this ever looks like superstition: inside ConverterScreenKt, `describe`
|
||||
// is the one non-Composable and is exercised by a plain JVM test -- it reported 8/8 covered while
|
||||
// every @Composable in the same class reported 0, including ones whose mutations demonstrably
|
||||
// failed the build when reverted.
|
||||
//
|
||||
// `excludes` is not optional. Without it JaCoCo walks JDK-internal classes that Robolectric has
|
||||
// no location for either, and the test JVM dies rather than reporting a number.
|
||||
tasks.withType<Test>().configureEach {
|
||||
extensions.configure<JacocoTaskExtension> {
|
||||
isIncludeNoLocationClasses = true
|
||||
excludes = listOf("jdk.internal.*")
|
||||
}
|
||||
}
|
||||
|
||||
jacoco {
|
||||
toolVersion = libs.versions.jacoco.get()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package org.libremediaconverter.convert
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.libremediaconverter.model.AudioCodec
|
||||
import org.libremediaconverter.model.Container
|
||||
import org.libremediaconverter.model.EnginePreference
|
||||
import org.libremediaconverter.model.OutputSpec
|
||||
import org.libremediaconverter.model.VideoCodec
|
||||
|
||||
/**
|
||||
* The four pure helpers behind the converter screen's prose, pinned at the points where they
|
||||
* change what they say.
|
||||
*
|
||||
* No Compose rule and no Robolectric: these are `String` in, `String` out, and running them under a
|
||||
* device sandbox would buy nothing while hiding the boundaries in a rendered tree.
|
||||
*
|
||||
* The defect each group bites on:
|
||||
*
|
||||
* - **[formatBytes] picks a unit by comparing against three thresholds.** Every one of them is a
|
||||
* `>=`, and a `>` would move a file sitting exactly on a boundary into the unit below -- `1 GB`
|
||||
* shown as `1000.0 MB`. Only a value *on* the threshold can tell the two apart, so each of the
|
||||
* three is asserted at the boundary and one below it. The unit prefixes are decimal, matching
|
||||
* what the file manager and the provider report, not powers of two.
|
||||
* - **[formatDuration] has no hours field.** An hour-long recording reads `60:00`, and that is the
|
||||
* contract rather than an oversight -- the row is a length, not a clock. Pinned so that adding
|
||||
* hours is a deliberate change with a red test in front of it instead of a silent reformat.
|
||||
* - **[describe] builds the suggestion-chip label out of up to three parts**, and the parts are
|
||||
* conditional: [VideoCodec.NONE] and [AudioCodec.NONE] drop out entirely, so an image output
|
||||
* with neither track has to render as the container alone rather than as a container followed
|
||||
* by a dangling separator.
|
||||
* - **[EnginePreference] carries no `label` property**, unlike every other enum the screen
|
||||
* renders; its three display strings live in a `when` in the screen file. Adding a constant is
|
||||
* caught by the compiler because that `when` is exhaustive, but nothing stops two constants
|
||||
* being given the same string, which is what the distinctness assertion is for.
|
||||
*/
|
||||
class ConverterFormattersTest {
|
||||
|
||||
@Test
|
||||
fun `bytes below a kilobyte are counted exactly`() {
|
||||
assertEquals("0 B", formatBytes(0))
|
||||
assertEquals("1 B", formatBytes(1))
|
||||
assertEquals("999 B", formatBytes(999))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each unit starts exactly on its threshold rather than one byte past it`() {
|
||||
assertEquals("1 kB", formatBytes(1_000))
|
||||
assertEquals("1.0 MB", formatBytes(1_000_000))
|
||||
assertEquals("1.0 GB", formatBytes(1_000_000_000))
|
||||
}
|
||||
|
||||
/**
|
||||
* One byte below each threshold, which is the half a `>=` to `>` change leaves alone. Both
|
||||
* halves are needed: the boundary values alone would still pass if the comparison let
|
||||
* everything through.
|
||||
*/
|
||||
@Test
|
||||
fun `a value just below a threshold stays in the smaller unit`() {
|
||||
assertEquals("999 B", formatBytes(999))
|
||||
assertEquals("1000 kB", formatBytes(999_999))
|
||||
assertEquals("1000.0 MB", formatBytes(999_999_999))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a real file size reads as one decimal place`() {
|
||||
assertEquals("12.3 MB", formatBytes(12_345_678))
|
||||
assertEquals("1.5 GB", formatBytes(1_500_000_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a duration is minutes and zero-padded seconds`() {
|
||||
assertEquals("0:00", formatDuration(0))
|
||||
assertEquals("0:01", formatDuration(1_000))
|
||||
assertEquals("0:59", formatDuration(59_000))
|
||||
assertEquals("1:00", formatDuration(60_000))
|
||||
assertEquals("1:30", formatDuration(90_000))
|
||||
}
|
||||
|
||||
/** Sub-second remainders are dropped rather than rounded up into the next second. */
|
||||
@Test
|
||||
fun `a partial second does not become a whole one`() {
|
||||
assertEquals("0:00", formatDuration(999))
|
||||
assertEquals("0:59", formatDuration(59_999))
|
||||
}
|
||||
|
||||
/** No hours field, deliberately: an hour is `60:00` and two hours are `120:00`. */
|
||||
@Test
|
||||
fun `an hour and beyond keeps counting in minutes`() {
|
||||
assertEquals("60:00", formatDuration(3_600_000))
|
||||
assertEquals("61:01", formatDuration(3_661_000))
|
||||
assertEquals("120:00", formatDuration(7_200_000))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a spec with both tracks names the container and joins the two codecs`() {
|
||||
assertEquals(
|
||||
"MP4 · H.264 + AAC",
|
||||
describe(OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.AAC)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a track set to none is left out instead of being named none`() {
|
||||
assertEquals(
|
||||
"MP3 · MP3",
|
||||
describe(OutputSpec(Container.MP3, VideoCodec.NONE, AudioCodec.MP3)),
|
||||
)
|
||||
assertEquals(
|
||||
"MP4 · H.264",
|
||||
describe(OutputSpec(Container.MP4, VideoCodec.H264, AudioCodec.NONE)),
|
||||
)
|
||||
}
|
||||
|
||||
/** An image output has neither track, so there is nothing for the separator to separate. */
|
||||
@Test
|
||||
fun `a spec with no tracks at all is the container alone, with no trailing separator`() {
|
||||
assertEquals("GIF", describe(OutputSpec(Container.GIF, VideoCodec.NONE, AudioCodec.NONE)))
|
||||
assertEquals(
|
||||
"PNG frames",
|
||||
describe(OutputSpec(Container.IMAGE_SEQUENCE, VideoCodec.NONE, AudioCodec.NONE)),
|
||||
)
|
||||
}
|
||||
|
||||
/** `Copy` is a codec here, not the absence of one, so a remux describes both tracks. */
|
||||
@Test
|
||||
fun `a remux names copy on both tracks rather than dropping them`() {
|
||||
assertEquals(
|
||||
"Matroska · Copy + Copy",
|
||||
describe(OutputSpec(Container.MKV, VideoCodec.COPY, AudioCodec.COPY)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `each engine preference has the wording the chips show`() {
|
||||
assertEquals("Automatic", EnginePreference.AUTO.label())
|
||||
assertEquals("Prefer hardware", EnginePreference.PREFER_HARDWARE.label())
|
||||
assertEquals("Force software", EnginePreference.FORCE_SOFTWARE.label())
|
||||
}
|
||||
|
||||
/**
|
||||
* Two constants sharing a label would render as two identical chips, one of which the user
|
||||
* could not choose deliberately. The exhaustive `when` cannot catch that; this does.
|
||||
*/
|
||||
@Test
|
||||
fun `no two engine preferences render the same chip`() {
|
||||
val labels = EnginePreference.entries.map { it.label() }
|
||||
|
||||
assertEquals(EnginePreference.entries.size, labels.toSet().size)
|
||||
assertEquals(emptyList<String>(), labels.filter { it.isBlank() })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
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.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.createDrainedComposeRule
|
||||
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 = createDrainedComposeRule()
|
||||
|
||||
@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")
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user