R38.6 — Every ConversionState renders its own affordances #78

Merged
JMR-dev merged 1 commits from test/r38-6-conversion-states into main 2026-08-25 00:49:06 +00:00
JMR-dev commented 2026-08-25 00:40:17 +00:00 (Migrated from github.com)

Closes #62. Child 6 of 8 decomposing #52, on top of #61's seam.

ConverterScreenContent takes the state as a parameter, so all seven ConversionStates can be
rendered directly — including the two no ConversionViewModel can be driven into. This adds the
matrix: 21 cases in one new file, ConverterStateAffordancesTest.

What it pins

state what the case says
Idle the prompt and CHOOSE_FILE; no Convert, no Cancel; tapping it fires onPickInput and nothing else
Ready card + all four pickers + both buttons; the card names the file the state carries; Convert enabled iff validation.isValid; both buttons wired to their own callback
Converting "Converting… 42%" and ProgressBarRangeInfo(0.42f, 0f..1f) — the heading and the bar are computed from percent separately, so neither assertion covers the other; Cancel present and wired
Waiting the Paused paragraph in full; Cancel present and wired
Converted Save + Start over, no Cancel; route chip text equals routeReason, and is absent when blank
Saved "Saved holiday.mp4." + Convert another, wired to onReset
Failed the message the state carries renders; Start over, no Save

Callbacks are asserted over the whole log — all twelve are recorded and each case compares the
complete list against one entry, so a case reads "this one fired and nothing else". A bare
"the callback ran" check stays green on an arm that fires the right callback for the wrong reason.

Production change

One, and only because the node was otherwise unlocatable: TestTags.Converter.ROUTE_REASON, applied
to the routing AssistChip. Its text comes from the finished job, so a text matcher would have to
name a routing explanation the screen does not own. TagTableUniquenessTest still passes.

The mutations

1. enabled = validation.isValid → enabled = true in the Ready arm — one case red,
convert is withheld for a spec that cannot be produced:

java.lang.AssertionError: Failed to assert the following: (is not enabled)
Semantics of the node:
Node #494 at (l=16.0, t=980.0, r=304.0, b=1036.0)px, Tag: 'converter.convert'
Focused = 'false'
Role = 'Button'
Text = '[Convert]'
Actions = [ClearTextSubstitution, GetTextLayoutResult, OnClick, RequestFocus, SetTextSubstitution, ShowTextSubstitution]
MergeDescendants = 'true'
Has 11 siblings
Selector used: (TestTag = 'converter.convert')

2. delete the Cancel button from the Waiting arm — two cases red:

TEST: a paused job explains why and still offers cancel
java.lang.AssertionError: Failed: assertExists.
Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'action.cancel')

TEST: tapping cancel on a paused job cancels it and does nothing else
java.lang.AssertionError: Action performScrollTo() failed.
Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'action.cancel')

Both restored; the suite is green at 360 tests, 0 failures.

Named exemptions, so each is a decision rather than an omission

  • Failed's error colour. #62's table asks for the message "in the error colour". Compose
    publishes no text colour to the semantics tree, so it is unobservable from a JVM test — the same
    limit FileCardTest records for HorizontalDivider. The message text itself is asserted; the
    colour would need a screenshot.
  • The three assertDoesNotExist checks on FILE_CARD are compile-guarded, not guarded here.
    Idle is a data object, Saved carries only a displayName and Failed only a message —
    none has an input, so FileCard(s.input) does not compile in those arms. The brief for this
    ticket said "nothing currently stops someone re-adding it"; the state shape does. The lines stay
    because they state the intent cheaply, but the PR does not claim they bite.
  • Which constant each chip hands back stays with ConverterPickerSelectionTest, and what the
    file card says about an unknown size
    with FileCardTest. This file asserts that Ready puts
    those leaves on screen at all, not what they then do.
  • The suggested name Converted hands to the save dialog is already pinned by
    ConverterScreenContentTest; Converted's identity assertion here is onReset instead.
  • ConverterScreen's permission dance. requestNotifications calls convert() on both grant
    and deny, deliberately, and it lives in the entry point above this seam. Untouched.
  • is ConversionState.Idle -> Unit in the nested when. The outer when peels Idle off
    first, so it is permanently unreachable. Not chased.
  • No coverage number is quoted anywhere, per #52's own correction.

Gate

assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin + ktlintCheck + detekt +
lintDebug --continue: all green. No existing test file was edited.

🤖 Generated with Claude Code

