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)); 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, "mined-delta.yaml"), "terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n") rawBook, err := os.ReadFile(bookPath) if err != nil { t.Fatal(err) } writeFile(t, bookPath, strings.Replace(string(rawBook), "pipeline: pipeline.yaml", "pipeline: pipeline.yaml\nmined_delta: mined-delta.yaml", 1)) 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)) 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)); 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)) 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)) 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)) 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}, }, } // $2.00 over 1 processed unit × 3 units in the book = $6.00. if got := projectBookUSD(units, byChunk, 1, 1); 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); 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))) 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) } }