Add the migration and four test files the previous commit left outside the tree

This commit is contained in:
heaven 2026-09-06 00:44:27 +03:00
parent 628cc56483
commit 36ea8b8a01
5 changed files with 1523 additions and 0 deletions

View file

@ -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())
}
}

View file

@ -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;

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)
}
}