149 lines
6.9 KiB
Go
149 lines
6.9 KiB
Go
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
|
||
}
|