1448 lines
60 KiB
Go
1448 lines
60 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{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")
|
|
}
|
|
}
|