The only test of either was SettingsEditsTest's "cancelling with no active job does nothing rather than throwing", whose own comment names the half it drives — "the null side". The other side had never been entered, and JoinViewModel.cancel() had no test at all.
The affordance tests click TestTags.CANCEL and assert the action fires into a stub; ScreenWiringTest asserts the action calls viewModel.cancel(). Both halves pinned, the join between them not — so nothing in 584 tests connected the Cancel button to WorkManager.
No line-level filter could have found this. It takes the method-level read (mi=11, ci=7, mb=1, cb=1 on both): a covered method with an arm nothing takes. That is the second of the two filters #194 records, and this is the gap that argued for adding it.
Why it stayed uncovered
The fixture, not the difficulty. The test WorkManager runs on a SynchronousExecutor, so an ordinary request finishes inline — by the time a test could call cancel(), convert()'s job was already terminal, leaving only the null arm reachable. setInitialDelay is what TestScheduler honours, so the job sits in ENQUEUED and the test never releases it.
Production never sets a delay, so the request is built by hand rather than through ConversionWorker.request. The state is not synthetic:ENQUEUED at runAttemptCount == 0 is what every job passes through before the scheduler picks it up, Reattachment.choose ranks it QUEUED, and conversionStateFrom maps it to Converting(input, 0). The delay changes how long the job stays in a real state, not which state it is in.
Three tests, and why the third is not padding
Without it, cancelAllWork() in place of cancelWorkById(activeWorkId) passes the other two. It asserts the shape rather than the identity — exactly one of two queued jobs is cancelled — because which one the ViewModel reattached to is the query's business, and Reattachment's ordering notes leave queued jobs tied deliberately.
WorkManager's own record is asserted before the screen. The screen alone would be weaker than it looks: CANCELLED maps to Idle for a reattached job, and Idle is also where a ViewModel that did nothing whatsoever would sit.
Acceptance: mutations run and restored
mutation
result
cancel() → no-op
all three red
cancelWorkById(id) → cancelAllWork()
only the third red
The second is what shows the third test does independent work rather than restating the first two. Working tree verified clean of production edits afterwards.
Verification
assembleDebug + testDebugUnitTest (full suite) + compileDebugAndroidTestKotlin + ktlintCheck + detekt + lintDebug — all green. One detekt MaxLineLength came up during the run and was fixed in the code, not the config.
Closes #192.
## The gap
Both ViewModels' `cancel()` is one line, and **JaCoCo reports every line of both as covered**:
```kotlin
fun cancel() {
activeWorkId?.let(workManager::cancelWorkById)
}
```
The only test of either was `SettingsEditsTest`'s *"cancelling with no active job does nothing rather than throwing"*, whose own comment names the half it drives — "the null side". The other side had never been entered, and `JoinViewModel.cancel()` had no test at all.
The affordance tests click `TestTags.CANCEL` and assert the action fires into a stub; `ScreenWiringTest` asserts the action calls `viewModel.cancel()`. Both halves pinned, the join between them not — so nothing in 584 tests connected the Cancel button to WorkManager.
**No line-level filter could have found this.** It takes the method-level read (`mi=11, ci=7, mb=1, cb=1` on both): a covered method with an arm nothing takes. That is the second of the two filters #194 records, and this is the gap that argued for adding it.
## Why it stayed uncovered
The fixture, not the difficulty. The test WorkManager runs on a `SynchronousExecutor`, so an ordinary request finishes inline — by the time a test could call `cancel()`, `convert()`'s job was already terminal, leaving only the null arm reachable. `setInitialDelay` is what `TestScheduler` honours, so the job sits in `ENQUEUED` and the test never releases it.
Production never sets a delay, so the request is built by hand rather than through `ConversionWorker.request`. **The state is not synthetic:** `ENQUEUED` at `runAttemptCount == 0` is what every job passes through before the scheduler picks it up, `Reattachment.choose` ranks it `QUEUED`, and `conversionStateFrom` maps it to `Converting(input, 0)`. The delay changes how long the job stays in a real state, not which state it is in.
## Three tests, and why the third is not padding
Without it, `cancelAllWork()` in place of `cancelWorkById(activeWorkId)` passes the other two. It asserts the *shape* rather than the identity — exactly one of two queued jobs is cancelled — because which one the ViewModel reattached to is the query's business, and `Reattachment`'s ordering notes leave queued jobs tied deliberately.
WorkManager's own record is asserted before the screen. The screen alone would be weaker than it looks: `CANCELLED` maps to `Idle` for a reattached job, and `Idle` is also where a ViewModel that did nothing whatsoever would sit.
## Acceptance: mutations run and restored
| mutation | result |
|---|---|
| `cancel()` → no-op | **all three red** |
| `cancelWorkById(id)` → `cancelAllWork()` | **only the third red** |
The second is what shows the third test does independent work rather than restating the first two. Working tree verified clean of production edits afterwards.
## Verification
`assembleDebug` + `testDebugUnitTest` (full suite) + `compileDebugAndroidTestKotlin` + `ktlintCheck` + `detekt` + `lintDebug` — all green. One detekt `MaxLineLength` came up during the run and was fixed in the code, not the config.
🤖 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 #192.
The gap
Both ViewModels'
cancel()is one line, and JaCoCo reports every line of both as covered:The only test of either was
SettingsEditsTest's "cancelling with no active job does nothing rather than throwing", whose own comment names the half it drives — "the null side". The other side had never been entered, andJoinViewModel.cancel()had no test at all.The affordance tests click
TestTags.CANCELand assert the action fires into a stub;ScreenWiringTestasserts the action callsviewModel.cancel(). Both halves pinned, the join between them not — so nothing in 584 tests connected the Cancel button to WorkManager.No line-level filter could have found this. It takes the method-level read (
mi=11, ci=7, mb=1, cb=1on both): a covered method with an arm nothing takes. That is the second of the two filters #194 records, and this is the gap that argued for adding it.Why it stayed uncovered
The fixture, not the difficulty. The test WorkManager runs on a
SynchronousExecutor, so an ordinary request finishes inline — by the time a test could callcancel(),convert()'s job was already terminal, leaving only the null arm reachable.setInitialDelayis whatTestSchedulerhonours, so the job sits inENQUEUEDand the test never releases it.Production never sets a delay, so the request is built by hand rather than through
ConversionWorker.request. The state is not synthetic:ENQUEUEDatrunAttemptCount == 0is what every job passes through before the scheduler picks it up,Reattachment.chooseranks itQUEUED, andconversionStateFrommaps it toConverting(input, 0). The delay changes how long the job stays in a real state, not which state it is in.Three tests, and why the third is not padding
Without it,
cancelAllWork()in place ofcancelWorkById(activeWorkId)passes the other two. It asserts the shape rather than the identity — exactly one of two queued jobs is cancelled — because which one the ViewModel reattached to is the query's business, andReattachment's ordering notes leave queued jobs tied deliberately.WorkManager's own record is asserted before the screen. The screen alone would be weaker than it looks:
CANCELLEDmaps toIdlefor a reattached job, andIdleis also where a ViewModel that did nothing whatsoever would sit.Acceptance: mutations run and restored
cancel()→ no-opcancelWorkById(id)→cancelAllWork()The second is what shows the third test does independent work rather than restating the first two. Working tree verified clean of production edits afterwards.
Verification
assembleDebug+testDebugUnitTest(full suite) +compileDebugAndroidTestKotlin+ktlintCheck+detekt+lintDebug— all green. One detektMaxLineLengthcame up during the run and was fixed in the code, not the config.🤖 Generated with Claude Code