246 lines
11 KiB
Go
246 lines
11 KiB
Go
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")
|
||
}
|
||
}
|