528 lines
22 KiB
Go
528 lines
22 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"crypto/sha256"
|
||
"encoding/hex"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/chunk/chunktest"
|
||
"textmachine/backend/internal/membank"
|
||
"textmachine/backend/internal/obs"
|
||
)
|
||
|
||
// decisionsDocFor writes a decision document for an ARBITRARY book id — the package's existing helper
|
||
// hardcodes the one book its own fixtures use.
|
||
func decisionsDocFor(t *testing.T, dir, bookID string, ds ...membank.Decision) string {
|
||
t.Helper()
|
||
body, err := json.Marshal(membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: bookID, Decisions: ds})
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
p := filepath.Join(dir, "decisions-for-"+bookID+".json")
|
||
writeFile(t, p, string(body))
|
||
return p
|
||
}
|
||
|
||
// assertStoreUntouched is the "nothing was written" instrument, and it is deliberately TWO assertions
|
||
// because one of them is not enough.
|
||
//
|
||
// ⚠ HASHING THE .db FILE ALONE PROVES LESS THAN IT LOOKS. SQLite in WAL mode puts a committed change in
|
||
// the `-wal` sidecar and leaves the main file untouched until a checkpoint, so a data write can land with
|
||
// the main file's bytes unmoved — the first version of this helper would have gone on reporting "nothing
|
||
// was written" straight through it. So: the data file must be byte-identical AND the write-ahead log must
|
||
// carry no frames.
|
||
//
|
||
// ⚠ AND `-shm` IS DELIBERATELY NOT CHECKED. Measured, not assumed: opening this database read-only
|
||
// CREATES an empty `-wal` and a live `-shm`, because the shared-memory index is how any WAL reader finds
|
||
// its snapshot. Requiring those two files not to appear would fail on a pure read and would be asserting
|
||
// something SQLite's design forbids, not something about this engine. An empty `-wal` is the honest line:
|
||
// the reader may set the machinery up, it may not commit a frame through it.
|
||
//
|
||
// It is corroboration, not the guarantee. What actually makes the read path unable to write is
|
||
// store.OpenReadOnly's PRAGMA query_only(1) — a write through it fails loudly rather than being dropped.
|
||
// This catches a path that reached a WRITE handle by some route the read was not supposed to take.
|
||
func assertStoreUntouched(t *testing.T, dbPath, dataBefore string) {
|
||
t.Helper()
|
||
if got := fileDigest(t, dbPath); got != dataBefore {
|
||
t.Fatalf("the project database changed during a read-only estimate (%s → %s): nothing beyond the first touch may be written", dataBefore[:12], got[:12])
|
||
}
|
||
wal, err := os.Stat(dbPath + "-wal")
|
||
if err != nil {
|
||
if !os.IsNotExist(err) {
|
||
t.Fatalf("stat %s-wal: %v", dbPath, err)
|
||
}
|
||
return // no write-ahead log at all — nothing could have been committed through one
|
||
}
|
||
if wal.Size() != 0 {
|
||
t.Fatalf("the write-ahead log holds %d byte(s) after a read-only estimate: a committed change can sit in -wal with the main database file still byte-identical, which is exactly what makes hashing the .db alone insufficient", wal.Size())
|
||
}
|
||
}
|
||
|
||
// fileDigest is a plain content hash of one file.
|
||
func fileDigest(t *testing.T, path string) string {
|
||
t.Helper()
|
||
b, err := os.ReadFile(path)
|
||
if err != nil {
|
||
t.Fatalf("read %s: %v", path, err)
|
||
}
|
||
sum := sha256.Sum256(b)
|
||
return hex.EncodeToString(sum[:])
|
||
}
|
||
|
||
// TestABankEditIsVisibleToTheEstimateBeforeAnythingIsBought is the deliverable of backlog row 231, and it
|
||
// is written against the state the FIRST attempt at this repro got wrong (errata 28.08-и/-к).
|
||
//
|
||
// THE DEGENERATE REPRO THAT MUST BE AVOIDED. "translate has never run" proves nothing: such a book has no
|
||
// chunk_status rows at all, and projectRebill skips rows without a snapshot (rebill.go), so it answers
|
||
// EMPTY both before and after any fix. The state that discriminates is: a run HAPPENED (rows and
|
||
// snapshots exist), THEN the bank was edited through `bank-apply`, which writes decision FILES and not one
|
||
// store row. On that state the engine used to answer "nothing moved" while the very next `translate` would
|
||
// re-seed from those files and bill for the difference.
|
||
//
|
||
// So the assertion is a DELTA, and both halves are executed here: the stored-glossary projection (what the
|
||
// engine answered before) must be zero on exactly the state where the folded projection is non-zero.
|
||
// Around it sit the two boundaries the estimate is sold with — not one provider call, and not one byte
|
||
// written to the store beyond the first touch that predates it.
|
||
func TestABankEditIsVisibleToTheEstimateBeforeAnythingIsBought(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
||
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
|
||
})
|
||
dir := filepath.Dir(bookPath)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
// --- the run that must have HAPPENED for the repro to discriminate ---
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err != nil {
|
||
t.Fatalf("the run this repro rests on did not complete: %v", err)
|
||
}
|
||
statuses, err := r1.Store.ChunkStatusesForBook("test-book")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if len(statuses) == 0 {
|
||
t.Fatalf("precondition: the repro needs stored chunk_status rows, the state the degenerate version lacked")
|
||
}
|
||
committedBefore, reservedBefore, err := r1.Store.SpentUSD("test-book")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
|
||
// --- the bank edit, through the verb the product actually uses ---
|
||
// 静か occurs in the source, so the term enters the ENRICHED bank, moves the edit-wave snapshot AND
|
||
// changes the injected bytes of the edit unit — i.e. it is genuinely re-paid work, not a $0 re-pin.
|
||
doc := decisionsDocFor(t, dir, "test-book", membank.Decision{
|
||
Action: membank.ActionApprove, Src: "静か", Dst: "тихий"})
|
||
if _, err := ApplyBankDecisions(ctx, bookPath, doc, false); err != nil {
|
||
t.Fatalf("bank-apply: %v", err)
|
||
}
|
||
|
||
callsBefore := rec.count()
|
||
dbPath := filepath.Join(dir, "test-book.db")
|
||
dbBefore := fileDigest(t, dbPath)
|
||
|
||
// --- the two projections over the SAME state ---
|
||
r2, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer r2.Close()
|
||
rp, err := r2.newRepricer()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
chunks, withText, err := r2.readModelChunks()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
// What the engine answered BEFORE this pack: the glossary the last run stored.
|
||
if err := r2.projectStoredMemory(); err != nil {
|
||
t.Fatalf("stored-glossary projection: %v", err)
|
||
}
|
||
storedProj, err := r2.projectRebill(statuses, chunks, withText, rp)
|
||
if err != nil {
|
||
t.Fatalf("stored-glossary re-bill projection: %v", err)
|
||
}
|
||
|
||
// What it answers now: the bank the next `translate` will actually build.
|
||
basis, warn := r2.foldMemoryForRead()
|
||
if warn != nil {
|
||
t.Fatalf("the fold refused on a healthy book: %v", warn)
|
||
}
|
||
if basis != RebillBasisPending {
|
||
t.Fatalf("basis = %q, want %q — a healthy book must be projected from its decision files", basis, RebillBasisPending)
|
||
}
|
||
foldedProj, err := r2.projectRebill(statuses, chunks, withText, rp)
|
||
if err != nil {
|
||
t.Fatalf("folded re-bill projection: %v", err)
|
||
}
|
||
|
||
// THE DELTA. Both halves are load-bearing: a non-zero "after" alone would also pass on a book where
|
||
// the old answer was non-zero too, and would not prove the blind window was the thing that closed.
|
||
if storedProj.Rows != 0 {
|
||
t.Fatalf("the repro does not discriminate: the STORED-glossary projection already sees %d unit(s); the blind window this pack closes would be invisible here", storedProj.Rows)
|
||
}
|
||
if foldedProj.Rows == 0 {
|
||
t.Fatalf("the folded projection still reports nothing after a bank edit — the blind window of row 231 is open")
|
||
}
|
||
|
||
// --- the two boundaries the estimate is SOLD with ---
|
||
if got := rec.count(); got != callsBefore {
|
||
t.Fatalf("the estimate made %d provider call(s); it is sold as $0 and must make none", got-callsBefore)
|
||
}
|
||
committedAfter, reservedAfter, err := r2.Store.SpentUSD("test-book")
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if committedAfter != committedBefore || reservedAfter != reservedBefore {
|
||
t.Fatalf("the ledger moved during a free estimate: committed %v→%v, reserved %v→%v",
|
||
committedBefore, committedAfter, reservedBefore, reservedAfter)
|
||
}
|
||
assertStoreUntouched(t, dbPath, dbBefore)
|
||
}
|
||
|
||
// TestTheEstimateSurvivesABookWhoseFoldRefuses pins the property rebill.go leans on — "a book that needs
|
||
// consent stays fully inspectable" (D20.4). `translate` fails loudly on a bank whose fold would abort
|
||
// ReplaceBank; a READ must not, or the book that most needs looking at is the one that cannot be looked
|
||
// at. It must fall back AND say that it fell back, because a stale number passed off as the answer is
|
||
// worse than a labelled one.
|
||
func TestTheEstimateSurvivesABookWhoseFoldRefuses(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
||
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
|
||
})
|
||
dir := filepath.Dir(bookPath)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
|
||
// A mined-delta row on the SAME UNIQUE key as the signed seed term: exactly what would abort
|
||
// ReplaceBank's flat INSERT, and what seedGlossary refuses on before it gets there.
|
||
writeFile(t, filepath.Join(dir, "test-book.mined-delta.yaml"), `
|
||
terms:
|
||
- src: 鈴木
|
||
dst: Судзуки-другой
|
||
type: name
|
||
status: approved
|
||
`)
|
||
r2, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer r2.Close()
|
||
|
||
basis, warn := r2.foldMemoryForRead()
|
||
if basis != RebillBasisStored {
|
||
t.Fatalf("basis = %q, want %q — a refused fold must fall back to the stored glossary, not fail the read", basis, RebillBasisStored)
|
||
}
|
||
if warn == nil {
|
||
t.Fatalf("a silent fallback is the defect: the reason the projection is a fact about the PAST must reach the log")
|
||
}
|
||
if r2.memory == nil || r2.baseMemory == nil {
|
||
t.Fatalf("the fallback must still leave a materialized bank behind (memory=%v base=%v)", r2.memory != nil, r2.baseMemory != nil)
|
||
}
|
||
// And the whole read still answers rather than erroring.
|
||
rep, err := r2.Status(ctx)
|
||
if err != nil {
|
||
t.Fatalf("status refused a book whose fold refuses — D20.4 says it must stay inspectable: %v", err)
|
||
}
|
||
if rep.RebillBasis != RebillBasisStored {
|
||
t.Fatalf("the report must carry the fallback basis, got %q", rep.RebillBasis)
|
||
}
|
||
}
|
||
|
||
// TestTheWireTellsFreeFromUnknown pins the omitempty mine D39.166 п.2 already paid for once: three states
|
||
// collapse to two zeroes unless the basis rides with them.
|
||
func TestTheWireTellsFreeFromUnknown(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
||
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
|
||
})
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
// A book nothing has run: the figures are zero because there is nothing to re-pay.
|
||
r0, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rep0, err := r0.Status(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r0.Close()
|
||
if rep0.RebillBasis != RebillBasisNone {
|
||
t.Fatalf("a book with no stored row must report basis %q, got %q", RebillBasisNone, rep0.RebillBasis)
|
||
}
|
||
|
||
// A book that ran and drifted from nothing: the figures are zero because they were COMPUTED as zero.
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
r2, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer r2.Close()
|
||
rep1, err := r2.Status(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rep1.RebillBasis != RebillBasisPending {
|
||
t.Fatalf("a healthy run must report basis %q, got %q", RebillBasisPending, rep1.RebillBasis)
|
||
}
|
||
if rep1.RebillUnits != 0 {
|
||
t.Fatalf("precondition: an undrifted book re-pays nothing, got %d", rep1.RebillUnits)
|
||
}
|
||
// The two states above BOTH carry zero. Before this pack they were the same bytes on the wire.
|
||
if rep0.RebillBasis == rep1.RebillBasis {
|
||
t.Fatalf("«nothing to re-pay» and «computed, and it is zero» must be distinguishable, both say %q", rep0.RebillBasis)
|
||
}
|
||
}
|
||
|
||
// TestAFailedBasisNeverCarriesAFigure pins the direction the basis field's first version did not
|
||
// consider. It was added so that "computed, and it is zero" could be told from "we could not compute it"
|
||
// — but a failed fold leaves r.memory nil, and projectRebill run against a nil bank renders both wave
|
||
// snapshots over an EMPTY bank, so EVERY stored row differs and the whole book comes back as a
|
||
// re-payment. The wire would then carry rebill_basis:"failed" beside the largest number the field can
|
||
// hold, and the CLI would print "the figures below are zero because they could not be computed" next to
|
||
// it. A basis that contradicts its own numbers is worse than no basis.
|
||
func TestAFailedBasisNeverCarriesAFigure(t *testing.T) {
|
||
big := RebillProjection{Rows: 4276, USD: 12.5, OutputUnits: 1400, HistoricalRows: 3, ModelMovedRows: 7}
|
||
for _, c := range []struct {
|
||
name string
|
||
hasRows bool
|
||
memBasis string
|
||
proj RebillProjection
|
||
projErr error
|
||
basis string
|
||
units int
|
||
}{
|
||
{"nothing stored", false, RebillBasisPending, big, nil, RebillBasisNone, 0},
|
||
{"healthy fold", true, RebillBasisPending, big, nil, RebillBasisPending, 4276},
|
||
{"fold refused, stored answered", true, RebillBasisStored, big, nil, RebillBasisStored, 4276},
|
||
// The two that matter: an unknown answer must never arrive carrying a number. Before this was one
|
||
// decision, the basis said "failed" while a whole-book figure — computed over an EMPTY bank, so
|
||
// every row looked superseded — sat next to it.
|
||
{"no bank at all", true, RebillBasisFailed, big, nil, RebillBasisFailed, 0},
|
||
{"projection errored", true, RebillBasisPending, big, errors.New("boom"), RebillBasisFailed, 0},
|
||
} {
|
||
t.Run(c.name, func(t *testing.T) {
|
||
basis, published := rebillOutcome(c.hasRows, c.memBasis, c.proj, c.projErr)
|
||
units, usd := published.Rows, published.USD
|
||
if basis != c.basis || units != c.units {
|
||
t.Fatalf("got (%q, %d, %v), want (%q, %d)", basis, units, usd, c.basis, c.units)
|
||
}
|
||
// EVERY figure, not just the two obvious ones: an output-unit count surviving an UNKNOWN
|
||
// answer would be the same contradiction in a different field — and so would a disclosure
|
||
// counter, which is why the whole projection is compared against its zero value.
|
||
if basis == RebillBasisFailed && published != (RebillProjection{}) {
|
||
t.Fatalf("an UNKNOWN answer carried a figure: %+v", published)
|
||
}
|
||
if basis == RebillBasisNone && published != (RebillProjection{}) {
|
||
t.Fatalf("nothing stored means nothing to re-pay, yet figures were reported: %+v", published)
|
||
}
|
||
})
|
||
}
|
||
// And the projection is not even attempted when there is no bank to compare against — running it
|
||
// would produce exactly the whole-book figure the case above must never show.
|
||
if projectable(true, RebillBasisFailed) {
|
||
t.Fatalf("with no materialized bank the projection must be skipped, not computed and then discarded")
|
||
}
|
||
if !projectable(true, RebillBasisPending) || projectable(false, RebillBasisPending) {
|
||
t.Fatalf("projectable must gate on stored rows and on a usable bank, and on nothing else")
|
||
}
|
||
|
||
// And end to end: a book whose fold refuses AND whose store cannot answer either. The fold refusal is
|
||
// built with a mined-delta duplicating the signed seed term; the stored side is broken by closing the
|
||
// store out from under the read, which is the only way both halves fail at once.
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
||
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
|
||
})
|
||
dir := filepath.Dir(bookPath)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
writeFile(t, filepath.Join(dir, "test-book.mined-delta.yaml"), `
|
||
terms:
|
||
- src: 鈴木
|
||
dst: Судзуки-другой
|
||
type: name
|
||
status: approved
|
||
`)
|
||
r2, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer r2.Close()
|
||
got, err := r2.Status(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
// Here only the FOLD fails, so the honest answer is the stored fallback with a real figure — the
|
||
// point being that "failed" is reserved for the state where nothing at all could be materialized.
|
||
if got.RebillBasis != RebillBasisStored {
|
||
t.Fatalf("a fold refusal alone falls back rather than failing: got %q", got.RebillBasis)
|
||
}
|
||
}
|
||
|
||
// TestExportAndStatusAnswerDifferentDriftQuestions pins a divergence that is DELIBERATE, so that nobody
|
||
// removes it as an inconsistency.
|
||
//
|
||
// After a bank-apply that has written only decision FILES, `status` reports drift and `export` does not,
|
||
// and both are right because they are asked different things. status is the money surface: its question
|
||
// is "what would the NEXT run cost", and an un-run decision file is precisely the spend row 231 exists to
|
||
// reveal. export's ConfigDrift is read by the polygon's extraction to decide whether a document is
|
||
// trustworthy to MEASURE — and an export whose bytes are exactly what its run shipped is measurable
|
||
// whether or not somebody has queued a bank edit beside it.
|
||
//
|
||
// The pack briefly made export fold too, for symmetry. Symmetry between two surfaces answering different
|
||
// questions is not worth having, and it would have disqualified sound measurements in another zone.
|
||
func TestExportAndStatusAnswerDifferentDriftQuestions(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
||
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
|
||
})
|
||
dir := filepath.Dir(bookPath)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
|
||
doc := decisionsDocFor(t, dir, "test-book", membank.Decision{
|
||
Action: membank.ActionApprove, Src: "静か", Dst: "тихий"})
|
||
if _, err := ApplyBankDecisions(ctx, bookPath, doc, false); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
callsBefore := rec.count()
|
||
rExp, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
exp, err := rExp.Export(false)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
rExp.Close()
|
||
if exp.ConfigDrift {
|
||
t.Fatalf("export marked a document drifted whose text is exactly what its run shipped: the polygon reads this field to decide whether a measurement is sound, and an un-run decision file does not make one unsound")
|
||
}
|
||
|
||
rSt, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer rSt.Close()
|
||
rep, err := rSt.Status(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !rep.ConfigDrift {
|
||
t.Fatalf("status must see the pending bank edit — that is the whole of row 231; got config_drift=false")
|
||
}
|
||
if rep.RebillUnits == 0 {
|
||
t.Fatalf("status must price the pending edit, got rebill_units=0 basis=%q", rep.RebillBasis)
|
||
}
|
||
// Both are $0 surfaces.
|
||
if got := rec.count(); got != callsBefore {
|
||
t.Fatalf("a read surface made %d provider call(s)", got-callsBefore)
|
||
}
|
||
}
|
||
|
||
// TestTheEstimateIsAlsoGivenInTheUnitAPurchaseIsSizedIn pins the denomination fix.
|
||
//
|
||
// `rebill_units` counts chunk×stage — the BILLING granularity — while every other "units" number in the
|
||
// same document (total_units, the progress totals, chapters[].units_total) counts OUTPUT units, which is
|
||
// also what `--max-units` bounds and what the platform sells chapters in. Both wore the word "units" with
|
||
// nothing marking the difference, and the ratio is not something a consumer can divide out: it is
|
||
// len(Members)·nDraftStages + nEditStages, which varies unit by unit inside one book and whose factors
|
||
// never cross the seam. A platform sizing a re-pass from the estimate and handing that number to
|
||
// --max-units would buy several times the book it meant to — this pack's own defect, one layer down.
|
||
func TestTheEstimateIsAlsoGivenInTheUnitAPurchaseIsSizedIn(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
// A cut fine enough that units hold several chunks, so the two denominations genuinely differ.
|
||
const seg = "\nsegmentation:\n draft_budget_out: 24\n edit_ceiling_out: 8000\n fertility: { cjk: 1.1978, other: 0.3852 }\n"
|
||
var eps []chunktest.Chapter
|
||
var spine []string
|
||
for i := 1; i <= 2; i++ {
|
||
id := fmt.Sprintf("c%d", i)
|
||
eps = append(eps, chunktest.Chapter{ID: id, Href: id + ".xhtml",
|
||
Body: fmt.Sprintf("<p>静かな図書館の朝%d。鈴木は本を読んだ。外では雨が降っていた。彼は窓を見た。</p>", i)})
|
||
spine = append(spine, id)
|
||
}
|
||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
||
epub: eps, spine: spine, regenerate: 1, waveWorkers: 1, gatesYAML: seg,
|
||
})
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
// A PIPELINE drift, not a bank edit: a mined bank edit moves only the edit wave, so it re-pays exactly
|
||
// one row per unit and the two denominations would coincide — the fixture would prove nothing. A config
|
||
// change moves BOTH waves, which is the ordinary shape in which a unit's several draft rows and its one
|
||
// edit row are all re-paid together, and where the collision this field exists for is real.
|
||
driftPipelineVersion(t, bookPath)
|
||
|
||
r2, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
defer r2.Close()
|
||
rep, err := r2.Status(ctx)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if rep.RebillUnits == 0 {
|
||
t.Fatalf("precondition: the bank edit must produce a re-payment, got 0 (basis %q)", rep.RebillBasis)
|
||
}
|
||
if rep.RebillOutputUnits == 0 {
|
||
t.Fatalf("the estimate must also be given in OUTPUT units — the granularity --max-units counts and the platform sells in; got 0 beside rebill_units=%d", rep.RebillUnits)
|
||
}
|
||
if rep.RebillOutputUnits > rep.RebillUnits {
|
||
t.Fatalf("an output unit holds one or more chunk×stage rows, so it can never exceed them: output=%d rows=%d", rep.RebillOutputUnits, rep.RebillUnits)
|
||
}
|
||
if rep.RebillOutputUnits > rep.TotalUnits {
|
||
t.Fatalf("the book has %d output units; a re-pass cannot touch %d of them", rep.TotalUnits, rep.RebillOutputUnits)
|
||
}
|
||
// The fixture must actually make the two differ, or it proves nothing about the collision.
|
||
if rep.RebillOutputUnits == rep.RebillUnits {
|
||
t.Fatalf("fixture is degenerate: the two denominations coincide (%d), so the trap this field exists for is untested", rep.RebillUnits)
|
||
}
|
||
}
|