Stop a permission answer starting a second conversion #216

Merged
JMR-dev merged 10 commits from fix/convert-guards-on-ready into main 2026-09-06 01:19:20 +00:00
JMR-dev commented 2026-09-03 00:09:51 +00:00 (Migrated from github.com)

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.

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

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)
Sign in to join this conversation.