test(chapters): an upload end to end, and the callback it found missing

Phase 9 of docs/chapter-uploads.md.

ChapterUploadJvmTest drives the view model over a real database: five files
become five sessions, one chapter leading its own chunks in each -- never two
chapters sharing a batch -- numbered contiguously from zero, which the old
`.size` numbering could not do because nothing is signed while the pass runs.
Each chapter's chunks are the blocks of its own file, markers off. Four
chapters in a three-dialect group ask for one scaffolding session of twelve
rows, not four.

It found one thing: propose() with nothing proposable returned without ever
calling its callback. The FAB is disabled in that case so the screen never
reached it, but a callback that sometimes never fires is a trap for the next
caller, so it now fires onDone(0, 0). That is the whole reason for writing the
end-to-end test rather than assuming the parts composed.

One thing it deliberately does not test the way the plan asked. "A failure on
file 4 leaves files 1-3 proposed" is unreachable by input: both caps are
checked in the form against the same functions the manager enforces, and
ChapterProposalSizeTest asserts the form's estimate is never under the
manager's measurement, so no file passes the review and then fails the
proposal. It is reachable by state, which is what the test does -- a room with
no shared key fails all three, every row says so and stays retryable, and a
second pass after the key arrives proposes all three from zero, the failed
pass having spent no indices because it created nothing.

995 tests, no failures; m3Audit's budgets all met.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Kgothatso Ngako
2026-10-02 19:53:54 +02:00
parent 7c76359b34
commit 1673ce0e5e
3 changed files with 476 additions and 5 deletions

View File

@@ -198,10 +198,20 @@ class UploadChaptersViewModel(
onDone: (proposed: Int, failed: Int) -> Unit,
) {
val loaded = _uiState.value as? UploadChaptersUIState.Loaded ?: return
// A pass already running will call [onDone] itself when it finishes;
// firing it here as well would announce one pass twice.
if (loaded.isProposing) return
val queue = loaded.proposable.map { it.id }
if (queue.isEmpty()) return
if (queue.isEmpty()) {
// Nothing to do still has to be said. The FAB is disabled in this
// case so the screen never reaches it, but a callback that
// sometimes never fires is a trap for the next caller -- and it is
// what a test of a second tap found.
onDone(0, 0)
return
}
updateLoaded { it.copy(isProposing = true) }