Say in tests what the file card says when it does not know #72

Merged
JMR-dev merged 2 commits from test/r38-2-filecard into main 2026-08-24 22:10:07 +00:00
JMR-dev commented 2026-08-24 21:16:29 +00:00 (Migrated from github.com)

Closes #58. Child 2 of 8 decomposing #52, on top of #57's internal leaves and tag table.

"Size unknown" is the line a previous stream declared untestable while fixing D5. It is two
assertTextEquals calls.

What landed

  • app/src/test/java/org/libremediaconverter/convert/FileCardTest.kt — 13
    Robolectric/Compose tests over FileCard and DetailRow: all four InputKind branches, the
    probe == null early return, and sizeBytes == null asserted twice, once with a probe and
    once without, because the independence of the size line from the probe is the actual contract.
  • app/src/test/java/org/libremediaconverter/convert/ConverterFormattersTest.kt — 13
    plain JVM tests over describe, formatDuration, formatBytes and EnginePreference.label().
    No Compose rule and no Robolectric: they are String in, String out.

Both classes locate nodes by TestTags.Converter, never by a text matcher, so a reword cannot
redden this PR or its two siblings. Nothing in app/src/main changed — the diff is two new
test files.

Two findings the issue could not have known

1. The second acceptance criterion is not a mutation. It is a compile error.

#58 asks for "delete the return@Column at the probe-null branch -> the Reading… test
goes red". It cannot go red, because it does not build. probe is a local val, so the early
return is what smart-casts it non-null; delete it and every use below stops compiling:

e: ConverterScreen.kt:499:24 Only safe (?.) or non-null asserted (!!.) calls are allowed on a
   nullable receiver of type 'InputProbe?'.

Ten of those, one per use of probe across the four kind branches.

The early exit is enforced by the type system, not by a test. That is a stronger guarantee than
the ticket assumed, and it matters for #61, which moves this code wholesale: if that refactor
hoists the probe into a non-null parameter or splits the branch into its own composable, the
compiler stops holding the line and these tests become the only thing that does.

The two compilable regressions someone would actually land in place of it are both covered
(mutations 2 and 3 below).

2. CodecNames.describeAudio has no UNPARSEABLE arm, unlike describeVideo

describeVideo(InputProbe.UNPARSEABLE) answers "Unrecognised". describeAudio has no such arm,
so it falls through to the raw-name fallback and answers the sentinel itself —
"\u0000unparseable", a NUL followed by a word, which would render verbatim in a DetailRow.

Not reachable today: a file carrying the sentinel has kind == UNPARSEABLE, and that branch renders
the explanatory line instead of any row. Left alone deliberately — changing CodecNames is
outside this ticket — but #61's owner should know it is there before restructuring the branch
that currently hides it.

What is asserted instead of the divider

The probe == null branch exits before HorizontalDivider, and the divider's absence cannot be
asserted
: Material 3 renders it as a Box with no semantics modifier, so it contributes no node
to the semantics tree at all.

What is asserted instead is everything the divider precedes — no detail row for any of the
six labels the four kind branches can emit — plus the card's child count, which catches
content the six-label enumeration cannot see. That second half is not a decorative extra: mutation 8
adds an untagged Text inside the branch and the count assertion is the only thing that reddens.
The class KDoc records the limitation, so it reads as a decision rather than a silent gap.

The mutations

Every assertion in both files has a named mutation. Eight were run; all eight went red, and each red
names the value it saw rather than merely failing.

# Mutation in ConverterScreen.kt What went red
1 ?: "Size unknown" -> ?: "0 B" both size tests — with a probe and without
2 early exit dropped, null defaulted away with ?: InputProbe() while the probe is still running the card shows the reading note and nothing else, on the rows
3 val probe = input.probe ?: InputProbe(), if block deleted the same test, on the note itself
4 bytes >= 1_000_000 -> bytes > 1_000_000 each unit starts exactly on its threshold rather than one byte past it
5 if (probe.width > 0) guard dropped an image whose dimensions nothing reported gets the type row alone
6 if (probe.durationMs > 0) guard dropped — both occurrences, one per branch the audio-only and the video degradation tests, independently
7 tracks.isEmpty() guard dropped in describe; two EnginePreference labels made identical three ConverterFormattersTest cases
8 an untagged fourth Text added inside the probe-null branch the child-count assertion, and nothing else

Mutation 1, verbatim:

java.lang.AssertionError: Failed to assert the following: (Text + EditableText = [Size unknown])
Semantics of the node:
Node #66 at (l=16.0, t=52.0, r=20.0, b=88.0)px, Tag: 'converter.fileCard.bytes'
Text = '[0 B]'

Both size tests produced that, one with a probe and one without, which is the independence contract
failing in both directions.

