textmachine/backend/internal/pipeline/rebill_test.go

658 lines
26 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"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/store"
)
// rebill_test.go: the consent-to-a-re-payment gate (D20.2-Q2, rebill.go). The load-bearing property is
// NEGATIVE — that a run which would re-pay for already-billed work stops BEFORE any reservation — so
// every scenario asserts the money state (committed unchanged, reserved 0), the provider-call count and
// the durable rows, not just the error string.
// driftPipelineVersion bumps the prompt_version of EVERY stage in the fixture pipeline, so BOTH wave
// snapshots move — the "the whole book would be re-paid" case the threshold exists for.
func driftPipelineVersion(t *testing.T, bookPath string) {
t.Helper()
path := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
changed := strings.ReplaceAll(string(raw), "prompt_version: v-test", "prompt_version: v-test-drift")
if changed == string(raw) {
t.Fatal("setup: prompt_version token not found in the fixture pipeline")
}
writeFile(t, path, changed)
}
// chunksOf is the book's current chunk manifest — the "which positions still exist" input the
// projection needs so an orphaned row is not billed to the operator's consent.
func chunksOf(t *testing.T, r *Runner) []chunk.Chunk {
t.Helper()
chunks, err := r.bookChunks()
if err != nil {
t.Fatal(err)
}
return chunks
}
// moneyState is the book's ledger + durable resume state, for before/after comparison across a refusal.
type moneyState struct {
committed, reserved float64
draftSnap, editSnap string
calls int
}
func readMoneyState(t *testing.T, r *Runner, rec *reqRec) moneyState {
t.Helper()
committed, reserved, err := r.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
m := moneyState{committed: committed, reserved: reserved, calls: rec.count()}
if cs, err := r.Store.GetChunkStatus("test-book", 1, 0, "draft"); err == nil && cs != nil {
m.draftSnap = cs.SnapshotID
}
if cs, err := r.Store.GetChunkStatus("test-book", 1, 0, "edit"); err == nil && cs != nil {
m.editSnap = cs.SnapshotID
}
return m
}
// TestRebillConsentRefusesOverThresholdBeforeAnyMoney is the pack's headline invariant: --resnapshot
// alone no longer re-buys a book silently. The run stops with the SUM and the unit count, having
// reserved nothing, called nothing and rewritten no durable row.
func TestRebillConsentRefusesOverThresholdBeforeAnyMoney(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
before := readMoneyState(t, r1, rec)
r1.Close()
if before.committed <= 0 || before.calls != 2 {
t.Fatalf("setup: run 1 must bill 2 calls, got committed=%v calls=%d", before.committed, before.calls)
}
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true // permission to RE-PIN — deliberately not consent to the AMOUNT
_, err := r2.TranslateBook(ctx)
if err == nil {
t.Fatal("a re-payment over the consent threshold must refuse, not proceed on --resnapshot alone")
}
msg := err.Error()
for _, want := range []string{"RE-PAY", "chunk×stage unit(s)", "--accept-rebill", "consent threshold"} {
if !strings.Contains(msg, want) {
t.Errorf("the refusal must name %q so the operator sees what to do; got: %s", want, msg)
}
}
// The projected sum itself must be IN the message (both rows of the fixture, 2×fakeCallUSD).
if !strings.Contains(msg, "0.003640") {
t.Errorf("the refusal must carry the projected amount (~$%.6f); got: %s", 2*fakeCallUSD, msg)
}
after := readMoneyState(t, r2, rec)
if after.calls != before.calls {
t.Errorf("a refused run must reach no provider: calls %d → %d", before.calls, after.calls)
}
if after.committed != before.committed || after.reserved != 0 {
t.Errorf("a refused run must move no money: committed %v → %v, reserved=%v", before.committed, after.committed, after.reserved)
}
if after.draftSnap != before.draftSnap || after.editSnap != before.editSnap {
t.Errorf("a refused run must not re-pin any durable row: draft %.12s → %.12s, edit %.12s → %.12s",
before.draftSnap, after.draftSnap, before.editSnap, after.editSnap)
}
}
// TestRebillConsentGivenProceeds is the positive half: with the consent the same run goes through and
// really does re-pay (so the gate is a gate, not a permanent stop).
func TestRebillConsentGivenProceeds(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
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()
callsAfterRun1 := rec.count()
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true
r2.AcceptRebill = RebillConsent{Given: true}
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatalf("consent given → the run must proceed: %v", err)
}
if rec.count() <= callsAfterRun1 {
t.Fatalf("the consented run must actually re-call the provider: calls=%d (after run1=%d)", rec.count(), callsAfterRun1)
}
if res.TotalUSD <= 0 {
t.Fatal("the consented re-translation must be billed")
}
}
// TestRebillConsentCapBelowProjectionRefuses pins the Р6 half that makes the consent CONCRETE: a named
// ceiling below the projection refuses, and the same ceiling above it proceeds. Without the cap branch
// `--accept-rebill=0.001` would be a blanket consent under a number.
func TestRebillConsentCapBelowProjectionRefuses(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
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()
callsAfterRun1 := rec.count()
driftPipelineVersion(t, bookPath)
// Ceiling BELOW the projected $0.00364 → refuse.
r2 := newRunner(t, bookPath)
r2.Resnapshot = true
r2.AcceptRebill = RebillConsent{Given: true, Capped: true, CapUSD: 0.001}
_, err := r2.TranslateBook(ctx)
r2.Close()
if err == nil || !strings.Contains(err.Error(), "caps consent at") {
t.Fatalf("a ceiling below the projection must refuse naming the cap, got: %v", err)
}
if rec.count() != callsAfterRun1 {
t.Fatalf("a cap-refused run must reach no provider: calls=%d", rec.count())
}
// Ceiling ABOVE it → proceed.
r3 := newRunner(t, bookPath)
defer r3.Close()
r3.Resnapshot = true
r3.AcceptRebill = RebillConsent{Given: true, Capped: true, CapUSD: 0.01}
if _, err := r3.TranslateBook(ctx); err != nil {
t.Fatalf("a ceiling above the projection must proceed: %v", err)
}
if rec.count() <= callsAfterRun1 {
t.Fatalf("the capped-but-sufficient consent must re-call the provider: calls=%d", rec.count())
}
}
// TestRebillUnderThresholdProceedsUnasked pins the ratified "below the threshold — automatically"
// behaviour, through the book's own `rebill_consent_usd` override (a cent-scale append must not cost
// the operator a round-trip). It also pins the override itself, which is otherwise unreachable.
func TestRebillUnderThresholdProceedsUnasked(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, rebillConsentUSD: 1.0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if r1.Book.RebillConsentUSD != 1.0 {
t.Fatalf("setup: the book override did not load, got %v", r1.Book.RebillConsentUSD)
}
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
callsAfterRun1 := rec.count()
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true // NO --accept-rebill: the projection ($0.00364) is under the book's $1.00 threshold
if _, err := r2.TranslateBook(ctx); err != nil {
t.Fatalf("a re-payment under the threshold must proceed unasked: %v", err)
}
if rec.count() <= callsAfterRun1 {
t.Fatalf("the under-threshold run must still do the work: calls=%d", rec.count())
}
}
// TestRebillConsentThresholdFormula is the ratified arithmetic, in isolation: min($0.50, 5%×projected),
// the $0.50 FLOOR for a book with nothing processed yet (the 5% branch would be $0 and would demand
// consent for a cent), and the book override winning over both.
func TestRebillConsentThresholdFormula(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
r := newRunner(t, setupProject(t, srv.URL))
defer r.Close()
for _, tc := range []struct {
name string
projected float64
override float64
want float64
}{
{"floor when nothing processed", 0, 0, 0.50},
{"5% of a cheap book", 4.0, 0, 0.20},
{"capped at $0.50 on an expensive book", 100.0, 0, 0.50},
{"exactly at the $10 crossover", 10.0, 0, 0.50},
{"book override wins", 100.0, 2.5, 2.5},
} {
r.Book.RebillConsentUSD = tc.override
got, _ := r.rebillConsentThreshold(tc.projected)
if got != tc.want {
t.Errorf("%s: threshold(projected=%v, override=%v) = %v, want %v", tc.name, tc.projected, tc.override, got, tc.want)
}
}
}
// TestRebillProjectionIsPerWave pins the money keystone the wave split bought: a mined sign moves ONLY
// the edit-wave snapshot, so the projection must count ONE row (the edit), never the draft. A whole-
// pipeline comparison would report the paid draft wave as re-billed and ask the owner to consent to
// double the real amount — the "re-paid ONCE" invariant, seen from the consent side.
func TestRebillProjectionIsPerWave(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
src := "方源走进了魔法学院的图书馆。"
seed := "terms:\n - src: 魔法学院\n dst: Академия магии\n status: approved\n"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: src, glossarySeed: seed, regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
editBefore, err := r1.Store.GetChunkStatus("test-book", 1, 0, "edit")
if err != nil || editBefore == nil {
t.Fatalf("setup: edit row after run 1: %v / %v", editBefore, err)
}
// Baseline, and the OTHER direction of the per-wave rule: with both waves resolved under their own
// current snapshots nothing is superseded. Comparing an edit row against the draft wave's snapshot
// (or vice versa) would report a re-bill on a book that has not drifted at all.
statusesBefore, err := r1.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
if proj, err := r1.projectRebill(statusesBefore, chunksOf(t, r1), func() ([]chunk.Chunk, error) { return chunksOf(t, r1), nil }, newRepricerT(t, r1)); err != nil || proj.Rows != 0 {
t.Fatalf("a freshly-run book projects no re-bill; each row must be judged against ITS OWN wave, got %+v (err=%v)", proj, err)
}
r1.Close()
dir := filepath.Dir(bookPath)
writeFile(t, filepath.Join(dir, "test-book.mined-delta.yaml"), "terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n")
r2 := newRunner(t, bookPath)
defer r2.Close()
if err := r2.seedGlossary(ctx); err != nil { // materializes the banks the wave snapshots fold
t.Fatal(err)
}
statuses, err := r2.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil }, newRepricerT(t, r2))
if err != nil {
t.Fatal(err)
}
if proj.Rows != 1 {
t.Fatalf("a mined sign re-bills the EDIT wave only — projection must count 1 row, got %d ($%.6f)", proj.Rows, proj.USD)
}
if proj.USD != editBefore.CostUSD {
t.Fatalf("the projected amount must be the edit row's stored cost $%.6f, got $%.6f", editBefore.CostUSD, proj.USD)
}
}
// TestRebillProjectionExcludesSkippedAndUnchanged: a `skipped` row never reached a provider and cost
// nothing, so re-running it is NEW work, not a re-payment — counting it would inflate the number the
// operator consents to. And with no drift at all the projection is empty, so an ordinary $0 resume is
// never asked anything.
func TestRebillProjectionExcludesSkippedAndUnchanged(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
return "Извините, я не могу перевести это.", "stop"
}
return draftEdit(body)
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
statuses, err := r1.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
var skipped int
for _, cs := range statuses {
if cs.Disposition == string(DispSkipped) {
skipped++
}
}
if skipped == 0 {
t.Fatal("setup: the refusal fixture must leave a skipped edit row")
}
// No drift yet: nothing is superseded, so nothing is projected.
if proj, err := r1.projectRebill(statuses, chunksOf(t, r1), func() ([]chunk.Chunk, error) { return chunksOf(t, r1), nil }, newRepricerT(t, r1)); err != nil || proj.Rows != 0 || proj.USD != 0 {
t.Fatalf("an undrifted book must project no re-bill, got %+v (err=%v)", proj, err)
}
r1.Close()
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
if err := r2.seedGlossary(ctx); err != nil {
t.Fatal(err)
}
proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil }, newRepricerT(t, r2))
if err != nil {
t.Fatal(err)
}
if proj.Rows != 1 {
t.Fatalf("only the BILLED draft row may be projected (the skipped edit costs nothing to redo), got %d rows", proj.Rows)
}
if proj.USD != fakeCallUSD {
t.Fatalf("projected amount = the draft row's stored cost $%.6f, got $%.6f", fakeCallUSD, proj.USD)
}
}
// TestRebillProjectionIgnoresRetiredStages: a row left behind by a stage the pipeline no longer runs
// can never be re-billed, because nothing will call it. Counting it would inflate the amount the
// operator is asked to consent to (and, on a book with a long stage history, arbitrarily so).
func TestRebillProjectionIgnoresRetiredStages(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
// A leftover row of a stage this pipeline does not have, under a snapshot that is nobody's current one.
if err := r.Store.UpsertChunkStatus(store.ChunkStatus{
BookID: "test-book", Chapter: 1, ChunkIdx: 0, Stage: "annotate",
SnapshotID: strings.Repeat("a", 64), ContentHash: "x", Disposition: string(DispOK), CostUSD: 9.99,
}); err != nil {
t.Fatal(err)
}
statuses, err := r.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
proj, err := r.projectRebill(statuses, chunksOf(t, r), func() ([]chunk.Chunk, error) { return chunksOf(t, r), nil }, newRepricerT(t, r))
if err != nil {
t.Fatal(err)
}
if proj.Rows != 0 || proj.USD != 0 {
t.Fatalf("a retired stage's row is not re-billable and must not be projected, got %+v", proj)
}
}
// TestRebillProjectionIgnoresVanishedChunks: the other orphan class. If the source is shortened, the
// rows of the removed positions stay in the store but nothing will re-run them, so they must not be
// billed to the operator's consent. Over-asking is the safe direction, but a number that is asked for
// and then not spent is how such a number stops being read.
func TestRebillProjectionIgnoresVanishedChunks(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАПЕРВАЯ\fГЛАВАВТОРАЯ", regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
statuses, err := r1.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
if len(statuses) != 4 { // 2 chapters × (draft + edit)
t.Fatalf("setup: want 4 stored rows over 2 chapters, got %d", len(statuses))
}
r1.Close()
// Chapter 2 disappears from the source, and the config drifts so everything surviving is superseded.
writeFile(t, filepath.Join(filepath.Dir(bookPath), "source.txt"), "ГЛАВАПЕРВАЯ")
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
if err := r2.seedGlossary(ctx); err != nil {
t.Fatal(err)
}
proj, err := r2.projectRebill(statuses, chunksOf(t, r2), func() ([]chunk.Chunk, error) { return chunksOf(t, r2), nil }, newRepricerT(t, r2))
if err != nil {
t.Fatal(err)
}
if proj.Rows != 2 {
t.Fatalf("only the SURVIVING chapter's 2 rows are re-billable (chapter 2 is gone from the manifest), got %d rows / $%.6f", proj.Rows, proj.USD)
}
}
// TestRebillConsentKeepsReadOnlyPathsAlive is the D20.4 $0 contract: a book whose next translate would
// be refused for want of consent must stay fully inspectable — status/report/export make no money
// decision, so the gate must not be anywhere near them.
func TestRebillConsentKeepsReadOnlyPathsAlive(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
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()
driftPipelineVersion(t, bookPath)
// The write path refuses…
rw := newRunner(t, bookPath)
rw.Resnapshot = true
_, werr := rw.TranslateBook(ctx)
rw.Close()
if werr == nil {
t.Fatal("setup: the write path must be refused for this to mean anything")
}
// …and every read-only surface still answers, at $0.
ro, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatalf("a book awaiting re-bill consent must still open read-only: %v", err)
}
defer ro.Close()
callsBefore := rec.count()
rep, err := ro.Status(ctx)
if err != nil {
t.Fatalf("status must stay alive: %v", err)
}
if !rep.ConfigDrift {
t.Error("status should report the drift that the consent gate is refusing over")
}
if _, err := ro.Export(false); err != nil {
t.Fatalf("export must stay alive: %v", err)
}
if _, err := ro.QualityReport(); err != nil {
t.Fatalf("report must stay alive: %v", err)
}
if rec.count() != callsBefore {
t.Fatalf("the read-only surfaces must stay $0: calls %d → %d", callsBefore, rec.count())
}
}
// TestRedriveRefusesRebillBeforeDestructiveReset is the external-review 1c discipline applied to the new
// gate: a redrive that would re-pay over the threshold must refuse BEFORE ResetChunkStages, so the flag
// telemetry it was about to re-attack still exists afterwards. Moving the check below the reset loop
// leaves this test with a deleted row.
func TestRedriveRefusesRebillBeforeDestructiveReset(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
return "Извините, я не могу перевести это.", "stop"
}
return draftEdit(body)
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0})
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()
callsAfterRun1 := rec.count()
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true // skips the drift COMPARISON — but not the question of what the re-pin costs
sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
if err == nil {
t.Fatal("redrive --resnapshot over the consent threshold must refuse")
}
if !strings.Contains(err.Error(), "--accept-rebill") {
t.Errorf("the redrive refusal must name the flag; got: %v", err)
}
if res != nil || sum.ResetRun {
t.Errorf("a consent-refused redrive must not re-run: res=%v resetRun=%v", res, sum.ResetRun)
}
if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != string(DispFlagged) {
t.Fatalf("the flagged row must survive a consent-refused redrive (1c torn-state class), got: %+v", cs)
}
if rec.count() != callsAfterRun1 {
t.Fatalf("a consent-refused redrive must reach no provider: calls=%d", rec.count())
}
}
// TestRedriveDryRunNeedsNoConsent: --dry-run reports the plan and touches nothing, so it is $0 by
// construction and must never be gated on a spend consent.
func TestRedriveDryRunNeedsNoConsent(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
return "Извините, я не могу перевести это.", "stop"
}
return draftEdit(body)
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0})
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()
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true
sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, DryRun: true})
if err != nil {
t.Fatalf("a dry-run redrive spends nothing and must not need consent: %v", err)
}
if res != nil || sum.ResetRun || len(sum.Targets) == 0 {
t.Fatalf("dry-run must report a plan without running: %+v (res=%v)", sum, res)
}
}
// TestProjectBookUSDExtrapolates pins the EXTRAPOLATION itself — the part a fully-processed fixture
// cannot see, because there processed == total and every wrong denominator gives the right answer. The
// threshold is 5% of this number, so an average taken over the wrong denominator would silently move
// the point at which the operator is asked for consent.
func TestProjectBookUSDExtrapolates(t *testing.T) {
units := []editUnit{
{Chapter: 1, FirstChunkIdx: 0, Members: []chunk.Chunk{{Chapter: 1, ChunkIdx: 0}}},
{Chapter: 2, FirstChunkIdx: 0, Members: []chunk.Chunk{{Chapter: 2, ChunkIdx: 0}}},
{Chapter: 3, FirstChunkIdx: 0, Members: []chunk.Chunk{{Chapter: 3, ChunkIdx: 0}}},
}
// Only chapter 1 has been attempted: draft + edit resolved ok at $1.00 each. Chapters 2-3 are pending.
byChunk := map[chunkKey][]store.ChunkStatus{
{chapter: 1, chunkIdx: 0}: {
{Stage: "draft", Disposition: string(DispOK), CostUSD: 1.0},
{Stage: "edit", Disposition: string(DispOK), CostUSD: 1.0},
},
}
// An empty repricer holds no checkpoint usage, so every row falls back to its stored amount — which
// is exactly the arithmetic this test is about (the re-pricing itself is reprice_test.go's subject).
noUsage := &repricer{}
// $2.00 over 1 processed unit × 3 units in the book = $6.00.
if got := projectBookUSD(units, byChunk, 1, 1, noUsage); got != 6.0 {
t.Fatalf("projected book cost = %v, want 6.0 ($2.00/processed unit × 3 units)", got)
}
// Nothing processed → no basis to extrapolate from → $0 (which is what makes the threshold fall back
// to its $0.50 floor rather than to 5%×0 = $0).
if got := projectBookUSD(units, map[chunkKey][]store.ChunkStatus{}, 1, 1, noUsage); got != 0 {
t.Fatalf("with nothing processed the projection is 0, got %v", got)
}
}
// TestProjectBookUSDMatchesStatus locks the single-definition extraction: the threshold's base and the
// number `tmctl status` publishes are computed by the SAME function, so they cannot drift apart.
func TestProjectBookUSDMatchesStatus(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
rep, err := r.Status(ctx)
if err != nil {
t.Fatal(err)
}
chunks, err := r.bookChunks()
if err != nil {
t.Fatal(err)
}
statuses, err := r.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
byChunk := map[chunkKey][]store.ChunkStatus{}
for _, cs := range statuses {
byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs)
}
got := projectBookUSD(r.outputUnits(chunks), byChunk,
len(r.waveStagesIndexed(waveDraft)), len(r.waveStagesIndexed(waveEdit)), newRepricerT(t, r))
if got != rep.ProjectedBookUSD {
t.Fatalf("projectBookUSD = %v but status reports %v — the threshold base and the published projection diverged", got, rep.ProjectedBookUSD)
}
if got != 2*fakeCallUSD {
t.Fatalf("the one-unit fixture projects its own cost: got %v, want %v", got, 2*fakeCallUSD)
}
}