Closes#202. Fixes a latent defect, not only a coverage gap.
What was wrong
currentInput() answered for Converting, Waiting and Converted as well as Ready. Those three arms are unreachable by tapping Convert — the button renders only in the Ready branch — but they are reachable through the POST_NOTIFICATIONS result, which ConverterScreen.kt:91 wires to convert() rather than to the button.
Reaching one enqueued a second job over a live one: activeWorkId overwritten, the first job still running with its foreground notification orphaned and nothing left holding its id to cancel it.
Why it was narrowed rather than tested as it stood
Per the decision on #202: a test written against the old shape would have frozen the double-enqueue as intended behaviour — the F1/F5 failure mode docs/coverage-read-findings.md names.
The first is the one that matters: it reddens the double-enqueue case, so the defect is demonstrated rather than asserted.
One fixture note
The second case needs a real staged file in the worker's output Data. A SUCCEEDED job with no output path maps to Failed rather than Converted, so Data.EMPTY never reaches the state the case is about — which cost a timed-out awaitState before it was spotted.
Closes #202. **Fixes a latent defect**, not only a coverage gap.
## What was wrong
`currentInput()` answered for `Converting`, `Waiting` and `Converted` as well as `Ready`. Those three arms are unreachable by *tapping* Convert — the button renders only in the `Ready` branch — but they are reachable through the **POST_NOTIFICATIONS result**, which `ConverterScreen.kt:91` wires to `convert()` rather than to the button.
Reaching one enqueued a **second job over a live one**: `activeWorkId` overwritten, the first job still running with its foreground notification orphaned and nothing left holding its id to cancel it.
## Why it was narrowed rather than tested as it stood
Per the decision on #202: a test written against the old shape would have frozen the double-enqueue as intended behaviour — the F1/F5 failure mode `docs/coverage-read-findings.md` names.
```kotlin
private fun currentInput(): InputFile? = (_state.value as? ConversionState.Ready)?.input
```
That is what `JoinViewModel.join()` has been all along. The two screens are the same shape and only one of them was over-general.
## Four cold refusal arms come with it
All reached the same way — a system callback arriving after the screen has moved on, which is what a result redelivered after process death does:
```
ConversionViewModel.kt:513 currentInput() ?: return
ConversionViewModel.kt:600 pendingSave() ?: return
JoinViewModel.kt:316 (as? Ready)?.inputs ?: return
JoinViewModel.kt:390 pendingSave() ?: return
```
## Acceptance: mutations run and restored
| mutation | red |
|---|---|
| **restore the over-general four-arm `when`** | 1 — *the defect this change fixes* |
| `currentInput()!!` at `:513` | 2 |
| `pendingSave()!!` in `save()` | 1 |
| drop both join guards | 1 |
The first is the one that matters: it reddens the double-enqueue case, so the defect is demonstrated rather than asserted.
## One fixture note
The second case needs a **real staged file** in the worker's output `Data`. A `SUCCEEDED` job with no output path maps to `Failed` rather than `Converted`, so `Data.EMPTY` never reaches the state the case is about — which cost a timed-out `awaitState` before it was spotted.
## Verification
`assembleDebug` + `testDebugUnitTest` (full suite) + `compileDebugAndroidTestKotlin` + `ktlintCheck` + `detekt` + `lintDebug` — green.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
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 #202. Fixes a latent defect, not only a coverage gap.
What was wrong
currentInput()answered forConverting,WaitingandConvertedas well asReady. Those three arms are unreachable by tapping Convert — the button renders only in theReadybranch — but they are reachable through the POST_NOTIFICATIONS result, whichConverterScreen.kt:91wires toconvert()rather than to the button.Reaching one enqueued a second job over a live one:
activeWorkIdoverwritten, the first job still running with its foreground notification orphaned and nothing left holding its id to cancel it.Why it was narrowed rather than tested as it stood
Per the decision on #202: a test written against the old shape would have frozen the double-enqueue as intended behaviour — the F1/F5 failure mode
docs/coverage-read-findings.mdnames.That is what
JoinViewModel.join()has been all along. The two screens are the same shape and only one of them was over-general.Four cold refusal arms come with it
All reached the same way — a system callback arriving after the screen has moved on, which is what a result redelivered after process death does:
Acceptance: mutations run and restored
whencurrentInput()!!at:513pendingSave()!!insave()The first is the one that matters: it reddens the double-enqueue case, so the defect is demonstrated rather than asserted.
One fixture note
The second case needs a real staged file in the worker's output
Data. ASUCCEEDEDjob with no output path maps toFailedrather thanConverted, soData.EMPTYnever reaches the state the case is about — which cost a timed-outawaitStatebefore it was spotted.Verification
assembleDebug+testDebugUnitTest(full suite) +compileDebugAndroidTestKotlin+ktlintCheck+detekt+lintDebug— green.🤖 Generated with Claude Code