Make a failure that says nothing still say something #207

Merged
JMR-dev merged 1 commits from test/null-message-fallbacks into main 2026-09-06 00:48:12 +00:00
JMR-dev commented 2026-09-02 23:02:58 +00:00 (Migrated from github.com)

Closes #193.

The gap

Three sites, all ci == 0 before this, all the same rule:

work/ConversionWorker.kt:316        cause.message ?: GENERIC_FAILURE_MESSAGE
convert/ConversionViewModel.kt:631  e.message ?: SAVE_FAILED_MESSAGE
join/JoinViewModel.kt:416           e.message ?: SAVE_FAILED_MESSAGE

Every existing test throws with a message, so the right-hand side had never been evaluated anywhere in the suite. A Throwable carrying none is not exotic — RuntimeException(), IOException() and most platform exceptions raised without an argument all have a null message, and a native engine that dies is exactly where one comes from.

Why the worker case survived three waves — measured, not reasoned

ConversionStateMappingTest's "a failure with nothing said still says something" looks like it covers :316. It does not: it drives the read side, map(FAILED, Data.EMPTY), and that side has a fallback of its own (ConversionViewModel.kt:147-149):

update.outputData.getString(ConversionWorker.KEY_ERROR)
    ?.takeIf { it.isNotBlank() }
    ?: ConversionWorker.GENERIC_FAILURE_MESSAGE

Mutate the worker to .orEmpty() and it writes KEY_ERROR to "", which the ViewModel turns straight back into the same constant. A test asserting on the resulting Failed state stays green while the worker's fallback is broken.

I checked this rather than assuming it. With :316 mutated, the full suite reported:

MessagelessFailureTest > a conversion that fails without a message still reports one FAILED
587 tests completed, 1 failed

Exactly one of 587, and it is the new one. Every existing test — including the one that appears to cover this — stayed green.

So the worker case reads KEY_ERROR off the worker's own Result, before anything downstream can repair it. The two save cases have no such second line (both write _state.value directly), so the state is right there — and both also assert pending still travels, since a fallback that dropped the handle would leave the file unreachable from the very screen that just said the save failed.

One class, against the ticket's suggestion of three

#193 proposed putting each case beside the behaviour it neighbours. They are together instead: one rule at three layers, and the masking above needs explaining once rather than three times. FailedSaveRetryTest already sets the precedent for both ViewModels in one file; this adds one worker to that shape.

Acceptance: mutations run and restored

site mutation result
ConversionWorker:316 ?: GENERIC_FAILURE_MESSAGE → .orEmpty() 1 of 587 red (this file)
ConversionViewModel:631 ?: SAVE_FAILED_MESSAGE → .orEmpty() red
JoinViewModel:416 ?: SAVE_FAILED_MESSAGE → .orEmpty() red

Each to .orEmpty(), never to a different constant — that would only prove the test reads a constant, which is the vacuous shape wave 3 rejected twice.

Verification

assembleDebug + testDebugUnitTest (full suite) + compileDebugAndroidTestKotlin + ktlintCheck + detekt + lintDebug — all green, production tree verified clean of mutation leftovers.

🤖 Generated with Claude Code

Closes #193. ## The gap Three sites, all `ci == 0` before this, all the same rule: ``` work/ConversionWorker.kt:316 cause.message ?: GENERIC_FAILURE_MESSAGE convert/ConversionViewModel.kt:631 e.message ?: SAVE_FAILED_MESSAGE join/JoinViewModel.kt:416 e.message ?: SAVE_FAILED_MESSAGE ``` Every existing test throws *with* a message, so the right-hand side had never been evaluated anywhere in the suite. A `Throwable` carrying none is not exotic — `RuntimeException()`, `IOException()` and most platform exceptions raised without an argument all have a null message, and a native engine that dies is exactly where one comes from. ## Why the worker case survived three waves — measured, not reasoned `ConversionStateMappingTest`'s *"a failure with nothing said still says something"* looks like it covers `:316`. It does not: it drives the **read** side, `map(FAILED, Data.EMPTY)`, and that side has a fallback of its own (`ConversionViewModel.kt:147-149`): ```kotlin update.outputData.getString(ConversionWorker.KEY_ERROR) ?.takeIf { it.isNotBlank() } ?: ConversionWorker.GENERIC_FAILURE_MESSAGE ``` Mutate the worker to `.orEmpty()` and it writes `KEY_ERROR to ""`, which the ViewModel turns straight back into the same constant. **A test asserting on the resulting `Failed` state stays green while the worker's fallback is broken.** I checked this rather than assuming it. With `:316` mutated, the full suite reported: ``` MessagelessFailureTest > a conversion that fails without a message still reports one FAILED 587 tests completed, 1 failed ``` **Exactly one of 587**, and it is the new one. Every existing test — including the one that appears to cover this — stayed green. So the worker case reads `KEY_ERROR` off the worker's own `Result`, before anything downstream can repair it. The two save cases have no such second line (both write `_state.value` directly), so the state is right there — and both also assert `pending` still travels, since a fallback that dropped the handle would leave the file unreachable from the very screen that just said the save failed. ## One class, against the ticket's suggestion of three #193 proposed putting each case beside the behaviour it neighbours. They are together instead: one rule at three layers, and the masking above needs explaining once rather than three times. `FailedSaveRetryTest` already sets the precedent for both ViewModels in one file; this adds one worker to that shape. ## Acceptance: mutations run and restored | site | mutation | result | |---|---|---| | `ConversionWorker:316` | `?: GENERIC_FAILURE_MESSAGE` → `.orEmpty()` | **1 of 587 red** (this file) | | `ConversionViewModel:631` | `?: SAVE_FAILED_MESSAGE` → `.orEmpty()` | **red** | | `JoinViewModel:416` | `?: SAVE_FAILED_MESSAGE` → `.orEmpty()` | **red** | Each to `.orEmpty()`, never to a different constant — that would only prove the test reads a constant, which is the vacuous shape wave 3 rejected twice. ## Verification `assembleDebug` + `testDebugUnitTest` (full suite) + `compileDebugAndroidTestKotlin` + `ktlintCheck` + `detekt` + `lintDebug` — all green, production tree verified clean of mutation leftovers. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.