dd405e90e5adf094fd4c7375002175879e2fdf7b
8 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
306b8bb150 |
test(chapters): the five gaps a coverage pass found, and two vacuous tests
A pass counting tests per file rather than per feature found five pieces of this work with none. One of them mattered. MAX_PROPOSAL_BYTES was enforced and never tested, on either side. The inbound check is the half that counts -- the proposal is the one place a remote party decides how much work everybody else does -- and it had nothing at all. Four tests now, propose and accept, bytes and count, in SignedGroupKeyStateTest where the adversarial inbound tests belong. The count cap's accept side was untested too, although the propose-side test's own comment already claimed otherwise. Two of those tests passed vacuously as first written, which is the part worth keeping. acceptProposal rebuilds every event from its own fields under the room's path and drops the whole proposal if an id does not match, so a hand-built event with an invented id is refused whatever its size -- and an assertNull then stays green with the guard deleted. authoredProposal fixes it by taking its events from real proposals, so their ids are the ones the manager computes, and a control asserts that a well-formed proposal of the same shape does open a session. That control failed on its first run, which is how the vacuity was found; commenting the guard out now fails exactly the test named after it. UploadChaptersScreen had no UI test, against a project with fifteen runDesktopComposeUiTest files that tests other screens this way. Ten now: the cost of an upload stated before the picker opens, the index and name per row, every per-row reason rendering, and the FAB counting only what it would actually propose rather than how many files were picked. canPropose, kibibytes and the chapter summary had none either, each being read only by a composable. Eleven, five and five. Still open, and not closable here: the three FilePicker actuals need instrumentation this project does not have, and on iOS a host that can compile them. 1030 tests, no failures; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
1673ce0e5e |
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> |
||
|
|
7c76359b34 |
feat(chapters): a chunk is a markdown block, not a blank-line paragraph
Phases 7 and 8 of docs/chapter-uploads.md. Measured against markdown-it -- the parser mantra.press translates with -- this app's blank-line split disagreed on ten cases in eleven, and on the text as well as the boundaries. markdown-it's content is a leaf block's inline source with the BLOCK marker removed, so `# Chapter one` is "Chapter one" while inline markup is kept. That is the half that matters to a translator: handed "# Chapter one" they can break the document by mistyping the hash, and handed "Chapter one" they cannot. So splitBlocks adopts it, boundaries and stripped text both, with three deliberate departures. Code blocks yield no chunk -- markdown-it's fence and code_block carry content, so mantra.press asks a translator to translate `code();` and the group to sign it as prose. A table is one chunk of its own source rather than one per cell, because four envelopes to carry one word each is a poor trade and a table's alignment is structure per-cell translation cannot preserve. And a link reference definition yields nothing, which is free: it is markdown-it's own behaviour with the `reference` rule left enabled, where mantra.press disables it -- reaching for a block rule to stop links rendering -- and makes a translatable chunk of a URL. Hand-rolled, not org.jetbrains:markdown, for one reason specific to this function: its output is hashed into ids a quorum signs, so a version bump that moved one boundary would silently re-split every chapter proposed afterwards and nothing would notice. What pins it instead is thirty golden fixtures generated FROM markdown-it by docs/scripts/markdown-block-fixtures.js, which also encodes the three departures so the Kotlin test is plain equality. MarkdownProducer is new and is what makes stripping markers safe: nothing in the app could put a `#` back before. It substitutes by index, which is strictly better than the thing it mirrors -- mantra.press matches on text, so a chapter with two identical paragraphs puts the first one's translation into both. The scanner segments every line rather than only the chunks, which is what makes the byte-for-byte round trip exact by construction rather than by getting every markup rule right. ProposedEvent.summarize stops re-splitting a chapter to count its chunks and takes the count from the batch. That number is a count of the batch dressed as a fact about the text: across a splitter change the figure in the approval gate would disagree with the events being approved, and it re-scanned every open proposal's prose on every emission of the room's list. Phase 2's constant was flagged as provisional pending this, and measured it did not move: prose separated by blank lines splits the same under both rules, so the fixture pinning 24 KiB over forty paragraphs is the shape this does not touch. The narrower claim holds -- a heading- or list-heavy chapter now reaches both caps sooner, visibly, in the form's own counter. 987 tests, no failures; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f8a0ee1cdb |
test(chapters): twelve proposals each keep their own summons
Phase 6 of docs/chapter-uploads.md, which asked for three things to be checked rather than assumed. All three already hold, so this is their tests and the record of it. The proposal list already groups: a "Waiting for you" header over the sessions isAwaitingApproval picks out, "Everything else" under them, over a query already ordered newest first. The chapter summary is already `name - N words - N chunks` rather than "Chapter", so twelve rows are twelve distinguishable rows. And isAbout already compares frostSigningSessionId when both rows carry one, falling back to the clock only when either does not. The last of those is the one with a history -- two concurrent sessions broke it once, which is why the column exists -- so it is now tested at twelve, with the answers given in the opposite order to the asking, so that neither the clock nor the list order could produce the right answer by accident. Plus one proposal failing and settling only its own row, which is the transcript's version of why an upload is N sessions and not one. Noted for Phase 8 rather than fixed here: twenty open chapter proposals is roughly 900 KB of unsignedEventJson parsed and summarised on every emission of that query, and summarize then re-splits each chapter's text to count its chunks. Phase 8 replaces that derived count with the session's own item count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
428026cdfd |
refactor(chapters): one scaffolding batch for every chapter, not one each
Phase 5 of docs/chapter-uploads.md, and the third fix to something wrong today. AddChapterViewModel was right that translation scaffolding needs its own session -- a chapter's paragraph budget should not be spent on the group's dialects, and how long a chapter may be has nothing to do with how many languages it is read in. It was wrong that it needs one session per chapter, which only becomes visible above one chapter: twelve chapters in a group with three dialects is thirty-six join rows, which fit in a single batch, where the per-chapter shape opens twelve sessions to carry three rows each. Twenty-four proposals where thirteen will do. ChapterScaffolding.propose is now the one answer to what a chapter's scaffolding is, and both view models call it. Two copies of a cross product would be two chances to disagree about what a translation covers. It also fixes a latent bug one chapter could never show. The old code chunked the *translations* by MAX_BATCH_SIZE -- right by coincidence at one chapter, where the cross product and the translation list are the same length, and wrong at any more. 25 chapters in a three-dialect group is 75 rows, which the translation axis would have put in one batch of 75 and proposeSigningBatch would have thrown. Chunking the product is the generalisation, and the test for it is the only one here that fails against the per-chapter shape. Two smaller decisions. The batch takes one timestamp, the latest of the chapters' own, because that is the moment the act finished and the clock would be a third time unrelated to either end of it. And a session whose lead event cannot be read is left out rather than guessed at: a join row naming the wrong chapter is worse than a missing one, because the catch-up can find a missing one. Failure policy unchanged and for the reason it already gave: logged and swallowed, since the chapters are what was asked for. At N the argument is stronger, not weaker -- one cross-product batch is only as available as its worst item. 970 tests, no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
83bd91f46c |
fix(chapters): number a chapter past the ones already proposed
Phase 3 of docs/chapter-uploads.md, and the second fix to something wrong today. A chapter's index is signed into the event and cannot be recounted on arrival: a device applying one cannot recount from a version other members may have added to in a different order. So the number has to be right when the chapter is proposed, and `getChaptersForArtifactVersion(...).size` is not it -- a proposed chapter is not a row until a quorum signs it, so two proposals a second apart read the same table and are signed at the same index. At one chapter at a time that is a race nobody hits. An upload of twelve files hits it eleven times out of eleven. The information was already published. A session's items carry the unsigned events it is signing, and an unsigned event's fields are settled at proposal time -- which is the whole reason FrostSigningRepository.unsignedEvents exists rather than only signedEvents. So ChapterIndexing.nextIndex reads the table and the room's proposals, and returns one past the highest rather than a count: a count is wrong the moment the sequence has a gap, and a chapter that failed to reach a quorum leaves one. Three things that are easy to get wrong and are tested. Every session counts whatever stage it is at, because filtering would trade a harmless gap for a collision with a stalled session that still completes. Items are filtered by kind, because a chunk carries an index too -- its position in the chapter -- and reading those as chapter indices puts the second chapter of a two-paragraph first chapter at 2. And proposals are scoped by the artifactVersionId tag, because the row query is scoped by version while observeSessionsForChatRoom returns the whole room. This fixes one device, not the group: two members proposing at the same moment still collide, and no amount of local reading will change that. That is tolerable where this was not -- an index orders reading, two chapters at index 4 sort arbitrarily between themselves, and nothing else breaks. 951 tests, no failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
dbc7940618 |
feat(frost): a signing proposal has a size, and the form says what it is
Phase 2 of docs/chapter-uploads.md, and a fix to something wrong today. MAX_BATCH_SIZE bounds how many events a batch holds. Nothing bounded how large they are, and those are not the same limit: sixty-four dialects are a few kilobytes and sixty-four chunks of a chapter are eighty. The proposal is the largest message in this protocol by an order of magnitude -- a signer's nonce payload is k*133 bytes, their partial k*65, the proposal k whole events -- so a legal batch could exceed what a group event carries, and when it did the failure was silent: the proposal went out and the session stalled with nothing to blame. MAX_PROPOSAL_BYTES sits beside MAX_BATCH_SIZE and is enforced the same way: when proposing, against the real encoding, and independently in acceptProposal, before parsing. That second check is the one that matters, for the reason the count check already gives -- the proposal is where a remote party decides how much work everybody else does. The chapter form needed the same limit early enough to be useful, which the exact answer cannot be: it needs the chapter built and the chunks cut, one line from opening a session. So ChapterProposal estimates it. Measured over chapters from 1 KiB to 24 KiB and 1 to 63 chunks, the per-event cost of an encoded proposal runs 357-388 bytes; the estimate pays 448, because over-estimating costs an author a shorter chapter and under-estimating costs the group a stalled session. A test asserts it never falls under the real encoding and stays within a tenth of it. The plan asked for a flat MAX_ORIGINAL_TEXT_BYTES and the measurement says that is wrong by half: a 63-chunk chapter fits about 20 KiB of markdown and a 10-chunk chapter about 30, because the envelopes are what varies. The chunk count is known as the text is typed, so the limit is too. While the screen was open, its last two hardcoded English literals moved to the catalogue, and the too-long message compares like with like -- what proposing would send against what a group message carries -- rather than a UTF-16 character count against a byte budget, which is what the first draft of it did. 944 tests, no failures; m3Audit's budgets all met. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
6ab4f0e557 |
docs: a phased plan for chapter uploads, group-signed
Mirrors mantra.press's multi-file chapter upload under this app's rule that the group, not the uploader, authors the work. The web app's shape -- N files in, N chapters out, one transaction, one redirect -- is the one shape this app cannot have. A signing session is one quorum, all-or-nothing, 64 items and about 64 KiB on the wire, so twelve chapters in one batch does not fit, should not fit, and would ask every member to read twelve chapters in a single approval gate. One upload is N sessions, one per chapter, and that cost belongs in front of whoever is picking files. Reading both trees turned up four things the single-chapter path gets away with at N = 1 and an upload does not: the index is read from rows and no proposed chapter is a row yet, so twelve proposals all sign the same index; nothing checks the proposal's size, which puts a silent ceiling near 28 KiB of markdown; there is no file picker in the app at all; and translation scaffolding opens a session per chapter where one batch would do. The splitter is measured rather than assumed. Run side by side, markdown-it's block tokens and this app's blank-line rule disagree on ten cases in eleven, and on the text as well as the boundaries -- markdown-it strips the block marker, so `# Chapter one` is `"Chapter one"`. Phases 7 and 8 adopt it, with three deliberate departures (no chunk for code blocks, one chunk per table, none for a link reference definition) and a producer, which the app has never had. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |