diff --git a/platform/internal/ingest/price_test.go b/platform/internal/ingest/price_test.go new file mode 100644 index 00000000..74882cd0 --- /dev/null +++ b/platform/internal/ingest/price_test.go @@ -0,0 +1,173 @@ +package ingest + +import ( + "encoding/json" + "testing" + + "textmachine/platform/internal/money" +) + +// pricedDoc is a two-chapter manifest carrying a whole projection, as the engine emits one. Written +// as JSON and decoded rather than built as a struct, because half of what these tests are about is +// the DECODING — a key the engine renames leaves a zero behind, and a struct literal cannot express +// that. +func pricedDoc(t *testing.T, edit func(map[string]any)) Manifest { + t.Helper() + const doc = `{ + "manifest_version": "tm-manifest-v2", + "key": "k1", "chunker_version": "cv1", "structure": "detected", + "chapters_total": 2, "units_total": 2, + "price": {"expected_usd": 2.0062, "book_once_usd": 2, "step_max_usd": 0.129051, "source_chars": 48}, + "chapters": [ + {"id": "c1", "number": 1, "units_total": 1, + "price": {"source_chars": 24, "expected_usd": 0.0031}, + "units": [{"id": "u1", "first_chunk_idx": 0, "price": {"source_chars": 24, "expected_usd": 0.0031}}]}, + {"id": "c2", "number": 2, "units_total": 1, + "price": {"source_chars": 24, "expected_usd": 0.0031}, + "units": [{"id": "u2", "first_chunk_idx": 0, "price": {"source_chars": 24, "expected_usd": 0.0031}}]} + ] + }` + var raw map[string]any + if err := json.Unmarshal([]byte(doc), &raw); err != nil { + t.Fatal(err) + } + if edit != nil { + edit(raw) + } + b, err := json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + m, err := DecodeManifest(b) + if err != nil { + t.Fatal(err) + } + return m +} + +// The projection arrives whole, and it arrives as MONEY: whole micro-USD converted at the seam, +// rounding UP, never a float64 carried inward. A JSON decimal bound to a float puts drift one step +// before the integer column that exists to prevent drift (PD-15). +func TestThePriceProjectionIsReadAsWholeMicroDollars(t *testing.T) { + m := pricedDoc(t, nil) + p, ok := m.Priced() + if !ok { + t.Fatal("a whole projection was not read") + } + // 0.129051 → 129051 exactly; 2.0062 → 2006200; the per-chapter 0.0031 → 3100. + if p.StepMaxUSD != 129_051 || p.ExpectedUSD != 2_006_200 || p.BookOnceUSD != 2_000_000 { + t.Errorf("the book price came out as %+v", p) + } + if p.SourceChars != 48 { + t.Errorf("source chars %d", p.SourceChars) + } + if len(m.Chapters) != 2 || m.Chapters[0].Price.ExpectedUSD != 3100 || m.Chapters[1].Price.ExpectedUSD != 3100 { + t.Errorf("the per-chapter bills came out as %+v", m.Chapters) + } + // Rounding is UP, and the direction is the engine's own rule for money on this seam: a price + // rounded down under-quotes the account by construction, every time, the same way. + up := pricedDoc(t, func(raw map[string]any) { + raw["price"].(map[string]any)["step_max_usd"] = 0.0000001 + }) + if p, _ := up.Priced(); p.StepMaxUSD != 1 { + t.Errorf("a tenth of a micro-dollar rounded to %d, want 1", p.StepMaxUSD) + } +} + +// ⛔ HALF-READ IS NOT ABSENT, AND ABSENT IS NOT ZERO. `json.Unmarshal` leaves a key the engine +// renamed at its zero value, so a projection whose `step_max_usd` decoded as zero is the exact shape +// of the PD-440 wall coming back in silence: the hold loses its floor, the run is admitted with a +// ceiling no single call can clear, and it dies at once having spent nothing and moved nothing. +// +// Every one of these documents is internally VALID — the tree passes Whole() — which is what makes +// the check worth having: nothing else in this package would notice. +// +// Mutation caught: any of the three guards in Priced turned into a truth. +func TestAHalfReadProjectionIsNotAFreeBook(t *testing.T) { + for name, edit := range map[string]func(map[string]any){ + "the engine renamed step_max_usd": func(raw map[string]any) { + p := raw["price"].(map[string]any) + delete(p, "step_max_usd") + }, + "the engine renamed expected_usd": func(raw map[string]any) { + p := raw["price"].(map[string]any) + delete(p, "expected_usd") + }, + "the engine renamed the per-chapter price": func(raw map[string]any) { + for _, c := range raw["chapters"].([]any) { + delete(c.(map[string]any), "price") + } + }, + "one chapter lost its price": func(raw map[string]any) { + delete(raw["chapters"].([]any)[1].(map[string]any), "price") + }, + "the chapters and the book disagree": func(raw map[string]any) { + raw["chapters"].([]any)[0].(map[string]any)["price"].(map[string]any)["expected_usd"] = 9.0 + }, + } { + t.Run(name, func(t *testing.T) { + if p, ok := pricedDoc(t, edit).Priced(); ok { + t.Errorf("a half-read projection was believed: %+v", p) + } + }) + } + // A book the engine could not price AT ALL is legal and says so — `price` is `omitempty` on the + // engine's side. It is the same answer as a half-read one, which is the point: the caller refuses + // the sale either way and never invents a number. + none := pricedDoc(t, func(raw map[string]any) { delete(raw, "price") }) + if _, ok := none.Priced(); ok { + t.Error("a manifest with no projection reported one") + } + // …and the TREE is still readable, which is what keeps an unpriceable book a book: the intake and + // the materializer must not refuse it, they must refuse to SELL it. + if err := none.Whole(); err != nil { + t.Errorf("an unpriced manifest failed the tree's own check: %v", err) + } +} + +// ⛔ `detected` IS TRUSTED AND `declared` IS NOT, which is the opposite of what the words suggest and +// therefore the thing most likely to be "corrected" back by a later reader. +// +// For an EPUB the engine cuts by SPINE DOCUMENTS — one document, one «chapter» — and labels that +// `declared`, honestly, because the format did draw those boundaries. But a spine is the READING +// ORDER, not a table of contents: the real chapter boundaries live in `nav`/NCX, which the engine +// does not read yet. So `declared` today means «this many documents»: a book shipped as one large +// document has one, a book split by scene has dozens. Selling «through chapter 12» against it hands +// a buyer twelve DOCUMENTS under the name of twelve chapters. Ratified 05.09. +// +// Mutation caught: adding StructureDeclared to ChapterOrdersOffered. +func TestOnlyADetectedCutMayBeSoldAgainstInChapters(t *testing.T) { + for structure, want := range map[string]bool{ + StructureDetected: true, + StructureDeclared: false, + StructureNone: false, + "": false, + "toc": false, // a word a later engine grows: unknown is untrusted, never refused + } { + if got := (Manifest{Structure: structure}).ChapterOrdersOffered(); got != want { + t.Errorf("structure %q offers chapter orders: %v, want %v", structure, got, want) + } + } +} + +// The projection's arithmetic is the ENGINE's and the seam only carries it: the chapters' bills plus +// the book-level bound ARE the book's expected bill. Asserted because the platform prices an ORDER +// from the chapters and shows the BOOK figure beside it, and two numbers that must agree are exactly +// where a silent drift lives. +func TestTheChaptersAndTheBookFigureDescribeTheSameBook(t *testing.T) { + m := pricedDoc(t, nil) + p, ok := m.Priced() + if !ok { + t.Fatal("not priced") + } + sum := money.MicroUSD(0) + for _, c := range m.Chapters { + sum += c.Price.ExpectedUSD + } + // The slack is the conversion's own: every amount is rounded UP independently, so the chapters + // can sum a micro-dollar per chapter above their share. + if diff := p.ExpectedUSD - p.BookOnceUSD - sum; diff < -3 || diff > 3 { + t.Errorf("the chapters sum to %s and the book says %s above its book-level bound", + sum.USD(), (p.ExpectedUSD - p.BookOnceUSD).USD()) + } +} diff --git a/platform/internal/pgstore/migrations/00033_order_and_price.sql b/platform/internal/pgstore/migrations/00033_order_and_price.sql new file mode 100644 index 00000000..398ab9f9 --- /dev/null +++ b/platform/internal/pgstore/migrations/00033_order_and_price.sql @@ -0,0 +1,211 @@ +-- +goose Up + +-- The ORDER and the PRICE (D39.196, unified backlog rows 279/280/282). +-- +-- Two facts arrive together because one is useless without the other. The engine has published what +-- a book costs since landing 81a89e9 — `expected_usd` per chapter, `book_once_usd`, `step_max_usd` +-- and `source_chars`, all in `manifest --json` — and until now the platform read none of it and +-- priced every purchase with a per-chapter CONSTANT ($0.03) that was measured to be four and a half +-- times too low (D39.179 §1). The constant is what made the last two chapters of any book unbuyable +-- at any price (PD-440): the new headroom of a run was `chaptersLeft × $0.03`, and two chapters of +-- it is six cents against a single editor reservation of about seven. +-- +-- And the intent of a purchase moves from the RUN to the BOOK. It lived on the run because a ceiling +-- was a property of one process; but «the book is bought through chapter 40» outlives any process, +-- and while it lived on the run, topping up after a stop meant starting a NEW run with a LARGER +-- ceiling — a shape nobody could explain to a buyer. + +alter table books + -- The engine's own count of the INGESTED text in runes, spaces included. It replaces the + -- intake's `character_count`, which counts the non-continuation bytes of the WRITE STREAM and is + -- therefore a property of a ZIP archive for an EPUB (row 282). Null until a manifest has been + -- read: absent is not zero, and a zero here would say "an empty book" on a product screen. + add column source_chars bigint, + -- WHICH PATH drew the chapter boundaries: `declared` (the format drew them), `detected` (matched + -- in the prose) or `none` (one chapter). It is the engine's PROVENANCE word, stored verbatim. + -- + -- ⛔ DELIBERATELY WITHOUT A CHECK CONSTRAINT, and the reason is that the vocabulary is not this + -- zone's. It belongs to the ENGINE's contract, and that contract is ratified to GROW — the + -- structure pack will extend it additively. A CHECK here would declare that somebody else's + -- vocabulary may not grow: the fourth value would arrive, `SaveStructure` would FAIL on the write + -- instead of degrading, the tree would not update and the book would go back into the queue — a + -- refusal in a place that has no cure for it, while the cure is written one layer up. + -- + -- The guarantee is not lost, it is placed where it can degrade: ingest.ChapterOrdersOffered reads + -- anything it does not know as «not trustworthy enough to sell chapters against», so an unknown + -- word costs a character slider and an honest sentence, never a failed materialization. That is + -- strictly stronger than a CHECK, which can only refuse. (Caught by the orchestrator's review as + -- a contradiction INSIDE this pack: the Go comment promised degradation while the schema forbade + -- it. Other CHECKs in this migration stand — their vocabularies are this zone's own.) + add column structure text, + -- The book price projection, in whole micro-USD, converted at the seam. The three move TOGETHER + -- or not at all: a book is priced or it is not, and a half-read projection is what would bring + -- the wall back silently (ingest.Priced refuses one). + add column expected_micro_usd bigint check (expected_micro_usd > 0), + add column book_once_micro_usd bigint check (book_once_micro_usd >= 0), + add column step_max_micro_usd bigint check (step_max_micro_usd > 0), + -- THE ORDER, on the book. `ordered_at` is the only fact that says an order exists at all, which + -- is what lets the columns below use NULL for «the whole book» as the canon asks (row 280) + -- without that NULL also having to mean «nothing was ever bought». + add column ordered_at timestamptz, + -- ⛔ THE ORDER'S BOUNDARY IS AN IDENTITY, NEVER AN ORDINAL, and the difference is money. + -- Ratified by the orchestrator 05.09, before this column was written. + -- + -- A book can be CUT AGAIN — a re-snapshot, a change of cutting rules, a corrected source — and + -- the two forms then behave in opposite ways. «Through chapter 12» stored as the NUMBER twelve + -- resolves, after a re-cut, to twelve DIFFERENT chapters: a different amount of text, a different + -- price, and nobody notices — a silent change of what was bought, which is the worst class of + -- defect this project has and a direct breach of «money is visible, there are no quiet + -- re-purchases». Stored as an IDENTITY the reference simply STOPS RESOLVING, which is not a + -- breakage but a signal: the order goes back to the person for an explicit re-confirmation, on + -- the machinery that already exists for re-purchase. + -- + -- The intent «up to this place in the story» survives a re-cut. The intent «up to the twelfth by + -- count» does not, because the count is precisely what a re-cut changes. + -- + -- ⛔ NO FOREIGN KEY, AND THAT IS THE WHOLE MECHANISM RATHER THAN AN OMISSION. `on delete set + -- null` would turn «through chapter 12» into NULL the moment a re-cut dropped that chapter's + -- row — and NULL here means THE WHOLE BOOK. The reference that was supposed to stop resolving + -- would instead resolve to a LARGER order, silently, which is the very defect the identity form + -- exists to prevent, arrived at from the other side. A dangling id is the signal; a cascade + -- would erase it. + add column ordered_through_chapter_id text, + -- The CHARACTER order's boundary, for a book whose chapters cannot be ordered against. A unit id + -- carries the cut in its own bytes and therefore dies WITH the cut — which is the same signal one + -- step finer, and the honest one here: a character order stops inside a chapter, so no chapter + -- identity can express where. + -- No foreign key here either, and for the same reason: a re-cut mints a new id for every unit, + -- so the cascade would fire on every re-cut and turn a partial order into the whole book. + add column ordered_through_unit_id text, + -- The buyer's own words at the moment of the order: «through chapter N». A LABEL, never a key — + -- kept so the screen can say what was bought and so a shifted number is VISIBLE beside the + -- identity that did not shift. + add column ordered_through_chapter_number integer check (ordered_through_chapter_number > 0); + +-- Whether the run's hold has room for the BOOK-LEVEL passes on top of the work it bought — the +-- terminology consolidation and its classifier, which feed the memory bank and are therefore the +-- mechanism behind consistency of terms across a whole book (D39.198, the owner's first priority). +-- +-- ⛔ IT IS ON THE RUN BECAUSE THE PROMISE HAS TO SURVIVE THE CLICK. The order form says, before the +-- click, whether the pass is funded; the balance then moves — another book's hold, a correction — +-- and the hold taken at admission is computed against the balance AS IT IS. So a buyer shown +-- «funded» can be sold a run that is not, and the passes DEGRADE rather than halt: the book arrives +-- and only its terms wander. A refusal shaped like normal work is the class D39.202 §3 names, and +-- this column is what lets the run say afterwards what it actually got. +-- +-- `false` for the rows that predate it, which is the safe reading: a run nobody recorded this for +-- makes no promise about it. +alter table runs add column bond_funded boolean not null default false; + +-- ⛔ A RUN WHOSE ORDER DOES NOT CLOSE WHOLE CHAPTERS COUNTS ITS BAR IN UNITS, and these three columns +-- are that bar. NULL `ordered_units` means the ordinary shape: the run bought whole chapters and its +-- bar is counted in them, exactly as before this pack. +-- +-- WHY IT IS NEEDED AT ALL. The order, the hold and `translate --max-units` all live in output UNITS; +-- the bar and the delivered counters live in CHAPTERS, and a chapter counts only once every unit in +-- it is done (`draftChapters`/`editChapters`). A CHARACTER order — the only partial order a book +-- whose chapters cannot be sold against can carry, which today is every EPUB and every non-CJK txt — +-- buys a PREFIX of a chapter. The engine ships it and exits 0, the chapter never closes, and the run +-- reads `0/N` for its entire life with `delivered_chapters: 0` for ever. +-- +-- ⛔ AND THAT STATE IS ONE THIS PLATFORM ALREADY CALLS INADMISSIBLE, in its own words: the admission +-- refuses a book whose tree is not materialised because «every counter the screen shows is a count +-- over chapters, so the run would read 0/total for its entire life while spending» (runs.Start, +-- PD-405). Introducing the same state through a different door would be this pack contradicting +-- itself. +-- +-- The two baselines mirror `chapters_before`/`draft_before` one level finer, and for the same reason +-- they exist there: each numerator is paired with the baseline captured on ITS OWN wave, so a wave +-- flip cannot strand the bar (the P9 blocker). +alter table runs + add column ordered_units integer check (ordered_units > 0), + add column units_before integer not null default 0, + add column draft_units_before integer not null default 0; + +-- The per-UNIT half of the projection, and the unit is where the price lives — there is deliberately +-- no per-chapter copy of it. +-- +-- ⛔ THE CHAPTER ROLL-UP WAS HERE AND IS GONE, because it became a SECOND CARRIER of one fact. An +-- order is priced by summing the UNDELIVERED units of a chapter, never the chapter's total divided by +-- a count of them — units are not equal, and dividing quoted a remainder of $0.099 as $0.050 on a +-- measured fixture. Once the sum reads units, the chapter columns were written by the materializer +-- and read by nobody: two numbers for one price, one of them updated and never checked, which is the +-- shape every drift in this package has had. The engine's own chapter roll-up is still WITNESSED, in +-- the place a witness belongs — `ingest.Priced` cross-checks it against the book figure inside the +-- document — and that is a different job from carrying it. +-- +-- NOT NULL with a zero default: these rows are written only by the materializer, which refuses to +-- write a tree from a manifest it did not read whole, so a unit with no price is one materialized +-- before this column existed. The BOOK's figures are nullable instead, because there absence is what +-- decides whether the book can be sold at all and must be visible. +alter table units + add column expected_micro_usd bigint not null default 0, + add column source_chars bigint not null default 0; + +-- Every book that can still be translated owes a fresh reading surface, because no book on this host +-- has a price yet: the columns above are new and the manifest that fills them was last read before +-- they existed. Without this line every existing book answers "not priced" — which is an honest +-- refusal to sell, and a permanent one, since nothing else would ever ask the engine again. +-- +-- It is the debt column and not a direct write on purpose: the materializer is the ONE writer of the +-- tree, it claims a book before it reads it, and it already retries, backs off and gives up. Stamping +-- the debt puts these books through exactly the path a re-cut puts them through. +-- +-- ⚠ THE ATTEMPT BUDGET IS RESET WITH IT, and the first edition of this line forgot to. A debt that +-- was WRITTEN OFF (`read_model_abandoned_at` set, attempts spent) is re-armed here — that is the +-- point, since such a book has no price and would never be asked about again — but re-armed with its +-- budget already exhausted it is written off again by the first transient failure, and stays +-- unsellable forever. The end-of-run path that legitimately re-arms a debt clears all three +-- (`owesAReadingSurface`); this line does what that one does, for the same reason. +-- +-- `uploading` and `parsing` are left alone — their intake will materialize them anyway — and so is +-- `rejected`, whose directory may not exist. +update books set read_model_owed_at = now(), + read_model_attempts = 0, + read_model_abandoned_at = null, + read_model_error = null + where status not in ('uploading', 'parsing', 'rejected') + and read_model_owed_at is null; + +-- `run_limit_reached` joins the run's pause vocabulary, and `credit_exhausted` stops being produced +-- for a run's own ceiling (PD-446, unified backlog row 279). +-- +-- The two were one word and the word was the ACCOUNT's. Measured 04.09: a run stood +-- `paused_reason: credit_exhausted` at the same moment `GET /v0/usage` answered `state: ok, +-- halt_reason: null` with $0.102494 of $0.30 still on the account. Both answers were right — the +-- account halt is read off the account — and a user read «your credit ran out» beside «34% left». +-- +-- ⚠ THE REWRITE IS IMPRECISE AND THE IMPRECISION IS NAMED RATHER THAN GLOSSED (an earlier edition of +-- this comment claimed every such row came from the book-scope ceiling path, and that was false). +-- The reconciler used to write this ONE word for BOTH of its verdicts — the run having spent its +-- order, and the ACCOUNT being unable to carry the rest — and nothing in the row tells them apart +-- afterwards. So a minority of historical rows are re-worded wrongly. +-- +-- Rewriting anyway is the lesser error, and which way it errs is the whole argument: the majority +-- are spent orders, and telling a buyer with money «your credit ran out» sends them to top up an +-- account that does not need it. The minority now read «the order ran out», and a buyer who acts on +-- that meets an honest refusal on their balance at the next purchase, which says the true thing. +-- Rows written from here on carry whichever word is true (reconcile.go, the two verdicts). +alter table runs drop constraint if exists runs_paused_reason_check; +alter table runs add constraint runs_paused_reason_check + check (paused_reason in ('run_limit_reached', 'credit_exhausted', 'daily_ceiling', 'ceiling_unknown')); +update runs set paused_reason = 'run_limit_reached' where paused_reason = 'credit_exhausted'; + +-- +goose Down +update runs set paused_reason = 'credit_exhausted' where paused_reason = 'run_limit_reached'; +alter table runs drop constraint if exists runs_paused_reason_check; +alter table runs add constraint runs_paused_reason_check + check (paused_reason in ('credit_exhausted', 'daily_ceiling', 'ceiling_unknown')); +alter table runs drop column bond_funded, drop column ordered_units, + drop column units_before, drop column draft_units_before; +alter table units drop column expected_micro_usd, drop column source_chars; +alter table books + drop column source_chars, + drop column structure, + drop column expected_micro_usd, + drop column book_once_micro_usd, + drop column step_max_micro_usd, + drop column ordered_at, + drop column ordered_through_chapter_id, + drop column ordered_through_unit_id, + drop column ordered_through_chapter_number; diff --git a/platform/internal/pgstore/price_test.go b/platform/internal/pgstore/price_test.go new file mode 100644 index 00000000..2970701a --- /dev/null +++ b/platform/internal/pgstore/price_test.go @@ -0,0 +1,196 @@ +package pgstore + +import ( + "testing" + + "textmachine/platform/internal/ingest" + "textmachine/platform/internal/money" +) + +// priced is twoChapters with a projection on it: a $2.00 book-level bound (the shipped arm's flat +// figure), two chapters of unequal price, and one editor reservation. +func priced() Structure { + in := twoChapters("k1") + in.Structure = ingest.StructureDetected + in.Price = &ingest.BookPrice{ + ExpectedUSD: 2_090_000, BookOnceUSD: 2_000_000, StepMaxUSD: 69_828, SourceChars: 3000, + } + // ⚠ The price lives on the UNIT and only there: a chapter's cost is the sum of the units still to + // deliver, so a chapter-level copy would be a second carrier free to drift from it. + in.Chapters[0].Units[0].Expected, in.Chapters[0].Units[0].SourceChars = 40_000, 1200 + in.Chapters[0].Units[1].Expected, in.Chapters[0].Units[1].SourceChars = 20_000, 800 + in.Chapters[1].Units[0].Expected, in.Chapters[1].Units[0].SourceChars = 30_000, 1000 + return in +} + +// The projection lands WITH the cut it was derived from, and it is read back as an order is priced: +// the book's three figures, and the chapters still to be delivered with their own bills. +// +// ⚠ The per-chapter figure is PRO-RATED by what is left, and the division truncates — so the error +// is downward, which is the ratified direction (D39.206: a hold that is short costs one top-up, a +// long one freezes credit the buyer cannot spend elsewhere). +func TestTheProjectionLandsWithTheCutAndPricesWhatIsLeft(t *testing.T) { + s, ctx := testDB(t) + book := readingBook(t, s, ctx, "u1") + if err := s.SaveStructure(ctx, book, priced()); err != nil { + t.Fatal(err) + } + got, err := s.ReadBookForOrder(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if !got.Priced { + t.Fatal("a book that carries a whole projection reads as unpriced") + } + if got.Expected != 2_090_000 || got.BookOnce != 2_000_000 || got.StepMax != 69_828 { + t.Errorf("the book's figures: %+v", got) + } + if got.Structure != ingest.StructureDetected { + t.Errorf("structure %q", got.Structure) + } + if len(got.Remaining) != 2 { + t.Fatalf("%d chapters remaining, want 2", len(got.Remaining)) + } + if got.Remaining[0].Expected != 60_000 || got.Remaining[0].Units != 2 || + got.Remaining[0].SourceChars != 2000 { + t.Errorf("chapter 1: %+v", got.Remaining[0]) + } + if got.Remaining[0].ID == "" || got.Remaining[0].Number != 1 { + t.Errorf("chapter 1 carries no identity to anchor an order on: %+v", got.Remaining[0]) + } + + // ⛔ ONE OF CHAPTER ONE'S TWO UNITS COMES BACK DELIVERED — THE CHEAP ONE — and the chapter's + // remainder is then the price of the unit that is LEFT, not half the chapter's total. + // + // The two units are $0.04 and $0.02, so the two arithmetics differ by half a cent here and by far + // more on a real book: measured on a chapter holding a 100-rune unit at $0.001 beside a + // 9900-rune one at $0.099, dividing by the COUNT quotes $0.050 against a real remainder of + // $0.099 — a `covers_all` the engine then stops halfway through — and, with the expensive unit + // delivered instead, quotes $0.050 against a real $0.001 and refuses a purchase the buyer can + // afford. The engine prices per UNIT and this platform stores it; the count was never the + // question. + // + // Written as a RESOLUTION ROW and not as a counter, because the rows are what the query reads and + // the counters are derived from them: a fixture that moved only the counter would be describing a + // state the materializer cannot produce. + deliver := func(chapter, unit int) { + t.Helper() + if _, err := s.pool.Exec(ctx, ` + insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at) + values ($1, $2, $3, 'edit', true, false, now())`, book, chapter, unit); err != nil { + t.Fatal(err) + } + } + deliver(1, 0) // the $0.04 unit of chapter one + got, err = s.ReadBookForOrder(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if len(got.Remaining) != 2 || got.Remaining[0].Units != 1 { + t.Fatalf("a half-delivered chapter: %+v", got.Remaining) + } + if got.Remaining[0].Expected != 20_000 { + t.Errorf("the remainder of chapter one is priced at %s, want the %s of the unit that is left "+ + "(half the chapter would be %s, and that is the arithmetic this test exists to refuse)", + got.Remaining[0].Expected.USD(), money.MicroUSD(20_000).USD(), money.MicroUSD(30_000).USD()) + } + // The TEXT that is left follows the same rule: what the screen says is «still to translate», not + // the whole chapter. + if got.Remaining[0].SourceChars != 800 { + t.Errorf("the remainder of chapter one is %d runes, want the 800 of the unit that is left", + got.Remaining[0].SourceChars) + } + + // A chapter fully delivered leaves the order entirely: it is not for sale twice. + deliver(1, 4) + got, err = s.ReadBookForOrder(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if len(got.Remaining) != 1 || got.Remaining[0].Number != 2 { + t.Errorf("a delivered chapter is still on offer: %+v", got.Remaining) + } +} + +// A cut with NO projection lands as not priced, and nothing is invented in its place. The book is +// still a book — its tree, its text and its reading surface are unaffected — it simply cannot be +// SOLD until the engine has priced it. +func TestACutWithoutAProjectionLandsAsUnpricedAndNotAsFree(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) + } + got, err := s.ReadBookForOrder(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if got.Priced { + t.Fatalf("a cut with no projection reads as priced: %+v", got) + } + if got.Expected != 0 || got.StepMax != 0 { + t.Errorf("figures were invented: %+v", got) + } + // The tree landed all the same. + page, err := s.ListChapters(ctx, "u1", book, 0, "") + if err != nil || len(page.Chapters) != 2 { + t.Fatalf("the tree did not land beside the missing price: %+v (%v)", page, err) + } +} + +// ⛔ THE PROJECTION FOLLOWS THE CUT AND NEVER OUTLIVES IT. A price written a moment apart from the +// tree is a price for a cut that may already be gone, so a re-cut that this build could not price +// must leave NOTHING behind rather than yesterday's figures beside today's chapters: the stale pair +// would sell a book that no longer exists, at a price nobody quoted. +func TestARecutWithoutAPriceDoesNotLeaveTheOldOneStanding(t *testing.T) { + s, ctx := testDB(t) + book := readingBook(t, s, ctx, "u1") + if err := s.SaveStructure(ctx, book, priced()); err != nil { + t.Fatal(err) + } + recut := twoChapters("k2") // a different cut, and this time the engine could not price it + if err := s.SaveStructure(ctx, book, recut); err != nil { + t.Fatal(err) + } + got, err := s.ReadBookForOrder(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if got.Priced { + t.Fatalf("the previous cut's price survived a re-cut: %+v", got) + } + for _, c := range got.Remaining { + if c.Expected != 0 { + t.Errorf("chapter %d kept the previous cut's bill %s", c.Number, c.Expected.USD()) + } + } +} + +// The honest character count reaches the library row, and the flag beside it says which number it +// is. ⚠ `null` keeps its own meaning — «the book is still arriving» — because re-using it for «we do +// not trust this number» would destroy a meaning the contract already promises (ratified D39.201 +// §5б: a flag BESIDE the number, never a changed meaning for null). +func TestTheEnginesCharacterCountReachesTheLibraryRowBesideItsAccuracy(t *testing.T) { + s, ctx := testDB(t) + book := readingBook(t, s, ctx, "u1") + before, _, err := s.GetBook(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if before.SourceChars != nil { + t.Fatalf("a book with no manifest read carries an engine count: %v", *before.SourceChars) + } + if err := s.SaveStructure(ctx, book, priced()); err != nil { + t.Fatal(err) + } + after, _, err := s.GetBook(ctx, "u1", book) + if err != nil { + t.Fatal(err) + } + if after.SourceChars == nil || *after.SourceChars != 3000 { + t.Fatalf("the engine's rune count did not land: %v", after.SourceChars) + } + if after.Structure != ingest.StructureDetected { + t.Errorf("the cut's provenance did not land: %q", after.Structure) + } +} diff --git a/platform/internal/runner/priceprojection_live_test.go b/platform/internal/runner/priceprojection_live_test.go new file mode 100644 index 00000000..f0e02379 --- /dev/null +++ b/platform/internal/runner/priceprojection_live_test.go @@ -0,0 +1,215 @@ +package runner + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" + + "textmachine/platform/internal/ingest" +) + +// ⛔ THE SEAM AGAINST THE REAL ENGINE, and it exists because every other test of the price +// projection reads a document THIS SIDE WROTE. +// +// The allowlist in `ingest` is a transcription of the engine's own JSON, and a transcription is +// exactly the artefact that goes stale in silence: a key the engine renames leaves a zero behind, +// `json.Unmarshal` says nothing, and the platform then sells a book with no floor under its hold — +// which is the PD-440 wall coming back invisibly. A hand-written fixture cannot catch that, because +// it agrees with the reader by construction. +// +// So this one asks the ENGINE. It runs `tmctl manifest --json` against a probe book, decodes the +// bytes with the platform's own reader, and asserts that the projection ARRIVED — not merely that +// the document parsed. +// +// Gated like every live-engine test: a bare clone stays green and says why. +func TestTheRealEnginesPriceProjectionIsReadByThisBuild(t *testing.T) { + bin := os.Getenv("TM_PLATFORM_TEST_ENGINE_BIN") + tpl := os.Getenv("TM_PLATFORM_TEST_BOOK_TEMPLATE") + if bin == "" || tpl == "" { + t.Skip("TM_PLATFORM_TEST_ENGINE_BIN and TM_PLATFORM_TEST_BOOK_TEMPLATE not set: " + + "the price projection is not checked against a real engine") + } + // Two chapters the engine's own heading rule will find, so the cut is `detected` and the tree has + // something to price per chapter. + const src = "第1章 测试\n这是一个测试文本。天空是蓝色的,云朵飘过。\n\n第2章 又一章\n他走进了那座古老的图书馆,书架高耸入云。\n" + dir := writeReadProbeBook(t, tpl, "bk_PRICEPROBE", src) + m, err := New(nil).Manifest(t.Context(), bin, dir) + if err != nil { + t.Fatalf("the live manifest did not run: %v", err) + } + if err := m.Whole(); err != nil { + t.Fatalf("this build did not read the live manifest whole: %v", err) + } + + // THE PROJECTION, read by the platform's own reader out of the engine's own bytes. + p, ok := m.Priced() + if !ok { + t.Fatalf("this build read no projection out of a live manifest of %d chapters: the allowlist "+ + "and the engine have drifted apart, and a book priced by nobody cannot be sold "+ + "(ingest.Priced refuses it, which is the honest half — but the SEAM is what broke)", + m.ChaptersTotal) + } + // ⚠ EVERY LOAD-BEARING FIGURE, each named for what depends on it rather than checked for being + // non-zero in a loop: what a reader needs from a failure here is WHICH number stopped arriving. + if p.StepMaxUSD <= 0 { + t.Error("step_max_usd did not arrive: it is the floor of every hold, and without it a run is " + + "admitted with a ceiling no single call can clear (PD-440)") + } + if p.ExpectedUSD <= 0 { + t.Error("expected_usd did not arrive: the order has nothing to be priced from") + } + if p.SourceChars <= 0 { + t.Error("source_chars did not arrive: the screen falls back to the intake's approximation") + } + // The per-chapter roll-up is what an ORDER is priced from, and its absence is the failure the + // book-level figure alone cannot show. + for _, c := range m.Chapters { + if c.Price == nil || c.Price.ExpectedUSD <= 0 { + t.Errorf("chapter %d arrived with no price, so no partial order over this book can be quoted", c.Number) + } + } + // The per-UNIT figure, which is what a CHARACTER order resolves against — the only partial order + // a book with no chapter structure can carry. + for _, c := range m.Chapters { + for _, u := range c.Units { + if u.Price == nil || u.Price.SourceChars <= 0 { + t.Errorf("unit %q arrived with no size, so a character order cannot be resolved", u.ID) + } + } + } + // The cut's PROVENANCE, and the vocabulary it comes in. This build reads three words and treats + // anything else as untrusted; a live engine emitting a fourth is not a failure — it is the day + // the platform stops offering chapter orders for this book, which is what should happen. + switch m.Structure { + case ingest.StructureDetected: + if !m.ChapterOrdersOffered() { + t.Error("a detected cut is not offered in chapters") + } + case ingest.StructureDeclared, ingest.StructureNone: + t.Logf("the live engine cut this probe as %q rather than `detected`", m.Structure) + case "": + t.Error("the live engine published no `structure` at all: this build then refuses to sell " + + "chapter orders for every book, which is safe and wrong") + default: + t.Logf("the live engine published a structure word this build does not know (%q); "+ + "chapter orders are withheld, which is the intended degradation", m.Structure) + } + + // ⛔ AND THE ONE ASSERTION A FIXTURE COULD NEVER MAKE: the book-level `expected_usd` INCLUDES a + // flat `book_once_usd`, and on a short book it DOMINATES. Measured on this very probe: two + // chapters whose own bills come to fractions of a cent against a book-level bound of whole + // dollars. It is pinned because the platform's whole order arithmetic rests on NOT using the + // book-level figure as the base of a hold — doing so would put a flat two-dollar threshold under + // every purchase and make a short book unbuyable again. + if p.BookOnceUSD > 0 { + units := p.ExpectedUSD - p.BookOnceUSD + if units <= 0 { + t.Fatalf("book_once_usd (%s) is not less than expected_usd (%s): the book-level bound is "+ + "not a PART of the expected bill, and every order priced from the difference is wrong", + p.BookOnceUSD.USD(), p.ExpectedUSD.USD()) + } + t.Logf("live projection: expected %s of which book-level %s; the units' own share is %s", + p.ExpectedUSD.USD(), p.BookOnceUSD.USD(), units.USD()) + } + // The chapters' own bills and the book figure describe ONE book. Two numbers that must agree are + // where a silent drift lives, and here they come from two different keys of one document. + sum := int64(0) + for _, c := range m.Chapters { + sum += int64(c.Price.ExpectedUSD) + } + if want := int64(p.ExpectedUSD - p.BookOnceUSD); sum < want-int64(len(m.Chapters))-1 || + sum > want+int64(len(m.Chapters))+1 { + t.Errorf("the live chapters sum to %d micro-USD and the book figure leaves %d for them", sum, want) + } +} + +// The engine's manifest is the ONE document the order form is built on, so this build's idea of its +// shape has to be checkable without a database and without a run. It is the same probe as above with +// the assertions inverted: what the engine publishes that this build does NOT read is listed, so a +// field arriving for the first time is a decision somebody takes rather than a value dropped. +func TestWhatTheLiveManifestCarriesAndThisBuildDeclinesToRead(t *testing.T) { + bin := os.Getenv("TM_PLATFORM_TEST_ENGINE_BIN") + tpl := os.Getenv("TM_PLATFORM_TEST_BOOK_TEMPLATE") + if bin == "" || tpl == "" { + t.Skip("TM_PLATFORM_TEST_ENGINE_BIN and TM_PLATFORM_TEST_BOOK_TEMPLATE not set") + } + dir := writeReadProbeBook(t, tpl, "bk_SHAPEPROBE", "第1章 测试\n这是一个测试文本。\n") + doc, _, err := readEngine(t.Context(), bin, dir, ManifestArgs(dir), maxManifest, "manifest") + if err != nil { + t.Fatalf("the live manifest did not run: %v", err) + } + // The version this build was written against. It is a SHAPE gate and not a value pin — see + // ingest.KnownManifestVersion — and the point of asserting it against a live engine is that the + // constant stops being a copy of a copy. + m, err := ingest.DecodeManifest(doc) + if err != nil { + t.Fatalf("the live manifest did not decode: %v", err) + } + if m.Version != ingest.KnownManifestVersion { + t.Errorf("the live engine publishes manifest %q and this build reads %s: the allowlist is a "+ + "transcription of a document that has moved", m.Version, ingest.KnownManifestVersion) + } + // ⚠ ASKED OF THE DECODED BOOK FIGURE, not grepped out of the bytes. A raw `strings.Contains` for + // `"expected_usd"` is satisfied by any CHAPTER's price object, so a rename of the BOOK-level key — + // the one the whole order form is built on — would leave this green. The keys that have no twin + // elsewhere in the document are still worth a grep, and they are the ones grepped. + if m.Price == nil { + t.Fatal("the live manifest carries no book-level `price` object at all") + } + if m.Price.ExpectedUSD <= 0 || m.Price.StepMaxUSD <= 0 || m.Price.SourceChars <= 0 { + t.Errorf("a book-level price key did not arrive: %+v", *m.Price) + } + for _, key := range []string{`"book_once_usd"`, `"step_max_usd"`, `"structure"`} { + if !strings.Contains(string(doc), key) { + t.Errorf("the live manifest carries no %s: the platform's order form is built on it", key) + } + } +} + +// writeReadProbeBook is writeProbeBook's READ-PATH twin: the deployment's own template, with this +// book's identity on it and nothing else changed. +// +// ⛔ IT DELIBERATELY DOES NOT REWRITE THE PIPELINE, and both halves of that matter. +// +// It does not swap the stage models for a local one, because `manifest` reaches no provider: it is a +// $0, key-less verb (D20.4) that ingests, cuts and prints. The swap exists so a WRITE-path probe does +// not buy its calls, and buying is not on this path. +// +// And it does not inherit the contrast-artefact SKIP. That condition is the write path's — its own +// message says «a live write-path probe cannot run» — because the bank contour mines during a +// translate. A manifest mines nothing. Inheriting it cost this probe its whole existence: the seam +// test that is supposed to be the one independent witness of the allowlist SKIPPED on every host +// without a multi-megabyte word list nobody needs to read a manifest, and skipped silently, which is +// the failure shape this project keeps paying for. +// +// ⚠ AND IT WANTS THE SHIPPING PIPELINE'S OWN NUMBERS, which is the positive reason rather than the +// absence of a negative one: the projection this test reads is made of that pipeline's stages, its +// prices and its gate budgets. Rewritten, the probe would measure a book nobody sells. +func writeReadProbeBook(t *testing.T, tpl, bookID, source string) string { + t.Helper() + dir := t.TempDir() + raw, err := os.ReadFile(tpl) + if err != nil { + t.Fatal(err) + } + var cfg map[string]any + if err := yaml.Unmarshal(raw, &cfg); err != nil { + t.Fatal(err) + } + cfg["book_id"], cfg["source_lang"], cfg["target_lang"] = bookID, "zh", "ru" + cfg["source_file"] = "source.txt" + out, err := yaml.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ConfigFile), out, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "source.txt"), []byte(source), 0o600); err != nil { + t.Fatal(err) + } + return dir +} diff --git a/platform/internal/runs/order_test.go b/platform/internal/runs/order_test.go new file mode 100644 index 00000000..12301ec7 --- /dev/null +++ b/platform/internal/runs/order_test.go @@ -0,0 +1,728 @@ +package runs + +import ( + "errors" + "slices" + "strings" + "testing" + "time" + + "textmachine/platform/internal/money" + "textmachine/platform/internal/pgstore" + "textmachine/platform/internal/pricing" + "textmachine/platform/internal/runner" +) + +// argvOf is the argv of the LAST unit this fixture started. +func argvOf(t *testing.T, f *fixture) []string { + t.Helper() + starts := f.runner.starts() + if len(starts) == 0 { + t.Fatal("no unit was started") + } + return starts[len(starts)-1].Args +} + +// flagValue reads the value of a `--flag value` pair, or "" when the flag is absent. +func flagValue(args []string, flag string) string { + if i := slices.Index(args, flag); i >= 0 && i+1 < len(args) { + return args[i+1] + } + return "" +} + +// ⛔ THE ORDER REACHES THE ENGINE, and until this pack it did not. The platform sold CHAPTERS and +// handed the engine a DOLLAR bound alone — so «buy ten chapters» arrived as a sum, and a sum buys +// whatever it buys: measured on real ledgers, ten chapters' worth of money bought sixteen to +// twenty-six (D39.165 §1). The knob said chapters and meant money. +// +// `--max-units` is the same quantity on both sides of the seam: the manifest's unit count is where +// this platform's per-chapter figure comes from, so the conversion is exact rather than estimated. +// +// Mutation caught: dropping maxUnits from TranslateArgs, or from spec. +func TestAPartialOrderReachesTheEngineAsAVolumeAndNotOnlyAsMoney(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(7)}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + args := argvOf(t, f) + // The fixture ships one unit per chapter, so seven chapters are seven units. + if got := flagValue(args, "--max-units"); got != "7" { + t.Fatalf("the engine was given --max-units %q, want 7: %q", got, strings.Join(args, " ")) + } + // And the MONEY bound is still there beside it: the two are orthogonal, one caps this run's work + // and the other the book's cumulative spend. + if !slices.Contains(args, "--ceiling-usd") { + t.Errorf("the volume bound replaced the money bound: %q", strings.Join(args, " ")) + } +} + +// The WHOLE-BOOK order carries no volume bound at all, and zero is the flag's own word for +// «unbounded» — so it must not be passed as a number either. A `--max-units 0` would be an order for +// nothing. +func TestAWholeBookOrderCarriesNoVolumeBound(t *testing.T) { + f := newFixture(t, "10", 5) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t)}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + if args := argvOf(t, f); slices.Contains(args, "--max-units") { + t.Errorf("a whole-book order was bounded by volume: %q", strings.Join(args, " ")) + } + // …and the book row says so in the only way that survives a re-cut: no boundary at all. + var chapterID, unitID *string + if err := f.store.Pool().QueryRow(f.ctx, + `select ordered_through_chapter_id, ordered_through_unit_id from books where id = $1`, + f.bookID(t)).Scan(&chapterID, &unitID); err != nil { + t.Fatal(err) + } + if chapterID != nil || unitID != nil { + t.Errorf("the whole book froze a boundary: chapter %v unit %v", chapterID, unitID) + } +} + +// The allowance is what is LEFT of the order, recomputed at every spawn. Frozen at admission, a run +// respawned after a restart would be handed its whole order a second time and the promise «you +// bought N units» would stop being about N. +// +// Mutation caught: maxUnitsFor ignoring DeliveredUnits. +func TestTheVolumeAllowanceIsWhatIsLeftOfTheOrderAtEverySpawn(t *testing.T) { + f := newFixture(t, "10", 500) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(10)}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + if got := flagValue(argvOf(t, f), "--max-units"); got != "10" { + t.Fatalf("the first spawn was given %q, want 10", got) + } + // Four chapters of the order come back delivered, and the machine reboots. + // + // ⚠ Written as RESOLUTION ROWS and not as chapter counters, because the rows are what the + // allowance is computed from and the counters are derived from them: a fixture that moved only + // the counter would describe a state the materializer cannot produce, and would pass while the + // arithmetic read something else. + if _, err := f.store.Pool().Exec(f.ctx, ` + insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at) + select $1, g, 0, 'edit', true, false, now() from generate_series(1, 4) g`, f.bookID(t)); err != nil { + t.Fatal(err) + } + f.engine.set(statusSpending(money.MicroUSD(100_000)), nil) + f.runner.alive = false + f.svc.Now = func() time.Time { return f.now.Add(2 * time.Hour) } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + got := flagValue(argvOf(t, f), "--max-units") + if got != "6" { + t.Fatalf("the respawn was given --max-units %q, want the six units of the order still owed", got) + } +} + +// ⛔ AND IT NEVER FALLS TO ZERO BY ARITHMETIC, because zero is the flag's word for «no bound». A run +// respawned after its order was fully delivered would otherwise be handed the whole book. +func TestAFullyDeliveredOrderIsRespawnedWithOneUnitAndNotWithNoBoundAtAll(t *testing.T) { + l := pgstore.LiveRun{RunID: "run_1", OrderedChapters: 10} + o := pgstore.SpawnOrder{Resolved: true, Units: 10, Delivered: 10, + Order: pgstore.BookOrder{ThroughChapterID: "c10"}} + n, err := maxUnitsFor(l, o) + if err != nil { + t.Fatal(err) + } + if n != 1 { + t.Fatalf("a fully delivered order was given --max-units %d; zero means UNBOUNDED", n) + } + o.Delivered = 40 // more delivered than ordered: free and carried units ride outside the grant + if n, err := maxUnitsFor(l, o); err != nil || n != 1 { + t.Fatalf("an over-delivered order was given %d (%v)", n, err) + } +} + +// ⛔ A BOOK CUT AGAIN UNDER A PURCHASE IS A REFUSAL, not a substituted number. The order's boundary is +// stored as an IDENTITY so that a re-cut makes it stop resolving; reading the dangling reference as +// «the whole book» would sell MORE than was bought and as «nothing» would sell less. Both are the +// silent change of a paid order that the identity form exists to prevent. +func TestAnOrderThatNoLongerNamesABoundaryRefusesToSpawn(t *testing.T) { + l := pgstore.LiveRun{RunID: "run_1", OrderedChapters: 10} + dangling := pgstore.SpawnOrder{Resolved: false, Units: 0, + Order: pgstore.BookOrder{ThroughChapterID: "a chapter this cut does not have"}} + if _, err := maxUnitsFor(l, dangling); !errors.Is(err, ErrOrderUnresolvable) { + t.Fatalf("an unresolvable order answered %v, want ErrOrderUnresolvable", err) + } + // The whole-book order has no boundary to lose and is unaffected by any re-cut, which is the + // other half of why it stores none. On a FIRST run it takes no volume bound at all. + if n, err := maxUnitsFor(l, pgstore.SpawnOrder{Resolved: true}); err != nil || n != 0 { + t.Fatalf("a whole-book order answered %d (%v)", n, err) + } + // ⛔ AND THE RE-PASS, which is the one shape that buys no volume: the book's own order says + // nothing about it, and bounding it by that order would leave a re-pass the buyer paid a whole + // book's projection for re-making a single unit. + repass := pgstore.LiveRun{RunID: "run_1", OrderedChapters: 0} + if n, err := maxUnitsFor(repass, pgstore.SpawnOrder{Resolved: true, Units: 4, Delivered: 4, + Order: pgstore.BookOrder{ThroughChapterID: "c2"}}); err != nil || n != 0 { + t.Fatalf("a re-pass was bounded by the book's order: %d (%v)", n, err) + } +} + +// ⛔ PD-422: `--resnapshot` RIDES EVERY CONTINUATION, not only a run over an edited bank. +// +// The auto-bank grows by MINING during an ordinary run; mining moves the ENRICHED memory version; +// the enriched version is folded into the EDIT wave's snapshot alone. So the SECOND purchase of a +// mining book meets its own already-pinned edit jobs under a moved snapshot, the drift guard stops +// the engine with exit 1 — which this platform can only report as `failed` — and it does so AFTER +// the hold was taken, with nothing translated. The condition used to be the correction door's flag +// alone, which no amount of mining ever sets. +// +// Mutation caught: `resnapshot := book.BankMoved` — the condition as it shipped. +func TestASecondPurchaseCarriesResnapshotEvenWithoutABankCorrection(t *testing.T) { + f := newFixture(t, "10", 500) + first, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, first.ID); err != nil { + t.Fatal(err) + } + // The FIRST purchase carries none: there is nothing pinned yet to be re-pinned, and a flag that + // rode every run would be a consent nobody needed. + if args := argvOf(t, f); slices.Contains(args, "--resnapshot") { + t.Fatalf("the first purchase of a book carries --resnapshot: %q", strings.Join(args, " ")) + } + // It ends cleanly. NO bank correction happens — this is the ordinary path, which is the whole + // point: mining moved the bank and nothing recorded that fact. + live := f.live(t) + if err := runner.WriteMarker(f.svc.markerPath(live.RunID, live.AttemptNo), + runner.Marker{Unit: live.UnitName, Result: "exit-code", Code: "exited", Status: "0"}); err != nil { + t.Fatal(err) + } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + second, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(3)}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, second.ID); err != nil { + t.Fatal(err) + } + args := argvOf(t, f) + if !slices.Contains(args, "--resnapshot") { + t.Fatalf("the second purchase of a book carries no --resnapshot, so the drift guard kills it "+ + "after the hold (PD-422): %q", strings.Join(args, " ")) + } + // …and the consent that rides with it is FUNDED — the run's own hold, never the blanket form: a + // projection grown past what the buyer saw must refuse, not be bought silently. + if want := "--accept-rebill=" + fixtureHold(3).USD(); !slices.Contains(args, want) { + t.Errorf("the continuation's consent is not its own hold (%s): %q", want, strings.Join(args, " ")) + } +} + +// ⛔ A BOOK THE ENGINE HAS NOT PRICED IS NOT SOLD. This is the line the per-chapter constant used to +// stand on: $0.03 was measured 4.47× low and made the last chapters of every book unbuyable at any +// balance, so replacing it with a quieter guess would keep the shape of the defect. There is one +// source for what a book costs, and when it has not spoken the platform says so. +// +// Mutation caught: any fallback in Order or Start when Priced is false. +func TestAnUnpricedBookIsRefusedRatherThanSoldAtAGuess(t *testing.T) { + f := newFixture(t, "10", 5) + if _, err := f.store.Pool().Exec(f.ctx, ` + update books set expected_micro_usd = null, book_once_micro_usd = null, step_max_micro_usd = null + where id = $1`, f.bookID(t)); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Order(f.ctx, "u1", f.bookID(t)); !errors.Is(err, ErrNotPriced) { + t.Errorf("the order form over an unpriced book answered %v, want ErrNotPriced", err) + } + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(2)}); !errors.Is(err, ErrNotPriced) { + t.Errorf("a purchase of an unpriced book answered %v, want ErrNotPriced", err) + } + // Nothing moved: a refusal before the hold is the only kind worth having. + if acct := f.account(t); acct.Reserved != 0 { + t.Errorf("a refused purchase reserved %s", acct.Reserved.USD()) + } + // ⚠ A book carrying only PART of a projection is refused for the same reason and by the same + // answer: two of three figures is a document this build did not read whole, and the one that + // would be missing is exactly the one whose absence is silent. + if _, err := f.store.Pool().Exec(f.ctx, ` + update books set expected_micro_usd = 150000, book_once_micro_usd = 0 where id = $1`, + f.bookID(t)); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Order(f.ctx, "u1", f.bookID(t)); !errors.Is(err, ErrNotPriced) { + t.Errorf("a book missing only its step_max was priced: %v", err) + } +} + +// The order form answers the buyer's question over a REAL book, end to end: the verdict, what the +// balance covers, and the pair «expected / reserved». +func TestTheOrderFormAnswersOverARealBook(t *testing.T) { + // Five chapters at the fixture's $0.03: the whole book is affordable at $10 and not at $0.10. + rich := newFixture(t, "10", 5) + got, err := rich.svc.Order(rich.ctx, "u1", rich.bookID(t)) + if err != nil { + t.Fatal(err) + } + if got.Verdict != pricing.VerdictCoversAll || got.ChaptersLeft != 5 || got.AffordableChapters != 5 { + t.Fatalf("a $10 balance over a $0.15 book: %+v", got.Options) + } + if got.Whole.Hold != fixtureHold(5) || got.Whole.Expected != fixtureChapterUSD*5 { + t.Errorf("the estimate is %s expected / %s held, want %s / %s", + got.Whole.Expected.USD(), got.Whole.Hold.USD(), + (fixtureChapterUSD * 5).USD(), fixtureHold(5).USD()) + } + if got.MinHold != fixtureStepMaxUSD { + t.Errorf("the money slider's minimum is %s, want the engine's own step", got.MinHold.USD()) + } + if !got.ChapterOrders || got.Structure != "detected" { + t.Errorf("a detected cut was not offered in chapters: %+v", got) + } + + poor := newFixture(t, "0.15", 5) + got, err = poor.svc.Order(poor.ctx, "u1", poor.bookID(t)) + if err != nil { + t.Fatal(err) + } + if got.Verdict != pricing.VerdictCoversPart || got.AffordableChapters == 0 || + got.AffordableChapters >= 5 { + t.Fatalf("a $0.15 balance over a book that reserves %s: %+v", fixtureHold(5).USD(), got.Options) + } +} + +// The three kinds of order end up as three DIFFERENT boundaries on the book row, and each is an +// identity. The chapter number rides beside its identity as a LABEL — which is what makes a shifted +// number visible rather than authoritative. +func TestEachKindOfOrderStoresItsOwnBoundaryAsAnIdentity(t *testing.T) { + f := newFixture(t, "10", 5) + book := f.bookID(t) + read := func() (chapterID, unitID *string, number *int) { + t.Helper() + if err := f.store.Pool().QueryRow(f.ctx, ` + select ordered_through_chapter_id, ordered_through_unit_id, ordered_through_chapter_number + from books where id = $1`, book).Scan(&chapterID, &unitID, &number); err != nil { + t.Fatal(err) + } + return + } + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(3)}) + if err != nil { + t.Fatal(err) + } + chapterID, unitID, number := read() + if chapterID == nil || *chapterID != "c3" || unitID != nil || number == nil || *number != 3 { + t.Fatalf("a chapter order stored chapter=%v unit=%v number=%v", chapterID, unitID, number) + } + f.finish(t, run.ID) + + // A CHARACTER order: the fixture's units are 1000 runes each, so 2500 buys three of them — + // rounded UP to the unit that contains the 2500th rune. + chars := int64(2500) + run, err = f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Characters: &chars}) + if err != nil { + t.Fatal(err) + } + chapterID, unitID, number = read() + if unitID == nil || *unitID != "u3" || chapterID != nil { + t.Fatalf("a character order stored chapter=%v unit=%v", chapterID, unitID) + } + if number != nil { + t.Errorf("a character order invented a chapter label: %v", number) + } + f.finish(t, run.ID) + + // The WHOLE BOOK: no boundary of any kind, which is the only form that keeps meaning the whole + // book after a re-cut. + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book}); err != nil { + t.Fatal(err) + } + if chapterID, unitID, _ = read(); chapterID != nil || unitID != nil { + t.Errorf("the whole book stored chapter=%v unit=%v", chapterID, unitID) + } +} + +// A character order over a book whose chapters ARE recognised still resolves to units, and an order +// in CHAPTERS over a book whose cut cannot be sold against is refused with its own word rather than +// silently rounded to the whole book. +func TestAChapterOrderIsRefusedOnACutThatCannotBeSoldAgainst(t *testing.T) { + for _, structure := range []string{"none", "declared", "a word a later engine grew"} { + f := newFixture(t, "10", 5) + if _, err := f.store.Pool().Exec(f.ctx, + `update books set structure = $2 where id = $1`, f.bookID(t), structure); err != nil { + t.Fatal(err) + } + _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(2)}) + if !errors.Is(err, ErrChapterOrdersUnavailable) { + t.Errorf("structure %q accepted a chapter order: %v", structure, err) + } + if acct := f.account(t); acct.Reserved != 0 { + t.Errorf("structure %q: a refused order reserved %s", structure, acct.Reserved.USD()) + } + // The whole book and a character order are both still available — the refusal is of the UNIT + // the order is phrased in, not of the book. + chars := int64(1500) + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Characters: &chars}); err != nil { + t.Errorf("structure %q refused a character order too: %v", structure, err) + } + } +} + +// A character order that reaches the end of the book IS the whole book, and is recorded as such: a +// boundary on the last unit would freeze an order that a re-cut then breaks for no reason. +func TestACharacterOrderThatReachesTheEndIsTheWholeBook(t *testing.T) { + f := newFixture(t, "10", 5) + chars := int64(999_999) + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Characters: &chars}); err != nil { + t.Fatal(err) + } + var unitID *string + if err := f.store.Pool().QueryRow(f.ctx, + `select ordered_through_unit_id from books where id = $1`, f.bookID(t)).Scan(&unitID); err != nil { + t.Fatal(err) + } + if unitID != nil { + t.Errorf("an order for more characters than the book has froze a boundary at %q", *unitID) + } +} + +// finish closes a live run so the fixture can buy again: one live run per book by construction. +func (f *fixture) finish(t *testing.T, runID string) { + t.Helper() + if _, err := f.store.Pool().Exec(f.ctx, + `update runs set finished_at = now(), status = 'stopped' where id = $1`, runID); err != nil { + t.Fatal(err) + } + if _, err := f.store.Pool().Exec(f.ctx, + `update reservations set state = 'released', closed_at = now() + where engine_run_id like $1 and state = 'open'`, runID+"#%"); err != nil { + t.Fatal(err) + } +} + +// ⛔ THE SERVER RE-JUDGES THE ORDER, and this is the half PD-375 found untested under the previous +// shape: the bound that decides HOW MUCH MONEY IS RESERVED can never be one the caller chose +// unilaterally. The options read is a READ, the balance moves under it, and a client is forbidden to +// clamp on its own account. +// +// Two halves, and both are asserted because removing either used to pass the whole battery: +// - an order the balance CANNOT carry is refused, before the hold, with nothing moved; +// - an order LARGER than the book is the whole book, not an error and not a larger reservation. +// +// Mutation caught: dropping the `quote.Hold > acct.Balance` refusal in Start; letting Quote reserve +// for more chapters than the book has. +func TestAnOrderTheBalanceCannotCarryIsRefusedBeforeTheHold(t *testing.T) { + // $0.10 against a five-chapter book whose whole order reserves more than that. + f := newFixture(t, "0.10", 5) + if fixtureHold(5) <= money.MicroUSD(100_000) { + t.Fatalf("the fixture does not test what it claims: five chapters reserve %s, which $0.10 covers", + fixtureHold(5).USD()) + } + before := f.account(t) + _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t), Chapters: order(5)}) + // ⚠ ITS OWN ERROR, and not ErrCeilingOutOfBounds. That one means «the options moved between the + // read and the call» and reaches the wire as `bounds_moved`, which promises a client that + // re-reading and retrying will work. Here nothing moved and retrying is futile: what is missing + // is money, and the remedy is to top up. This is the refusal the whole order form exists to make + // honest, and it used to travel under the other word. + if !errors.Is(err, ErrBalanceCannotCarry) { + t.Fatalf("an unaffordable order answered %v, want ErrBalanceCannotCarry", err) + } + if after := f.account(t); after.Reserved != before.Reserved || after.Balance != before.Balance { + t.Fatalf("money moved on a refused order: before %+v, after %+v", before, after) + } + if live, err := f.store.ListLiveRuns(f.ctx); err != nil || len(live) != 0 { + t.Fatalf("a refused order left a live run: %+v (%v)", live, err) + } + // And an order LARGER than the book is the whole book — the reservation is the book's, never the + // number the caller typed. + rich := newFixture(t, "10", 5) + if _, err := rich.svc.Start(rich.ctx, StartRequest{UserID: "u1", BookID: rich.bookID(t), Chapters: order(9999)}); err != nil { + t.Fatal(err) + } + if got := rich.account(t).Reserved; got != fixtureHold(5) { + t.Errorf("an order for 9999 chapters of a five-chapter book reserved %s, want the book's own %s", + got.USD(), fixtureHold(5).USD()) + } +} + +// ⛔ THE ORDER FORM'S PROMISE HAS TO SURVIVE THE CLICK, and until an adversarial pass looked it did +// not. The form answers, before the click, whether the book-wide consistency pass is funded; the +// form is a READ and the balance moves under it — another book's hold, a correction — and the hold +// taken at admission is computed against the balance AS IT IS. So a buyer shown «funded» could be +// sold a run that was not, and the passes DEGRADE rather than halt: the book arrives and only its +// terms wander. Consistency of terms across a book is the owner's first stated priority (D39.198), +// and a refusal shaped like normal work is the class D39.202 §3 names. +// +// The run carries what it actually got, so the question is answerable after the click too. +// +// Mutation caught: dropping BondFunded from the StartRunInput, or reading it from anything but the +// quote the admission acted on. +func TestWhatTheRunActuallyGotIsRecordedAndNotOnlyWhatTheFormPromised(t *testing.T) { + // A book-level bound the balance CAN carry: the run is sold with the pass funded. + f := newFixture(t, "10", 3) + if _, err := f.store.Pool().Exec(f.ctx, + `update books set book_once_micro_usd = 2000000 where id = $1`, f.bookID(t)); err != nil { + t.Fatal(err) + } + form, err := f.svc.Order(f.ctx, "u1", f.bookID(t)) + if err != nil { + t.Fatal(err) + } + if !form.Whole.BondFunded { + t.Fatalf("a $10 balance does not fund a $2 book-level pass: %+v", form.Options) + } + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t)}) + if err != nil { + t.Fatal(err) + } + if !f.run(t, run.ID).BondFunded { + t.Error("a run sold with the pass funded does not say so") + } + + // …and the case the form CANNOT promise: a balance that carries the chapters and not the bound. + poor := newFixture(t, "0.5", 3) + if _, err := poor.store.Pool().Exec(poor.ctx, + `update books set book_once_micro_usd = 2000000 where id = $1`, poor.bookID(t)); err != nil { + t.Fatal(err) + } + form, err = poor.svc.Order(poor.ctx, "u1", poor.bookID(t)) + if err != nil { + t.Fatal(err) + } + if form.Verdict != pricing.VerdictCoversAll || form.Whole.BondFunded { + t.Fatalf("the chapters are covered and the bound is not: %+v", form.Options) + } + run, err = poor.svc.Start(poor.ctx, StartRequest{UserID: "u1", BookID: poor.bookID(t)}) + if err != nil { + t.Fatal(err) + } + if poor.run(t, run.ID).BondFunded { + t.Error("a run sold WITHOUT the book-wide pass claims it is funded, which is the silence " + + "this column exists to break") + } +} + +// ⛔ A WHOLE-BOOK CONTINUATION IS BOUNDED BY VOLUME TOO, and the reason is the engine's, not this +// platform's caution: with no ceiling in force the engine builds NO volume scope, and the scope is +// what carries its protective order of work — new book before re-made book. Without one, a +// continuation carrying `--resnapshot` walks the edit wave in BOOK order and re-pays the beginning +// of the book before editing the chapters just bought. +// +// That is exactly the argument this platform made for riding `--resnapshot` on every continuation +// (PD-422), and it held only for PARTIAL orders until this bound existed. Found by an adversarial +// pass reading the engine, not by a failure here. +// +// The FIRST run of a book gets no bound: nothing is delivered, so nothing can be re-made. +// +// Mutation caught: returning 0 for a whole-book order unconditionally. +func TestAWholeBookContinuationIsGivenTheRemainderAsItsVolume(t *testing.T) { + f := newFixture(t, "10", 5) + first, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t)}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, first.ID); err != nil { + t.Fatal(err) + } + if args := argvOf(t, f); slices.Contains(args, "--max-units") { + t.Fatalf("the FIRST whole-book run is bounded by volume: %q", strings.Join(args, " ")) + } + // It delivers two of the five chapters and ends. + if _, err := f.store.Pool().Exec(f.ctx, ` + insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at) + select $1, g, 0, 'edit', true, false, now() from generate_series(1, 2) g`, f.bookID(t)); err != nil { + t.Fatal(err) + } + live := f.live(t) + if err := runner.WriteMarker(f.svc.markerPath(live.RunID, live.AttemptNo), + runner.Marker{Unit: live.UnitName, Result: "exit-code", Code: "exited", Status: "0"}); err != nil { + t.Fatal(err) + } + if err := f.svc.Sweep(f.ctx); err != nil { + t.Fatal(err) + } + second, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: f.bookID(t)}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, second.ID); err != nil { + t.Fatal(err) + } + args := argvOf(t, f) + if got := flagValue(args, "--max-units"); got != "3" { + t.Fatalf("a whole-book CONTINUATION was given --max-units %q, want the three units still "+ + "owed — without a ceiling the engine builds no scope and re-pays the beginning of the "+ + "book before editing what was bought: %q", got, strings.Join(args, " ")) + } + if !slices.Contains(args, "--resnapshot") { + t.Errorf("the continuation carries no --resnapshot: %q", strings.Join(args, " ")) + } +} + +// A stranger's book is INDISTINGUISHABLE from a missing one on the order form, exactly as it is +// everywhere else in this package: telling the two apart is what would let anyone enumerate other +// people's libraries by asking what a book costs. +// +// Pinned here because the form is a NEW path with its own query, and ownership on it is that query's +// own `where b.owner_id = $2` rather than a guard somebody remembered to put in front of it — a +// guard is exactly the thing a later refactor drops. +// +// Mutation caught: dropping `b.owner_id = $2` from ReadBookForOrder or RemainingUnits. +func TestTheOrderFormDoesNotAnswerAboutSomebodyElsesBook(t *testing.T) { + f := newFixture(t, "10", 5) + if _, err := f.store.Pool().Exec(f.ctx, + `insert into users (id, email) values ('u2','u2@example.org')`); err != nil { + t.Fatal(err) + } + if _, err := f.svc.Order(f.ctx, "u2", f.bookID(t)); !errors.Is(err, pgstore.ErrNoBook) { + t.Errorf("the order form over a stranger's book answered %v, want ErrNoBook", err) + } + if _, err := f.svc.Order(f.ctx, "u1", "bk_THISDOESNOTEXIST"); !errors.Is(err, pgstore.ErrNoBook) { + t.Errorf("the order form over a missing book answered %v, want ErrNoBook", err) + } + // The purchase path too, and it must not move money on the way to refusing. + before := f.account(t) + if _, err := f.svc.Start(f.ctx, StartRequest{UserID: "u2", BookID: f.bookID(t)}); !errors.Is(err, pgstore.ErrNoBook) { + t.Errorf("a stranger's purchase answered %v, want ErrNoBook", err) + } + if after := f.account(t); after.Reserved != before.Reserved { + t.Errorf("a stranger's refused purchase moved the owner's money: %s → %s", + before.Reserved.USD(), after.Reserved.USD()) + } + // …and the character order's own read, which is a SECOND query with its own ownership clause. + if got, err := f.store.RemainingUnits(f.ctx, "u2", f.bookID(t)); err != nil || len(got) != 0 { + t.Errorf("a stranger read %d units of somebody else's book (%v)", len(got), err) + } +} + +// ⛔ THE ALLOWANCE IS IN UNITS AND THE ORDER IS IN CHAPTERS, and until this test nothing in the zone +// could tell the two apart: every other fixture gives a chapter exactly one unit, so `chapters` and +// `units` were the same number everywhere and swapping one for the other stayed green. +// +// Three chapters of four units each: an order of two chapters is EIGHT units, not two. +// +// Mutation caught: `maxUnitsFor` returning `l.OrderedChapters - o.Delivered` — the unit of measure +// swapped for the one beside it. +func TestTheVolumeAllowanceCountsUnitsAndNotChapters(t *testing.T) { + f := newFixture(t, "10", 5) + book := multiUnitBook(t, f, "multi", 3, 4) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(2)}) + if err != nil { + t.Fatal(err) + } + if err := f.svc.Spawn(f.ctx, run.ID); err != nil { + t.Fatal(err) + } + if got := flagValue(argvOf(t, f), "--max-units"); got != "8" { + t.Fatalf("an order of two four-unit chapters was given --max-units %q, want 8: the allowance "+ + "is counted in the unit the ENGINE ships, and two is what it would be if the chapter count "+ + "were handed over instead", got) + } + // …and what was SOLD is still counted in chapters, because that is what the buyer chose and what + // the run's own bar is measured in. + if got := f.run(t, run.ID).OrderedChapters; got != 2 { + t.Errorf("the run says it bought %d chapters", got) + } + // The hold is the units' money, not the chapters': eight units at the fixture's rate. + if got, want := f.account(t).Reserved, fixtureHold(8); got != want { + t.Errorf("the order reserved %s, want %s — the price of eight units", got.USD(), want.USD()) + } +} + +// ⛔ A CHARACTER ORDER'S BAR MOVES, and before this it could not: the order buys a PREFIX of a +// chapter, a chapter counts as done only once EVERY unit in it is done, so the run read `0/N` for +// its whole life and `delivered_chapters: 0` for ever. +// +// ⚠ THE PLATFORM ALREADY CALLS THAT STATE INADMISSIBLE — in its own words, in the admission that +// refuses a book with no materialised tree: «every counter the screen shows is a count over +// chapters, so the run would read 0/total for its entire life while spending» (PD-405). Introducing +// the same state through the character order would be this pack contradicting itself, and the +// character order is not a corner: `declared` is led as `none`, so today it is the ONLY partial +// purchase available for every EPUB and every non-CJK txt. +// +// Mutation caught: dropping the `r.ordered_units is not null` branch from runDone/runTotal, or +// leaving OrderedUnits nil on a character order. +func TestACharacterOrdersBarIsCountedInWhatItActuallyBought(t *testing.T) { + f := newFixture(t, "10", 5) + // One chapter of six units: the shape of a book whose chapters cannot be sold against. + book := multiUnitBook(t, f, "one-chapter", 1, 6) + chars := int64(2500) // 1000 runes a unit ⇒ three units + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Characters: &chars}) + if err != nil { + t.Fatal(err) + } + got := f.run(t, run.ID) + // Two passes over three units: the same shape the chapter bar has, one level finer. + if got.Progress.Total != 6 || got.Progress.Done != 0 { + t.Fatalf("a three-unit order opens at %d/%d, want 0/6", got.Progress.Done, got.Progress.Total) + } + if got.DeliveredChapters != nil { + t.Errorf("a run that bought a prefix of a chapter reports %d chapters delivered; it has "+ + "delivered no CHAPTER, and 0 reads as «nothing happened»", *got.DeliveredChapters) + } + // The engine drafts the three it was sold. The chapter is NOT finished — three of six units — so + // the chapter bar would still read zero here, and that is the whole defect. + if _, err := f.store.Pool().Exec(f.ctx, ` + insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at) + select $1, 1, g - 1, 'draft', true, false, now() from generate_series(1, 3) g`, book); err != nil { + t.Fatal(err) + } + got = f.run(t, run.ID) + if got.Progress.Done != 3 { + t.Fatalf("after the draft pass over all three units the bar reads %d/%d: counted in chapters "+ + "it can never move, because the chapter holds six", got.Progress.Done, got.Progress.Total) + } + if got.Progress.Stage != "editing" { + t.Errorf("the draft pass over what was bought is done and the caption says %q", got.Progress.Stage) + } + // …and the edit pass closes it. + if _, err := f.store.Pool().Exec(f.ctx, ` + insert into unit_resolutions (book_id, chapter, unit, wave, shipped, flagged, at) + select $1, 1, g - 1, 'edit', true, false, now() from generate_series(1, 3) g`, book); err != nil { + t.Fatal(err) + } + if got = f.run(t, run.ID); got.Progress.Done != 6 || got.Progress.Total != 6 { + t.Errorf("a fully delivered three-unit order reads %d/%d", got.Progress.Done, got.Progress.Total) + } +} + +// The ORDINARY shape is untouched: a run that bought whole chapters keeps the bar it always had, +// counted in chapters, with `delivered_chapters` a number rather than null. Asserted beside the test +// above because the unit-shaped branch is a NEW first case in three SQL expressions every existing +// bar reads, and «the new branch is not entered» is the property that keeps thirty other tests true. +func TestAChapterOrderKeepsTheBarItAlwaysHad(t *testing.T) { + f := newFixture(t, "10", 5) + book := multiUnitBook(t, f, "chapters", 3, 2) + run, err := f.svc.Start(f.ctx, StartRequest{UserID: "u1", BookID: book, Chapters: order(2)}) + if err != nil { + t.Fatal(err) + } + got := f.run(t, run.ID) + if got.Progress.Total != 4 { + t.Fatalf("a two-chapter order over an editor pipeline opens with a total of %d, want 4 — two "+ + "passes over two CHAPTERS, not over their four units", got.Progress.Total) + } + if got.DeliveredChapters == nil || *got.DeliveredChapters != 0 { + t.Errorf("a chapter order reports delivered chapters as %v, want 0 rather than null", got.DeliveredChapters) + } + // One whole chapter delivered moves both the bar and the delivered count. + if _, err := f.store.Pool().Exec(f.ctx, ` + update chapters set units_draft_done = 2, units_edit_done = 2 + where book_id = $1 and number = 1`, book); err != nil { + t.Fatal(err) + } + got = f.run(t, run.ID) + if got.DeliveredChapters == nil || *got.DeliveredChapters != 1 { + t.Errorf("one whole chapter delivered reports %v", got.DeliveredChapters) + } + if got.Progress.Done != 2 { + t.Errorf("one whole chapter through both passes reads %d/%d", got.Progress.Done, got.Progress.Total) + } +}