textmachine/backend/internal/pipeline/volume_midrun_bankmove_test.go

149 lines
6.9 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 pipeline
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/chunk/chunktest"
"textmachine/backend/internal/obs"
)
// volume_midrun_bankmove_test.go: the SCENARIO behind rescopeEditWave — a purchaser is charged twice for
// a chapter they already own, because the run moved the bank after deciding what was free.
//
// It is deliberately not the same assertion as the invariant test. That one pins "the re-plan runs before
// the wave"; this one pins "the buyer is not billed for a unit no grant paid for", which is the thing the
// buyer's money is actually exposed to. The two can come apart: a re-plan that ran but judged wrongly, a
// third path that moves the bank, or a later refactor that re-orders the seed — each keeps the invariant
// and breaks this.
//
// THE FIXTURE'S ONE TRICK, and the reason a homogeneous book will not show the defect. The mined delta has
// two sides. The WHICH side (the term list) is mined from the SOURCE of the whole book, so it is complete
// after the first purchase and never moves again. The WHAT side (each term's dst) is drafted-side: it is
// folded from the banknote blocks the translator emitted, so it grows with every purchase. So the source
// here is IDENTICAL in every chapter — the asymmetry is in what the drafts PROPOSE:
//
// - chapters 1-2 emit a banknote naming only 青茅山;
// - chapters 3-4 also propose a dst for 方源, which occurs in every chapter's source, chapters 1-2
// included.
//
// Purchase 1 buys chapters 1-2, so the auto-bank it writes holds 方源 with NO rendering. Purchase 2 drafts
// chapter 3, whose banknote supplies one — and the re-seed at the bank-mining stop folds it into the
// ENRICHED bank the editor selects over. Chapters 1-2 were admitted as free against the bank as it was at
// planning; their editor injection now carries a term it did not carry, so their content hash moves and
// they cost a fresh paid call. That is production's ordinary shape (mining.go says so where it re-seeds:
// "a term whose drafts disagreed can carry a different proposal than it did before ... that is bank
// CONTENT"), and it needs no heterogeneous source at all.
func TestAMidRunBankMoveNeverBillsBeyondTheGrant(t *testing.T) {
const chapters, grant2 = 4, 1
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
// The early chapters' drafts propose no rendering for 方源; the later ones do.
note := bankSeparator + "\n青茅山\tгора Цинмао\tplace"
if !strings.Contains(body, "MARK1") && !strings.Contains(body, "MARK2") {
note = bankSeparator + "\n方源\tФан Юань\tname\n青茅山\tгора Цинмао\tplace"
}
return "Фан Юань пришёл к горе Цинмао.\n" + note, "stop"
})
defer srv.Close()
bookPath := miningBookOfNChapters(t, srv.URL, chapters)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
// PURCHASE 1: two units. They are delivered, and the auto-bank written at the mining stop holds 方源
// with no rendering yet.
r1 := newRunner(t, bookPath)
r1.MaxUnits = 2
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatalf("purchase 1: %v", err)
}
r1.Close()
// PURCHASE 2: ONE unit. --resnapshot because the previous purchase's edit jobs are pinned to the
// snapshot this run's own re-seed will move — the state planVolume warns about by name.
before := rec.count()
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.MaxUnits = grant2
r2.Resnapshot = true
res2, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatalf("purchase 2: %v", err)
}
newBodies := rec.all()[before:]
// (1) THE MONEY. A grant of N units buys at most N drafts and N edits. Anything more is a unit the
// buyer did not pay for being billed to them.
if want := grant2 * 2; len(newBodies) != want {
t.Errorf("a grant of %d output unit(s) made %d provider call(s), want exactly %d (draft+edit each): the surplus is work no grant authorised",
grant2, len(newBodies), want)
}
if maxUSD := float64(grant2*2) * fakeCallUSD; res2.TotalUSD > maxUSD+1e-9 {
t.Errorf("the run spent $%.6f on a grant of %d unit(s); at most $%.6f can be owed", res2.TotalUSD, grant2, maxUSD)
}
// (2) WHICH unit was billed, named. The already-delivered chapters must not be re-edited at all.
for _, b := range newBodies {
if !isEditBody(b) {
continue
}
for _, owned := range []string{"MARK1", "MARK2"} {
if strings.Contains(b, owned) {
t.Errorf("chapter %s was delivered by the previous purchase and was EDITED again on a grant that never covered it — the buyer paid twice for a chapter they already owned", owned)
}
}
}
// (3) THE REPORT. A unit that was billed must never be counted among the ones that rode along at $0 —
// that is the line an operator reads to decide the run was cheap.
if res2.Volume == nil {
t.Fatalf("the run stopped short of the book and said nothing about which ceiling did it")
}
if res2.Volume.Free != 0 {
t.Errorf("VolumeStop reports %d unit(s) as riding along at $0, but the bank moved under every one of them: %+v",
res2.Volume.Free, *res2.Volume)
}
if res2.Volume.Paid() > res2.Volume.MaxUnits {
t.Errorf("VolumeStop admits paying for %d unit(s) under a ceiling of %d: %+v",
res2.Volume.Paid(), res2.Volume.MaxUnits, *res2.Volume)
}
t.Logf("purchase 2 (grant %d): %d call(s), $%.6f — %v", grant2, len(newBodies), res2.TotalUSD, res2.Volume)
}
// miningBookOfNChapters is the mining-stop fixture over N EPUB chapters (setupMiningStopProject is
// single-source, and a volume ceiling needs a book with more than one output unit to bound). The chapter
// bodies are byte-identical apart from a MARK<n> tag the provider stub keys off — see the file comment:
// the defect needs asymmetric BANKNOTES, not an asymmetric source.
func miningBookOfNChapters(t *testing.T, providerURL string, n int) string {
t.Helper()
body := strings.Repeat("方源来到青茅山。方源很强。花家很大。花家的人。", 6)
var eps []chunktest.Chapter
var spine []string
for i := 1; i <= n; i++ {
id := fmt.Sprintf("c%d", i)
eps = append(eps, chunktest.Chapter{ID: id, Href: id + ".xhtml", Body: fmt.Sprintf("<p>MARK%d %s</p>", i, body)})
spine = append(spine, id)
}
bookPath := setupProjectOpts(t, providerURL, projectOpts{
epub: eps, spine: spine, regenerate: 1,
gatesYAML: "\nmining:\n contrast_path: mining-contrast.txt\ngates:\n banknote:\n enabled: true\n",
})
dir := filepath.Dir(bookPath)
writeFile(t, filepath.Join(dir, "mining-contrast.txt"), miningContrastData)
packRoot, err := filepath.Abs("../../configs/langpacks")
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(bookPath)
if err != nil {
t.Fatal(err)
}
writeFile(t, bookPath, strings.Replace(string(raw), "source_lang: ja", "source_lang: zh\nlangpack_root: "+packRoot, 1))
return bookPath
}