329 lines
14 KiB
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))
|
|
}
|
|
}
|