Mutation 2, verbatim. Note that #57's ConverterLeafTagsTest stays green through it —
that is what makes this PR's assertions the thing doing the work rather than the tag smoke test:

java.lang.AssertionError: Failed: assertDoesNotExist.
Reason: Did not expect any node but found '1' node that satisfies: (TestTag = 'converter.fileCard.row:Container')
Node found:
Node #120 at (l=16.0, t=141.0, r=35.0, b=177.0)px, Tag: 'converter.fileCard.row:Container'
Text = '[Container: Unknown]'

Mutation 3, verbatim:

java.lang.AssertionError: Failed to assert the following: (Text + EditableText = [Reading…])
Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'converter.fileCard.note')

Mutation 8, verbatim:

java.lang.AssertionError: Failed to assert count of nodes.
Reason: Expected '3' nodes but found '4' nodes that satisfy: ((TestTag = 'converter.fileCard').children)

Mutations 5 and 6 are why each > 0 guard has a case on both sides. The present side alone
stays green when the guard is deleted; what the deletion produces is Size: 0×0 and
Length: 0:00 — invented measurements, the same defect class as "0 B":

Reason: Did not expect any node but found '1' node that satisfies: (TestTag = 'converter.fileCard.row:Size')
Text = '[Size: 0×0]'

Mutation 4, verbatim — the reason formatBytes is pinned at each threshold and one
byte below it, since only a value sitting exactly on the boundary can tell >= from >:

org.junit.ComparisonFailure: expected:<1[.0 M]B> but was:<1[000 k]B>

Notes for the two sibling children

  • EnginePreference.label() is asserted directly here, since #58 lists it as one of the four pure
    helpers. #59 covering it again through EnginePicker's chips is a different bite — rendered
    versus returned — and the two are in different files, so they do not collide.
  • createDrainedComposeRule() from #57 is used throughout, never createComposeRule().
  • onNodeWithTag(FILE_CARD).onChildren() resolves to exactly the Text nodes: the Card does not
    merge its descendants and the Column flattens away. Useful if #59 or #60 wants the same
    "and nothing else" shape.

Gate

:app:assembleDebug :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:ktlintCheck :app:detekt :app:lintDebug --continue — green. detekt stays at 0 findings; lintDebug
reports 0 errors, 0 warnings and the one pre-existing UsableSpace hint in OutputPublisher.kt.

