textmachine/platform/internal/pgstore/price_test.go

196 lines
8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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