Pick a file through the real system picker, then rotate the phone #80

Merged
JMR-dev merged 4 commits from test/r38-8-saf-e2e into main 2026-08-25 02:23:37 +00:00
JMR-dev commented 2026-08-25 01:59:08 +00:00 (Migrated from github.com)

Closes #64. Last of the eight children of #52.

The verdict #64 asked for: Half B's mutation is constructible

The comment on #64 flagged the rotation half as "asserted, not verified" — that viewModel()
resolves through LocalViewModelStoreOwner, so "changing that scope may not be expressible
without restructuring the call site". It is expressible, and it was built and run before the
test was written, exactly as the ticket asked.

viewModel() takes an explicit viewModelStoreOwner. Swapping LocalViewModelStoreOwner.current
for a remember-scoped one is the change — plus a factory and creation extras, which is the
part that is not obvious and is probably why it looked inexpressible:

viewModel: ConversionViewModel = viewModel(
    viewModelStoreOwner = remember {
        object : ViewModelStoreOwner {
            override val viewModelStore = ViewModelStore()
        }
    },
    factory = ViewModelProvider.AndroidViewModelFactory(),
    extras = MutableCreationExtras().apply {
        set(
            ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY,
            LocalContext.current.applicationContext as Application,
        )
    },
)

An owner that is not a HasDefaultViewModelProviderFactory contributes no creation extras, and
the default factory cannot construct an AndroidViewModel without APPLICATION_KEY. The owner
swap alone crashes on construction rather than demonstrating the scope — a genuine trap, and
the reason to state the working form here rather than "change the scope".

The ticket's second doubt — that a recreated ViewModel merely returns to Idle, "a different
assertion rather than a mutation" — is answered by what the run did. The mutation reddened one
instrumented test, left the other test in the same class green, and left the whole JVM suite
green. A mutation that reddens exactly the test that names it is a mutation.

Why it bites at all is a fact the ticket did not have: ConversionViewModel holds the picked
file in a plain MutableStateFlow with no SavedStateHandle. Nothing persists it. The only
thing carrying it across a rotation is the ViewModelStore the Activity retains — and nothing in
the repo asserted that. AppRootRestorationTest injects a stub content lambda specifically to
avoid standing up either ViewModel, and StateRestorationTester saves into an in-memory map. So
the bite is not a duplicate of that test's; it is a property no JVM test can reach.

The mutations, verbatim

Both run on a local API 34 emulator, reverted and restored after each.

Baseline — tests=2 failures=0 errors=0 skipped=0

1. The MIME filter. arrayOf("*/*") -> arrayOf("application/x-lmc-no-such-type") at the one
site in ConverterScreen.kt. DocumentsUI matches the request against the fixture root's
Root.COLUMN_MIME_TYPES and drops roots that cannot answer, so the root leaves the picker
entirely — along with "Images", "Audio", "Videos" and "Documents". tests=2 failures=2:

java.lang.IllegalArgumentException: the system picker never showed BySelector [TEXT='\QLMC R38 fixtures\E']
	at org.libremediaconverter.saf.SafPickerRoundTripTest.awaitPickerNode(SafPickerRoundTripTest.kt:210)
	at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:185)
	at org.libremediaconverter.saf.SafPickerRoundTripTest.pickingAFileThroughTheSystemPickerFillsInTheFileCard(SafPickerRoundTripTest.kt:115)

Both tests fail because both drive the picker, which is right: the filter is upstream of
everything.

2. The ViewModel scope. The block quoted above. tests=2 failures=1 — only the rotation test:

androidx.compose.ui.test.ComposeTimeoutException: Condition (a node tagged converter.fileCard.name exists) still not satisfied after 30000 ms
	at androidx.compose.ui.test.AndroidComposeUiTestEnvironment$AndroidComposeUiTestImpl.waitUntil(ComposeUiTest.android.kt:901)
	at org.libremediaconverter.saf.SafPickerRoundTripTest.awaitNode(SafPickerRoundTripTest.kt:224)
	at org.libremediaconverter.saf.SafPickerRoundTripTest.thePickedInputSurvivesARealRotation(SafPickerRoundTripTest.kt:164)

and, under the same mutation, ./gradlew :app:testDebugUnitTest -> BUILD SUCCESSFUL. That
divergence is the whole reason #64 exists.

