package pgstore import ( "context" "encoding/json" "errors" "fmt" "strings" "testing" "time" "github.com/jackc/pgx/v5" "textmachine/platform/internal/ingest" "textmachine/platform/internal/money" ) // readmodel_test.go: the reading surface against a live schema. // // The properties here are the ones a client's memory model stands on — an identity that survives a // refresh, a version that moves only when the book was actually cut again, and a delta read that // refuses rather than lies. // readingBook is one funded account with one parsed book, which is the state every read below // starts from. func readingBook(t *testing.T, s *Store, ctx context.Context, owner string) string { t.Helper() now := fundedAccount(t, s, ctx, owner, "10") id, err := s.AddBook(ctx, NewBook{OwnerID: owner, Title: "蛊真人", SourceLang: "zh", TargetLang: "ru", Workdir: "/srv/books/a", Now: now}) if err != nil { t.Fatal(err) } return id } func twoChapters(key string) Structure { return Structure{TextRead: true, ManifestKey: key, Chapters: []StructureChapter{ {EngineID: "c1", Number: 1, Units: []StructureUnit{ {EngineID: "c1:cut:0", Ordinal: 0, Source: "第一节", Target: "Первый", State: "translated", TextKnown: true}, {EngineID: "c1:cut:4", Ordinal: 4, Source: "第二节", State: "pending", TextKnown: true}, }}, {EngineID: "c2", Number: 2, Units: []StructureUnit{ {EngineID: "c2:cut:0", Ordinal: 0, Source: "第三节", State: "pending", TextKnown: true}, }}, }} } // The identity of a chapter is a FUNCTION of what it names, so re-reading the same manifest hands a // client back the ids it already holds. A random id would invalidate every anchor on every refresh. // // Mutation caught: minting a fresh id in SaveStructure; bumping structure_version on an unchanged // manifest key. func TestARefreshOfTheSameCutKeepsEveryIdentityAndTheVersion(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } first, err := s.ListChapters(ctx, "u1", book, 0, "") if err != nil { t.Fatal(err) } if len(first.Chapters) != 2 || first.StructureVersion != 1 { t.Fatalf("tree: %+v", first) } if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } again, err := s.ListChapters(ctx, "u1", book, 0, "") if err != nil { t.Fatal(err) } if again.StructureVersion != 1 { t.Errorf("an unchanged cut moved the structure version to %d", again.StructureVersion) } for i := range first.Chapters { if first.Chapters[i].ID != again.Chapters[i].ID { t.Errorf("chapter %d changed identity across a refresh", i) } } // The revision DID move: the text may have, and a client that dropped the read would keep an // older translation on the screen. if again.Revision <= first.Revision { t.Errorf("a refresh did not move the revision: %d -> %d", first.Revision, again.Revision) } } // A book cut again moves the structure version, and a chapter outside the new cut is GONE — which // is what makes `410` answerable at all. func TestACutThatChangedMovesTheVersionAndRemovesWhatIsNotInIt(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } before, err := s.ListChapters(ctx, "u1", book, 0, "") if err != nil { t.Fatal(err) } gone := before.Chapters[1].ID oldPairs, err := s.ListUnits(ctx, "u1", book, before.Chapters[0].ID, 0, "") if err != nil { t.Fatal(err) } recut := Structure{TextRead: true, ManifestKey: "k2", Chapters: []StructureChapter{ {EngineID: "c1", Number: 1, Units: []StructureUnit{{EngineID: "c1:cut2:0", Ordinal: 0, Source: "第一节", State: "pending"}}}, }} if err := s.SaveStructure(ctx, book, recut); err != nil { t.Fatal(err) } after, err := s.ListChapters(ctx, "u1", book, 0, "") if err != nil { t.Fatal(err) } if after.StructureVersion != 2 || len(after.Chapters) != 1 { t.Fatalf("after the re-cut: %+v", after) } if _, err := s.ListUnits(ctx, "u1", book, gone, 0, ""); !errors.Is(err, ErrNoChapter) { t.Errorf("a chapter outside the cut answered %v, want ErrNoChapter", err) } // The pair identity did NOT survive, and it says so in its own bytes: the engine's unit id // carries the cut, so an anchor a client stored is invalid the moment the version moves. page, err := s.ListUnits(ctx, "u1", book, after.Chapters[0].ID, 0, "") if err != nil { t.Fatal(err) } if len(page.Units) != 1 { t.Fatalf("units: %+v", page.Units) } if oldPairs.Units[0].ID == page.Units[0].ID { t.Error("a pair kept its identity across a re-cut") } // The chapter, by contrast, is the SAME object: its identity is derived from its own text, so an // anchor on a chapter survives what an anchor on a pair cannot. if after.Chapters[0].ID != before.Chapters[0].ID { t.Error("a chapter whose text did not change lost its identity") } } // A cursor is bound to the structure version it was minted under, and rejecting a stale one is the // SERVER's duty: the client holds an opaque string and cannot judge it. func TestACursorFromAnotherCutIsRefused(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } page, err := s.ListChapters(ctx, "u1", book, 1, "") if err != nil { t.Fatal(err) } if page.NextCursor == "" { t.Fatal("a torn page carries no cursor") } if err := s.SaveStructure(ctx, book, twoChapters("k2")); err != nil { t.Fatal(err) } if _, err := s.ListChapters(ctx, "u1", book, 1, page.NextCursor); !errors.Is(err, ErrBadCursor) { t.Errorf("a cursor from the previous cut answered %v, want ErrBadCursor", err) } } // Notes are PROJECTED from what the stream resolved, and their identity is derived from the // resolution — so a line delivered twice is one note, not two. func TestNotesAreProjectedFromResolutionsWithAStableIdentity(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } at := time.Now().UTC().Truncate(time.Millisecond) for range 2 { // at-least-once: the same line arrives twice exec(t, s, ctx, ` insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, reason, at, revision) values ($1, 1, 0, 'edit', true, true, 'glossary_miss', $2, 5) on conflict (book_id, chapter, unit, wave) do update set at = excluded.at`, book, at) } // The counter is maintained by whichever writer records the resolution; these rows were put in // by hand, so the OTHER writer — the materializer's own recompute — is what brings it in step. if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } page, err := s.ListNotes(ctx, "u1", book, 0, "", nil) if err != nil { t.Fatal(err) } if len(page.Notes) != 1 { t.Fatalf("a re-delivered line produced %d notes", len(page.Notes)) } note := page.Notes[0] if note.Reason != "glossary_miss" || note.ChapterID == "" || note.UnitID == "" { t.Errorf("note: %+v", note) } if note.ID != noteID(book, 1, 0, "edit") { t.Errorf("the note's identity is not derived from the resolution: %q", note.ID) } // The pair carries it too, so a reader screen need not join two collections. tree, err := s.ListChapters(ctx, "u1", book, 0, "") if err != nil { t.Fatal(err) } units, err := s.ListUnits(ctx, "u1", book, tree.Chapters[0].ID, 0, "") if err != nil { t.Fatal(err) } if len(units.Units[0].Notes) != 1 || units.Units[0].Notes[0].ID != note.ID { t.Errorf("the pair does not carry its note: %+v", units.Units[0]) } if tree.Chapters[0].NoteCount != 1 { t.Errorf("the chapter counts %d notes", tree.Chapters[0].NoteCount) } } // A delta read is INCLUSIVE: one transaction is one revision but several rows, and a strict // comparison loses the neighbours of the last row a client applied. func TestADeltaReadIsInclusiveAndRefusesAWatermarkFromBeforeAReplacement(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } // TWO rows at ONE revision, which is the shape the rule exists for: a transaction is one // revision but several rows, so a strict `>` loses the neighbours of the last row applied. at := time.Now().UTC() tree, err := s.ListChapters(ctx, "u1", book, 0, "") if err != nil { t.Fatal(err) } mark := tree.Revision exec(t, s, ctx, ` insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, reason, at, revision) values ($1, 1, 0, 'edit', false, true, 'empty', $2, $3), ($1, 1, 4, 'edit', false, true, 'empty', $2, $3)`, book, at, mark) page, err := s.ListNotes(ctx, "u1", book, 0, "", &mark) if err != nil { t.Fatal(err) } if len(page.Notes) != 2 { t.Errorf("an inclusive delta at the rows' own revision returned %d of 2", len(page.Notes)) } // A re-cut replaces the collection wholesale, and a watermark from before it cannot be answered // with a delta at all. if err := s.SaveStructure(ctx, book, twoChapters("k2")); err != nil { t.Fatal(err) } if _, err := s.ListNotes(ctx, "u1", book, 0, "", &mark); !errors.Is(err, ErrVersionTooOld) { t.Errorf("a watermark from before the re-cut answered %v, want ErrVersionTooOld", err) } } // The bank is REPLACED from the engine's read-out: it is rebuilt from its inputs on every run, so // what a rebuild answers is the read-out and nothing carried over. // // ⚠ This test used to assert the other half too — that per-term decisions survive the rebuild — and // that half went with the model. D39.144 abolished per-term signing (owner, 16.08) and the write // path was removed on the owner's word of 22.08; a test of code that no longer exists is not a // coverage loss. What the future EDIT handle needs is asserted where it lands, not here. func TestTheBankIsReplacedFromTheEnginesReadOut(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") since := 3 terms := []ingest.BankTerm{ {ID: "e1", Src: "方源", Dst: "Фан Юань", Kind: "name", Status: "proposed", Origin: "found"}, {ID: "e2", Src: "蛊", Status: "proposed", Origin: "given", SinceChapter: &since}, } if err := s.SaveBank(ctx, book, terms); err != nil { t.Fatal(err) } page, err := s.ListBank(ctx, "u1", book, 0, "", nil) if err != nil { t.Fatal(err) } if page.Counts.Total != 2 || page.Counts.Signed != 0 { t.Fatalf("counts: %+v", page.Counts) } // A rebuild of the same read-out answers the same bank: the identities are derived from the term // itself, so nothing is duplicated and nothing is lost. if err := s.SaveBank(ctx, book, terms); err != nil { t.Fatal(err) } after, err := s.ListBank(ctx, "u1", book, 0, "", nil) if err != nil { t.Fatal(err) } if after.Counts.Total != 2 { t.Errorf("the rebuild changed the bank: %+v", after.Counts) } // A term dropped from the read-out is GONE from the bank — the whole point of a replacement. if err := s.SaveBank(ctx, book, terms[:1]); err != nil { t.Fatal(err) } shrunk, err := s.ListBank(ctx, "u1", book, 0, "", nil) if err != nil { t.Fatal(err) } if shrunk.Counts.Total != 1 { t.Errorf("a term the engine no longer reports survived the rebuild: %+v", shrunk.Counts) } } // The floor that refuses to rewrite a RE-CUT book whose text could not be read. // // It guards the one write in this zone that can erase a book's paid text: a re-cut changes every // identity, so the old rows are deleted and the new ones inserted with whatever the caller carried — // nothing, whenever the manifest read and the export did not. `TextKnown` protects a pair only where // its row survives, which is exactly not this case. // // Mutation caught: dropping the refusal (the tree is rewritten empty); refusing on the FIRST // materialization too (a book that never had a cut would never get one). func TestATreeIsNotRewrittenForANewCutWithoutItsText(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") paid := Structure{TextRead: true, ManifestKey: "k1", Chapters: []StructureChapter{ {EngineID: "c1", Number: 1, Units: []StructureUnit{ {EngineID: "c1:k1:0", Ordinal: 0, Source: "第一节", Target: "Первый", State: "translated", TextKnown: true}}}}} if err := s.SaveStructure(ctx, book, paid); err != nil { t.Fatal(err) } // A NEW cut whose export did not answer. Refused: the text of the old cut is what would be lost. recut := Structure{ManifestKey: "k2", Chapters: []StructureChapter{ {EngineID: "c9", Number: 1, Units: []StructureUnit{ {EngineID: "c9:k2:0", Ordinal: 0, State: "pending"}}}}} if err := s.SaveStructure(ctx, book, recut); !errors.Is(err, ErrTextUnknownForANewCut) { t.Fatalf("a re-cut with no text answered %v, want a refusal", err) } // …and the book still has what it was paid for. page, err := s.ListChapters(ctx, "u1", book, 0, "") if err != nil { t.Fatal(err) } if len(page.Chapters) != 1 { t.Fatalf("chapters after the refusal: %+v", page.Chapters) } units, err := s.ListUnits(ctx, "u1", book, page.Chapters[0].ID, 0, "") if err != nil { t.Fatal(err) } if len(units.Units) != 1 || units.Units[0].Target != "Первый" { t.Errorf("the refused re-cut took the paid text with it: %+v", units.Units) } // The SAME cut with no text is not refused: there the rows survive and TextKnown leaves them be. same := Structure{ManifestKey: "k1", Chapters: []StructureChapter{ {EngineID: "c1", Number: 1, Units: []StructureUnit{ {EngineID: "c1:k1:0", Ordinal: 0, State: "pending"}}}}} if err := s.SaveStructure(ctx, book, same); err != nil { t.Errorf("a refresh of the SAME cut was refused: %v", err) } } // A rebuild leaves `bank_decisions` alone — the order inside SaveBank is what makes that true. // // Nothing writes that table today: the per-term verb it served was abolished (D39.144) and removed // with it. It is pinned anyway, and precisely because nothing writes it: the note left where the // write path stood tells the next session the storage is ready for the EDIT that replaces the verb, // and a rebuild that wiped the table would make that promise false with no test to notice. The row // is written the only way it can be now — directly, as the schema takes it. // // Mutation caught: deleting from `bank_decisions` inside SaveBank, or clearing `bank_terms` before // the new rows are written (the cascade takes the table with it). func TestARebuildOfTheBankLeavesTheDecisionsTableAlone(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") terms := []ingest.BankTerm{ {ID: "e1", Src: "方源", Dst: "Фан Юань", Kind: "name", Status: "proposed", Origin: "found"}, } if err := s.SaveBank(ctx, book, terms); err != nil { t.Fatal(err) } page, err := s.ListBank(ctx, "u1", book, 0, "", nil) if err != nil { t.Fatal(err) } if len(page.Terms) != 1 { t.Fatalf("the fixture has %d terms", len(page.Terms)) } if _, err := s.pool.Exec(ctx, ` insert into bank_decisions (book_id, term_id, action, dst, decided_by) values ($1, $2, 'approve', 'Фан Юань', 'u1')`, book, page.Terms[0].ID); err != nil { t.Fatal(err) } if err := s.SaveBank(ctx, book, terms); err != nil { t.Fatal(err) } var left int if err := s.pool.QueryRow(ctx, `select count(*) from bank_decisions where book_id = $1`, book).Scan(&left); err != nil { t.Fatal(err) } if left != 1 { t.Errorf("the rebuild left %d rows in bank_decisions: what a person corrected must outlive the engine's read-out", left) } } // The aggregates ride on the FIRST page — any response to a request with no cursor — which puts // them at the same moment as the oldest rows of the walk. func TestTheBankAggregatesAreAnsweredOnTheFirstPageOnly(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveBank(ctx, book, []ingest.BankTerm{ {ID: "e1", Src: "a", Status: "proposed", Origin: "found"}, {ID: "e2", Src: "b", Status: "approved", Origin: "given"}, }); err != nil { t.Fatal(err) } first, err := s.ListBank(ctx, "u1", book, 1, "", nil) if err != nil { t.Fatal(err) } if !first.First || first.Counts.Total != 2 || first.Counts.Signed != 1 { t.Fatalf("first page: %+v", first) } next, err := s.ListBank(ctx, "u1", book, 1, first.NextCursor, nil) if err != nil { t.Fatal(err) } if next.First { t.Error("a later page claims to carry the whole-bank aggregates") } } // A book that owes a materialization is NOT at rest, and that is the whole of Ф-56: the parse // commits, the tree lands seconds later, and in between the stream used to send `end` and answer the // browser's own reconnect 204 — the client stopped watching exactly when the chapters appeared. // // Mutation caught: dropping `read_model_owed_at is null` from the predicate. func TestABookThatOwesAReadingSurfaceIsNotAtRest(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") before, err := s.ReadStream(ctx, "u1", book) if err != nil { t.Fatal(err) } if !before.AtRest { t.Fatal("a book with no run and no debt is not at rest, so this fixture cannot observe the debt") } owed := oweAReadingSurface(t, s, ctx, book) state, err := s.ReadStream(ctx, "u1", book) if err != nil { t.Fatal(err) } if state.AtRest { t.Error("a book whose tree has not landed is reported at rest, and its stream will say `end`") } if err := s.ClearReadModelDebt(ctx, book, owed); err != nil { t.Fatal(err) } state, err = s.ReadStream(ctx, "u1", book) if err != nil { t.Fatal(err) } if !state.AtRest { t.Error("a discharged debt still holds the stream open") } } // The debt is discharged by the BOUNDARY that was answered, never by book id alone. Materializing a // large book is minutes of engine time and a run can finish inside them; clearing the column outright // would answer the newer boundary with a read taken before it, and that run's text — already paid // for — would never reach its reader. // // Mutation caught: dropping `and read_model_owed_at = $2`. func TestADebtStampedDuringAMaterializationSurvivesIt(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") stale := oweAReadingSurface(t, s, ctx, book) // The later boundary, while the materialization that answered the first one was still reading. fresh := oweAReadingSurface(t, s, ctx, book) if !fresh.After(stale) { t.Fatalf("the two boundaries are not distinguishable (%v, %v): this fixture cannot observe the property", stale, fresh) } if err := s.ClearReadModelDebt(ctx, book, stale); err != nil { t.Fatal(err) } owed, err := s.BooksOwedReadModel(ctx, 10) if err != nil { t.Fatal(err) } if len(owed) != 1 || !owed[0].OwedAt.Equal(fresh) { t.Fatalf("owed %+v, want the boundary that was stamped during the materialization", owed) } } // oweAReadingSurface stamps a boundary's debt the way FinishParse and FinishRun do, and returns it. func oweAReadingSurface(t *testing.T, s *Store, ctx context.Context, bookID string) time.Time { t.Helper() var at time.Time if err := s.pool.QueryRow(ctx, `update books set read_model_owed_at = clock_timestamp() where id = $1 returning read_model_owed_at`, bookID).Scan(&at); err != nil { t.Fatal(err) } return at } // Frames are minted by the WRITER so that two viewers of one book see one frame under one id, and // the buffer is pruned rather than kept: this is a live buffer, not an event store. func TestFramesAreMintedPerBookAndPruned(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") for i := range bufferFrames + 10 { if err := s.SaveBank(ctx, book, []ingest.BankTerm{ {ID: fmt.Sprintf("e%d", i), Src: fmt.Sprintf("src%d", i), Status: "proposed", Origin: "found"}}); err != nil { t.Fatal(err) } } state, err := s.ReadStream(ctx, "u1", book) if err != nil { t.Fatal(err) } if state.Position < int64(bufferFrames) { t.Fatalf("the frame counter is at %d after %d writes", state.Position, bufferFrames+10) } frames, err := s.ReadFrames(ctx, book, 0, bufferFrames*2) if err != nil { t.Fatal(err) } if len(frames) > bufferFrames { t.Errorf("%d frames are buffered, want the window bounded", len(frames)) } if state.Oldest == 0 { t.Error("the oldest buffered frame is not reported, so a resync can never be decided") } // The positions are the book's own and strictly increasing. for i := 1; i < len(frames); i++ { if frames[i].Position <= frames[i-1].Position { t.Fatalf("frame positions are not increasing: %d then %d", frames[i-1].Position, frames[i].Position) } } } // The frame is the CONTRACT's note and never the resolution: `code` and `severity` translated, the // engine's own reason nowhere in it. Frames are stored wire-ready and replayed verbatim, so a word // that gets in here reaches a client and stays in the buffer after the code is fixed. // // Mutation caught: putting `u.Reason` into the payload; dropping `severity`/`code`; emitting // `unit_id: null` instead of omitting it. func TestTheNoteFrameCarriesTheContractsNoteAndNotTheEnginesReason(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, VerifyBank: false, OrderedChapters: 2, Ceiling: money.MicroUSD(300_000), Now: time.Now().UTC()}, 0, nil) if err != nil { t.Fatal(err) } sink := s.NewRunSink(run.AttemptID, run.ID, book) ev := ingest.Envelope{Seq: 1, Type: ingest.TypeUnitDone, Time: time.Now().UTC(), Data: []byte(`{"chapter":1,"unit":0,"wave":"edit","shipped":false,"flagged":true,"reason":"glossary_miss"}`)} if err := sink.Apply(ctx, ev, ingest.Cursor{Offset: 1}); err != nil { t.Fatal(err) } frames, err := s.ReadFrames(ctx, book, 0, 100) if err != nil { t.Fatal(err) } var note map[string]any for _, f := range frames { if f.Event != FrameNote { continue } var payload struct { Note map[string]any `json:"note"` } if err := json.Unmarshal(f.Data, &payload); err != nil { t.Fatal(err) } note = payload.Note } if note == nil { t.Fatal("a flagged unit produced no note frame") } for _, k := range []string{"id", "created_at", "severity", "code", "chapter_id"} { if _, ok := note[k]; !ok { t.Errorf("the note frame is missing the required field %q", k) } } if note["code"] != "term_not_applied" { t.Errorf("code = %v, want the contract's word", note["code"]) } if _, leaked := note["reason"]; leaked { t.Errorf("the engine's flag reason rode the frame: %v", note) } if unit, ok := note["unit_id"]; !ok || unit == nil { t.Errorf("unit_id = %v (present %v), want the pair it is about", unit, ok) } } // The status frame carries the CONTRACT's vocabulary. The columns hold this platform's own words, // the wire has no name for some of them, and the frame used to carry them raw while the JSON path // next door mapped them properly — the same fact then read `null` on the card and `daily_ceiling` on // the stream. // // Mutation caught: emitting `st.PausedReason` / `st.RejectReason` without the translation. func TestTheStatusFrameCarriesTheContractsVocabularyOrNothing(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") now := time.Now().UTC() run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, OrderedChapters: 1, Ceiling: money.MicroUSD(300_000), Now: now}, 0, nil) if err != nil { t.Fatal(err) } // The engine's own daily ceiling: a reason the contract has no word for. if paused, err := s.PauseRun(ctx, run.ID, run.AttemptID, PausedDailyCeiling, now); err != nil || !paused { t.Fatalf("PauseRun = %v, %v", paused, err) } frames, err := s.ReadFrames(ctx, book, 0, 100) if err != nil { t.Fatal(err) } var last map[string]any for _, f := range frames { if f.Event != FrameStatus { continue } if err := json.Unmarshal(f.Data, &last); err != nil { t.Fatal(err) } } if last == nil { // The reconciler's pause is the real credit-exhausted one and it sets finished_at, so a client // that hears nothing is told 204 on its next reconnect while holding `translating`. t.Fatal("a pause produced no status frame") } if last["status"] != "paused" { t.Errorf("status = %v", last["status"]) } if last["paused_reason"] != nil { t.Errorf("paused_reason = %v, want null for a reason the contract has no word for", last["paused_reason"]) } for _, leak := range []string{"daily_ceiling", "ceiling_unknown", "parser_unavailable"} { for _, f := range frames { if strings.Contains(string(f.Data), leak) { t.Errorf("the platform's internal word %q rode a frame: %s", leak, f.Data) } } } } // A continuation run's bar measures ITS OWN segment. Resolutions persist across runs — a resumed run // re-walks finished chapters at $0 and must not move them — so counting the book's finished chapters // opens the second run at everything the first one did, against a denominator of what THIS one // bought: a fraction starting above zero and able to exceed one. // // Mutation caught: dropping `- r.chapters_before`. func TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } // The first chapter is finished: both its pairs resolved by the edit pass. at := time.Now().UTC() exec(t, s, ctx, ` insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at, revision) values ($1,1,0,'edit',true,false,$2,1), ($1,1,4,'edit',true,false,$2,1)`, book, at) exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1 and number = 1`, book) run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, OrderedChapters: 1, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil) if err != nil { t.Fatal(err) } // The denominator is in chapter-passes through BOTH waves: one bought chapter is two passes on a // pipeline whose editor is not known to be absent (row 200 — the through-bar). if run.Progress.Total != 2 { t.Errorf("the start receipt carries total %d, want both passes of what the run bought", run.Progress.Total) } _, card, err := s.GetBook(ctx, "u1", book) if err != nil { t.Fatal(err) } if card.Progress.Done != 0 || card.Progress.Total != 2 { t.Errorf("a second run over a half-finished book opens at %d/%d, want 0/2", card.Progress.Done, card.Progress.Total) } if card.Progress.Stage != "drafting" { t.Errorf("a fresh run's stage is %q, want drafting", card.Progress.Stage) } // And the BOOK's own figure is the lifetime one, which is a different question. b, _, err := s.GetBook(ctx, "u1", book) if err != nil { t.Fatal(err) } if b.ChaptersDone != 1 { t.Errorf("the book's own progress = %d, want the chapter that is finished", b.ChaptersDone) } } // The other end of the same bar: it never exceeds what the run BOUGHT. A run of one chapter watches // the rest of the book finish — a neighbouring pass, a redrive — and a bar of 2/1 is a fraction // above one on the screen. // // ⚠ The fixture must FINISH MORE than the run bought, from a baseline of zero. The earlier one // finished exactly one chapter and bought exactly one, so the clamp never bound and removing it // changed nothing. // // Mutation caught: dropping the `least(…, ceiling_chapters)` clamp. func TestTheRunsBarNeverExceedsWhatItBought(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } at := time.Now().UTC() // Bought one chapter over a book with nothing finished, so the baseline is zero. if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, OrderedChapters: 1, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil); err != nil { t.Fatal(err) } exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book) b, card, err := s.GetBook(ctx, "u1", book) if err != nil { t.Fatal(err) } if b.ChaptersDone != 2 { t.Fatalf("the book finished %d chapters: this fixture cannot bind a clamp at 1", b.ChaptersDone) } // Two chapters finished the last pass against ONE bought: the last-pass half of the bar clamps // at 1, over the two passes the purchase covers (the draft half is honestly still at zero). if card.Progress.Done != 1 || card.Progress.Total != 2 { t.Errorf("the bar reads %d/%d, want the last-pass half clamped to what the run bought", card.Progress.Done, card.Progress.Total) } } // The card's note counter and the notes LIST describe the same set. A note carries a chapter_id, so // a resolution whose chapter has no row has no address on the wire and `GET /notes` cannot return // it — a card that counted it would promise a note nothing can fetch (PD-288). // // The counter lives on the chapter now, so this holds by construction rather than by a join that // cost an index scan of every note of every book on the page (migration 00022). What this pins is // the construction: a resolution ahead of the tree is counted by NOBODY, and the moment its chapter // lands it is counted by both. // // Mutation caught: counting `unit_resolutions` for the card instead of summing the chapters. func TestTheCardsNoteCountAndTheNotesListDescribeOneSet(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") at := time.Now().UTC().Truncate(time.Millisecond) // A run resolved a unit of chapter 3 before the tree landed — which the sink allows on purpose: // the resolution IS recorded, and the counters come out exact the moment a chapter row exists. exec(t, s, ctx, ` insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, reason, at, revision) values ($1, 3, 0, 'edit', true, true, 'glossary_miss', $2, 1)`, book, at) if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } card, _, err := s.GetBook(ctx, "u1", book) if err != nil { t.Fatal(err) } notes, err := s.ListNotes(ctx, "u1", book, 0, "", nil) if err != nil { t.Fatal(err) } if len(notes.Notes) != 0 { t.Fatalf("a note outside the tree was listed: %+v", notes.Notes) } if card.NoteCount != 0 { t.Errorf("the card counts %d notes the list cannot return", card.NoteCount) } // The cut catches up: the same resolution now has a chapter, so BOTH see it. three := Structure{TextRead: true, ManifestKey: "k1", Chapters: append(twoChapters("k1").Chapters, StructureChapter{EngineID: "c3", Number: 3, Units: []StructureUnit{ {EngineID: "c3:cut:0", Ordinal: 0, Source: "第四节", State: "pending", TextKnown: true}}})} if err := s.SaveStructure(ctx, book, three); err != nil { t.Fatal(err) } card, _, err = s.GetBook(ctx, "u1", book) if err != nil { t.Fatal(err) } notes, err = s.ListNotes(ctx, "u1", book, 0, "", nil) if err != nil { t.Fatal(err) } if len(notes.Notes) != 1 || card.NoteCount != 1 { t.Errorf("the list has %d notes and the card counts %d", len(notes.Notes), card.NoteCount) } } // The bar's BASELINE and its numerator must count the same pass — both halves of "same": the signing // half AND the pipeline half. On a deployment with no editor the numerator counts the draft column, // so a baseline taken on the edit column stays at zero and a second run over an already-drafted book // opens at its full ceiling. // // The shape comes from the book's PREVIOUS run, which is the only place it can come from at start: // the new run has announced nothing yet. // // Mutation caught: naming units_edit_done outright in StartRun's baseline. func TestASecondRunOnADraftOnlyDeploymentStillOpensAtZero(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } at := time.Now().UTC() first, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, OrderedChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil) if err != nil { t.Fatal(err) } // The engine's first progress event settles a pipeline with no editor, and the run drafts the // whole book. exec(t, s, ctx, `update books set edit_wave = false where id = $1`, book) exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) if closed, err := s.FinishRun(ctx, RunEnding{RunID: first.ID, AttemptID: first.AttemptID, Status: "ready", ExitResult: "exit-code", Now: at.Add(time.Minute)}); err != nil || !closed { t.Fatalf("closing the first run: %v %v", closed, err) } b, _, err := s.GetBook(ctx, "u1", book) if err != nil { t.Fatal(err) } if b.ChaptersDone != 2 { t.Fatalf("the draft-only run finished %d chapters: this fixture cannot observe the baseline", b.ChaptersDone) } // A SECOND run: it has done none of what it bought, whatever the first one did. second, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, OrderedChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at.Add(2 * time.Minute)}, 0, nil) if err != nil { t.Fatal(err) } got, err := s.ReadRun(ctx, "u1", second.ID) if err != nil { t.Fatal(err) } if got.Progress.Done != 0 { t.Errorf("a second draft-only run opens at %d/%d, want 0 of what IT bought", got.Progress.Done, got.Progress.Total) } } // A chapter of a signing run counts by the pass that is CURRENT FOR THAT CHAPTER, and the rule is // evaluated per chapter — which is the whole of it, and what two withdrawn editions got wrong. // // Why the draft arm exists at all: under an editor pipeline `units_edit_done` is zero for the whole // draft wave, so a counter read only off the last pass shows a user nothing done for most of a run // they are paying for. A run that stops for signing is exactly the run whose user is asked to LOOK at // that half, so it is the one that must show it. // // Why it is per chapter: the frames the sink pushes are per chapter, announced by an event of that // same chapter. A book-wide term flips on a unit of some OTHER chapter and silently changes what a // re-read says about chapters nothing touched — measured on the real sink, and the reason the first // edition of this rule was withdrawn. The second edition asked only whether the run was standing AT // the stop: announced and per-run, but it read zeroes through the entire draft wave, which is the // defect the acceptance found. This one moves only on the chapter's own column. // // Mutation caught: dropping the `c.units_edit_done = 0` arm (the draft wave shows nothing again); // replacing it with a run-level or book-wide term (the flip stops being the chapter's own). func TestAChapterOfASigningRunCountsByThePassCurrentForThatChapter(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } at := time.Now().UTC().Truncate(time.Microsecond) if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, VerifyBank: true, OrderedChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil); err != nil { t.Fatal(err) } exec(t, s, ctx, `update books set edit_wave = true, epoch_editor = true where id = $1`, book) units := func(number int) int { t.Helper() var got int if err := s.pool.QueryRow(ctx, `select `+segmentUnits+` from chapters c join books b on b.id = c.book_id `+lastRun+` where c.book_id = $1 and c.number = $2`, book, number).Scan(&got); err != nil { t.Fatal(err) } return got } // THE DRAFT WAVE, which is most of the run: chapter 1 is half drafted and nothing is edited. exec(t, s, ctx, `update chapters set units_draft_done = 1 where book_id = $1 and number = 1`, book) if got := units(1); got != 1 { t.Errorf("mid-draft the chapter counts %d, want its draft progress 1: a user watching the half"+ " they are asked to sign off is shown nothing done", got) } // …and it stays the draft pass when that chapter's draft finishes. The whole wave, not a moment. exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1 and number = 1`, book) if got := units(1); got != 2 { t.Errorf("with its draft finished the chapter counts %d, want 2", got) } // PER CHAPTER: chapter 2 has not been drafted, and finishing chapter 1 said nothing about it. if got := units(2); got != 0 { t.Errorf("chapter 2 counts %d after work on chapter 1 alone: the rule is not the chapter's own,"+ " and a re-read now disagrees with a frame nothing announced", got) } // THE EDIT PASS begins for chapter 1: its counter follows the pass that is current for IT, while // chapter 2 — still untouched by the editor — goes on reporting its own draft progress. exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) exec(t, s, ctx, `update chapters set units_edit_done = 1 where book_id = $1 and number = 1`, book) if got := units(1); got != 1 { t.Errorf("with one unit edited the chapter counts %d, want the edit pass's 1", got) } if got := units(2); got != 1 { t.Errorf("chapter 2 counts %d, want its own draft progress 1: chapter 1's editor did not reach it", got) } // A run that never asked for the stop is untouched by the draft arm: it counts by the last pass, // exactly as it did before this rule existed. exec(t, s, ctx, `update runs set verify_bank = false where book_id = $1`, book) if got := units(2); got != 0 { t.Errorf("an ordinary editing run counts %d for an unedited chapter, want 0", got) } } // THE rule of the through-bar (owner's word of 20.08, row 200): the bar is ONE monotonic fraction // over the run's whole work, so lifting the signing stop MOVES NOTHING — the re-basing that used to // restart it from zero is gone, and «100%, then zero» with it. Through the stop the draft half // stands, the edit half continues, and the caption follows the counters. // // Mutation caught: re-taking either baseline in RestartRun. func TestTheBarIsOneMonotonicFractionThroughTheSigningStop(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } at := time.Now().UTC() run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, VerifyBank: true, OrderedChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil) if err != nil { t.Fatal(err) } // The first segment drafts the whole purchase and stops for the signature: half the work. exec(t, s, ctx, `update books set edit_wave = true where id = $1`, book) exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) first, err := s.ReadRun(ctx, "u1", run.ID) if err != nil { t.Fatal(err) } if first.Progress.Done != 2 || first.Progress.Total != 4 { t.Fatalf("a fully drafted purchase reads %d/%d, want 2/4 — half the run's work", first.Progress.Done, first.Progress.Total) } if first.Progress.Stage != "editing" { t.Errorf("with the draft half done the stage is %q, want editing", first.Progress.Stage) } if _, err := s.RestartRun(ctx, RestartInput{RunID: run.ID, AttemptID: run.AttemptID, UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), Now: at.Add(time.Minute)}); err != nil { t.Fatal(err) } lifted, err := s.ReadRun(ctx, "u1", run.ID) if err != nil { t.Fatal(err) } if lifted.Progress.Done != 2 || lifted.Progress.Total != 4 { t.Errorf("lifting the stop moved the bar to %d/%d, want the 2/4 it stood at", lifted.Progress.Done, lifted.Progress.Total) } // The edit wave finishes what was bought: the same bar reaches its own end. exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book) done, err := s.ReadRun(ctx, "u1", run.ID) if err != nil { t.Fatal(err) } if done.Progress.Done != 4 || done.Progress.Total != 4 { t.Errorf("the finished work reads %d/%d, want 4/4", done.Progress.Done, done.Progress.Total) } } // A continuation run over a DRAFTED backlog owes only the last pass, and its denominator says so: // a previous run drafted the chapters, stopped at the signature and was abandoned — the new run's // work is the edit wave alone. Counting the draft wave it never performs left such a run finishing // a clean `ready` at 50% with the caption stuck on drafting (refuter finding, P9). // // Mutation caught: runTotal reading `2 * ceiling` instead of folding in draftWork. func TestARunOverADraftedBacklogOwesOnlyTheLastPass(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } at := time.Now().UTC() // The whole book is drafted and none of it is edited — the state an abandoned signing run leaves. exec(t, s, ctx, `update books set edit_wave = true where id = $1`, book) exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, OrderedChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil) if err != nil { t.Fatal(err) } if run.Progress.Done != 0 || run.Progress.Total != 2 { t.Errorf("the receipt reads %d/%d, want 0/2 — the draft passes are not this run's work", run.Progress.Done, run.Progress.Total) } if run.Progress.Stage != "editing" { t.Errorf("stage is %q, want editing: the run owes no draft pass", run.Progress.Stage) } exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book) got, err := s.ReadRun(ctx, "u1", run.ID) if err != nil { t.Fatal(err) } if got.Progress.Done != 2 || got.Progress.Total != 2 { t.Errorf("the finished work reads %d/%d, want 2/2", got.Progress.Done, got.Progress.Total) } } // THE FLIP ORDER, pinned exactly as it happens in production (reviewer's blocker, P9): a book // drafted on a no-editor pipeline (`edit_wave = false`), the operator deploys an editor, a // continuation run starts — and the flag flips to true only AFTER the start, when the engine // announces its wave shape with its first progress event. The baselines are captured before the // flip; captured through the flag they sat on the draft column while the numerator moved to the // edit one, and the bar read 0/N after ALL the bought work was done, forever. // // Mutation caught: taking `chapters_before` on the flag-following predicate at StartRun (the exact // defect); pairing either numerator with the other column's baseline in runDone. func TestAFlagThatFlipsAfterStartDoesNotStrandTheBar(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } at := time.Now().UTC() // The book's draft era: everything drafted, nothing edited, and the flag SAYS no editor. exec(t, s, ctx, `update books set edit_wave = false where id = $1`, book) exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, OrderedChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil) if err != nil { t.Fatal(err) } // The engine's first progress event announces an editor: the flag grows AFTER the start. exec(t, s, ctx, `update books set edit_wave = true where id = $1`, book) // The run does the whole of what it bought — the edit pass over the drafted chapters. exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book) got, err := s.ReadRun(ctx, "u1", run.ID) if err != nil { t.Fatal(err) } if got.Progress.Done != got.Progress.Total || got.Progress.Total != 2 { t.Errorf("after all the bought work the bar reads %d/%d, want 2/2 — a baseline captured "+ "through the flag never catches the numerator", got.Progress.Done, got.Progress.Total) } if got.Progress.Stage != "editing" { t.Errorf("stage is %q, want editing: the run owed no draft pass", got.Progress.Stage) } } // A pipeline with no editor has ONE wave: the bar is what the draft pass finished over what was // bought — never doubled, never re-counted — and lifting a signing stop moves nothing there either: // the run's work was done when the draft was. func TestADraftOnlyDeploymentCountsItsOneWaveOnce(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } at := time.Now().UTC() run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, VerifyBank: true, OrderedChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil) if err != nil { t.Fatal(err) } exec(t, s, ctx, `update books set edit_wave = false where id = $1`, book) exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) first, err := s.ReadRun(ctx, "u1", run.ID) if err != nil { t.Fatal(err) } if first.Progress.Done != 2 || first.Progress.Total != 2 { t.Fatalf("a drafted draft-only purchase reads %d/%d, want 2/2", first.Progress.Done, first.Progress.Total) } if _, err := s.RestartRun(ctx, RestartInput{RunID: run.ID, AttemptID: run.AttemptID, UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000), Now: at.Add(time.Minute)}); err != nil { t.Fatal(err) } got, err := s.ReadRun(ctx, "u1", run.ID) if err != nil { t.Fatal(err) } if got.Progress.Done != 2 || got.Progress.Total != 2 { t.Errorf("lifting the stop moved a finished bar to %d/%d, want 2/2 — the work was done", got.Progress.Done, got.Progress.Total) } } // A debt this pass could not pay goes to the BACK of the queue, and a debt stamped SINCE is not // overwritten by that move — the second is the same compare-and-set the discharge uses, for the same // reason: a boundary recorded while a materialization was reading the engine must survive it. // // Mutation caught: deferring by book id alone. func TestADeferredDebtGoesToTheBackAndNeverOverwritesANewerOne(t *testing.T) { s, ctx := testDB(t) first := readingBook(t, s, ctx, "u1") second := readingBook(t, s, ctx, "u2") oldest := oweAReadingSurface(t, s, ctx, first) newer := oweAReadingSurface(t, s, ctx, second) if !newer.After(oldest) { t.Fatalf("the two debts are not ordered (%v, %v): this fixture cannot observe a queue", oldest, newer) } if _, err := s.DeferReadModelDebt(ctx, first, oldest, time.Now().UTC(), "the engine said no", SpendsAnAttempt); err != nil { t.Fatal(err) } owed, err := s.BooksOwedReadModel(ctx, 10) if err != nil { t.Fatal(err) } if len(owed) != 2 || owed[0].ID != second || owed[1].ID != first { t.Fatalf("the queue is %+v, want the deferred book last", owed) } // A stamp that has moved on belongs to a LATER boundary: deferring the one this pass held must // not push that one back. fresh := oweAReadingSurface(t, s, ctx, first) if _, err := s.DeferReadModelDebt(ctx, first, oldest, time.Now().UTC(), "the engine said no", SpendsAnAttempt); err != nil { t.Fatal(err) } owed, err = s.BooksOwedReadModel(ctx, 10) if err != nil { t.Fatal(err) } if len(owed) != 2 || !owed[1].OwedAt.Equal(fresh) { t.Errorf("the queue is %+v, want the newer boundary untouched (%v)", owed, fresh) } } // Starting a run must not walk the BOOK's progress backwards. Which pass finishes a chapter is a // property of the BOOK's pipeline, and it was read off the latest run — the latest run being the // NEWEST one, and a run just admitted has announced nothing. On a deployment with no editor every // book's `chapters_done` therefore fell to zero the moment a run was created, and rose again on that // run's first progress event; the canon forbids that counter to move backwards within one structure // version. // // Mutation caught: reading the wave shape from the latest run instead of from the book. func TestAdmittingARunDoesNotWalkTheBooksProgressBackwards(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } at := time.Now().UTC() exec(t, s, ctx, `update books set edit_wave = false where id = $1`, book) exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book) before, _, err := s.GetBook(ctx, "u1", book) if err != nil { t.Fatal(err) } if before.ChaptersDone != 2 { t.Fatalf("the book reads %d chapters done: this fixture cannot observe a fall", before.ChaptersDone) } // A run is admitted and has announced nothing yet — which is every run, for its first seconds. if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, OrderedChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil); err != nil { t.Fatal(err) } after, _, err := s.GetBook(ctx, "u1", book) if err != nil { t.Fatal(err) } if after.ChaptersDone != before.ChaptersDone { t.Errorf("admitting a run moved the book from %d chapters done to %d", before.ChaptersDone, after.ChaptersDone) } } // And the shape itself is written by the engine's own announcement, through either channel, and is // monotone: a book that has been through an editing pipeline stays one, so a later run reporting no // editor cannot make half-done chapters count as finished. // // Mutation caught: assigning the flag instead of accumulating it; writing it from an event that // announced no shape at all. func TestTheWaveShapeIsWrittenByTheEngineAndOnlyGrows(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") shape := func() *bool { t.Helper() var edits *bool if err := s.pool.QueryRow(ctx, `select edit_wave from books where id = $1`, book).Scan(&edits); err != nil { t.Fatal(err) } return edits } if shape() != nil { t.Fatalf("a book no run has reported on already claims a shape: %v", *shape()) } record := func(draft, edit int) { t.Helper() if err := s.inTx(ctx, func(tx pgx.Tx) error { return recordWaveShape(ctx, tx, book, draft, edit) }); err != nil { t.Fatal(err) } } // An event that announces no shape at all settles nothing. record(0, 0) if shape() != nil { t.Errorf("an announcement of nothing settled the shape: %v", *shape()) } record(10, 0) if got := shape(); got == nil || *got { t.Fatalf("a pipeline with no editor: %v", got) } record(10, 10) if got := shape(); got == nil || !*got { t.Fatalf("an editing pipeline: %v", got) } // …and it does not go back: a later run with no editor must not turn half-done chapters into // finished ones. record(10, 0) if got := shape(); got == nil || !*got { t.Errorf("the shape went backwards: %v", got) } } // A claim takes a debt off the queue for its window, so a second worker is not offered the same book, // and hands back the stamp the holder must present to discharge it. A debt a LATER boundary replaced // cannot be claimed with the old stamp — the same compare-and-set every other writer of this column // uses. // // Mutation caught: claiming by book id alone; listing debts that are not yet due. func TestAClaimedDebtLeavesTheQueueUntilItsWindowLapses(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") owed := oweAReadingSurface(t, s, ctx, book) if q := owedNow(t, s, ctx); len(q) != 1 { t.Fatalf("the queue holds %+v, want the book that owes a surface", q) } held, err := s.ClaimReadModelDebt(ctx, book, owed, time.Hour) if err != nil { t.Fatal(err) } if held.IsZero() || !held.After(owed) { t.Fatalf("the claim answered %v, want a stamp pushed past %v", held, owed) } if q := owedNow(t, s, ctx); len(q) != 0 { t.Errorf("a claimed book is still offered to the queue: %+v", q) } // A second worker cannot take it, and the holder still can discharge it. if again, err := s.ClaimReadModelDebt(ctx, book, owed, time.Hour); err != nil || !again.IsZero() { t.Errorf("a second claim on one debt answered (%v, %v), want nothing", again, err) } if err := s.ClearReadModelDebt(ctx, book, held); err != nil { t.Fatal(err) } if q := owedNow(t, s, ctx); len(q) != 0 { t.Errorf("the debt survived its own holder's discharge: %+v", q) } } func owedNow(t *testing.T, s *Store, ctx context.Context) []OwedBook { t.Helper() owed, err := s.BooksOwedReadModel(ctx, 10) if err != nil { t.Fatal(err) } return owed } // A debt that was given up on lets the book's stream END, and the next boundary of real work brings // the budget back. // // `AtRest` is the flag a stream ends on, and it requires this column to be null — so a debt that // could never be paid kept every browser watching that book reconnecting for the life of the // deployment. That is the consequence with the longest reach, and it is not visible from either side // alone: the materializer sees a failing engine call, the stream sees a book that is never at rest. // // The reset lives in `owesAReadingSurface`, so it is one place and every ending gets it: a boundary // is new evidence, and a fresh debt inheriting a spent budget would be written off with no tries. // // Mutation caught: abandoning by clearing the stamp without recording it (the stream then ends and // nobody knows why); leaving the attempt count out of owesAReadingSurface. func TestAWrittenOffDebtLetsTheStreamEndAndTheNextBoundaryBringsItBack(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") owed := oweAReadingSurface(t, s, ctx, book) // Owing one, the book is never at rest, which is what makes a client hold the connection open. state, err := s.ReadStream(ctx, "u1", book) if err != nil { t.Fatal(err) } if state.AtRest { t.Fatal("a book owing a materialization reports itself at rest: this fixture is not the one it claims") } // Four failures, each pushed further out than the last. var last time.Time for i := 1; i < 5; i++ { attempts, err := s.DeferReadModelDebt(ctx, book, owed, time.Now().UTC().Add(time.Duration(i)*time.Minute), "the engine did not answer", SpendsAnAttempt) if err != nil { t.Fatal(err) } if attempts != i { t.Fatalf("after %d deferrals the row counts %d: no budget can be spent this way", i, attempts) } if err := s.pool.QueryRow(ctx, `select read_model_owed_at from books where id = $1`, book).Scan(&owed); err != nil { t.Fatal(err) } if !owed.After(last) { t.Errorf("deferral %d pushed the debt to %v, not past %v", i, owed, last) } last = owed } // A book that is not DUE is not offered to the drain, which is the whole of the starvation fix. if due, err := s.BooksOwedReadModel(ctx, 10); err != nil { t.Fatal(err) } else if len(due) != 0 { t.Errorf("a deferred debt is still offered to the drain: %+v", due) } if err := s.AbandonReadModelDebt(ctx, book, owed, time.Now().UTC(), "the workdir is gone"); err != nil { t.Fatal(err) } state, err = s.ReadStream(ctx, "u1", book) if err != nil { t.Fatal(err) } if !state.AtRest { t.Error("a book whose surface was given up on is still never at rest: every watcher reconnects for good") } gone, err := s.AbandonedSurfaces(ctx) if err != nil { t.Fatal(err) } // FIVE, not four: the attempt that ran out of the budget is one of the tries, and the operator's // table has to say the same number as the log line beside it. if len(gone) != 1 || gone[0].ID != book || gone[0].Attempts != 5 || gone[0].LastError == "" { t.Errorf("the operator's list is %+v, want this book with all five tries and its reason", gone) } // The next boundary of real work: a fresh debt, and a fresh budget with it. if _, err := s.pool.Exec(ctx, `update books set `+owesAReadingSurface+` revision = revision where id = $1`, book); err != nil { t.Fatal(err) } var attempts int var abandoned *time.Time if err := s.pool.QueryRow(ctx, `select read_model_attempts, read_model_abandoned_at from books where id = $1`, book). Scan(&attempts, &abandoned); err != nil { t.Fatal(err) } if attempts != 0 || abandoned != nil { t.Errorf("a new boundary inherited %d spent attempts (abandoned=%v): it would be written off without a try", attempts, abandoned) } if due, err := s.BooksOwedReadModel(ctx, 10); err != nil { t.Fatal(err) } else if len(due) != 1 || due[0].ID != book { t.Errorf("after a new boundary the drain is offered %+v", due) } } // The operator's handle on a written-off surface: ask again, without waiting for the book's next run. func TestAWrittenOffSurfaceCanBeAskedForAgain(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") owed := oweAReadingSurface(t, s, ctx, book) if err := s.AbandonReadModelDebt(ctx, book, owed, time.Now().UTC(), "the workdir is gone"); err != nil { t.Fatal(err) } armed, err := s.RearmReadModelDebt(ctx, book, time.Now().UTC()) if err != nil { t.Fatal(err) } if !armed { t.Fatal("re-arming a written-off debt did nothing") } due, err := s.BooksOwedReadModel(ctx, 10) if err != nil { t.Fatal(err) } if len(due) != 1 || due[0].ID != book || due[0].Attempts != 0 { t.Errorf("the drain is offered %+v, want this book with a clean budget", due) } // …and asking again while one is already owed is a no-op that SAYS so, rather than a success that // quietly moved a debt another worker is holding. if again, err := s.RearmReadModelDebt(ctx, book, time.Now().UTC()); err != nil { t.Fatal(err) } else if again { t.Error("re-arming a debt that is already owed reported that it did something") } } // A re-pass run's bar is ONE UNIT OF WORK (P10 §3.2, errata 28.08-к): 0 while it runs, 1 when it // finishes clean — declared in the canon, not smuggled into a chapter count (the engine // re-announces nothing on a re-pass, so no finer honest granularity exists). The 0/0 frame stays // unreachable (the denominator is the literal 1), and a zero ceiling WITHOUT the re-pass consent // stays refused. func TestARePassRunsBarIsOneUnitOfWork(t *testing.T) { s, ctx := testDB(t) book := readingBook(t, s, ctx, "u1") if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil { t.Fatal(err) } at := time.Now().UTC() run, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, OrderedChapters: 0, Resnapshot: true, AcceptRebill: money.MicroUSD(130_000), Ceiling: money.MicroUSD(130_000), Now: at}, 0, nil) if err != nil { t.Fatal(err) } if run.Progress.Done != 0 || run.Progress.Total != 1 || run.Progress.Stage != "re_pass" { t.Fatalf("a fresh re-pass opens at %d/%d %q, want 0/1 re_pass", run.Progress.Done, run.Progress.Total, run.Progress.Stage) } // ⛔ THE TWO VOLUME FIGURES A RE-PASS PUTS ON THE WIRE, pinned because the CONTRACT asserts them // and a contract may only assert what a pin holds. Both are ZERO and neither is null: a re-pass // was ordered in chapters (it buys none) and it is not measured in units, so `ordered_units` is // the one that is absent. This is what makes `ordered_chapters == 0` the re-pass's own mark — the // FIRST check a client owes, before it reads `delivered_chapters`, which here is a number and // would otherwise be taken for a chapter count. if run.OrderedChapters == nil || *run.OrderedChapters != 0 { t.Errorf("the start receipt of a re-pass reports ordered_chapters %v, want 0", run.OrderedChapters) } if run.OrderedUnits != nil { t.Errorf("a re-pass reports ordered_units %d; it is not measured in units", *run.OrderedUnits) } exec(t, s, ctx, `update runs set status = 'ready', finished_at = $2 where id = $1`, run.ID, at.Add(time.Minute)) got, err := s.ReadRun(ctx, "u1", run.ID) if err != nil { t.Fatal(err) } if got.Progress.Done != 1 || got.Progress.Total != 1 || got.Progress.Stage != "re_pass" { t.Fatalf("a finished re-pass reads %d/%d %q, want 1/1 re_pass", got.Progress.Done, got.Progress.Total, got.Progress.Stage) } // ⚠ AND THE READ PATH SAYS THE SAME AS THE RECEIPT — they are two different SQL expressions // (`insertRun`'s RETURNING and `runOrderedChapters` over the lateral), and a contract that // promises one shape must be held to it on both. if got.OrderedChapters == nil || *got.OrderedChapters != 0 { t.Errorf("a re-pass reads back ordered_chapters %v, want 0", got.OrderedChapters) } if got.OrderedUnits != nil { t.Errorf("a re-pass reads back ordered_units %d", *got.OrderedUnits) } // ⛔ ZERO, NOT NULL — and this is the value that makes `delivered_chapters` USELESS as the sole // discriminator of the bar's unit: a re-pass answers with a NUMBER here, so the rule «a number // means the counters are chapters» would have a client render a re-pass as «0 of 1 chapters», // which the contract's own Progress paragraph forbids. if got.DeliveredChapters == nil || *got.DeliveredChapters != 0 { t.Errorf("a re-pass reads back delivered_chapters %v, want 0 rather than null", got.DeliveredChapters) } if _, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, OrderedChapters: 0, Ceiling: money.MicroUSD(100_000), Now: at.Add(time.Hour)}, 0, nil); err == nil { t.Fatal("a zero-chapter run without the re-pass consent was admitted") } }