textmachine/platform/internal/pgstore/recut_test.go

329 lines
14 KiB
Go

package pgstore
import (
"testing"
"time"
"textmachine/platform/internal/money"
)
// recut_test.go: what happens to a book that the engine cut DIFFERENTLY.
//
// The pack's only re-cut test removed a trailing chapter — the one shape that neither renumbers a
// chapter nor moves an id — and the two defects below both lived in the shapes it did not reach.
// A re-cut that shifts chapter NUMBERS must not collide with the uniqueness of (book, number).
//
// A chapter's id is the hash of its text and survives a re-cut; its number is positional and moves.
// A langpack whose heading rule changes drops or restores a heading-only chapter and shifts every
// later number while the ids stay put — after which the tree could never be refreshed again, and no
// client was ever told, because the transaction rolled back whole.
//
// Mutation caught: making `chapters_book_id_number_key` immediate again.
func TestARecutThatShiftsChapterNumbersIsMaterialized(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
first := Structure{TextRead: true, ManifestKey: "k1", Chapters: []StructureChapter{
{EngineID: "a", Number: 1, Units: []StructureUnit{{EngineID: "a:1:0", Source: "первая", State: "pending"}}},
{EngineID: "b", Number: 2, Units: []StructureUnit{{EngineID: "b:1:0", Source: "вторая", State: "pending"}}},
}}
if err := s.SaveStructure(ctx, book, first); err != nil {
t.Fatal(err)
}
// A chapter appears at the HEAD: a and b keep their ids and both move one number down.
inserted := Structure{TextRead: true, ManifestKey: "k2", Chapters: []StructureChapter{
{EngineID: "z", Number: 1, Units: []StructureUnit{{EngineID: "z:2:0", Source: "нулевая", State: "pending"}}},
{EngineID: "a", Number: 2, Units: []StructureUnit{{EngineID: "a:2:0", Source: "первая", State: "pending"}}},
{EngineID: "b", Number: 3, Units: []StructureUnit{{EngineID: "b:2:0", Source: "вторая", State: "pending"}}},
}}
if err := s.SaveStructure(ctx, book, inserted); err != nil {
t.Fatalf("a chapter inserted at the head: %v", err)
}
page, err := s.ListChapters(ctx, "u1", book, 0, "")
if err != nil {
t.Fatal(err)
}
if len(page.Chapters) != 3 || page.StructureVersion != 2 {
t.Fatalf("after the insert: %d chapters at structure %d", len(page.Chapters), page.StructureVersion)
}
// …and the mirror shape, which delete-first would NOT have covered: the head chapter goes away
// again and the survivors move back up into the numbers they used to hold.
removed := Structure{TextRead: true, ManifestKey: "k3", Chapters: []StructureChapter{
{EngineID: "a", Number: 1, Units: []StructureUnit{{EngineID: "a:3:0", Source: "первая", State: "pending"}}},
{EngineID: "b", Number: 2, Units: []StructureUnit{{EngineID: "b:3:0", Source: "вторая", State: "pending"}}},
}}
if err := s.SaveStructure(ctx, book, removed); err != nil {
t.Fatalf("the head chapter removed again: %v", err)
}
if page, err = s.ListChapters(ctx, "u1", book, 0, ""); err != nil || len(page.Chapters) != 2 {
t.Fatalf("after the removal: %+v (%v)", page, err)
}
}
// A re-cut drops the resolutions of the cut that is gone.
//
// They are addressed by the engine's (chapter number, unit ordinal), which the new cut re-points, so
// keeping them made a chapter report the finished units — and the notes — of whoever held its number
// before.
//
// Mutation caught: leaving the resolutions in place across a re-cut.
func TestARecutDoesNotLetAChapterInheritTheProgressOfTheOneBeforeIt(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)
}
if _, err := s.pool.Exec(ctx, `
insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, reason, at, revision)
values ($1, 1, 0, 'edit', true, true, 'glossary_miss', now(), 1)`, book); err != nil {
t.Fatal(err)
}
notes, err := s.ListNotes(ctx, "u1", book, 0, "", nil)
if err != nil || len(notes.Notes) != 1 {
t.Fatalf("before the re-cut: %d notes (%v)", len(notes.Notes), err)
}
recut := Structure{TextRead: true, ManifestKey: "k2", Chapters: []StructureChapter{
{EngineID: "c9", Number: 1, Units: []StructureUnit{{EngineID: "c9:2:0", Source: "новая", State: "pending"}}},
}}
if err := s.SaveStructure(ctx, book, recut); err != nil {
t.Fatal(err)
}
after, err := s.ListNotes(ctx, "u1", book, 0, "", nil)
if err != nil {
t.Fatal(err)
}
if len(after.Notes) != 0 {
t.Errorf("a note of the previous cut was re-addressed to the chapter now holding its number: %+v", after.Notes)
}
}
// A materialization whose TEXT channel failed leaves the stored text alone.
//
// The tree and the pairs are two engine calls. The second one failing is not "this book has no
// translation" — it is "this read knows nothing about the text" — and an unconditional upsert made
// the two indistinguishable by erasing a translated book with empty strings.
//
// Mutation caught: writing source/target unconditionally.
func TestAFailedExportDoesNotEraseTheTextAlreadyMaterialized(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)
}
chapters, err := s.ListChapters(ctx, "u1", book, 0, "")
if err != nil {
t.Fatal(err)
}
before, err := s.ListUnits(ctx, "u1", book, chapters.Chapters[0].ID, 0, "")
if err != nil || before.Units[0].Target == "" {
t.Fatalf("the fixture carries no translation to lose: %+v (%v)", before.Units, err)
}
// The same cut, read again while the pairs channel was unreadable: same ids, no text.
blind := twoChapters("k1")
for i := range blind.Chapters {
for j := range blind.Chapters[i].Units {
u := &blind.Chapters[i].Units[j]
u.Source, u.Target, u.State, u.TextKnown = "", "", "pending", false
}
}
if err := s.SaveStructure(ctx, book, blind); err != nil {
t.Fatal(err)
}
after, err := s.ListUnits(ctx, "u1", book, chapters.Chapters[0].ID, 0, "")
if err != nil {
t.Fatal(err)
}
if after.Units[0].Target != before.Units[0].Target || after.Units[0].Source != before.Units[0].Source {
t.Errorf("a failed export overwrote the text: %q/%q, was %q/%q",
after.Units[0].Source, after.Units[0].Target, before.Units[0].Source, before.Units[0].Target)
}
if after.Units[0].State != before.Units[0].State {
t.Errorf("a failed export moved the state to %q, was %q", after.Units[0].State, before.Units[0].State)
}
}
// A cursor is scoped to its BOOK, not only to the structure version it was minted at.
//
// Two books at the same structure version handed cursors that were accepted on each other, and the
// second book then answered its own rows from the first one's offset.
//
// Mutation caught: dropping the book from the cursor's scope tag.
func TestACursorMintedForOneBookIsRefusedOnAnother(t *testing.T) {
s, ctx := testDB(t)
first := readingBook(t, s, ctx, "u1")
second, err := s.AddBook(ctx, NewBook{OwnerID: "u1", Title: "вторая", SourceLang: "zh",
TargetLang: "ru", Workdir: "/srv/books/b", Now: time.Now().UTC()})
if err != nil {
t.Fatal(err)
}
for _, b := range []string{first, second} {
if err := s.SaveStructure(ctx, b, twoChapters("k1")); err != nil {
t.Fatal(err)
}
}
page, err := s.ListChapters(ctx, "u1", first, 1, "")
if err != nil || page.NextCursor == "" {
t.Fatalf("no cursor to carry across: %+v (%v)", page, err)
}
if _, err := s.ListChapters(ctx, "u1", second, 1, page.NextCursor); err == nil {
t.Error("a cursor minted for another book was accepted")
}
}
// The page size is CLAMPED here and refused nowhere: the HTTP layer forwards whatever it was given,
// so this is the only place the canon's "clamp, never refuse" is decided.
//
// Mutation caught: returning the default instead of maxPage for an over-large ask, which is what
// made "ask for more, get fewer rows than a smaller request" true.
func TestAPageSizeIsClampedAndNeverRefused(t *testing.T) {
for in, want := range map[int]int{
0: DefaultPage, -3: DefaultPage, 1: 1, 100: 100,
maxPage: maxPage, maxPage + 1: maxPage, 100000: maxPage,
} {
if got := clampPage(in); got != want {
t.Errorf("clampPage(%d) = %d, want %d", in, got, want)
}
}
}
// The run's bar and its BASELINES count the same passes: each numerator is subtracted only from
// the baseline captured on ITS OWN column (draft against draft_before, edit against
// chapters_before), so a signing run over an already-drafted book opens at 0 done — through the
// old segment counting the mismatch opened it at its full ceiling.
//
// ⚠ The fixture keeps the two columns APART on purpose (one chapter edited, both drafted): with
// `units_edit_done = 0` everywhere both pairings answered the same 0, and this test's earlier
// claim named a mutation it could not see — the PD-1 class, in the pin rewritten to cure one.
//
// Mutation caught: subtracting the EDIT baseline (`chapters_before`) from the draft numerator in
// draftBar. The mirror pairing — the edit numerator against `draft_before` — needs edits ahead of
// drafts, which no real state produces; on its artificial fixture it is held by
// TestASecondRunsBarStartsAtZeroOverAHalfFinishedBook.
func TestTheBarAndItsBaselineCountTheSamePass(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)
}
// Drafted end to end, ONE chapter already edited — the columns differ, so a swapped pairing has
// something to be wrong about.
if _, err := s.pool.Exec(ctx,
`update chapters set units_draft_done = units_total,
units_edit_done = case when number = 1 then units_total else 0 end
where book_id = $1`, book); err != nil {
t.Fatal(err)
}
started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, VerifyBank: true,
OrderedChapters: 2, Ceiling: money.MicroUSD(300_000), Now: time.Now().UTC()}, 0, nil)
if err != nil {
t.Fatal(err)
}
got, err := s.ReadRun(ctx, "u1", started.ID)
if err != nil {
t.Fatal(err)
}
if got.Progress.Done != 0 {
t.Errorf("a signing run over an already-drafted book opens at %d/%d, want 0 done",
got.Progress.Done, got.Progress.Total)
}
}
// Lifting the signing stop leaves the bar where it stood, and the edit pass then moves it chapter
// by chapter — through the through-bar (row 200) there is no second segment and nothing re-bases.
//
// ⚠ REWRITTEN by pack P9 with the semantics it pins: the earlier edition was named
// «RestartsTheBarFromZero» and claimed to catch «dropping the re-capture from the re-open» — that
// re-capture was REMOVED with the segment model, and the test stayed green, so its words proved
// less than they said (the PD-1 class). What its fixture still pins is real and distinct: a fully
// drafted, unedited book under a signing run — the bar opens at 0 done, the lift moves nothing,
// and one edited chapter moves it by exactly one.
//
// Mutation caught: pairing the edit numerator with the draft baseline in runDone.
func TestLiftingTheSigningStopLeavesTheBarWhereItStood(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)
}
if _, err := s.pool.Exec(ctx,
`update chapters set units_draft_done = units_total, units_edit_done = 0
where book_id = $1`, book); err != nil {
t.Fatal(err)
}
started, err := s.StartRun(ctx, StartRunInput{UserID: "u1", BookID: book, VerifyBank: true,
OrderedChapters: 2, Ceiling: money.MicroUSD(300_000), Now: time.Now().UTC()}, 0, nil)
if err != nil {
t.Fatal(err)
}
// Through the path a resume actually takes: the stop is lifted by the re-open itself.
if _, err := s.RestartRun(ctx, RestartInput{RunID: started.ID, AttemptID: started.AttemptID,
UserID: "u1", BookID: book, Ceiling: money.MicroUSD(100_000),
Now: time.Now().UTC()}); err != nil {
t.Fatal(err)
}
got, err := s.ReadRun(ctx, "u1", started.ID)
if err != nil {
t.Fatal(err)
}
if got.Progress.Done != 0 {
t.Fatalf("after the lift the bar reads %d/%d, want 0 done — the run has done nothing yet", got.Progress.Done, got.Progress.Total)
}
// One chapter finishes its EDIT pass: the bar moves by exactly one.
if _, err := s.pool.Exec(ctx, `
update chapters set units_edit_done = units_total
where book_id = $1 and number = 1`, book); err != nil {
t.Fatal(err)
}
if got, err = s.ReadRun(ctx, "u1", started.ID); err != nil {
t.Fatal(err)
}
if got.Progress.Done != 1 {
t.Errorf("after one edited chapter the bar reads %d, want 1", got.Progress.Done)
}
}
// Deferring the chapter-number constraint must not DISABLE it: two chapters may share a number in
// the middle of a transaction and never at its end.
//
// Mutation caught: dropping the constraint instead of deferring it.
func TestTheDeferredChapterNumberIsStillUnique(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 chapters of one book claiming number 1 at COMMIT.
_, err := s.pool.Exec(ctx, `
insert into chapters (id, book_id, number, units_total) values ('ch_dup', $1, 1, 1)`, book)
if err == nil {
t.Error("two chapters kept one number past the end of the transaction")
}
}
// The FIRST materialization is not a re-cut, and must not throw away work.
//
// A book whose tree lands late — the intake's own refresh failed (PD-276) — can already have a
// finished run behind it. Treating "no cut has been seen" as "the cut changed" deleted that run's
// resolutions: the card read zero chapters done and every note of a paid run was gone.
//
// Mutation caught: dropping resolutions whenever the stored key differs, empty included.
func TestTheFirstMaterializationDoesNotDiscardAFinishedRunsWork(t *testing.T) {
s, ctx := testDB(t)
book := readingBook(t, s, ctx, "u1")
// A run finished before any tree was materialized.
if _, err := s.pool.Exec(ctx, `
insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, reason, at, revision)
values ($1, 1, 0, 'edit', true, true, 'glossary_miss', now(), 1)`, book); err != nil {
t.Fatal(err)
}
if err := s.SaveStructure(ctx, book, twoChapters("k1")); 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 {
t.Errorf("the first materialization threw away a finished run's notes: %d left", len(notes.Notes))
}
}