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%"andProgressBarRangeInfo(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.
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)
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #62. Child 6 of 8 decomposing #52, on top of #61's seam.
ConverterScreenContenttakes the state as a parameter, so all sevenConversionStates can berendered directly — including the two no
ConversionViewModelcan be driven into. This adds thematrix: 21 cases in one new file,
ConverterStateAffordancesTest.What it pins
IdleCHOOSE_FILE; no Convert, no Cancel; tapping it firesonPickInputand nothing elseReadyvalidation.isValid; both buttons wired to their own callbackConverting"Converting… 42%"andProgressBarRangeInfo(0.42f, 0f..1f)— the heading and the bar are computed frompercentseparately, so neither assertion covers the other; Cancel present and wiredWaitingConvertedrouteReason, and is absent when blankSaved"Saved holiday.mp4."+Convert another, wired toonResetFailedCallbacks 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, appliedto the routing
AssistChip. Its text comes from the finished job, so a text matcher would have toname a routing explanation the screen does not own.
TagTableUniquenessTeststill passes.The mutations
1.
enabled = validation.isValid→enabled = truein theReadyarm — one case red,convert is withheld for a spec that cannot be produced:2. delete the
Cancelbutton from theWaitingarm — two cases red: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". Composepublishes no text colour to the semantics tree, so it is unobservable from a JVM test — the same
limit
FileCardTestrecords forHorizontalDivider. The message text itself is asserted; thecolour would need a screenshot.
assertDoesNotExistchecks onFILE_CARDare compile-guarded, not guarded here.Idleis adata object,Savedcarries only adisplayNameandFailedonly amessage—none has an
input, soFileCard(s.input)does not compile in those arms. The brief for thisticket 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.
ConverterPickerSelectionTest, and what thefile card says about an unknown size with
FileCardTest. This file asserts thatReadyputsthose leaves on screen at all, not what they then do.
Convertedhands to the save dialog is already pinned byConverterScreenContentTest;Converted's identity assertion here isonResetinstead.ConverterScreen's permission dance.requestNotificationscallsconvert()on both grantand deny, deliberately, and it lives in the entry point above this seam. Untouched.
is ConversionState.Idle -> Unitin the nestedwhen. The outerwhenpeelsIdleofffirst, so it is permanently unreachable. Not chased.
Gate
assembleDebug+testDebugUnitTest+compileDebugAndroidTestKotlin+ktlintCheck+detekt+lintDebug --continue: all green. No existing test file was edited.🤖 Generated with Claude Code
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 theFailedarm and running:app:compileDebugKotlin:Restored. So the three
assertDoesNotExistlines onFILE_CARDdocument the intent but are not what enforces it — the state shape is.