658 lines
26 KiB
Go
658 lines
26 KiB
Go
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)
|
||
}
|
||
}
|