Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9c32c6ce5 | ||
|
|
c43d865651 | ||
|
|
8a23f2a0b8 | ||
|
|
25992863e6 | ||
|
|
232cbd1949 | ||
|
|
099b7fd7c4 | ||
|
|
7f2a6e1376 | ||
|
|
dc8b7c3944 | ||
|
|
1d80e88f9b | ||
|
|
27d7cc0a86 | ||
|
|
6df1bdf31a | ||
|
|
c27881ab64 | ||
|
|
0f39964193 | ||
|
|
81ad102f2a |
@@ -13,6 +13,7 @@
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
.kotlin
|
||||
|
||||
# The FFmpeg AAR is committed under bin/ so test runs do not depend on a rebuild.
|
||||
# Build outputs from tools/ffmpeg are not.
|
||||
|
||||
@@ -246,3 +246,12 @@ Because versions float, a build can change without a commit. `./gradlew :app:dep
|
||||
`@OptIn`. Android lint's `UnsafeOptInUsageError` catches a missed one.
|
||||
- **Release builds ship both ABIs.** `-PabiFilters` is a test-run override only; `build.yml`
|
||||
verifies the released APK carries every ABI and that all native libraries are 16 KB aligned.
|
||||
- **A JUnit `Timeout` — rule or `@Test(timeout=)` — cannot be used in the JVM suite.** Both run the
|
||||
test body on a separate thread, and every Compose test here goes through Robolectric's paused
|
||||
main looper: `UnsupportedOperationException: main looper can only be controlled from main
|
||||
thread`, from `ShadowPausedLooper` under `RobolectricIdlingStrategy.runUntilIdle`. The identical
|
||||
tests pass with the timeout removed, so it is the mechanism, not the test. What bounds a hung
|
||||
run instead is `timeout` on the `Test` tasks plus the jstack watchdog beside it in
|
||||
`app/build.gradle.kts`, neither of which moves a thread. `HangBoundTest` guards both numbers,
|
||||
and **a timed-out run writes no XML for the class that hung** — the dump is its only
|
||||
attribution, so do not delete the watchdog as stray config.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
import org.gradle.testing.jacoco.tasks.JacocoReport
|
||||
import java.io.File
|
||||
import java.time.Duration
|
||||
|
||||
plugins {
|
||||
// Applied by id: these two come from the root buildscript classpath, which is what
|
||||
@@ -217,6 +219,115 @@ tasks.withType<Test>().configureEach {
|
||||
.withPropertyName("releaseWorkflow")
|
||||
.withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
|
||||
// Same reasoning, same trap: HangBoundTest reads the two numbers below out of this file, and
|
||||
// they are the one part of the change that does not compile. Without this the task stays
|
||||
// UP-TO-DATE when the build script changes, so the guard would go stale on exactly the edit
|
||||
// it exists to catch.
|
||||
inputs.file(project.file("build.gradle.kts"))
|
||||
.withPropertyName("moduleBuildScript")
|
||||
.withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
|
||||
// --- Bounding a hung run (#125) -----------------------------------------------------------
|
||||
//
|
||||
// This suite had no timeout of any kind, so a hang ran until something outside it gave up.
|
||||
// #125 is a real Java-level deadlock -- a lock-order inversion between Room's
|
||||
// TransactionExecutor and WorkManager's SerialExecutorImpl, reached through the WorkInfo flow
|
||||
// -- and one local run sat in it for 47 minutes. On CI it would burn the Unit tests job's
|
||||
// 30-minute cap and report as a job timeout with no cause at all.
|
||||
//
|
||||
// WHY NOT A JUnit `Timeout` RULE, which is the obvious answer: it runs the test body on a
|
||||
// separate thread, and this suite is thread-affine. Measured here, `@Rule Timeout` and
|
||||
// `@Test(timeout = ...)` against a `createComposeRule()` Robolectric test both give:
|
||||
//
|
||||
// java.lang.UnsupportedOperationException: main looper can only be controlled from main
|
||||
// at org.robolectric.shadows.ShadowPausedLooper.executeOnLooper
|
||||
// at androidx.compose.ui.test.RobolectricIdlingStrategy.runUntilIdle
|
||||
//
|
||||
// The same two tests with the timeout removed pass, so that is the mechanism and not the
|
||||
// probe. Nothing that moves a test off its own thread can be used here.
|
||||
//
|
||||
// `Task.timeout` moves nothing -- it stops the forked test JVM from outside. Its weakness is
|
||||
// that it kills without a thread dump, and the jstack is the only reason #125 could be named
|
||||
// at all; the watchdog below is what answers that, and it only dumps.
|
||||
//
|
||||
// THE NUMBER, against the slowest observed *pass* rather than the typical one. Eight CI runs
|
||||
// sampled 2026-08-26, whole `./gradlew :app:testDebugUnitTest` invocation with compilation in
|
||||
// it and this task a subset: 62, 76, 77, 79, 81, 84, 86 and 90 seconds. Locally the task
|
||||
// itself is ~11 s over 454 tests. Ten minutes is ~6.7x the slowest of those and a third of
|
||||
// the job's 30-minute cap, so a fired timeout still has room to be reported and uploaded. It
|
||||
// is deliberately nowhere near the observed duration: a timeout that fires on a healthy slow
|
||||
// runner turns a real signal into noise and teaches people to re-run reflexively.
|
||||
timeout.set(Duration.ofMinutes(10))
|
||||
|
||||
// The dump, two minutes before the kill. jstack is what turned #125 from "CI timed out" into
|
||||
// a named lock-order inversion, and `Task.timeout` on its own would have thrown it away.
|
||||
//
|
||||
// It is deliberately incapable of failing a build: it reads a live process and writes a file.
|
||||
// Nothing here kills, interrupts or signals anything, so the worst a misfire can do is leave a
|
||||
// stack trace nobody needed. It has one, and it is the ordinary CI shape rather than an exotic
|
||||
// case: the worker is found by scanning this daemon's descendants for GradleWorkerMain, which
|
||||
// cannot tell one invocation's worker from the next, and the Unit tests job runs
|
||||
// testDebugUnitTest and jacocoTestReport back to back against the same daemon. If this task's
|
||||
// own worker lived and died inside a single poll, the watchdog can adopt the following one.
|
||||
//
|
||||
// Everything it needs is read here, at configuration time, and captured by value. Reaching
|
||||
// back through the task or the project from inside the action would not survive the
|
||||
// configuration cache, which `gradle.properties` turns on for every build.
|
||||
val threadDump = layout.buildDirectory.file("reports/hang/$name-threads.txt").get().asFile
|
||||
val taskPath = path
|
||||
val dumpAfterNanos = Duration.ofMinutes(8).toNanos()
|
||||
val captureWindowNanos = Duration.ofMinutes(1).toNanos()
|
||||
val pollMillis = 1_000L
|
||||
doFirst {
|
||||
val watchdog = Thread {
|
||||
val startedAt = System.nanoTime()
|
||||
var worker: ProcessHandle? = null
|
||||
while (true) {
|
||||
Thread.sleep(pollMillis)
|
||||
val elapsed = System.nanoTime() - startedAt
|
||||
val watched = worker
|
||||
if (watched == null) {
|
||||
// Gradle forks the worker moments after this task starts. If none has shown
|
||||
// up by the end of the capture window there is nothing to watch, and going on
|
||||
// polling would only risk adopting some other build's.
|
||||
if (elapsed > captureWindowNanos) return@Thread
|
||||
worker = ProcessHandle.current().descendants()
|
||||
.filter { it.info().commandLine().orElse("").contains("GradleWorkerMain") }
|
||||
.findFirst().orElse(null)
|
||||
} else if (!watched.isAlive) {
|
||||
return@Thread // the run finished; this is the healthy exit
|
||||
} else if (elapsed >= dumpAfterNanos) {
|
||||
val jstack = File(File(System.getProperty("java.home"), "bin"), "jstack")
|
||||
threadDump.parentFile.mkdirs()
|
||||
if (jstack.canExecute()) {
|
||||
ProcessBuilder(jstack.absolutePath, "-l", watched.pid().toString())
|
||||
.redirectErrorStream(true)
|
||||
.redirectOutput(threadDump)
|
||||
.start()
|
||||
.waitFor()
|
||||
} else {
|
||||
threadDump.writeText("no jstack at ${jstack.absolutePath}\n")
|
||||
}
|
||||
// To stdout as well as to the file, and that is the half that matters on CI:
|
||||
// the Unit tests job uploads app/build/reports/tests/ and nothing else, so a
|
||||
// dump that only ever existed under reports/hang/ would be unreachable from a
|
||||
// red run -- which is the "timed out with no cause" this exists to end. The
|
||||
// step log always survives, and needs no workflow edit to say so.
|
||||
println(
|
||||
"$taskPath is still running after ${Duration.ofNanos(elapsed).toMinutes()} " +
|
||||
"minutes and is about to be timed out. Thread dump of pid " +
|
||||
"${watched.pid()}, also written to $threadDump -- look for 'Found one " +
|
||||
"Java-level deadlock' (that is #125).\n" + threadDump.readText(),
|
||||
)
|
||||
return@Thread
|
||||
}
|
||||
}
|
||||
}
|
||||
watchdog.isDaemon = true
|
||||
watchdog.name = "hang-watchdog"
|
||||
watchdog.start()
|
||||
}
|
||||
|
||||
extensions.configure<JacocoTaskExtension> {
|
||||
isIncludeNoLocationClasses = true
|
||||
excludes = listOf("jdk.internal.*")
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package org.libremediaconverter.ci
|
||||
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* That a hung unit-test run still ends by itself, and still says why.
|
||||
*
|
||||
* `:app:testDebugUnitTest` had no timeout of any kind until #125 was filed. That ticket is a real
|
||||
* Java-level deadlock between Room's `TransactionExecutor` and WorkManager's `SerialExecutorImpl`,
|
||||
* reached through the WorkInfo flow the ViewModel collects, and one local run sat in it for 47
|
||||
* minutes. Nothing inside the suite could break it: the deadlock is monitor contention, which is
|
||||
* not interruptible, so it runs until something outside the JVM gives up.
|
||||
*
|
||||
* Two numbers in `app/build.gradle.kts` are what bound it now, and neither compiles, so nothing
|
||||
* else would notice their removal:
|
||||
*
|
||||
* - `timeout.set(...)` on every `Test` task, which stops the forked test JVM.
|
||||
* - the watchdog's `dumpAfterNanos`, which jstacks that JVM *before* the timeout kills it.
|
||||
*
|
||||
* The second is the one worth guarding hardest, and the one that most looks like stray config.
|
||||
* Gradle's timeout kills without a thread dump, and the jstack -- with its "Found one Java-level
|
||||
* deadlock" section naming both monitors -- is the only reason #125 could be described at all.
|
||||
* The ordering between the two numbers is what makes it work: dump first, kill second. Reverse
|
||||
* them, or delete the watchdog, and the suite still stops hanging but every hang from then on
|
||||
* reports as a bare "Timeout has been exceeded" with nothing to read. Measured against a probe
|
||||
* that hung one test: no test XML was written for the class that hung, so the hanging test itself
|
||||
* gets no attribution from the report at all.
|
||||
*
|
||||
* The range on the timeout is not decoration either, and it is the half a future edit is most
|
||||
* likely to get wrong. Below it, a healthy-but-slow runner trips the bound and a real signal
|
||||
* becomes noise people learn to re-run through; above it, CI's 30-minute job cap fires first and
|
||||
* the bound never gets to say anything.
|
||||
*
|
||||
* `ReleasePermissionTest` is the precedent and its caveat applies here too. This asserts the two
|
||||
* numbers are present, sanely sized and correctly ordered. It cannot assert that the timeout
|
||||
* fires -- that needs a hang, which is what the whole change exists to prevent. Refs #125.
|
||||
*/
|
||||
class HangBoundTest {
|
||||
|
||||
@Test
|
||||
fun `every Test task is bounded, and bounded between the slow runner and the job cap`() {
|
||||
assertTrue(
|
||||
"app/build.gradle.kts sets its Test task timeout to ${timeoutMinutes}m, which is " +
|
||||
"outside $SANE_MINUTES. Under that range a slow CI runner trips a bound meant for " +
|
||||
"deadlocks -- the slowest observed passing run of the whole invocation was 90s. " +
|
||||
"Over it, the Unit tests job's own 30-minute cap kills the job first and the " +
|
||||
"timeout never reports. `null` means the line is gone or the block was rewritten, " +
|
||||
"and without it #125's deadlock has nothing to stop it: monitor contention breaks " +
|
||||
"no interrupt, so it runs until CI gives up and reports a timeout with no cause.",
|
||||
timeoutMinutes in SANE_MINUTES,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the thread dump is taken before the timeout kills the JVM it would dump`() {
|
||||
assertTrue(
|
||||
"app/build.gradle.kts takes its hang thread dump after ${dumpAfterMinutes}m but times " +
|
||||
"the task out at ${timeoutMinutes}m, so the JVM is already dead when jstack runs " +
|
||||
"and every future hang reports as a bare `Timeout has been exceeded`. The dump " +
|
||||
"has to come first -- it is the only attribution a hanging test gets, since the " +
|
||||
"test XML never names it.",
|
||||
(dumpAfterMinutes ?: 0) < (timeoutMinutes ?: 0),
|
||||
)
|
||||
}
|
||||
|
||||
/** Minutes given to a whole `Test` task before Gradle stops the forked JVM. */
|
||||
private val timeoutMinutes: Int?
|
||||
get() = minutesIn("""timeout\.set\(Duration\.ofMinutes\((\d+)\)\)""")
|
||||
|
||||
/** Minutes the watchdog waits before jstacking the forked JVM. */
|
||||
private val dumpAfterMinutes: Int?
|
||||
get() = minutesIn("""val dumpAfterNanos = Duration\.ofMinutes\((\d+)\)""")
|
||||
|
||||
/**
|
||||
* Read out of the build script rather than from a model: the numbers live in a Kotlin DSL block
|
||||
* that no unit test can instantiate, and a scan that reports `null` when the shape changes is a
|
||||
* better trade than not checking them at all.
|
||||
*/
|
||||
private fun minutesIn(pattern: String): Int? =
|
||||
Regex(pattern).find(buildScript.readText())?.groupValues?.get(1)?.toInt()
|
||||
|
||||
/**
|
||||
* Found by walking up rather than by a fixed relative path: Gradle's working directory for the
|
||||
* unit tests is the module, but that is a default rather than a promise.
|
||||
*/
|
||||
private val buildScript: File
|
||||
get() = generateSequence(File(".").absoluteFile) { it.parentFile }
|
||||
.map { File(it, "app/build.gradle.kts") }
|
||||
.firstOrNull { it.isFile }
|
||||
?: error("could not find app/build.gradle.kts above ${File(".").absolutePath}")
|
||||
|
||||
private companion object {
|
||||
/** Above the slowest observed passing run, below the Unit tests job's `timeout-minutes`. */
|
||||
val SANE_MINUTES = 3..29
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
# Coverage-read findings
|
||||
|
||||
**Status:** five findings, none fixed, none urgent. F5 was added on 2026-08-27, found while decomposing #132 into children — it had been listed there as a test gap, and is not one. Every entry here is a *code* observation —
|
||||
something a test would document rather than repair. The test gaps found in the same read are
|
||||
tickets #132 and #133, not entries here; see [Not covered here](#not-covered-here).
|
||||
**Scope:** what a JaCoCo read on 2026-08-26 turned up that writing a test would not fix. This is
|
||||
a survey, not a work order. Acting on any entry is a separate decision and would be its own commit.
|
||||
**Last verified:** `main` at `dc8b7c3`, 2026-08-26. Coverage re-measured that day with
|
||||
`./gradlew :app:jacocoTestReport`: **84.9% line (1971/2321), 63.8% branch (900/1410)**, against
|
||||
**456 JVM tests in 68 classes**. `CLAUDE.md` quotes 454 in 67 from four hours earlier; the
|
||||
percentages are unchanged, so no figure there is stale.
|
||||
|
||||
## Why this document is separate from `defect-audit.md`
|
||||
|
||||
`defect-audit.md` is the record of the 2026-08-22 defect sweep: sixteen entries, each a thing that
|
||||
is *wrong at runtime*. Nothing here is wrong at runtime today. These are arms that cannot be
|
||||
reached, accessors nobody calls, and one KDoc that contradicts the code beside it — the category
|
||||
`defect-audit.md` calls **latent**, plus one that is not a defect at all and is recorded so the
|
||||
next coverage read does not re-file it.
|
||||
|
||||
They are here rather than in that document because folding them in would inflate a sixteen-entry
|
||||
audit whose status metadata has already gone stale once, and because they share a provenance:
|
||||
every one fell out of reading a coverage report, and every one is the kind of thing a coverage
|
||||
report is *good* at surfacing and a test is bad at fixing. F5 is the clearest case — it was filed
|
||||
as a test gap first, and only stopped being one when someone went looking for its callers.
|
||||
|
||||
Entry ids are `F1`–`F5` so they cannot be confused with `defect-audit.md`'s `D1`–`D16`.
|
||||
|
||||
## How to read the confidence labels
|
||||
|
||||
Same vocabulary as `defect-audit.md`, deliberately, so the two read alike:
|
||||
|
||||
- **Confirmed by inspection** — the control flow is fully readable and the finding follows from it.
|
||||
- **Latent** — not reachable through today's UI, but wrong, and one change away from being live.
|
||||
- **No action** — recorded because it looks like a finding and is not.
|
||||
|
||||
Nothing below was observed on a device, and nothing below needs to be: every entry is a claim about
|
||||
what the code says, checkable by reading it.
|
||||
|
||||
---
|
||||
|
||||
## F1 — `FFmpegCommandBuilder` emits a Vorbis encoder that `ContainerCapabilities` says does not exist
|
||||
|
||||
**Severity: low · Latent · the more interesting reading is a missing feature, not dead code**
|
||||
|
||||
```
|
||||
app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt:188
|
||||
app/src/main/java/org/libremediaconverter/model/ContainerCapabilities.kt:84-91
|
||||
```
|
||||
|
||||
`FFmpegCommandBuilder.audioArgs` carries a live Vorbis arm:
|
||||
|
||||
```kotlin
|
||||
AudioCodec.VORBIS -> listOf("-c:a", "libvorbis", "-q:a", "5")
|
||||
```
|
||||
|
||||
`ContainerCapabilities` states, immediately above the set that governs it, that no such thing
|
||||
exists:
|
||||
|
||||
> `/** Vorbis is absent for the same reason: nothing here emits a Vorbis encoder. */`
|
||||
> `private val ENCODABLE_AUDIO = setOf(AAC, OPUS, MP3, FLAC, PCM)`
|
||||
|
||||
One of those two is wrong. The comment is the one that is wrong as written — something here does
|
||||
emit a Vorbis encoder, twelve lines of `FFmpegCommandBuilder`.
|
||||
|
||||
### Why the arm is unreachable today
|
||||
|
||||
Traced, not assumed:
|
||||
|
||||
| step | where | effect |
|
||||
|---|---|---|
|
||||
| `validate` runs before routing | `ConversionWorker.kt:123` | a spec is checked on every job, however it was enqueued |
|
||||
| `validateAudio` refuses non-encodable | `ContainerCapabilities.kt:246-251` | `VORBIS !in ENCODABLE_AUDIO` → `Invalid("This app cannot encode Vorbis audio.")` |
|
||||
| the only spec→plan encode path | `CopyPlanner.kt:104` | `AudioPlan.Encode(requested)` — but `requested` cannot be Vorbis by the row above |
|
||||
| the fallback encode path | `CopyPlanner.kt:112-115` | draws from `encodableAudio(container)`, itself filtered by `ENCODABLE_AUDIO` |
|
||||
|
||||
So `AudioPlan.Encode(VORBIS)` is not constructible through the app, and line 188 is dead.
|
||||
|
||||
### The reading that matters more
|
||||
|
||||
`CARRIES_AUDIO` lists Vorbis for WebM (`ContainerCapabilities.kt:62`) and OGG (`:67`). Because
|
||||
`encodableAudio` filters through `ENCODABLE_AUDIO`, the picker offers **Opus and nothing else** for
|
||||
WebM, and Opus/FLAC for OGG. FFmpeg on this device can encode Vorbis — the command is written and
|
||||
correct — and the app declines to offer it.
|
||||
|
||||
So the honest framing is not "delete a dead arm". It is: **is `ENCODABLE_AUDIO`'s omission of
|
||||
Vorbis a deliberate product call, or an accident that has been costing WebM/OGG users a format the
|
||||
app already supports?** Nothing in the repo records that decision.
|
||||
|
||||
### The precedent for whichever way it goes
|
||||
|
||||
`Media3Engine.audioMimeTypeFor` has the *same* Vorbis arm, and handles it exactly right
|
||||
(`Media3Engine.kt:221-233`): the KDoc names it dead, says why the arm stays anyway ("deleting a
|
||||
right answer out of unreachable code buys nothing"), and points at `Media3EngineMimeTypesTest`,
|
||||
which asserts which three of six codecs actually arrive — so the set moving fails a test rather
|
||||
than surprising someone.
|
||||
|
||||
`FFmpegCommandBuilder`'s arm has none of that. Whatever is decided, the fix is to make the two
|
||||
files agree and to say so in one place.
|
||||
|
||||
### What a fix has to decide
|
||||
|
||||
1. Whether Vorbis belongs in `ENCODABLE_AUDIO`. If yes, this is a feature and needs an e2e test
|
||||
that produces a playable Vorbis file; if no, go to 2.
|
||||
2. Correct the `ContainerCapabilities.kt:84` comment, which is false as written, and give the
|
||||
`FFmpegCommandBuilder` arm the treatment `Media3Engine.kt:221-233` already models.
|
||||
|
||||
---
|
||||
|
||||
## F2 — `ConversionRequest.hardwareEncodeAvailable` is written, read by nothing, and its KDoc describes behaviour that was removed
|
||||
|
||||
**Severity: low · Confirmed by inspection**
|
||||
|
||||
```
|
||||
app/src/main/java/org/libremediaconverter/model/OutputFormat.kt:211-219
|
||||
app/src/main/java/org/libremediaconverter/work/ConversionWorker.kt:117
|
||||
```
|
||||
|
||||
The property is set on every request:
|
||||
|
||||
```kotlin
|
||||
hardwareEncodeAvailable = devices.canEncode(spec.videoCodec),
|
||||
```
|
||||
|
||||
`grep -rn 'hardwareEncodeAvailable' app/src/main` returns **that line and nothing else**. No
|
||||
production code reads it. Its getter is one of three uncovered methods in `OutputFormat.kt`, which
|
||||
is what surfaced it.
|
||||
|
||||
Its KDoc (`OutputFormat.kt:211-218`) explains at length what it is for:
|
||||
|
||||
> Knowing this lets the Fast tier choose a genuinely fast software preset instead of a mislabelled
|
||||
> slow one.
|
||||
|
||||
`FFmpegCommandBuilder` no longer does that, and its own test says so —
|
||||
`FFmpegCommandBuilderTest.kt:132`, `the encoder choice no longer depends on hardware availability`:
|
||||
|
||||
> Once FFmpeg stopped selecting MediaCodec encoders, this flag only affects whether the router sends
|
||||
> the job to Media3 at all — not what FFmpeg does.
|
||||
|
||||
That second clause is also not true. `ConversionRouter` decides hardware encodability by calling
|
||||
`device.canEncode(videoEncode)` itself (`ConversionRouter.kt:153`); it never reads
|
||||
`request.hardwareEncodeAvailable`. The flag is computed from the same source the router
|
||||
independently consults, carried through the request, and dropped.
|
||||
|
||||
This is the shape of open issue **#68** — a KDoc promising a switch that does not exist.
|
||||
|
||||
**Not harmful.** It costs one `canEncode` call per job and a field on a data class. It is recorded
|
||||
because the KDoc actively misleads: a reader changing the Fast-tier preset logic would look here
|
||||
first, and this is not where that decision lives.
|
||||
|
||||
### What a fix has to decide
|
||||
|
||||
Whether to delete the property (and the constructor parameter, and the four
|
||||
`FFmpegCommandBuilderTest` call sites that pass it) or to keep it and rewrite the KDoc to say it is
|
||||
vestigial. Deleting is cleaner; the test at `:132` is worth keeping either way, since it pins the
|
||||
"FFmpeg does not select MediaCodec encoders" rule that the deletion would otherwise erase.
|
||||
|
||||
---
|
||||
|
||||
## F3 — `ConversionRequest.videoCodec` and `.audioCodec` have no callers anywhere
|
||||
|
||||
**Severity: low · Confirmed by inspection**
|
||||
|
||||
```
|
||||
app/src/main/java/org/libremediaconverter/model/OutputFormat.kt:222-223
|
||||
```
|
||||
|
||||
```kotlin
|
||||
val container: Container get() = spec.container // used: FFmpegConcatCommand.kt:42, :80
|
||||
val videoCodec: VideoCodec get() = spec.videoCodec // no callers
|
||||
val audioCodec: AudioCodec get() = spec.audioCodec // no callers
|
||||
```
|
||||
|
||||
Three delegating accessors on `ConversionRequest`; the first is used twice, the other two are used
|
||||
nowhere in `main`, `test` or `androidTest`. Everything that wants those values reads
|
||||
`request.spec.videoCodec` or takes the `OutputSpec` directly.
|
||||
|
||||
**This is not a test gap and must not be filed as one.** A test asserting
|
||||
`request.videoCodec == request.spec.videoCodec` is vacuous by construction — it restates the
|
||||
implementation and would pass against any delegation, right or wrong. That is precisely the failure
|
||||
mode `CLAUDE.md` records from the mutation review (9 of 46 mutations vacuous, five over completely
|
||||
unguarded paths).
|
||||
|
||||
The two accessors are either convenience worth keeping for symmetry with `container`, or two lines
|
||||
to delete. Deleting them costs nothing and removes two uncovered methods that will otherwise be
|
||||
re-found by every future coverage read.
|
||||
|
||||
---
|
||||
|
||||
## F4 — Two guards are reachable only by direct call, and that is correct
|
||||
|
||||
**Severity: n/a · No action**
|
||||
|
||||
```
|
||||
app/src/main/java/org/libremediaconverter/ffmpeg/FFmpegCommandBuilder.kt:167-168
|
||||
app/src/main/java/org/libremediaconverter/model/ConversionRouter.kt:175-176
|
||||
```
|
||||
|
||||
```kotlin
|
||||
VideoCodec.COPY, VideoCodec.NONE -> error("encodeVideo called for $codec, which is not an encode")
|
||||
```
|
||||
|
||||
```kotlin
|
||||
if (plan.video == VideoPlan.Copy && video == null) return false
|
||||
if (plan.audio == AudioPlan.Copy && audio == null) return false
|
||||
```
|
||||
|
||||
Both sit in private functions (`encodeVideo`, `media3CanMux`), and both are unreachable because a
|
||||
caller upstream already excluded the case — which each says in its own comment. `ConversionRouter`'s
|
||||
is labelled "the second line of defence"; `CopyPlanner` is the first.
|
||||
|
||||
**Recorded so the next coverage read does not treat them as gaps.** A second line of defence that
|
||||
can be provoked is not a second line of defence. Making these reachable from a test would mean
|
||||
widening the functions to `internal`, which buys a test that asserts an `error()` fires when called
|
||||
in a way production cannot call it. This is the same judgement issue **#88** reached about
|
||||
`getForegroundInfo` and closed on: naming the exemption rather than covering it.
|
||||
|
||||
Neither should change unless the upstream guard does. If `CopyPlanner` ever stops resolving `COPY`
|
||||
before the builder sees it, `FFmpegCommandBuilder.kt:167` becomes live and wants a test that day.
|
||||
|
||||
---
|
||||
|
||||
## F5 — `ConversionNotifications.areEnabled()` is never called
|
||||
|
||||
**Severity: low · Confirmed by inspection · found while decomposing the test-gap ticket**
|
||||
|
||||
```
|
||||
app/src/main/java/org/libremediaconverter/work/ConversionNotifications.kt:60-62
|
||||
```
|
||||
|
||||
```kotlin
|
||||
fun areEnabled(): Boolean = context.getSystemService(NotificationManager::class.java)
|
||||
.areNotificationsEnabled()
|
||||
.also { if (!it) Log.i(TAG, "Notifications disabled; progress will not be visible.") }
|
||||
```
|
||||
|
||||
`grep -rn 'areEnabled' app/src` returns **that declaration and nothing else**. `ConversionNotifications`
|
||||
is constructed in both workers (`ConversionWorker.kt:55`, `ConcatWorker.kt:35`) and only `build()` is
|
||||
ever called on it.
|
||||
|
||||
**This entry exists because it was very nearly filed as a test gap.** Its three lines are cold on the
|
||||
JVM, it has a KDoc explaining real user-visible stakes — a foreground service without
|
||||
`POST_NOTIFICATIONS` shows only in the Task Manager, so progress silently vanishes — and Robolectric
|
||||
can flip that permission in one line. Everything about it reads like a cheap, worthwhile test.
|
||||
|
||||
It is not, because **the behaviour the KDoc describes does not happen**. Nothing consults
|
||||
`areEnabled()`, so nothing warns, degrades, or logs when notifications are off. A test would assert
|
||||
that a function nobody calls returns what the platform told it — green, vacuous, and actively
|
||||
misleading, since it would imply the app handles the disabled-notification case. That is the failure
|
||||
mode `CLAUDE.md` records from the mutation review, reached from the opposite direction: not a test
|
||||
that fails to bite, but a test with nothing to bite.
|
||||
|
||||
### What a fix has to decide
|
||||
|
||||
Whether the app should act on this at all. The KDoc argues it should — a conversion whose progress is
|
||||
invisible is a real complaint, and `ConversionViewModel` or the worker's foreground start is where a
|
||||
check would go. If yes, that is a **feature** with a test; if no, delete the method and the KDoc's
|
||||
claim with it. What must not happen is a test that makes the current state look handled.
|
||||
|
||||
Related: **#16** is open on an adjacent gap — a user who *can* unblock a foreground-denied retry has
|
||||
no way to make it happen now.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| ID | Finding | Severity | Evidence | Action |
|
||||
|---|---|---|---|---|
|
||||
| F1 | `FFmpegCommandBuilder` emits a Vorbis encoder `ContainerCapabilities` says does not exist | low | confirmed by inspection; unreachability traced through four call sites | **decide**: feature or dead arm — the comment is false either way |
|
||||
| F2 | `hardwareEncodeAvailable` written, never read; KDoc describes removed behaviour | low | confirmed by inspection; `FFmpegCommandBuilderTest:132` corroborates | **decide**: delete or mark vestigial |
|
||||
| F3 | `ConversionRequest.videoCodec` / `.audioCodec` have no callers | low | confirmed by inspection | delete, or keep for symmetry — **not** a test gap |
|
||||
| F4 | Two private guards reachable only by direct call | n/a | confirmed by inspection | **no action** — named exemption, per #88 |
|
||||
| F5 | `ConversionNotifications.areEnabled()` is never called | low | confirmed by inspection; grep returns the declaration only | **decide**: act on it or delete it — **not** a test gap |
|
||||
|
||||
Order, if these are acted on: **F1 and F5 first, separately.** They are the two with a possible
|
||||
user-visible answer — a format the app can produce and does not offer, and a warning the app
|
||||
documents and does not give — and either answer changes what the tidying should look like. F2 and F3
|
||||
are tidying and belong in one commit with each other, not with F1 or F5. F4 is finished by being
|
||||
written down.
|
||||
|
||||
**F1 and F5 share a shape worth naming:** both are places where a comment describes behaviour the
|
||||
code does not have, and in both the tempting fix (delete the dead arm, test the dead method) would
|
||||
freeze the wrong answer in place. The decision comes first.
|
||||
|
||||
## Not covered here
|
||||
|
||||
**The test gaps from the same read.** Seven JVM-side gaps (**#132**) and three seam questions
|
||||
(**#133**) came out of this coverage read and are tracked there, because they are work rather than
|
||||
observations. This document holds only what a test would not fix. #133 also records why
|
||||
`AndroidDeviceCodecs.probe()` was considered and left out, so that spike is not run a third time.
|
||||
|
||||
**`ConversionForegroundType.current()`**, which looked like the sharpest gap in the read and is not.
|
||||
Its API 33 and 34 arms are cold on the JVM, but issue **#88** already established that the class is
|
||||
covered by `ConversionWorkerTest.foregroundTypeMatchesTheRunningApiLevel` across the CI matrix, and
|
||||
that its 0% is the `testDebugUnitTest`-only measurement boundary.
|
||||
|
||||
The premise worth re-checking was whether the 33/34 legs still complete, given #122's wedge.
|
||||
**They mostly do, and #122 is not resolved** — this entry said "they do" on first writing, from a
|
||||
single green run, and the PR carrying this very document proved that wrong:
|
||||
|
||||
| run | API 33 leg | shape |
|
||||
|---|---|---|
|
||||
| `32933262839` (#127) | success, 7m16s | `expected 60, received 60, failed 0, completed cleanly: yes` |
|
||||
| `33033036857` (PR #131, docs-only) | **failure, 23m08s** | `expected 60, received 60, failed unknown, wedged: yes — gradle killed after 1200s` |
|
||||
|
||||
Five of the last six completed API 33 legs passed in about seven minutes, so the wedge is
|
||||
intermittent rather than systematic. **What it costs is the verdict, not the execution**: `received:
|
||||
60` on the wedged run means all sixty tests still reported, so the API 33 regime *was* exercised —
|
||||
but `failed:` reads `unknown`, so that leg could not have told anyone if it had broken.
|
||||
|
||||
That is why this stays a note and not a ticket, and also why it is not simply deleted: #88's
|
||||
reasoning holds, but the leg it rests on cannot be relied on to report a failure. A
|
||||
`@Config(sdk = 33)` / `@Config(sdk = 34)` JVM test would pin all three arms deterministically in one
|
||||
run for about three lines. Small, and worth doing the next time this file is opened — but it is
|
||||
insurance against a flaky leg, not the uncovered behaviour it first looked like.
|
||||
|
||||
**The Compose screens' branch coverage.** `ConverterScreenKt` reports 110 of 200 branches missed and
|
||||
`JoinScreenKt` 60 of 82, which looks alarming and is not a signal: the Compose compiler synthesises
|
||||
`$changed`/`$dirty` recomposition-skip tests that JaCoCo counts as branches. The line figures are
|
||||
the real ones — **34 of 383** and **20 of 143** missed — and the screens are among the
|
||||
better-covered files in the repo, which is what #52, #57 and #61 were for. **Do not chase the
|
||||
branch number here.** If a future read wants a screen metric, use lines.
|
||||
|
||||
**Anything requiring a device.** `MediaProbe`'s FFprobe half (`MediaProbe.kt:151, 156-158, 173-188`)
|
||||
and `FFmpegEngine` in full report 0% on the JVM and are covered by `androidTest`. JaCoCo measures
|
||||
`testDebugUnitTest` only; their zeroes are a boundary, as #84, #85, #86 and #88 each recorded
|
||||
before this.
|
||||
Reference in New Issue
Block a user