ConversionViewModel.onInputPicked(uri: Uri) and .save(destination: Uri) are both
(Uri) -> Unit, so swapping the two launcher callbacks at ConverterScreen.kt:70 and :83
compiles, renders, and passed the entire suite. Picking a file would attempt a save to it;
choosing a destination would load it as input.
That is the defect class ScreenWiringTest exists for, on the one pair it declines to cover:
it drives converterActions directly and says the launcher-backed actions stay parameters.
Right about the actions seam, and it leaves the edge above that seam unpinned. Join's
equivalents are List<Uri> and Uri, so they are not transposable and get no such test.
Two mechanics, neither used anywhere else in the suite, so both were spiked before any
assertion was written:
shadowOf(activity).nextStartedActivityForResult reads the launched Intent, EXTRA_MIME_TYPES intact
shadowOf(activity).receiveResult(...) reaches ComponentActivity's ActivityResultRegistry
and fires the rememberLauncherForActivityResult callback
createAndroidComposeRule for AdaptiveShellTest's reason: the screens compose real ViewModels
through viewModel(), and the plain rule supplies no ViewModelStoreOwner.
The picker filter rides along, since the harness is the same. ConverterScreen.kt:65-67
records why the all-types wildcard is load-bearing -- the photo picker offers no audio and
misses mkv/flac/webm -- and narrowing it would have made every audio conversion unreachable
from the picker with nothing going red.
One incidental: a KDoc cannot contain the all-types wildcard, because its second half closes
the block comment. The literal is spelled only in the assertion, and the KDoc says why.
Mutations, all run and restored:
transpose onInputPicked and save 1 red
narrow the converter picker to video only 1 red
widen the join picker to every type 1 red
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ThemeColorSchemeTest resolves every branch of the `when` and always passes darkTheme
explicitly, so the $default bridge is never entered and isSystemInDarkTheme() is never
called. MainActivity.kt:79 is its only default-argument caller and does not execute on the
JVM, which left the app's actual call shape -- no arguments at all -- the one nothing
exercised. LibreMediaConverterTheme reported mi=21, mb=6, cb=12 at method level.
Not #68. That issue is the two unreachable arms, DarkColorScheme and LightColorScheme,
which cannot run because dynamicColor is always true and nothing can flip it; it is an open
product decision and stays open. This is the reachable half.
The assertion compares schemes rather than reading a luminance threshold, which would be a
guess about the device palette. What is asserted is that the no-argument call resolves the
SAME scheme an explicit darkTheme of the matching value does, and a different one from its
opposite -- true whatever palette the platform hands back, and exactly the claim being made:
the default reads the system rather than picking a side. The two assertions are also what
stops the pair passing vacuously if all three resolutions were identical.
Two @Config(qualifiers = ...) cases rather than two classes: qualifiers are settable per
method, unlike the sdk pinning ForegroundTypeRegimeTest needed nested classes for.
Mutations, all run and restored, and each reddening a different half -- which is also what
shows the qualifiers take effect rather than both cases running in one mode:
darkTheme defaulted to false night case red
darkTheme defaulted to true light case red
isSystemInDarkTheme() inverted both red
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ConversionWorker.kt:208-210 is a second onProgress lambda at a second call site -- the one
handed to engine.transcode -- and it reported ci == 0. Every test in this file drives the
FFmpeg path; HardwareFallbackTest reaches runMedia3OrFallBack but its recording transcoder
records the call and never invokes the callback it was handed.
So the two engines' progress wiring was one tested and one not, and the untested one is the
default: ConversionRouter sends everything it can to Media3, which makes this the lambda
most conversions actually use. Same asymmetry argument CLAUDE.md records for
ContainerCapabilities:94.
It goes in this file rather than beside HardwareFallbackTest because this is where progress
plumbing lives and where RecordingForegroundUpdater already is -- and because the software
and hardware cases now sit side by side, which is what makes the asymmetry visible rather
than merely fixed. workerReporting gains an engine-preference parameter defaulted to
FORCE_SOFTWARE, so no existing case changes.
AUTO with a real H.264 probe, because FORCE_SOFTWARE is exactly what keeps the other tests
out of this branch, and because InputProbe() reports UNPARSEABLE -- which PERMISSIVE.canDecode
refuses, sending every job to FFmpeg with no test saying why.
The percentage is asserted, not merely that an update happened: publishProgress takes a
display name and a percent, and replacing the percent with a constant compiles fine.
Mutations, both run and restored:
empty the hardware onProgress lambda 1 red
report a constant percent instead of the engine's 1 red
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
readMediaInformation was 114 missed instructions and 24 missed branches -- the second
largest block on the wave-4 report -- and exactly one line of it needed a device:
FFprobeKit.getMediaInformation(path).getMediaInformation()
Everything after it reads an ordinary object, so it moves into ffprobeInfoFrom and the edge
keeps the call and the null check. Verified JVM-safe rather than assumed: javap over the
committed AAR's runtime jar shows MediaInformation(JSONObject, List<StreamInformation>,
List<Chapter>) and StreamInformation(JSONObject) as plain public constructors whose <clinit>
does not load the native library, so a test builds its own without libffmpegkit present.
The decision worth reaching is containerFrom's SECOND argument. FFprobe reports
"matroska,webm" for both MKV and WebM because they share a demuxer, so the video codec is
the only thing separating them. containerFrom has thirty-three covered branches and not one
can notice that argument being dropped -- the mistake is at the call, not in the callee, so
every existing containerFrom test stays green while every VP9 WebM quietly becomes an MKV.
Two things the tests found rather than confirmed.
The format properties are NESTED under "format": getFormat() resolves through
getStringFormatProperty, not off the top-level object. The first fixture put the keys at the
top level and four cases failed with a null container. The helper says so now.
And one mutation SURVIVED on the first pass -- reading dimensions with
streams.firstNotNullOfOrNull { it.getWidth() } instead of video?.getWidth(). The fixture put
the dimensions on the chosen video stream, which is also the first stream carrying any, so
the two readings agreed and the test could not tell them apart. Separating them needs a
chosen video stream with NO dimensions and a later one that has them, which is a real shape:
FFprobe omits width/height for a stream it could not measure. That case is now its own test
and the mutation reddens it.
Mutations, each run and restored:
drop the video codec argument to containerFrom 1 red
take the LAST video stream instead of the first 1 red
read dimensions from any stream, not the chosen one 0 red -> 1 red after the new case
let an unparseable duration throw instead of zero 1 red
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
probe() was 20 never-executed lines, the biggest single block on the report. It has been
looked at twice and left out twice, and both closes were right about what they closed:
#86 ruled it device-bound, and #133 re-checked that with ShadowMediaCodecList in hand and
still declined, because MediaCodecInfoBuilder has no setIsAlias and no setCanonicalName --
"so the alias skip and the canonical-name dedup, the two things the class's KDoc calls out
as easy to get wrong, are not reachable through it."
That objection is about the shadow. It does not apply to a function taking its own entry
type, which #133 did not evaluate. capabilitiesFrom(enumerate: () -> Sequence<CodecEntry>)
holds every rule; the edge keeps only the mapping from MediaCodecList onto CodecEntry.
The parameter is a Sequence rather than a List on purpose. runCatching has always wrapped
the *iteration*, so a MediaCodecInfo whose properties throw partway leaves the codecs
already read in place. A List parameter would move that throw outside the loop and turn a
partial answer into an empty one -- a behaviour change smuggled in as a refactor. There is
now a test for the partial case, and swapping the Sequence for an eager toList() reddens it.
The behaviour change this DOES make is one line of log, and it is the reason the seam was
worth cutting at all. The fallback said "Codec enumeration failed; assuming permissive" and
returned empty sets -- but "video/avc" in emptySet() is false, so canEncode and canDecode
answer no to everything and every job routes to FFmpeg. That is the restrictive answer, and
it is the right one: FFmpeg does whatever Media3 does, only slower. The code stays; the
message and the class KDoc now describe it.
Seven tests. One of them was wrong first and the mutation is what said so: the alias case
originally listed the alias *after* the codec it aliases and passed with the skip deleted,
because canonicalName is shared and the dedup catches the second entry either way. The two
rules overlap, so a fixture that does not separate them tests neither. Order separates
them -- an alias arriving first claims the canonical name in `seen` and gets its own types
credited, and the real codec is then dropped by the dedup. That is now the test, and it
also says what the rule is worth: with a Set accumulator, an alias declaring the same types
as its codec changes nothing, so the skip earns its place only when the two disagree.
Mutations, each run and restored, each reddening the test that owns it:
drop !isSoftwareOnly from the encoder predicate 1 red
remove the alias skip 1 red (0 before the fixture was fixed)
remove the canonical-name dedup 1 red
remove the video/ prefix filter 1 red
apply the hardware predicate to decoders too 1 red
make the failure fallback permissive 2 red
eager toList() instead of the lazy Sequence 1 red
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
audioArgs' Drop arm -- `AudioPlan.Drop -> listOf("-an")` -- was ci == 0. The suite's only
-an assertion lives in "gif generates a palette to avoid banding and drops audio", and that
one comes from the image path at FFmpegCommandBuilder.kt:79/:90, which emits -an directly
and never reaches audioArgs. Two sites, one string, one tested.
It is a live path rather than defensive code. AdvancedPicker renders all of
AudioCodec.entries including NONE, ContainerCapabilities.validate permits audio-off whenever
the input has video, and MKV routes the job to FFmpeg -- so "convert this and drop the
soundtrack" is something a user can do today and nothing had built the command for.
Both halves are asserted, and the second is not padding: -an alone still passes if the arm
falls through to the else and emits an AAC encoder beside the flag, which is a file that is
silent because the flag won while carrying an encoder nobody asked for.
Three mutations, all run and restored. The third is the one that justifies the second
assertion, since the first two break -an as a side effect and so cannot show it:
Drop -> emptyList() red
Drop -> the else arm's aac encoder red (loses -an as well)
Drop -> listOf("-an", "-c:a", "aac") red -- -an intact, caught by assertFalse
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three sites, all ci == 0 before this, and all the same rule:
work/ConversionWorker.kt:316 cause.message ?: GENERIC_FAILURE_MESSAGE
convert/ConversionViewModel.kt:631 e.message ?: SAVE_FAILED_MESSAGE
join/JoinViewModel.kt:416 e.message ?: SAVE_FAILED_MESSAGE
Every existing test throws WITH a message, so the right-hand side had never been evaluated
anywhere in the suite. A Throwable carrying none is not exotic: RuntimeException(),
IOException() and most platform exceptions raised without an argument all have a null
message, and a native engine that dies is exactly where one comes from.
The worker case needed care, and the care is the reason it survived three waves.
ConversionStateMappingTest's "a failure with nothing said still says something" looks like
it covers that site and does not -- it drives the READ side, map(FAILED, Data.EMPTY), and
that side has a fallback of its own at ConversionViewModel.kt:147-149 which turns a blank
KEY_ERROR back into the same constant. So a test asserting on the resulting Failed state
stays green while the worker's fallback is broken. Measured rather than reasoned: with
:316 mutated to .orEmpty(), exactly ONE of 587 tests went red, and it was the new one.
Everything else, including the test that appears to cover it, stayed green.
So the worker case reads KEY_ERROR off the worker's own Result, before anything downstream
can repair it. The two save cases have no such second line -- both write _state.value
directly -- so the state is the right thing to assert there, and both also assert that
`pending` still travels: a fallback that dropped the handle would leave the file
unreachable from the very screen that just said the save failed.
Held in one class against the ticket's suggestion of three. They are one rule at three
layers, and the masking above has to be explained once rather than three times.
FailedSaveRetryTest already sets the precedent for both ViewModels in one file; this adds
one worker to that shape.
FORCE_SOFTWARE in the worker fixture so the failure comes straight out of runFFmpeg. AUTO
would enter runMedia3OrFallBack, whose catch runs the job a second time in software: the
same exception arrives, but by a path this is not about and which HardwareFallbackTest owns.
Mutations, all run and restored -- each site to .orEmpty(), never to a different constant,
which would only prove the test reads a constant:
ConversionWorker:316 1 of 587 red (this file)
ConversionViewModel:631 red
JoinViewModel:416 red
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both ViewModels' cancel() is one line -- activeWorkId?.let(workManager::cancelWorkById) --
and JaCoCo reports every line of both as covered. The only test of either was
SettingsEditsTest's "cancelling with no active job does nothing rather than throwing",
whose own comment names the half it drives: "the null side". The other side had never been
entered, and JoinViewModel.cancel() had no test at all.
So nothing in 584 tests connected the Cancel button to WorkManager. The affordance tests
click TestTags.CANCEL and assert the action fires into a stub; ScreenWiringTest asserts the
action calls viewModel.cancel(). Both halves were pinned and the join between them was not.
No line-level filter could have found this. It takes a method-level read -- mi=11, ci=7,
mb=1, cb=1 on both -- a covered method with an arm nothing takes, which is the second of
the two filters #194 records and the gap that argued for adding it.
The fixture is why this stayed uncovered rather than why it is hard. The test WorkManager
runs on a SynchronousExecutor, so an ordinary request finishes inline: by the time a test
could call cancel(), convert()'s job was already terminal, leaving only the null arm
reachable. setInitialDelay is what TestScheduler honours, so the job sits in ENQUEUED and
the test never releases it. Production never sets a delay, so the request is built by hand
rather than through ConversionWorker.request -- but ENQUEUED at runAttemptCount 0 is a real
state every job passes through, Reattachment.choose ranks it QUEUED, and conversionStateFrom
maps it to Converting(input, 0). The delay changes how long the job stays in a real state,
not which state it is in.
Three tests, and the third is not padding: without it, cancelAllWork() in place of
cancelWorkById(activeWorkId) passes the other two. It asserts the shape rather than the
identity -- exactly one of two queued jobs is cancelled -- because which one the ViewModel
reattached to is the query's business, and Reattachment's ordering notes say queued jobs
are left tied deliberately.
WorkManager's own record is asserted before the screen. The screen alone would be weaker
than it looks: CANCELLED maps to Idle for a reattached job, and Idle is also where a
ViewModel that did nothing whatsoever would sit.
Mutations, both run and both restored:
cancel() -> no-op all three red
cancelWorkById(id) -> cancelAllWork() only the third red
The second is what shows the third test does independent work rather than restating the
first two.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five findings (F6-F10) and a methodology correction. The twelve test tickets the same
read produced are #192-#203, with #204 for four candidates whose cost was not obviously
worth paying; nothing here is work, by this document's standing rule.
The correction is the part worth carrying forward. Wave 3 filtered candidates on `mi > 0`
and CLAUDE.md recommended it. That filter fails in both directions. It over-reports on
Compose: JoinScreen.kt:222 reads mi=10 and also ci=38, and JoinStateAffordancesTest
already clicks that Save button and asserts save:joined.mp4 -- the missed instructions are
the synthesized $changed/$dirty recomposition-skip path, the same codegen this repo
already knew inflated the branch count, showing up in the instruction count too. Every
onClick lambda flagged that way turned out to be covered at method level.
It under-reports on the case that mattered more. ConversionViewModel.cancel() and
JoinViewModel.cancel() miss no line at all, so no line-level filter can see them -- yet
only the null arm of activeWorkId?.let(workManager::cancelWorkById) had ever been entered,
and nothing in 584 tests connected the Cancel button to WorkManager. That is #192, and it
needs `ci > 0 && mb > 0` at method level to surface. Use both filters; `ci == 0` alone is
JaCoCo's own missed-line definition and needs no judgement, which is why it is the first.
The five findings are what a test would not fix. F6: four more unreachable arms, each
traced to the upstream guard that makes it so, one of which (ConversionRouter:214-217)
carries a KDoc describing a hazard :117 already removed. F7: probeWithExtractor's catch is
unreachable for the same reason probeForConcat's is -- the measurement was on record for
one site and not the other, three lines apart in the same file. F8: three more dead
members and six unused defaults. F9: both getForegroundInfo overrides are dead because
getForegroundInfoAsync is only called for expedited work and nothing sets it -- which
sharpens #88's close rather than reopening it. F10: three arms that ARE reachable and
still cannot be made to bite, recorded because all three were picked up as candidates and
put down again.
Six of the ten findings are now "no action" or "not a test gap", and that shape is the
honest summary of what is left: arms nothing can reach, members nothing calls, and arms a
test can reach but not pin. A coverage number tells none of them apart.
One close is qualified rather than overturned. #86 and #133 ruled AndroidDeviceCodecs.probe()
out through ShadowMediaCodecList, on the grounds that MediaCodecInfoBuilder cannot set
isAlias or canonicalName. A pure seam does not have that constraint and #133 did not
evaluate one, so #194 is a different mechanism, not a third run of the same spike -- and
its argument is not coverage but that the runCatching fallback logs "assuming permissive"
while returning empty sets, which makes canEncode and canDecode answer no for everything.
Documentation only: no Kotlin, Gradle or shell file is touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
92.8% line (2183/2352), 81.3% branch (1091/1342), 584 JVM tests in 87 classes, measured
2026-09-02 on the tree this branch creates rather than quoted from a PR body.
The shape of the wave is worth more than the number, and it is different from the two
before it. Waves 1 and 2 were finding uncovered code; by wave 3 there was little of that
left, so the gaps had to be sorted before any test was written. Coverage gaps -- filtered
to sites where JaCoCo reports mi > 0, which is what separates a real gap from a partial
branch on a compound condition, and which cut the candidate list roughly in half. And
assertion gaps, where JaCoCo is green and nothing checks the answer: MainActivity's rail
and bottom bar were both executed and transposing them passed the entire suite, as did
swapping the two progress-notification strings and swapping Content's two destinations.
No coverage number would have found any of the three.
Naming the required mutation per ticket earned its keep three times, each recorded with
what the weak assertion actually was. Also recorded: a green mutation is only evidence
when the mutation is a real change -- one classify reordering was semantically equivalent
for every reachable input, and a bad mutation and a weak test look identical in the output.
Two entries came back as not gaps, which is a result rather than a shortfall:
ContainerCapabilities:282's exclude filter cannot drop anything, and probeForConcat's
catch arm is unreachable on this runtime -- Robolectric's MediaExtractor never throws from
setDataSource, measured across four input shapes.
Both denominators moved, in opposite directions and for different reasons, so they are
stated rather than folded into the percentage: 1340 -> 1342 branches from MediaProbe.merge,
2348 -> 2352 lines from the ConcatJoiner interface. Neither is new untested code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Recovered onto main after hitting #160's trap for real. #189 was opened against
test/concat-engine-seam and, unlike #184-#188, never retargeted to main before merging --
so it merged into a branch that had already been merged and left behind. GitHub reported
`merged`, the PR shows MERGED, and none of it was on main: `git merge-base --is-ancestor`
is what said so, one line, immediately.
That check is the entire reason this was a five-minute recovery rather than a coverage
entry that silently stayed three points stale. The failure mode is exactly what CLAUDE.md
warns about; what it did not say, and now would, is that the auto-retarget it describes
belongs to GitHub's stacking feature, so a stack opened with plain `gh pr create --base`
has to be retargeted by hand for every single PR -- and missing one is invisible until you
check ancestry.
Numbers re-measured on this tree with --rerun-tasks rather than inherited from the branch
they were taken on: 584 tests, 0 failures, 2183/2352 line, 1091/1342 branch. Same figures,
earned again.
ConcatWorker constructed ConcatEngine in place while ConversionWorker reached its engines
through ConversionDependencies. That asymmetry is the whole reason one worker had a tested
failure path and the other had none: everything past setForeground was untested on *every*
source set, JVM and device alike.
The repo had already measured the cost and written it down. PerJobStagingTest's KDoc
records that **reverting ConcatWorker to a constant staging name left all 257 tests
green**, because nothing could reach the line that names the file. RefusedJobTest says it
from the other side -- "the next thing past the count guard is ConcatEngine, which is
native". That mutation is red now.
The seam is `ConversionDependencies.concat: (Context) -> ConcatJoiner`, beside .hardware
and .software. ConcatEngine implements the interface; its Result type stays nested in the
implementation, because moving it would touch every call site to buy nothing -- what a
test needs is the ability to not run FFmpeg, and that is the method, not the type.
Five tests, and the mutations that hold them:
the engine's own reason reaches the user replace e.message with the generic string
a failure with no message still says one drop the ?: GENERIC_FAILURE_MESSAGE fallback
a failed join deletes its partial drop staged.delete() from the catch
no input array at all is refused swap in TOO_FEW_INPUTS_MESSAGE
a join reports its own staged file revert to the constant staging name
The delete test was vacuous on its first draft and the mutation caught it: it scanned the
staging directory for a "join-" prefix that StagingNames.forJob does not produce -- it
names files <jobId>.<ext> -- so the assertion was trivially true. Rewritten to assert
against the handle the joiner was actually given.
Two small fixes ride along, both the repo's own conventions rather than new opinions.
"No input files." becomes NO_INPUTS_MESSAGE, per #158: a message the user can see is
named once, so a test asserts the string the worker writes rather than a copy that can
drift. And the JVM now covers that arm, which ran before staging and before any native
code and had no business being a device test.
579 -> 584 JVM tests, 0 failures.
ConcatWorker: 14 -> 5 missed lines, 2 -> 0 missed branches.
Line 2173/2348 -> 2183/2352; branch 1091/1342 unchanged in the numerator.
The line denominator moved 2348 -> 2352: that is the ConcatJoiner interface, not new
untested code. Said plainly because CLAUDE.md's coverage entry has a history of explaining
its own numbers wrongly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
probe() runs MediaExtractor and FFprobe independently and merges the two, and every rule
in that merge is a decision nothing held. The reason is structural rather than an
oversight: RemuxTest drives the whole thing on a device against committed fixtures, but
only ever with one probe answering and the other agreeing or also failing. Nothing on any
source set can arrange for a real extractor and a real FFprobe to *disagree*, so every
elvis in the merge was taken in one direction and never the other.
The seam is `internal fun merge(Extracted?, FFprobeInfo?): InputProbe`, pulled out of
probe() whole -- probe() now reads the two probes, merges, and keeps the log. FFprobeInfo
becomes internal alongside it; Extracted already was, with a KDoc giving this exact reason,
and FFprobeInfo simply never got the same treatment. Half a signature being private is
what made the function unnameable from a test.
Eleven tests, and the mutations that hold them:
image beats a real video codec demote the isImage arm below the video arm
the extractor wins on codecs flip the elvis to FFprobe-first
duration is the larger reading replace maxOf with extractor-first
dimensions prefer the extractor flip the width elvis
no recognised stream is unreadable narrow the guard to `extracted == null && info == null`
All five red, then restored. One mutation I tried first was *semantically equivalent* --
moving the image arm above the both-null arm changes nothing for any reachable input -- so
it stayed green and is recorded here rather than counted: a green mutation is only evidence
when the mutation is a real change.
The last row is the arm the ticket was filed for: parsed, and carrying no stream either
probe recognised, which is what a container holding only subtitles looks like. Its input
was already being constructed elsewhere in the suite -- MediaProbeTrackWalkTest calls
extractedFrom(emptyList()) and gets exactly it -- and had never been handed to the merge.
568 -> 579 JVM tests, 0 failures.
MediaProbe: 35 -> 24 missed lines, 72 -> 40 missed branches.
Line 2103/2348 -> 2173/2348; branch 1029/1340 -> 1091/1342.
The branch denominator moved by two, and it is the seam that moved it -- worth stating
separately from the numerator, because CLAUDE.md's coverage entry has a documented history
of explaining its own numbers wrongly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two assertion gaps, not coverage gaps, which is why they lasted. AppRootRestorationTest
already drives AppRoot at Compact and Expanded, so JaCoCo is green on useRail -- but it
asserts only that the selected tab survives recreation, through a stub `content`
composable. Nothing anywhere queried for a rail or a bar, and nothing composed the real
screens. Measured before this file existed:
- transposing the NavigationRail and NavigationBar bodies passed the entire suite
- transposing Content's two arms passed it too
A tablet showing phone chrome, or the Convert tab opening the Join screen, and 546 tests
with nothing to say about either. AppRoot's own KDoc is why that matters more than it
looks: from targetSdk 37 the app is resized and rotated whether or not it is ready, so the
width class is not a preference.
WindowWidthSizeClass.Medium appears in no test in either source set today. useRail is
`!= Compact`, so Medium takes the rail; narrowing it to `== Expanded` is one character and
breaks every tablet and unfolded foldable. That mutation is red now, and it is red only
because of the Medium test -- the Compact and Expanded ones both survive it.
Two things this needed:
**createAndroidComposeRule rather than createComposeRule.** Rendering AppRoot with its
default content reaches ConverterScreen's `viewModel = viewModel()`, which needs a
ViewModelStoreOwner. It works because both ViewModels are
`@JvmOverloads constructor(app: Application, ...)` so AndroidViewModelFactory can build
them, and because ui-test-manifest's debugImplementation entry already puts a
ComponentActivity in the merged manifest the unit tests build against -- which
app/build.gradle.kts says in terms. Checked with a throwaway spike before the ticket was
filed, rather than discovered here.
**Two tags, applied inside main.** The only production change: TestTags.Shell, set on the
rail and the bar. There is no other way to tell the two apart -- both render the same two
destinations with the same labels and the same selection state, so any assertion writable
without them is satisfied by either layout. In TestTags and applied by the shell rather
than handed down by the test, for the reason that file's KDoc gives: a tag the test
supplies proves only that the test set it. TagTableUniquenessTest covers the new group.
564 -> 568 JVM tests, 0 failures.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
audioArgs has six arms. Five are named codecs with tests; AAC arrives through the `else`,
so nothing named it -- neither "aac" nor "192k" appeared anywhere in
FFmpegCommandBuilderTest. It is the audio MP4 and M4A get, which is to say the audio the
picker offers first and most conversions produce.
Both halves are asserted, and the bitrate is the half worth arguing for: an -b:a that
quietly changed would fail nothing, look wrong in no command line, and surface only as
files that sound different from the ones the app produced last month. Both mutations
confirmed red -- 192k -> 128k and aac -> libfdk_aac.
Asserted through MP4_H264 and M4A_AAC rather than one of them, so an AAC arm added above
the `else` later has to keep answering the same way for both.
**Deliberately not added here: an ENCODABLE_AUDIO-vs-audioArgs agreement test**, the
obvious companion to VideoCodecMimeAgreementTest. It would freeze the answer to F1, which
is open: ContainerCapabilities.kt:84 says "nothing here emits a Vorbis encoder" and
FFmpegCommandBuilder.kt:188 does. docs/coverage-read-findings.md says in terms that the
tempting fix there locks in the wrong answer and that the decision comes first. This is
the AAC arm only.
563 -> 564 JVM tests, 0 failures. No production code changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An assertion gap rather than a coverage one, which is the reason it survived. JaCoCo is
green on build()'s `if (indeterminate)` because ProgressNotificationTest drives it through
a real worker -- but that test reads the notification id and EXTRA_PROGRESS and nothing
else. Nothing had ever read the text. Swapping the two branches passed the whole suite;
so did replacing the caller's title with a constant. Both are red now.
What it costs to get wrong is small and permanent: a conversion four minutes in still
saying "Preparing", or one that has not started reporting yet claiming 0%. Neither is a
crash, and nothing else here would have found it.
Nothing in the suite had constructed ConversionNotifications directly, and the reason
turned out to be mechanical rather than an oversight: build() reaches
WorkManager.getInstance for the Cancel action's PendingIntent, so the notification cannot
be built without one. installTestWorkManager in setUp is the whole fixture, and the KDoc
records the coupling so the next person does not rediscover it.
areEnabled() in the same file is deliberately still untested. It has no caller anywhere in
app/src/main, so a test would assert that a function nobody calls returns what the platform
told it -- and would imply the app handles the disabled-notification case, which it does
not. That is F5 in docs/coverage-read-findings.md, and it asks for a decision rather than a
test.
561 -> 563 JVM tests, 0 failures. No production code changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Media3Muxers' KDoc names the defect this guards -- "the router claimed five containers
while the engine silently wrote MP4 for all of them" -- and the repair itself was
untested: Media3Engine$buildTransformer$3, the requireNotNull message lambda, was four
lines and four branches at 0%. Nothing had ever driven a plan whose container Media3
cannot mux, and factoryFor answers null for fourteen of them.
Weakening it does not crash. The wrong output is a playable file with the wrong
container, which is why a test rather than a bug report is what would catch it.
Same harness and the same two disciplines as Media3EngineEmptyCompositionTest, which is
the sibling this joins: assert the plan really is the one the test needs before driving
the engine, and rule out CancellationException so an unresumed continuation cannot read
as a pass. Three premises are asserted here rather than assumed -- that the plan is still
WebM by the time the engine sees it, that Media3 really has no muxer for WebM, and that
neither track was dropped, since the empty-composition refusal fires earlier and is a
different test's subject.
The assertion is on the exception type *and* its message, and the ticket predicted why:
replacing requireNotNull with `?: DefaultMuxer.Factory()` does not make the export
succeed, it lets it run on and fail some other way. Measured -- that mutation fails the
type assertion, so the guard is genuinely what this test is holding, and the message
assertion stands behind it.
560 -> 561 JVM tests, 0 failures. No production code changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
runMedia3OrFallBack was eleven lines at 0% and isCancellation had never been called by any
JVM test -- ci=0, not merely a missed branch. The seam to reach it has existed the whole
time: ConversionDependencies.hardware, which no unit test had ever set. What kept the path
cold is that every worker test uses EnginePreference.FORCE_SOFTWARE, which never enters
the function, and the probe defaults to UNPARSEABLE, which PERMISSIVE.canDecode refuses --
so even AUTO would have routed straight to FFmpeg for a reason no assertion mentioned.
Both are now stated in setUp rather than inherited.
Four behaviours, each with the mutation that proves it:
hardware failure falls back to software delete the fallback call
... on a *clean* staging file delete staged.delete() before it
cancellation is rethrown, not fallen back delete `if (isCancellation(e)) throw e`
engine.close() runs either way empty the finally block
the display-name fallback (#169) change "input" to anything else
All five confirmed red, then restored.
The cancellation one is the reason this ticket was first in the group. runMedia3OrFallBack
catches Throwable, so without that re-throw a user cancelling a hardware transcode has the
app quietly start a *second* conversion in software -- the one thing cancelling is for.
ForcedFailureTest covers the failure half on a device and does not cover this half at all.
The clean-staging assertion is made where it is observable rather than by reading the
file: the software fake records whether the output existed when it was entered, so a
missing delete shows up as FFmpeg finding a half-written hardware output at the path it is
about to write.
556 -> 560 JVM tests, 0 failures. No production code changed.
ConversionWorker: 22 -> 9 missed lines, 14 -> 7 missed branches.
Line 2090/2348 -> 2103/2348; branch 1022/1340 -> 1029/1340.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The ticket asked for two things. One of them is not reachable from the JVM, and saying
so is most of the value here.
**probeForConcat's catch arm cannot be provoked on this runtime.** Robolectric's
MediaExtractor never throws from setDataSource -- measured across four input shapes: an
unregistered content:// authority, a missing file://, a file of garbage bytes, and an
http:// URL. All four returned normally with trackCount = 0. So a failed read arrives as
an empty track list rather than as an exception and reaches the same
ConcatInput(null, null, 0, 0, 0) by the other road. The catch stays covered only by
ConcatEngineTest on a device. The test file says this rather than implying the arm is
handled.
**What is reachable, and was genuinely missing, is the span.** Both halves were already
covered and neither reached the other: MediaProbeTrackWalkTest pins what concatInputFrom
makes of a track list, ConcatPlannerTest's `an unknown codec is not treated as a match`
pins what the planner does with a hand-built ConcatInput(video = null). The planner's
safety rests on the probe really producing that shape, and the hand-built fixture would
go on passing if it stopped.
Measured rather than claimed: mutating concatInputFrom's initial `video` to a non-null
placeholder leaves ConcatPlannerTest green and turns this red. Dropping the planner's
video null guard turns both red -- so that half was already held, and this file does not
claim credit for it.
The coupling itself is worth writing down: ConcatPlanner guards video against a null codec
and audio not at all, and that asymmetry is correct rather than an oversight --
MediaProbe.shortName returns a non-null String, so a null audioCodec means the track is
absent and two clips with no audio really do match, while a null videoCodec means absent
*or* unreadable. The audio check is safe because the video guard fires first on a clip
nothing could read. Nothing held that.
555 -> 556 JVM tests, 0 failures. No production code changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Each of these is a site where JaCoCo reported mi > 0 -- a concrete instruction no test
runs -- rather than a partial branch on a compound condition, which is how the group was
filtered in the first place. Six closed, one moved to the exclusions.
AndroidDeviceCodecs:35 the UNPARSEABLE sentinel, refused where an unknown name is not
ContainerCapabilities:94 accepts(container, VideoCodec.NONE, mode) -- the audio twin has
had a test since #136; the asymmetry is the argument
ContainerCapabilities:323 repairVideo's keep-the-requested-codec arm
ContainerCapabilities:348 firstContainerHolding's fallback container
OutputPublisher:230 the resolver call that throws -- the third case the KDoc names
and the one the shape list was missing
JobSnapshots:31 a job that recorded no output path at all
Every one was mutated and confirmed red, then restored. Two are worth stating because
they did not go red first time or would not have:
**The firstContainerHolding test was vacuous on its first draft.** It asserted the
refusal still offered *something*, and deleting the fallback left it green: the source
container is a candidate in its own right, so the list stays non-empty and only its
contents change. Rewritten around AVI, which has no mapping for H.265, and asserting the
codec survives -- without the fallback the app silently offers H.264 instead, which is
the actual loss. This is the failure mode CLAUDE.md records from the mutation review, met
head on rather than in the abstract.
**OutputPublisher:230 needed one line.** `a destination whose size cannot be determined is
never deleted` already walked three RowShapes; QUERY_THROWS was the fourth case its own
KDoc names -- "a resolver call that throws" -- and the only one that reaches `?: false`
through runCatching rather than through a cursor answer.
ContainerCapabilities:282's `.filter { it != exclude }` is **not** closed here and is not
a gap: nothing can make it drop anything. On the shared container `repair` always changes
at least one codec, because a codec it left alone is one validate would not have refused;
every other candidate differs by container; and the single call site passing a non-default
exclude (validateVideo:186) excludes a spec carrying VideoCodec.COPY while every repaired
candidate carries NONE. F4-shaped -- recorded rather than covered, and 550 tests agree.
550 -> 555 JVM tests, 0 failures. Five new tests rather than six: OutputPublisher:230
is one line inside a test that already existed. No production code changed.
Line 2087/2348 -> 2090/2348; branch 1011/1340 -> 1022/1340.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ConversionForegroundType.current()` has three arms and the JVM suite executed one.
`robolectric.properties` pins everything to `sdk=36`, and `@Config` appears nowhere in
`app/src/test`, so 3 lines and 3 of 4 branches were cold.
The instrumented test is not a substitute, and the reason is specific rather than
general. `ConversionWorkerTest.foregroundTypeMatchesTheRunningApiLevel` asserts against
whichever API the leg is, so it covers one arm per leg and never the other two -- and the
legs that would cover 33 and 34 are the ones #122 wedges. From
docs/coverage-read-findings.md, an API 33 run reported `received: 60` with
`failed: unknown`: the regime was exercised and that leg could not have said so if it had
broken. This runs all three deterministically in the same ./gradlew invocation.
Four classes, not three. 35 shares its answer with 36 and looks redundant; it is the
whole point. Relaxing `>= VANILLA_ICE_CREAM` to `>` is invisible at every level except
exactly 35 -- measured, not assumed: that mutation failed ForegroundTypeApi35Test alone,
while swapping DATA_SYNC and MEDIA_PROCESSING failed 34, 35 and 36. Without the 35 class
the first mutation survives the suite.
546 -> 550 JVM tests, 0 failures. No production code changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
COVERAGE. 87.1% line / 69.1% branch, 502 tests -> 88.9% line (2087/2348), 75.4%
branch (1011/1340), 546 tests in 76 classes, as #153's five children land.
The branch figure moved for two reasons and the entry now says so, because only
one of them is new tests. The numerator rose 974 -> 1011; the denominator *fell*
1410 -> 1340. Both are the seam work: pulling a `when` out of a lambda inside a
`collect` deletes the coroutine state machine's synthesized branches around it,
and leaves a plain function whose branches a test can choose.
`ConversionViewModel$observe$1$1` went from carrying the whole mapping to six
branches, while the extracted `ConversionViewModelKt` covers 41 of 42 and
`JoinViewModelKt` 38 of 39.
That is worth stating rather than quoting the percentage alone. A number that
rises because the denominator shrank is a different claim from one that rises
because more branches are tested, and this entry has a documented history of
explaining its own movements wrongly.
STACKED PRS. A new Conventions entry, from two traps measured on 2026-08-27
while landing #144-#151.
`gh pr merge` refuses a stacked PR outright -- "must be merged using the
asynchronous merge REST API" -- and so does the plain `/merge` endpoint. The one
that works is `PUT .../pulls/N/merge-async`, which returns a uuid to poll.
The second is worse because nothing looks wrong. GitHub retargets a stacked PR's
base to main when the one below it merges, but asynchronously. Merging five about
thirty seconds apart outran it, so each merged into its own already-merged base
branch. Every call returned `status: merged`, every PR read MERGED, `gh pr list
--state open` was empty, and none of the content was on main.
What caught it was a coverage re-measure two points below what the same tree had
produced an hour earlier -- a fresh `git pull` changed nothing, which is what
made it a question rather than a stale checkout. `git merge-base --is-ancestor`
answers it in one line. #160 is what the recovery cost.
Also recorded: the auto-retarget belongs to the stacking feature. A PR opened
with a plain `--base some-branch` does not retarget when that branch merges, and
has to be moved by hand -- which is what #163 needed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The stateful outer composables hand `ConverterScreenContent` and
`JoinScreenContent` a list of `viewModel::` references. No test in the suite had
ever seen that list: the content tests build their own `ConverterActions`, so
they drive the stateless inner and never touch the wiring.
THE TICKET'S PREMISE WAS HALF WRONG, AND CHECKING BEAT ASSUMING. #156 was filed
claiming a transposition of any two of seventeen bindings would survive the
suite. Measured instead of trusted:
onVideoCodec <-> onAudioCodec -> REJECTED: "Inapplicable candidate(s):
fun setAudioCodec(codec: AudioCodec)"
onCancel <-> onReset -> COMPILES
Every typed binding -- container, both codecs, preset, suggestion, quality,
engine preference -- takes a distinct parameter type, so the compiler is already
the test. Writing assertions against those transpositions would have been
theatre, and this file says so rather than quietly including them.
WHAT IS ACTUALLY AT RISK is the `() -> Unit` bindings, which are interchangeable
to the compiler: two on the converter screen (onCancel, onReset) and *three* on
the join screen (onJoin, onCancel, onReset). A Cancel button that discards the
finished file, a Start-over that leaves it on screen, or a Join button that
cancels -- each is one wrong word and each ships.
I got that wrong in the first check too: an early run reported the onCancel/
onReset swap as rejected, from a grep-and-exit-code test that misread a stale
build. Re-running it properly printed BUILD SUCCESSFUL with the swap in place.
THE SEAM. `converterActions(viewModel, onPickInput, onConvert, onSave)` and
`joinActions(viewModel, onPickInputs, onSave)`. The launcher-backed actions stay
parameters -- they need an ActivityResultLauncher, which is the part that
genuinely needs a composition, and keeping them out means the rest needs none.
Told apart by effect rather than by a recording double: `reset()` sets the state
to Idle, `cancel()` with no active job leaves it alone (`activeWorkId?.let`,
which SettingsEditsTest pins).
Mutations -- every transposition caught, each by two tests:
converter onCancel <-> onReset | 2 tests
join onJoin <-> onCancel | 2 tests
join onReset <-> onCancel | 2 tests
a typed binding dropped to {} | 1 test
a launcher action rerouted | 1 test
The two-test symmetry is deliberate: one direction alone passes against a wiring
with BOTH actions bound to the same method, which is what a copy-pasted line
produces.
Three guard assertions earned their place during writing -- the picks land
through an injected dispatcher, and without `ParkedPickDispatcher.runAll()` all
three state-based tests sat on Idle and would have asserted nothing. They failed
loudly instead of passing quietly.
`@UnstableApi` on both builders, per CLAUDE.md; lint caught their absence, as it
did in W1.
525 -> 537 tests, 88.0% -> 88.9% line, 70.5% -> 75.4% branch. Gate green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>