diff --git a/docs/coverage-read-findings.md b/docs/coverage-read-findings.md new file mode 100644 index 0000000..fb225f4 --- /dev/null +++ b/docs/coverage-read-findings.md @@ -0,0 +1,259 @@ +# Coverage-read findings + +**Status:** four findings, none fixed, none urgent. 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, 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: +all four fell out of reading a coverage report, and all four are the kind of thing a coverage +report is *good* at surfacing and a test is bad at fixing. + +Entry ids are `F1`–`F4` 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. + +--- + +## 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 | + +Order, if these are acted on: **F1 first and alone.** It is the only one with a possible +user-visible answer, and answering it may make its own comment fix unnecessary. F2 and F3 are +tidying and belong in one commit with each other, not with F1. F4 is finished by being written down. + +## Not covered here + +**The test gaps from the same read.** Ten JVM-side gaps and three seam questions came out of this +coverage read and are tracked as tickets, because they are work rather than observations. This +document holds only what a test would not fix. + +**`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 one premise worth re-checking +was whether the 33/34 legs still complete, given #122's wedge — they do: the API 33 leg on the most +recent `status_check` run reports **expected 60, received 60, failed 0, completed cleanly: yes**, +with no `wedged:` row. Recorded because that check is the whole reason this is not a ticket. + +**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.