Measured beforehand rather than assumed: no JVM test composes the public ConverterScreen at
all.
Every one of them composes ConverterScreenContent, the seam #61 extracted. That is
structurally why the mutation cannot reach them.

Four things that were not what the ticket said

  • arrayOf("*/*") is at ONE site, not two. #61 unified the Idle "Choose file" and Ready
    "Choose a different file" branches onto a single ConverterActions.onPickInput. The mutation is
    simpler than advertised.

  • The fixture provider had to be Java — the only Java file in the module. A manifest-declared
    provider is a component of the instrumentation package, so the system starts a plain
    org.libremediaconverter.test process for it with only the test APK on its dex path — and the
    test APK is built without the Kotlin stdlib, because the app APK has it and duplicating it is
    what checkDebugAndroidTestDuplicateClasses prevents. The Kotlin draft died on its first query:

    FATAL EXCEPTION: binder:6369_2
    Process: org.libremediaconverter.test, PID: 6369
    java.lang.NoClassDefFoundError: Failed resolution of: Lkotlin/jvm/internal/Intrinsics;
        at org.libremediaconverter.saf.FixtureDocumentsProvider.queryDocument
    

    The compiler emits that reference for the null checks on nearly every function, so no Kotlin
    dialect avoids it. Same reason nothing in that file imports androidx. The comment in
    build.gradle.kts that said "all hand-written code in this module is Kotlin" is corrected.

  • The MIME mutation removes the ROOT, not just the document. That is what makes the fixture
    provider worth its lines over writing a file into Downloads: Root.COLUMN_MIME_TYPES gives the
    filter a failure with a shape.

  • The picker needs no drawer navigation on a stock emulator. DocumentsUI's landing screen
    lists third-party roots under "BROWSE FILES IN OTHER APPS". The "Show roots" fallback is kept
    for a device with a populated Recent; it widens where the root is looked for and does not weaken
    what has to be found.

Two silent-pass guards, because a rotation test is easy to fake

thePickedInputSurvivesARealRotation asserts the display rotation actually changed, and that
MainActivity is a different instance afterwards, before it asserts anything about the file.
Without the second, a configChanges attribute or an orientation lock would quietly turn this
into a recomposition test. Both were verified to hold on the baseline run.

The build change

androidx.test.uiautomator floats at 2.+, which is the argument the catalog already makes
for work and lifecycle rather than a new one: the group is inside floatedGroupPrefixes, so
the componentSelection guard makes + mean "newest RELEASED". That is load-bearing — this
library publishes 2.4.0-alphas above its stable, so without the guard the float would be a pin
to a prerelease. Resolved to 2.4.0 on debugAndroidTestRuntimeClasspath, checked rather than
assumed.

Deliberately not pinned alongside ktlint/detekt/JaCoCo/Robolectric. Those are pinned because a
new rule or a new runtime changes the verdict on files nobody touched; UiAutomator has no verdict.
The 2. rather than a bare + is the one thing held back — a major is where the selector API
would be free to change under exactly that assumption.

Where it ran

Every number below was measured on this workstation or read out of a CI artifact. None is inferred.

level how result
API 34 single class, -gpu host green — the iteration loop, and where both mutations were run
API 33 run-e2e.sh 33, whole suite 59 / 0 / 0 / 2
API 36 run-e2e.sh 36, whole suite 59 / 0 / 0 / 2
API 34, cold picker ×5 pm clear com.google.android.documentsui between runs 5 / 5 green — the flake regression check, below
API 37.0 one method per fresh emulator, swangle_indirect, SystemUI disabled picker passes; rotation aborts the framework

The whole-suite runs are not belt-and-braces: a single-class run structurally cannot show a
test-ordering interaction, and this class has an @After that moves the display. The suite was
57 before this PR and is 59 after.

The Pixel 10 Pro XL was not used and cannot be. It is secure-locked and cannot be unlocked
from a shell, so the picker cannot be driven on it. That is why this gap survived.

CI found a flake this workstation could not, and it changed a conclusion

The first push went red on API 33, 34 and 35 with:

androidx.test.uiautomator.StaleObjectException
	at androidx.test.uiautomator.UiObject2.getAccessibilityNodeInfo(UiObject2.java:1042)
	at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526)
	at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:223)

