Make the screen leaves nameable from a test, and give tests a tag vocabulary #65

Merged
JMR-dev merged 2 commits from test/r38-1-screen-test-seam into main 2026-08-24 20:53:38 +00:00
JMR-dev commented 2026-08-24 20:32:13 +00:00 (Migrated from github.com)

Closes #57. Blocks #58, #59, #60 and #64 — those four start by naming something this PR makes nameable.

No behaviour change. ConverterScreen and JoinScreen render exactly what they rendered before; the
when (state) extraction is deliberately not here, it is #61.

What is in it

1. private -> internal, one word per declaration. private on a top-level declaration is
file-scoped, so the leaves were invisible even to the JVM test source set, which is a friend of
main. Eleven in ConverterScreen (FormatPicker, AdvancedPicker, ValidationError,
QualityPicker, EnginePicker, FileCard, DetailRow, describe, EnginePreference.label,
formatDuration, formatBytes) and FileRow in JoinScreen. Same reasoning MainActivity
already writes down for Destination.

2. ui/TestTags.kt, applied through Modifier.testTag to every affordance in both screens.
There were zero testTag, semantics or contentDescription calls anywhere in app/src/main
before this, so this is the vocabulary five later tickets will use.

  • Tags are applied inside main, never handed in by a caller. A tag the test supplies would
    survive the affordance losing its own — the vacuous shape CLAUDE.md records. FileRow and
    DetailRow therefore derive theirs from data they already hold (displayName, label) rather
    than taking an index from the call site.
  • The picker tags sit on the chip FlowRow, not on the picker: the pickers emit three siblings
    into their parent's Arrangement, and wrapping them in a Column to have something to tag would
    be a layout change. The KDoc says so, since FORMAT_CHIPS does not cover "Custom — set below.".
  • ADVANCED_CONTAINER_CHIPS / _VIDEO_ / _AUDIO_ are separate because the labels genuinely
    collide: "Copy" and "None" are both a VideoCodec and an AudioCodec, "MP3" and "FLAC"
    are both a Container and an AudioCodec. A text matcher over the open panel is ambiguous for
    four chips — worth knowing in #60.

3. A smoke test per leaf — it renders, and its tag resolves to exactly one node. Counting
rather than asserting existence, because a duplicated tag fails differently depending on which
finder a later test happens to use. Plus a reflection check that no two constants share a value.

4. A drain for an escaped coroutine error (first commit, separate because it stands alone).
See below — it blocked this PR and it will block every later Compose test.

androidTest is a friend source set of main

Unverified when #57 was filed; settled here. An androidTest file referencing the internal
Destination.CONVERT compiles clean through :app:compileDebugAndroidTestKotlin under AGP 9.3.1.
The probe was removed after measuring; the answer is in TestTags' KDoc so nobody repeats it.

internal would therefore work for #64. The table is public anyway, as #57 asked — that
friendship is AGP wiring rather than anything this project states, and public buys no external
exposure in an application module. Downgrading it later is one word if you would rather.

The mutation

Modifier.testTag(TestTags.Converter.FORMAT_CHIPS) deleted from FormatPicker's FlowRow, full
suite, no --tests filter:

ConverterLeafTagsTest > the format picker tags its chip row FAILED
289 tests completed, 1 failed

java.lang.AssertionError: Failed to assert count of nodes.
Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'converter.formatChips')
	at androidx.compose.ui.test.AssertionsKt.assertCountEquals(Assertions.kt:311)
	at org.libremediaconverter.convert.ConverterLeafTagsTest.assertResolvesToOneNode(ConverterLeafTagsTest.kt:53)

Four more, each red on its named test alone and restored after:

mutation what went red
FILE_CARD_NAME tag deleted the file card tags itself, its name and its size line — (TestTag = 'converter.fileCard.name')
detailRow(label) -> detailRow("row") both detail-row tests — (TestTag = 'converter.fileCard.row:Container')
fileRow(input.displayName) -> fileRow("row") each file row is tagged with the name it displays — (TestTag = 'join.fileRow:first.mp4')
JOIN_MORE given JOIN's value every tag constant has its own value — expected:<[]> but was:<[join.join]>

The last one is the only bite the uniqueness check has, and nothing else catches it.

The flake this ran into, and why it is fixed here

