textmachine/platform/internal/pgstore/shape_epoch_test.go

246 lines
11 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package pgstore
import (
"testing"
"time"
"textmachine/platform/internal/money"
)
// announceShape is one progress event of a run, which is where the engine states the shape of the
// pipeline it is running: an edit denominator of zero means this deployment has no editor.
func announceShape(t *testing.T, s *Store, book string, draftTotal, editTotal int) {
t.Helper()
tx, err := s.pool.Begin(t.Context())
if err != nil {
t.Fatal(err)
}
defer func() { _ = tx.Rollback(t.Context()) }()
if err := recordWaveShape(t.Context(), tx, book, draftTotal, editTotal); err != nil {
t.Fatal(err)
}
if err := tx.Commit(t.Context()); err != nil {
t.Fatal(err)
}
}
func shapeEpoch(t *testing.T, s *Store, book string) (epoch int, editor *bool) {
t.Helper()
if err := s.pool.QueryRow(t.Context(),
`select shape_epoch, epoch_editor from books where id = $1`, book).Scan(&epoch, &editor); err != nil {
t.Fatal(err)
}
return epoch, editor
}
// draftedBook is a book whose chapters are fully DRAFTED and not edited: the state every editor
// pipeline passes through, and the state both halves of this file start from.
func draftedBook(t *testing.T, s *Store) string {
t.Helper()
ctx := t.Context()
book := readingBook(t, s, ctx, "u1")
if err := s.SaveStructure(ctx, book, twoChapters("k1")); err != nil {
t.Fatal(err)
}
exec(t, s, ctx, `update chapters set units_draft_done = units_total where book_id = $1`, book)
return book
}
// THE EDITOR IS REMOVED, and the book stops being frozen half-done (PD-403).
//
// The old rule read the book's lifetime count through the MONOTONE flag, and the flag cannot come
// back down. So a book that had been through an editing pipeline, on a deployment where the operator
// has since removed the editor, counted against an edit column that no run would ever fill again: the
// count froze, the run closed `ready` at half, and — this is the money — `ChaptersLeft` never fell,
// so the purchase scale went on offering chapters that were already translated. The user is sold the
// same work twice.
//
// The owner ruled the cure on 28.08 (D39.165 §2): a change of pipeline shape is an EVENT of the book,
// like cutting it again, and the count is legitimately recomputed at the boundary. The monotone flag
// is NOT repealed by this — it still refuses to come down, and its pin still stands. What moved is
// which fact the count reads.
//
// Mutation caught: pointing finishedUnits back at the monotone `editWave`; making `epoch_editor`
// accumulate (`or`) instead of assign.
func TestRemovingTheEditorRecomputesTheBooksCountInsteadOfFreezingIt(t *testing.T) {
s, ctx := testDB(t)
book := draftedBook(t, s)
// Epoch 1: the deployment HAS an editor. Nothing is edited yet, so nothing is finished.
announceShape(t, s, book, 2, 2)
before, _, err := s.GetBook(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if before.ChaptersDone != 0 {
t.Fatalf("under an editor a merely drafted book reads %d done, want 0", before.ChaptersDone)
}
epoch, editor := shapeEpoch(t, s, book)
if epoch != 0 || editor == nil || !*editor {
t.Fatalf("first announcement left epoch=%d editor=%v, want the epoch unmoved and the shape recorded",
epoch, editor)
}
// The operator removes the editor and the next run announces a zero edit denominator.
announceShape(t, s, book, 2, 0)
after, _, err := s.GetBook(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if after.ChaptersDone != 2 {
t.Errorf("with the editor gone the book still reads %d/%d done, want 2: it is frozen against an"+
" edit column no run will fill, and the purchase scale goes on selling chapters already translated",
after.ChaptersDone, after.ChapterCount)
}
// The recomputation ANNOUNCES itself, or the client cannot tell it from a count walking backwards.
if after.ShapeEpoch == before.ShapeEpoch {
t.Errorf("the count was recomputed at epoch %d without moving it: the jump is indistinguishable"+
" from an illegal move and the canon's amnesty is unobservable", after.ShapeEpoch)
}
// ⚠ The monotone flag is NOT repealed. D39.153 §4б still holds and its pin still stands.
var flag *bool
if err := s.pool.QueryRow(ctx, `select edit_wave from books where id = $1`, book).Scan(&flag); err != nil {
t.Fatal(err)
}
if flag == nil || !*flag {
t.Errorf("edit_wave came down to %v: the historical flag is monotone (D39.153 §4б) and this"+
" work does not repeal it — only the LIFETIME COUNT stopped being read off it", flag)
}
// And the purchase scale, which is the money half of PD-403: what is left to buy must have fallen.
rc, err := s.ReadBookForRun(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if rc.ChaptersLeft != 0 {
t.Errorf("the purchase scale still offers %d chapters of a fully translated book", rc.ChaptersLeft)
}
}
// THE EDITOR IS ADDED — the one direction the old flag could move — and the count still recomputes,
// but it now says so (PD-404).
//
// The flag flips false→true on the first progress event of the run that has an editor, and the
// lifetime count moves to the edit column in the same transaction: a book showing 2/2 shows 0/2,
// backwards, inside ONE `structure_version` — which the canon forbade in as many words. The owner's
// ruling makes the jump legitimate; what makes it legible is that the epoch moves with it, so a
// client sees a new generation rather than a server walking a counter down.
//
// Mutation caught: not counting a false→true boundary (an epoch that only moves one way).
func TestAddingTheEditorMovesTheEpochWithTheCountItRecomputes(t *testing.T) {
s, ctx := testDB(t)
book := draftedBook(t, s)
// Epoch 0: a draft-only deployment. Every drafted chapter is finished, because drafting is the
// last pass the book gets.
announceShape(t, s, book, 2, 0)
before, _, err := s.GetBook(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if before.ChaptersDone != 2 {
t.Fatalf("a draft-only deployment reads %d done on a fully drafted book, want 2", before.ChaptersDone)
}
// The operator ADDS an editor. The work already done is now half-work, and the count says so.
announceShape(t, s, book, 2, 2)
after, _, err := s.GetBook(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if after.ChaptersDone != 0 {
t.Errorf("after the editor arrived the book reads %d done, want 0: a drafted chapter is no"+
" longer finished work", after.ChaptersDone)
}
if after.ShapeEpoch <= before.ShapeEpoch {
t.Errorf("the count went %d → %d — BACKWARDS inside one structure_version — and the epoch"+
" stayed at %d: without the boundary this is exactly what the canon forbids",
before.ChaptersDone, after.ChaptersDone, after.ShapeEpoch)
}
}
// The epoch counts BOUNDARIES, not announcements: a run re-stating the shape it already has moves
// nothing. Otherwise every progress event of every run would look to a client like a recomputation,
// and the signal would mean nothing by the time it mattered.
//
// Mutation caught: incrementing unconditionally, or on the FIRST announcement (which crosses nothing).
func TestTheEpochMovesOnBoundariesAndNotOnEveryAnnouncement(t *testing.T) {
s, _ := testDB(t)
book := draftedBook(t, s)
announceShape(t, s, book, 2, 2)
first, _ := shapeEpoch(t, s, book)
for range 3 {
announceShape(t, s, book, 2, 2)
}
same, _ := shapeEpoch(t, s, book)
if same != first {
t.Errorf("three restatements of the same shape moved the epoch %d → %d", first, same)
}
announceShape(t, s, book, 2, 0)
crossed, _ := shapeEpoch(t, s, book)
if crossed != first+1 {
t.Errorf("one boundary moved the epoch %d → %d, want exactly one step", first, crossed)
}
// …and back again is another boundary, not a return to the old number: the epoch is a generation
// counter, not a state.
announceShape(t, s, book, 2, 2)
back, _ := shapeEpoch(t, s, book)
if back != crossed+1 {
t.Errorf("crossing back left the epoch at %d, want %d: it counts crossings, not shapes", back, crossed+1)
}
}
// THE RUN'S BAR is NOT on the epoch, and this test is what holds that line.
//
// ⚠ Its predecessor asserted the opposite — that a run on a deployment which dropped the editor can
// reach one — and that was this pack's own mistake, caught by its adversarial pass and measured on a
// live database: pointing the bar at the ASSIGNABLE epoch let one run read 4/4 and then 2/2 across
// its own two attempts, the same row, the same `structure_version`, the fraction walking backwards.
// The canon holds a run's bar to one monotonic fraction over the run's whole work (row 200), so the
// bar stays on the MONOTONE `books.edit_wave` and only the BOOK's lifetime count moved to the epoch.
//
// What that leaves open is stated in the register rather than hidden here: on a deployment where the
// editor was removed, a run's bar still tariffs an edit wave that will not happen (PD-403's second
// half). Closing it needs a per-RUN record of the shape that run works under — and the shape is not
// known at StartRun, because the engine announces it with the run's FIRST progress event, which is
// precisely what PD-401 found. That is a decision of its own size.
//
// Mutation caught: pointing runDone/runTotal/runStage at epochWave.
func TestTheRunsBarIsMonotoneAcrossAShapeBoundaryItSpans(t *testing.T) {
s, ctx := testDB(t)
book := draftedBook(t, s)
// An editor deployment: the run buys two chapters and the bar tariffs both waves.
announceShape(t, s, book, 2, 2)
at := time.Now().UTC().Truncate(time.Microsecond)
run, 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)
}
exec(t, s, ctx, `update chapters set units_edit_done = units_total where book_id = $1`, book)
before, err := s.ReadRun(ctx, "u1", run.ID)
if err != nil {
t.Fatal(err)
}
// The operator removes the editor and the run's SECOND attempt announces a draft-only shape.
announceShape(t, s, book, 2, 0)
after, err := s.ReadRun(ctx, "u1", run.ID)
if err != nil {
t.Fatal(err)
}
if after.Progress.Done < before.Progress.Done || after.Progress.Total < before.Progress.Total {
t.Errorf("the bar of ONE run walked backwards across a shape boundary it spans: %d/%d → %d/%d."+
" The canon holds a run's bar to one monotonic fraction, and a run does not get to un-do"+
" work it was paid for because the deployment changed shape under it",
before.Progress.Done, before.Progress.Total, after.Progress.Done, after.Progress.Total)
}
// …while the BOOK's count DID move: that is the ruling, and the epoch announces it.
card, _, err := s.GetBook(ctx, "u1", book)
if err != nil {
t.Fatal(err)
}
if card.ShapeEpoch == 0 {
t.Error("the book crossed a shape boundary and its epoch did not move")
}
}