UiObject2 caches the AccessibilityNodeInfo it was found with, and DocumentsUI is still
settling when a node first appears. On a cold emulator this is not intermittent — it hit
every one of those three legs on the first run. It never appeared locally because the emulator
had been warm for an hour. tapPickerNode now re-finds and re-taps, three attempts; verified
5 / 5 green on API 34 with pm clear on DocumentsUI between runs.

What retrying does not weaken: every attempt still goes through awaitPickerNode, which
fails outright if the node is absent. A root that is not in the picker is not found on any
attempt, so the MIME mutation still fails with the system picker never showed ….

The correction that flake forced

The commit before it marked the whole class @FailsOnEmulatorApi37, on the strength of two
measured API 37 failures. One of them was this bug. Re-measured with the fix, one method per
fresh android-37.0 emulator:

thePickedInputSurvivesARealRotation             INSTRUMENTATION_ABORTED: System has crashed.
                                                Expected 1 tests, received 0
pickingAFileThroughTheSystemPickerFillsInTheFileCard                          PASSED

So a rotation — which rebuilds every surface at once — is what the gralloc mapper does not
survive; starting another app's activity is not. The marker moved to the one method that earned
it, and the picker test runs on the gating API 37 leg like anything else.

The lesson is worth more than the measurement, and docs/api-37-emulator-crash.md keeps it:
an annotation is a claim about an image, and a broken test makes every image look broken.
Both a framework abort and a stale node read as "the run fell over". Re-measure after fixing a
test before deciding what the platform did.

The annotation filter itself was measured, not assumed

It is what keeps the gating API 37 leg green, so it is not a thing to reason about. On API 34,
annotation= selected exactly 4 tests (the two Media3EngineTest methods plus both of
these), and notAnnotation= selected 55 with neither of these in it — so the runner expands
a class-level marker to every method. CI's own gating E2E API 37 leg then reported 55 tests,
0 failures, zero SafPicker cases
on the first push. Moving the marker to one method is
therefore a narrowing of something already known to work.

Scope: three files #64 did not name

Stated plainly rather than left for a reviewer to find. .github/workflows/status_check.yml,
tools/local-emulator/run-e2e.sh and docs/api-37-emulator-crash.md all asserted "nothing in
this suite touches system UI"
— which is what makes the API 37 leg's SystemUI-disable deviation
defensible. This PR makes that false, so all three are corrected in it. The rule they state is
being applied, not broken: the test that does not survive the leg is excluded from it.

run-e2e.sh's summary also promised "exactly two failures" at API 37 and now says what it really
expects, including that the level no longer finishes — its totals come back short with an
arbitrary tail.

Deliberately not done: the advisory job is still named E2E API 37 Media3 hardware transcode (advisory) and now carries a test that is neither. Renaming a check touches branch protection,
so it is left for a separate decision; the doc records the staleness and the "when to revisit"
trigger now says the marker covers two unrelated bugs that can go green independently.

Exemptions, named

  • OpenMultipleDocuments is not covered. It is JoinScreen's picker; #64 scopes this to the
    converter's input. The same machinery would drive it, and it is a different screen's ticket.
  • CreateDocument is not covered. Reaching the Save button needs ConversionState.Converted,
    which needs a worker run that has already succeeded — a full transcode inside a UI test, on five
    gating CI legs, to assert one launcher line. What it leads to is already covered by
    OutputPublisherPublishTest.
  • The advanced panel's rememberSaveable is deliberately not re-asserted here.
    AdvancedPanelSavedStateTest already drives it through a real Bundle/Parcel round trip on
    the JVM, and a second test whose bite duplicates an existing one is the failure #52 exists to
    prevent.
  • No coverage number is quoted. JaCoCo does not measure the instrumented suite at all here.

Gate

./gradlew :app:assembleDebug :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin \
          :app:assembleDebugAndroidTest :app:ktlintCheck :app:detekt :app:lintDebug --continue

BUILD SUCCESSFUL. assembleDebugAndroidTest is in there on purpose and is not in CLAUDE.md's
list: compileDebugAndroidTestKotlin does not run manifest merging, and this PR adds the first
androidTest manifest. It caught the first draft of it — -- is illegal inside an XML comment.
Pinned shellcheck 0.11.0 clean on the edited run-e2e.sh.

🤖 Generated with Claude Code