Closes #58. Child 2 of 8 decomposing #52, on top of #57's `internal` leaves and tag table. `"Size unknown"` is the line a previous stream declared untestable while fixing D5. It is two `assertTextEquals` calls. ## What landed - **`app/src/test/java/org/libremediaconverter/convert/FileCardTest.kt`** — 13 Robolectric/Compose tests over `FileCard` and `DetailRow`: all four `InputKind` branches, the `probe == null` early return, and `sizeBytes == null` asserted **twice**, once with a probe and once without, because the independence of the size line from the probe is the actual contract. - **`app/src/test/java/org/libremediaconverter/convert/ConverterFormattersTest.kt`** — 13 plain JVM tests over `describe`, `formatDuration`, `formatBytes` and `EnginePreference.label()`. No Compose rule and no Robolectric: they are `String` in, `String` out. Both classes locate nodes by `TestTags.Converter`, never by a text matcher, so a reword cannot redden this PR or its two siblings. Nothing in `app/src/main` changed — the diff is two new test files. ## Two findings the issue could not have known ### 1. The second acceptance criterion is not a mutation. It is a compile error. #58 asks for "delete the `return@Column` at the probe-null branch -> the `Reading…` test goes red". It cannot go red, because it does not build. `probe` is a local `val`, so the early return is what smart-casts it non-null; delete it and every use below stops compiling: ``` e: ConverterScreen.kt:499:24 Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'InputProbe?'. ``` Ten of those, one per use of `probe` across the four kind branches. **The early exit is enforced by the type system, not by a test.** That is a stronger guarantee than the ticket assumed, and it matters for **#61**, which moves this code wholesale: if that refactor hoists the probe into a non-null parameter or splits the branch into its own composable, the compiler stops holding the line and these tests become the only thing that does. The two compilable regressions someone would actually land in place of it are both covered (mutations 2 and 3 below). ### 2. `CodecNames.describeAudio` has no `UNPARSEABLE` arm, unlike `describeVideo` `describeVideo(InputProbe.UNPARSEABLE)` answers `"Unrecognised"`. `describeAudio` has no such arm, so it falls through to the raw-name fallback and answers the sentinel itself — `"\u0000unparseable"`, a NUL followed by a word, which would render verbatim in a `DetailRow`. Not reachable today: a file carrying the sentinel has `kind == UNPARSEABLE`, and that branch renders the explanatory line instead of any row. Left alone deliberately — changing `CodecNames` is outside this ticket — but #61's owner should know it is there before restructuring the branch that currently hides it. ## What is asserted instead of the divider The `probe == null` branch exits before `HorizontalDivider`, and **the divider's absence cannot be asserted**: Material 3 renders it as a `Box` with no semantics modifier, so it contributes no node to the semantics tree at all. What is asserted instead is everything the divider precedes — no detail row for any of the six labels the four kind branches can emit — plus the card's child count, which catches content the six-label enumeration cannot see. That second half is not a decorative extra: mutation 8 adds an untagged `Text` inside the branch and the count assertion is the only thing that reddens. The class KDoc records the limitation, so it reads as a decision rather than a silent gap. ## The mutations Every assertion in both files has a named mutation. Eight were run; all eight went red, and each red names the value it saw rather than merely failing. | # | Mutation in `ConverterScreen.kt` | What went red | |---|---|---| | 1 | `?: "Size unknown"` -> `?: "0 B"` | **both** size tests — with a probe and without | | 2 | early exit dropped, null defaulted away with `?: InputProbe()` | `while the probe is still running the card shows the reading note and nothing else`, on the rows | | 3 | `val probe = input.probe ?: InputProbe()`, `if` block deleted | the same test, on the note itself | | 4 | `bytes >= 1_000_000` -> `bytes > 1_000_000` | `each unit starts exactly on its threshold rather than one byte past it` | | 5 | `if (probe.width > 0)` guard dropped | `an image whose dimensions nothing reported gets the type row alone` | | 6 | `if (probe.durationMs > 0)` guard dropped — **both** occurrences, one per branch | the audio-only and the video degradation tests, independently | | 7 | `tracks.isEmpty()` guard dropped in `describe`; two `EnginePreference` labels made identical | three `ConverterFormattersTest` cases | | 8 | an untagged fourth `Text` added inside the probe-null branch | the child-count assertion, and nothing else | **Mutation 1**, verbatim: ``` java.lang.AssertionError: Failed to assert the following: (Text + EditableText = [Size unknown]) Semantics of the node: Node #66 at (l=16.0, t=52.0, r=20.0, b=88.0)px, Tag: 'converter.fileCard.bytes' Text = '[0 B]' ``` Both size tests produced that, one with a probe and one without, which is the independence contract failing in both directions. **Mutation 2**, verbatim. Note that #57's `ConverterLeafTagsTest` stays **green** through it — that is what makes this PR's assertions the thing doing the work rather than the tag smoke test: ``` java.lang.AssertionError: Failed: assertDoesNotExist. Reason: Did not expect any node but found '1' node that satisfies: (TestTag = 'converter.fileCard.row:Container') Node found: Node #120 at (l=16.0, t=141.0, r=35.0, b=177.0)px, Tag: 'converter.fileCard.row:Container' Text = '[Container: Unknown]' ``` **Mutation 3**, verbatim: ``` java.lang.AssertionError: Failed to assert the following: (Text + EditableText = [Reading…]) Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'converter.fileCard.note') ``` **Mutation 8**, verbatim: ``` java.lang.AssertionError: Failed to assert count of nodes. Reason: Expected '3' nodes but found '4' nodes that satisfy: ((TestTag = 'converter.fileCard').children) ``` **Mutations 5 and 6** are why each `> 0` guard has a case on **both** sides. The present side alone stays green when the guard is deleted; what the deletion produces is `Size: 0×0` and `Length: 0:00` — invented measurements, the same defect class as `"0 B"`: ``` Reason: Did not expect any node but found '1' node that satisfies: (TestTag = 'converter.fileCard.row:Size') Text = '[Size: 0×0]' ``` **Mutation 4**, verbatim — the reason `formatBytes` is pinned at each threshold *and* one byte below it, since only a value sitting exactly on the boundary can tell `>=` from `>`: ``` org.junit.ComparisonFailure: expected:<1[.0 M]B> but was:<1[000 k]B> ``` ## Notes for the two sibling children - `EnginePreference.label()` is asserted directly here, since #58 lists it as one of the four pure helpers. #59 covering it again through `EnginePicker`'s chips is a different bite — rendered versus returned — and the two are in different files, so they do not collide. - `createDrainedComposeRule()` from #57 is used throughout, never `createComposeRule()`. - `onNodeWithTag(FILE_CARD).onChildren()` resolves to exactly the `Text` nodes: the `Card` does not merge its descendants and the `Column` flattens away. Useful if #59 or #60 wants the same "and nothing else" shape. ## Gate `:app:assembleDebug :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin :app:ktlintCheck :app:detekt :app:lintDebug --continue` — green. detekt stays at 0 findings; `lintDebug` reports 0 errors, 0 warnings and the one pre-existing `UsableSpace` hint in `OutputPublisher.kt`.
Sign in to join this conversation.