ConversionViewModelProbeFailureTest.an OutOfMemoryError is not swallowed lets a real error escape
viewModelScope.launch on purpose — the ViewModel's KDoc says an OOM belongs to the process, not to
the file. On the JVM, kotlinx-coroutines-test's process-wide collector catches it, holds it, and
reports it to the next runTest that starts. Every Compose test is a runTest; that is how
createComposeRule runs a composition.

Nothing had hit it because the one existing Compose test happens to run before it. Two of the tests
in this PR did, and the suite failed in two different files on two consecutive runs of identical
code
— the throw is on a real Dispatchers.IO thread, delivered after the assertion that ends the
test responsible, so which class catches it is a race.

drainEscapedCoroutineErrors() clears it while the rule is built. @Before cannot: the rule's
runTest wraps the statement that calls it. @BeforeClass cannot either: Robolectric runs it
outside the sandbox classloader, where the collector is a different object.

Every new Compose test class in src/test must use createDrainedComposeRule() — #58, #59,
#60, #62 and #63 all add one. AppRootRestorationTest is moved onto it too, since class order is
not guaranteed.

Containment, not the cure. The cure is a seam: an injectable dispatcher for the probe hop, exactly
mirroring cleanupDispatcher in the same constructor, so the error has somewhere to land. That is
a production change and wants its own ticket.

Not covered, deliberately

  • The state-branch tags have no bite yet — CHOOSE_FILE, CONVERT, CHOOSE_DIFFERENT_FILE,
    CONVERT_ANOTHER, CANCEL, START_OVER, SAVE_FILE, both progress bars, and Join's four.
    Reaching a branch needs a state injection point, which is #61; the matrix is #62/#63.
  • The four pure helpers are only made internal here. #58 owns their tests.
  • Behaviour of any leaf — selection, callbacks, the expand gate — is #59 and #60.

Gate

assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + ktlintCheck + detekt +
lintDebug --continue: all green. detekt 0 findings, lint 0 errors, 0 warnings, 1 hint —
unchanged. 289 JVM tests, and the full suite ran green four consecutive times after the drain
landed, which is the bar the flake set. AdvancedPicker still has exactly 6 parameters, so
LongParameterList stays under its threshold.

🤖 Generated with Claude Code