Closes #64. Last of the eight children of #52. ## The verdict #64 asked for: Half B's mutation **is** constructible The comment on #64 flagged the rotation half as *"asserted, not verified"* — that `viewModel()` resolves through `LocalViewModelStoreOwner`, so "changing that scope may not be expressible without restructuring the call site". It is expressible, and it was built and run **before** the test was written, exactly as the ticket asked. `viewModel()` takes an explicit `viewModelStoreOwner`. Swapping `LocalViewModelStoreOwner.current` for a `remember`-scoped one is the change — **plus** a factory and creation extras, which is the part that is not obvious and is probably why it looked inexpressible: ```kotlin viewModel: ConversionViewModel = viewModel( viewModelStoreOwner = remember { object : ViewModelStoreOwner { override val viewModelStore = ViewModelStore() } }, factory = ViewModelProvider.AndroidViewModelFactory(), extras = MutableCreationExtras().apply { set( ViewModelProvider.AndroidViewModelFactory.APPLICATION_KEY, LocalContext.current.applicationContext as Application, ) }, ) ``` An owner that is not a `HasDefaultViewModelProviderFactory` contributes no creation extras, and the default factory cannot construct an `AndroidViewModel` without `APPLICATION_KEY`. The owner swap **alone** crashes on construction rather than demonstrating the scope — a genuine trap, and the reason to state the working form here rather than "change the scope". The ticket's second doubt — that a recreated ViewModel merely returns to `Idle`, "a different assertion rather than a mutation" — is answered by what the run did. The mutation reddened one instrumented test, left the other test **in the same class** green, and left the whole JVM suite green. A mutation that reddens exactly the test that names it is a mutation. **Why it bites at all** is a fact the ticket did not have: `ConversionViewModel` holds the picked file in a plain `MutableStateFlow` with **no `SavedStateHandle`**. Nothing persists it. The only thing carrying it across a rotation is the `ViewModelStore` the Activity retains — and nothing in the repo asserted that. `AppRootRestorationTest` injects a stub `content` lambda specifically to avoid standing up either ViewModel, and `StateRestorationTester` saves into an in-memory map. So the bite is not a duplicate of that test's; it is a property no JVM test can reach. ## The mutations, verbatim Both run on a local API 34 emulator, reverted and restored after each. **Baseline** — `tests=2 failures=0 errors=0 skipped=0` **1. The MIME filter.** `arrayOf("*/*")` -> `arrayOf("application/x-lmc-no-such-type")` at the one site in `ConverterScreen.kt`. DocumentsUI matches the request against the fixture root's `Root.COLUMN_MIME_TYPES` and drops roots that cannot answer, so the root leaves the picker entirely — along with "Images", "Audio", "Videos" and "Documents". `tests=2 failures=2`: ``` java.lang.IllegalArgumentException: the system picker never showed BySelector [TEXT='\QLMC R38 fixtures\E'] at org.libremediaconverter.saf.SafPickerRoundTripTest.awaitPickerNode(SafPickerRoundTripTest.kt:210) at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:185) at org.libremediaconverter.saf.SafPickerRoundTripTest.pickingAFileThroughTheSystemPickerFillsInTheFileCard(SafPickerRoundTripTest.kt:115) ``` Both tests fail because both drive the picker, which is right: the filter is upstream of everything. **2. The ViewModel scope.** The block quoted above. `tests=2 failures=1` — only the rotation test: ``` androidx.compose.ui.test.ComposeTimeoutException: Condition (a node tagged converter.fileCard.name exists) still not satisfied after 30000 ms at androidx.compose.ui.test.AndroidComposeUiTestEnvironment$AndroidComposeUiTestImpl.waitUntil(ComposeUiTest.android.kt:901) at org.libremediaconverter.saf.SafPickerRoundTripTest.awaitNode(SafPickerRoundTripTest.kt:224) at org.libremediaconverter.saf.SafPickerRoundTripTest.thePickedInputSurvivesARealRotation(SafPickerRoundTripTest.kt:164) ``` and, under the same mutation, `./gradlew :app:testDebugUnitTest` -> **BUILD SUCCESSFUL**. That divergence is the whole reason #64 exists. Measured beforehand rather than assumed: **no JVM test composes the public `ConverterScreen` at all.** Every one of them composes `ConverterScreenContent`, the seam #61 extracted. That is structurally why the mutation cannot reach them. ## Four things that were not what the ticket said - **`arrayOf("*/*")` is at ONE site, not two.** #61 unified the `Idle` "Choose file" and `Ready` "Choose a different file" branches onto a single `ConverterActions.onPickInput`. The mutation is simpler than advertised. - **The fixture provider had to be Java** — the only Java file in the module. A manifest-declared provider is a component of the instrumentation *package*, so the system starts a plain `org.libremediaconverter.test` process for it with only the test APK on its dex path — and the test APK is built without the Kotlin stdlib, because the app APK has it and duplicating it is what `checkDebugAndroidTestDuplicateClasses` prevents. The Kotlin draft died on its first query: ``` FATAL EXCEPTION: binder:6369_2 Process: org.libremediaconverter.test, PID: 6369 java.lang.NoClassDefFoundError: Failed resolution of: Lkotlin/jvm/internal/Intrinsics; at org.libremediaconverter.saf.FixtureDocumentsProvider.queryDocument ``` The compiler emits that reference for the null checks on nearly every function, so no Kotlin dialect avoids it. Same reason nothing in that file imports `androidx`. The comment in `build.gradle.kts` that said "all hand-written code in this module is Kotlin" is corrected. - **The MIME mutation removes the ROOT, not just the document.** That is what makes the fixture provider worth its lines over writing a file into Downloads: `Root.COLUMN_MIME_TYPES` gives the filter a failure with a shape. - **The picker needs no drawer navigation on a stock emulator.** DocumentsUI's landing screen lists third-party roots under "BROWSE FILES IN OTHER APPS". The "Show roots" fallback is kept for a device with a populated Recent; it widens where the root is looked for and does not weaken what has to be found. ## Two silent-pass guards, because a rotation test is easy to fake `thePickedInputSurvivesARealRotation` asserts the display rotation actually changed, and that `MainActivity` is a **different instance** afterwards, before it asserts anything about the file. Without the second, a `configChanges` attribute or an orientation lock would quietly turn this into a recomposition test. Both were verified to hold on the baseline run. ## The build change `androidx.test.uiautomator` **floats at `2.+`**, which is the argument the catalog already makes for `work` and `lifecycle` rather than a new one: the group is inside `floatedGroupPrefixes`, so the `componentSelection` guard makes `+` mean "newest RELEASED". That is load-bearing — this library publishes `2.4.0-alpha`s above its stable, so without the guard the float would be a pin to a prerelease. Resolved to **2.4.0** on `debugAndroidTestRuntimeClasspath`, checked rather than assumed. Deliberately **not** pinned alongside ktlint/detekt/JaCoCo/Robolectric. Those are pinned because a new rule or a new runtime changes the verdict on files nobody touched; UiAutomator has no verdict. The `2.` rather than a bare `+` is the one thing held back — a major is where the selector API would be free to change under exactly that assumption. ## Where it ran Every number below was measured on this workstation or read out of a CI artifact. None is inferred. | level | how | result | |---|---|---| | **API 34** | single class, `-gpu host` | green — the iteration loop, and where both mutations were run | | **API 33** | `run-e2e.sh 33`, **whole suite** | **59 / 0 / 0 / 2** | | **API 36** | `run-e2e.sh 36`, **whole suite** | **59 / 0 / 0 / 2** | | **API 34, cold picker ×5** | `pm clear com.google.android.documentsui` between runs | 5 / 5 green — the flake regression check, below | | **API 37.0** | one method per fresh emulator, `swangle_indirect`, SystemUI disabled | picker **passes**; rotation **aborts the framework** | The whole-suite runs are not belt-and-braces: a single-class run structurally cannot show a test-ordering interaction, and this class has an `@After` that moves the display. The suite was 57 before this PR and is 59 after. **The Pixel 10 Pro XL was not used and cannot be.** It is secure-locked and cannot be unlocked from a shell, so the picker cannot be driven on it. That is why this gap survived. ## CI found a flake this workstation could not, and it changed a conclusion The first push went red on API 33, 34 and 35 with: ``` androidx.test.uiautomator.StaleObjectException at androidx.test.uiautomator.UiObject2.getAccessibilityNodeInfo(UiObject2.java:1042) at androidx.test.uiautomator.UiObject2.click(UiObject2.java:526) at org.libremediaconverter.saf.SafPickerRoundTripTest.pickTheFixture(SafPickerRoundTripTest.kt:223) ``` `UiObject2` caches the `AccessibilityNodeInfo` it was found with, and DocumentsUI is still settling when a node first appears. **On a cold emulator this is not intermittent** — it hit every one of those three legs on the first run. It never appeared locally because the emulator had been warm for an hour. `tapPickerNode` now re-finds and re-taps, three attempts; verified 5 / 5 green on API 34 with `pm clear` on DocumentsUI between runs. **What retrying does *not* weaken:** every attempt still goes through `awaitPickerNode`, which fails outright if the node is absent. A root that is not in the picker is not found on any attempt, so the MIME mutation still fails with `the system picker never showed …`. ### The correction that flake forced The commit before it marked the **whole class** `@FailsOnEmulatorApi37`, on the strength of two measured API 37 failures. **One of them was this bug.** Re-measured with the fix, one method per fresh `android-37.0` emulator: ``` thePickedInputSurvivesARealRotation INSTRUMENTATION_ABORTED: System has crashed. Expected 1 tests, received 0 pickingAFileThroughTheSystemPickerFillsInTheFileCard PASSED ``` So a rotation — which rebuilds every surface at once — is what the gralloc mapper does not survive; starting another app's activity is not. The marker moved to the one method that earned it, and the picker test runs on the gating API 37 leg like anything else. The lesson is worth more than the measurement, and `docs/api-37-emulator-crash.md` keeps it: **an annotation is a claim about an image, and a broken test makes every image look broken.** Both a framework abort and a stale node read as "the run fell over". Re-measure after fixing a test before deciding what the platform did. ### The annotation filter itself was measured, not assumed It is what keeps the gating API 37 leg green, so it is not a thing to reason about. On API 34, `annotation=` selected exactly **4** tests (the two `Media3EngineTest` methods plus both of these), and `notAnnotation=` selected **55** with neither of these in it — so the runner expands a class-level marker to every method. CI's own gating `E2E API 37` leg then reported **55 tests, 0 failures, zero `SafPicker` cases** on the first push. Moving the marker to one method is therefore a narrowing of something already known to work. ## Scope: three files #64 did not name Stated plainly rather than left for a reviewer to find. `.github/workflows/status_check.yml`, `tools/local-emulator/run-e2e.sh` and `docs/api-37-emulator-crash.md` all asserted **"nothing in this suite touches system UI"** — which is what makes the API 37 leg's SystemUI-disable deviation defensible. This PR makes that false, so all three are corrected in it. The rule they state is being *applied*, not broken: the test that does not survive the leg is excluded from it. `run-e2e.sh`'s summary also promised "exactly two failures" at API 37 and now says what it really expects, including that **the level no longer finishes** — its totals come back short with an arbitrary tail. **Deliberately not done:** the advisory job is still named `E2E API 37 Media3 hardware transcode (advisory)` and now carries a test that is neither. Renaming a check touches branch protection, so it is left for a separate decision; the doc records the staleness and the "when to revisit" trigger now says the marker covers two unrelated bugs that can go green independently. ## Exemptions, named - **`OpenMultipleDocuments` is not covered.** It is `JoinScreen`'s picker; #64 scopes this to the converter's input. The same machinery would drive it, and it is a different screen's ticket. - **`CreateDocument` is not covered.** Reaching the Save button needs `ConversionState.Converted`, which needs a worker run that has already succeeded — a full transcode inside a UI test, on five gating CI legs, to assert one launcher line. What it leads to is already covered by `OutputPublisherPublishTest`. - **The advanced panel's `rememberSaveable` is deliberately not re-asserted here.** `AdvancedPanelSavedStateTest` already drives it through a real `Bundle`/`Parcel` round trip on the JVM, and a second test whose bite duplicates an existing one is the failure #52 exists to prevent. - **No coverage number is quoted.** JaCoCo does not measure the instrumented suite at all here. ## Gate ``` ./gradlew :app:assembleDebug :app:testDebugUnitTest :app:compileDebugAndroidTestKotlin \ :app:assembleDebugAndroidTest :app:ktlintCheck :app:detekt :app:lintDebug --continue ``` BUILD SUCCESSFUL. `assembleDebugAndroidTest` is in there on purpose and is not in `CLAUDE.md`'s list: `compileDebugAndroidTestKotlin` does not run manifest merging, and this PR adds the first `androidTest` manifest. It caught the first draft of it — `--` is illegal inside an XML comment. Pinned shellcheck 0.11.0 clean on the edited `run-e2e.sh`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.