Closes #62. Child 6 of 8 decomposing #52, on top of #61's seam. `ConverterScreenContent` takes the state as a parameter, so all seven `ConversionState`s can be rendered directly — including the two no `ConversionViewModel` can be driven into. This adds the matrix: 21 cases in one new file, `ConverterStateAffordancesTest`. ### What it pins | state | what the case says | |---|---| | `Idle` | the prompt and `CHOOSE_FILE`; no Convert, no Cancel; tapping it fires `onPickInput` and nothing else | | `Ready` | card + all four pickers + both buttons; the card names the file the state carries; **Convert enabled iff `validation.isValid`**; both buttons wired to their own callback | | `Converting` | `"Converting… 42%"` **and** `ProgressBarRangeInfo(0.42f, 0f..1f)` — the heading and the bar are computed from `percent` separately, so neither assertion covers the other; Cancel present and wired | | `Waiting` | the Paused paragraph in full; Cancel present and wired | | `Converted` | Save + Start over, no Cancel; **route chip text equals `routeReason`, and is absent when blank** | | `Saved` | `"Saved holiday.mp4."` + `Convert another`, wired to `onReset` | | `Failed` | the message the state carries renders; Start over, no Save | Callbacks are asserted **over the whole log** — all twelve are recorded and each case compares the complete list against one entry, so a case reads "this one fired and nothing else". A bare "the callback ran" check stays green on an arm that fires the right callback for the wrong reason. ### Production change One, and only because the node was otherwise unlocatable: `TestTags.Converter.ROUTE_REASON`, applied to the routing `AssistChip`. Its text comes from the finished job, so a text matcher would have to name a routing explanation the screen does not own. `TagTableUniquenessTest` still passes. ### The mutations **1. `enabled = validation.isValid` → `enabled = true`** in the `Ready` arm — one case red, `convert is withheld for a spec that cannot be produced`: ``` java.lang.AssertionError: Failed to assert the following: (is not enabled) Semantics of the node: Node #494 at (l=16.0, t=980.0, r=304.0, b=1036.0)px, Tag: 'converter.convert' Focused = 'false' Role = 'Button' Text = '[Convert]' Actions = [ClearTextSubstitution, GetTextLayoutResult, OnClick, RequestFocus, SetTextSubstitution, ShowTextSubstitution] MergeDescendants = 'true' Has 11 siblings Selector used: (TestTag = 'converter.convert') ``` **2. delete the `Cancel` button from the `Waiting` arm** — two cases red: ``` TEST: a paused job explains why and still offers cancel java.lang.AssertionError: Failed: assertExists. Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'action.cancel') TEST: tapping cancel on a paused job cancels it and does nothing else java.lang.AssertionError: Action performScrollTo() failed. Reason: Expected exactly '1' node but could not find any node that satisfies: (TestTag = 'action.cancel') ``` Both restored; the suite is green at 360 tests, 0 failures. ### Named exemptions, so each is a decision rather than an omission - **`Failed`'s error colour.** #62's table asks for the message "in the error colour". Compose publishes no text colour to the semantics tree, so it is unobservable from a JVM test — the same limit `FileCardTest` records for `HorizontalDivider`. The message text itself is asserted; the colour would need a screenshot. - **The three `assertDoesNotExist` checks on `FILE_CARD` are compile-guarded, not guarded here.** `Idle` is a `data object`, `Saved` carries only a `displayName` and `Failed` only a `message` — none has an `input`, so `FileCard(s.input)` does not compile in those arms. The brief for this ticket said "nothing currently stops someone re-adding it"; the state shape does. The lines stay because they state the intent cheaply, but the PR does not claim they bite. - **Which constant each chip hands back** stays with `ConverterPickerSelectionTest`, and **what the file card says about an unknown size** with `FileCardTest`. This file asserts that `Ready` puts those leaves on screen at all, not what they then do. - **The suggested name `Converted` hands to the save dialog** is already pinned by `ConverterScreenContentTest`; `Converted`'s identity assertion here is `onReset` instead. - **`ConverterScreen`'s permission dance.** `requestNotifications` calls `convert()` on both grant and deny, deliberately, and it lives in the entry point above this seam. Untouched. - **`is ConversionState.Idle -> Unit` in the nested `when`.** The outer `when` peels `Idle` off first, so it is permanently unreachable. Not chased. - **No coverage number is quoted anywhere**, per #52's own correction. ### Gate `assembleDebug` + `testDebugUnitTest` + `compileDebugAndroidTestKotlin` + `ktlintCheck` + `detekt` + `lintDebug --continue`: all green. No existing test file was edited. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
JMR-dev commented 2026-08-25 00:48:09 +00:00 (Migrated from github.com)

Verification of the compile-guard claim in the body, since it is asserted in three places and was not measured when written. Inserting FileCard(s.input) into the Failed arm and running :app:compileDebugKotlin:

e: .../convert/ConverterScreen.kt:323:36 Unresolved reference 'input'.
BUILD FAILED in 3s

Restored. So the three assertDoesNotExist lines on FILE_CARD document the intent but are not what enforces it — the state shape is.

Verification of the compile-guard claim in the body, since it is asserted in three places and was not measured when written. Inserting `FileCard(s.input)` into the `Failed` arm and running `:app:compileDebugKotlin`: ``` e: .../convert/ConverterScreen.kt:323:36 Unresolved reference 'input'. BUILD FAILED in 3s ``` Restored. So the three `assertDoesNotExist` lines on `FILE_CARD` document the intent but are not what enforces it — the state shape is.
Sign in to join this conversation.