Closes #57. **Blocks #58, #59, #60 and #64** — those four start by naming something this PR makes nameable. No behaviour change. `ConverterScreen` and `JoinScreen` render exactly what they rendered before; the `when (state)` extraction is deliberately not here, it is #61. ## What is in it **1. `private` -> `internal`**, one word per declaration. `private` on a top-level declaration is *file*-scoped, so the leaves were invisible even to the JVM test source set, which is a friend of `main`. Eleven in `ConverterScreen` (`FormatPicker`, `AdvancedPicker`, `ValidationError`, `QualityPicker`, `EnginePicker`, `FileCard`, `DetailRow`, `describe`, `EnginePreference.label`, `formatDuration`, `formatBytes`) and `FileRow` in `JoinScreen`. Same reasoning `MainActivity` already writes down for `Destination`. **2. `ui/TestTags.kt`**, applied through `Modifier.testTag` to every affordance in both screens. There were zero `testTag`, `semantics` or `contentDescription` calls anywhere in `app/src/main` before this, so this is the vocabulary five later tickets will use. - Tags are applied **inside main**, never handed in by a caller. A tag the test supplies would survive the affordance losing its own — the vacuous shape `CLAUDE.md` records. `FileRow` and `DetailRow` therefore derive theirs from data they already hold (`displayName`, `label`) rather than taking an index from the call site. - The picker tags sit on the **chip `FlowRow`**, not on the picker: the pickers emit three siblings into their parent's `Arrangement`, and wrapping them in a `Column` to have something to tag would be a layout change. The KDoc says so, since `FORMAT_CHIPS` does not cover `"Custom — set below."`. - `ADVANCED_CONTAINER_CHIPS` / `_VIDEO_` / `_AUDIO_` are separate because the labels genuinely collide: `"Copy"` and `"None"` are both a `VideoCodec` and an `AudioCodec`, `"MP3"` and `"FLAC"` are both a `Container` and an `AudioCodec`. A text matcher over the open panel is ambiguous for four chips — worth knowing in #60. **3. A smoke test per leaf** — it renders, and its tag resolves to **exactly one** node. Counting rather than asserting existence, because a duplicated tag fails differently depending on which finder a later test happens to use. Plus a reflection check that no two constants share a value. **4. A drain for an escaped coroutine error** (first commit, separate because it stands alone). See below — it blocked this PR and it will block every later Compose test. ## `androidTest` **is** a friend source set of `main` Unverified when #57 was filed; settled here. An `androidTest` file referencing the `internal` `Destination.CONVERT` compiles clean through `:app:compileDebugAndroidTestKotlin` under AGP 9.3.1. The probe was removed after measuring; the answer is in `TestTags`' KDoc so nobody repeats it. `internal` would therefore work for #64. **The table is public anyway**, as #57 asked — that friendship is AGP wiring rather than anything this project states, and public buys no external exposure in an application module. Downgrading it later is one word if you would rather. ## The mutation `Modifier.testTag(TestTags.Converter.FORMAT_CHIPS)` deleted from `FormatPicker`'s `FlowRow`, full suite, no `--tests` filter: ``` ConverterLeafTagsTest > the format picker tags its chip row FAILED 289 tests completed, 1 failed java.lang.AssertionError: Failed to assert count of nodes. Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'converter.formatChips') at androidx.compose.ui.test.AssertionsKt.assertCountEquals(Assertions.kt:311) at org.libremediaconverter.convert.ConverterLeafTagsTest.assertResolvesToOneNode(ConverterLeafTagsTest.kt:53) ``` Four more, each red on its named test alone and restored after: | mutation | what went red | |---|---| | `FILE_CARD_NAME` tag deleted | `the file card tags itself, its name and its size line` — `(TestTag = 'converter.fileCard.name')` | | `detailRow(label)` -> `detailRow("row")` | both detail-row tests — `(TestTag = 'converter.fileCard.row:Container')` | | `fileRow(input.displayName)` -> `fileRow("row")` | `each file row is tagged with the name it displays` — `(TestTag = 'join.fileRow:first.mp4')` | | `JOIN_MORE` given `JOIN`'s value | `every tag constant has its own value` — `expected:<[]> but was:<[join.join]>` | The last one is the only bite the uniqueness check has, and nothing else catches it. ## The flake this ran into, and why it is fixed here `ConversionViewModelProbeFailureTest.an OutOfMemoryError is not swallowed` lets a real error escape `viewModelScope.launch` on purpose — the ViewModel's KDoc says an OOM belongs to the process, not to the file. On the JVM, kotlinx-coroutines-test's process-wide collector catches it, holds it, and reports it to **the next `runTest` that starts**. Every Compose test is a `runTest`; that is how `createComposeRule` runs a composition. Nothing had hit it because the one existing Compose test happens to run before it. Two of the tests in this PR did, and the suite failed in **two different files on two consecutive runs of identical code** — the throw is on a real `Dispatchers.IO` thread, delivered after the assertion that ends the test responsible, so which class catches it is a race. `drainEscapedCoroutineErrors()` clears it while the rule is built. `@Before` cannot: the rule's `runTest` wraps the statement that calls it. `@BeforeClass` cannot either: Robolectric runs it outside the sandbox classloader, where the collector is a different object. **Every new Compose test class in `src/test` must use `createDrainedComposeRule()`** — #58, #59, #60, #62 and #63 all add one. `AppRootRestorationTest` is moved onto it too, since class order is not guaranteed. Containment, not the cure. The cure is a seam: an injectable dispatcher for the probe hop, exactly mirroring `cleanupDispatcher` in the same constructor, so the error has somewhere to land. That is a production change and wants its own ticket. ## Not covered, deliberately - **The state-branch tags have no bite yet** — `CHOOSE_FILE`, `CONVERT`, `CHOOSE_DIFFERENT_FILE`, `CONVERT_ANOTHER`, `CANCEL`, `START_OVER`, `SAVE_FILE`, both progress bars, and Join's four. Reaching a branch needs a state injection point, which is #61; the matrix is #62/#63. - **The four pure helpers** are only made `internal` here. #58 owns their tests. - Behaviour of any leaf — selection, callbacks, the expand gate — is #59 and #60. ## Gate `assembleDebug` + `testDebugUnitTest` + `compileDebugAndroidTestKotlin` + `ktlintCheck` + `detekt` + `lintDebug --continue`: all green. detekt **0 findings**, lint **0 errors, 0 warnings, 1 hint** — unchanged. 289 JVM tests, and the full suite ran green **four consecutive times** after the drain landed, which is the bar the flake set. `AdvancedPicker` still has exactly 6 parameters, so `LongParameterList` stays under its threshold. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.