Add the migration and four test files the previous commit left outside the tree
This commit is contained in:
parent
628cc56483
commit
36ea8b8a01
5 changed files with 1523 additions and 0 deletions
173
platform/internal/ingest/price_test.go
Normal file
173
platform/internal/ingest/price_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
211
platform/internal/pgstore/migrations/00033_order_and_price.sql
Normal file
211
platform/internal/pgstore/migrations/00033_order_and_price.sql
Normal 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;
|
||||
196
platform/internal/pgstore/price_test.go
Normal file
196
platform/internal/pgstore/price_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
215
platform/internal/runner/priceprojection_live_test.go
Normal file
215
platform/internal/runner/priceprojection_live_test.go
Normal 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
|
||||
}
|
||||
728
platform/internal/runs/order_test.go
Normal file
728
platform/internal/runs/order_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue