textmachine/platform/internal/pgstore/readmodel_test.go

964 lines
37 KiB
Go

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{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{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 and the decisions are not: they are the
// accumulating instruction that survives the rebuild, which is the whole reason they live in a
// table of their own.
func TestTheBankIsReplacedWhileTheDecisionsSurvive(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.PendingDecisions != 2 || page.Counts.Complete {
t.Fatalf("counts: %+v", page.Counts)
}
var decided string
for _, term := range page.Terms {
if term.Src == "方源" {
decided = term.ID
}
}
receipt, err := s.SubmitBankDecisions(ctx, "u1", book, []BankDecision{
{TermID: decided, Action: "approve", Dst: "Фан Юань"}}, time.Now())
if err != nil {
t.Fatal(err)
}
if receipt.Counts.PendingDecisions != 1 || receipt.Revision == 0 {
t.Fatalf("receipt: %+v", receipt)
}
// The engine rebuilds the bank; the decision is keyed by the term's derived identity and rides
// through it.
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.PendingDecisions != 1 {
t.Errorf("the rebuild lost a decision: %+v", after.Counts)
}
// A term this book's bank does not hold refuses the WHOLE batch: a silently dropped decision
// reads on the screen as a decision that was saved.
if _, err := s.SubmitBankDecisions(ctx, "u1", book, []BankDecision{
{TermID: decided, Action: "decline"},
{TermID: "tm_nothing", Action: "decline"}}, time.Now()); !errors.Is(err, ErrUnknownTerm) {
t.Errorf("an unknown term answered %v, want ErrUnknownTerm", err)
}
final, err := s.ListBank(ctx, "u1", book, 0, "", nil)
if err != nil {
t.Fatal(err)
}
if final.Counts.PendingDecisions != 1 {
t.Errorf("a refused batch was applied in part: %+v", final.Counts)
}
}
// 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,
CeilingChapters: 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, CeilingChapters: 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, CeilingChapters: 1,
Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil)
if err != nil {
t.Fatal(err)
}
if run.Progress.Total != 1 {
t.Errorf("the start receipt carries total %d, want 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 != 1 {
t.Errorf("a second run over a half-finished book opens at %d/%d, want 0/1",
card.Progress.Done, card.Progress.Total)
}
// 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, CeilingChapters: 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)
}
if card.Progress.Done != 1 || card.Progress.Total != 1 {
t.Errorf("the bar reads %d/%d, want it 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{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, CeilingChapters: 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, CeilingChapters: 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)
}
}
// The same rule one segment later: lifting the signing stop RE-TAKES the baseline, and it must
// re-take it on the pass the numerator will count from then on. On a deployment with no editor that
// is the draft column, and a baseline re-taken on the edit one opens the second segment at its
// ceiling.
//
// Mutation caught: naming units_edit_done outright in RestartRun's re-capture.
func TestLiftingTheStopOnADraftOnlyDeploymentRetakesTheRightBaseline(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,
CeilingChapters: 2, Ceiling: money.MicroUSD(300_000), Now: at}, 0, nil)
if err != nil {
t.Fatal(err)
}
// A pipeline with no editor drafts the whole book in the first segment.
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 {
t.Fatalf("the first segment reads %d/%d: this fixture never drafted anything",
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), LiftBankStop: true,
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 != 0 {
t.Errorf("the second segment opens at %d/%d, want 0 of the work it still has to do",
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); 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); 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, CeilingChapters: 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
}