322 lines
14 KiB
Go
322 lines
14 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"fmt"
|
||
"io"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"textmachine/backend/internal/pipeline"
|
||
)
|
||
|
||
// rebill_cli_test.go closes the D20.4 «parsed but silently ignored» regression class for
|
||
// --accept-rebill: parsing the flag correctly is worthless if the value never reaches the runner. The
|
||
// invocation tests cover the parse; this one drives the REAL `translate` entry point end-to-end against
|
||
// a mock provider, so the wiring itself is executed, not asserted about.
|
||
|
||
// writeCLIFile writes a fixture file, creating parents.
|
||
func writeCLIFile(t *testing.T, path, content string) {
|
||
t.Helper()
|
||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
}
|
||
|
||
// setupCLIProject builds the smallest runnable project (one draft stage) pointed at providerURL and
|
||
// returns the path of its book.yaml.
|
||
func setupCLIProject(t *testing.T, providerURL string) string {
|
||
t.Helper()
|
||
dir := t.TempDir()
|
||
writeCLIFile(t, filepath.Join(dir, "prompts", "translator.md"),
|
||
"Переводи с {{source_lang}} на {{target_lang}}.\n---USER---\n{{text}}")
|
||
writeCLIFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
|
||
prices_checked: %q
|
||
default_model: fake-model
|
||
providers:
|
||
fake:
|
||
kind: openai
|
||
base_url: %q
|
||
timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
|
||
models:
|
||
fake-model:
|
||
provider: fake
|
||
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
|
||
`, time.Now().UTC().Format("2006-01-02"), providerURL))
|
||
writeCLIFile(t, filepath.Join(dir, "pipeline.yaml"), `
|
||
core: C1
|
||
version: 1
|
||
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
|
||
retries: { regenerate_before_escalate: 0 }
|
||
context: { glossary_injection: selective, glossary_token_budget: 800 }
|
||
stages:
|
||
- { name: draft, role: translator, model: fake-model, prompt_override: prompts/translator.md, prompt_version: v-cli, temperature: 0.3, reasoning: "off" }
|
||
`)
|
||
writeCLIFile(t, filepath.Join(dir, "source.txt"), "静かな図書館の朝。")
|
||
writeCLIFile(t, filepath.Join(dir, "book.yaml"), `
|
||
book_id: cli-book
|
||
title: Тест
|
||
source_lang: ja
|
||
target_lang: ru
|
||
genre: ранобэ
|
||
audience: тест
|
||
venuti: 0.5
|
||
honorifics: keep
|
||
transcription: polivanov
|
||
footnotes: minimal
|
||
pipeline: pipeline.yaml
|
||
models: models.yaml
|
||
source_file: source.txt
|
||
ceilings: { book_usd: 1.0, day_usd: 2.0 }
|
||
`)
|
||
return filepath.Join(dir, "book.yaml")
|
||
}
|
||
|
||
// TestTranslateWiresAcceptRebill: after a config drift, `translate --resnapshot` alone must be REFUSED
|
||
// with the projected sum, and the same call carrying the consent must go through. Dropping
|
||
// `r.AcceptRebill = acceptRebill` in main.go makes the second half fail here — which is the whole point:
|
||
// the flag exists to change what the run does, not what the parser returns.
|
||
func TestTranslateWiresAcceptRebill(t *testing.T) {
|
||
calls := 0
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = io.ReadAll(r.Body)
|
||
calls++
|
||
tb, _ := json.Marshal("ЧЕРНОВИК ПЕРЕВОДА")
|
||
fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":"stop"}],
|
||
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`, tb)
|
||
}))
|
||
defer srv.Close()
|
||
bookPath := setupCLIProject(t, srv.URL)
|
||
ctx := context.Background()
|
||
|
||
if err := translate(ctx, bookPath, false, pipeline.RebillConsent{}, false, 0, 0); err != nil {
|
||
t.Fatalf("first run: %v", err)
|
||
}
|
||
if calls != 1 {
|
||
t.Fatalf("setup: the first run must bill one call, got %d", calls)
|
||
}
|
||
|
||
// Drift the config so the stored row's snapshot is superseded.
|
||
pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
|
||
raw, err := os.ReadFile(pipePath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
writeCLIFile(t, pipePath, strings.Replace(string(raw), "prompt_version: v-cli", "prompt_version: v-cli-drift", 1))
|
||
|
||
// --resnapshot WITHOUT consent → refused, nothing called.
|
||
err = translate(ctx, bookPath, true, pipeline.RebillConsent{}, false, 0, 0)
|
||
if err == nil {
|
||
t.Fatal("`translate --resnapshot` without --accept-rebill must be refused over the threshold")
|
||
}
|
||
if !strings.Contains(err.Error(), "--accept-rebill") {
|
||
t.Errorf("the refusal must name the flag; got: %v", err)
|
||
}
|
||
if calls != 1 {
|
||
t.Fatalf("a refused translate must reach no provider, calls=%d", calls)
|
||
}
|
||
|
||
// A ceiling BELOW the projection is still a refusal — proving the CAPPED value is wired, not just
|
||
// the boolean.
|
||
if err := translate(ctx, bookPath, true, pipeline.RebillConsent{Given: true, Capped: true, CapUSD: 0.0001}, false, 0, 0); err == nil {
|
||
t.Fatal("a ceiling below the projection must be refused through the CLI entry point too")
|
||
}
|
||
if calls != 1 {
|
||
t.Fatalf("a cap-refused translate must reach no provider, calls=%d", calls)
|
||
}
|
||
|
||
// Consent given → the run proceeds and really re-pays.
|
||
if err := translate(ctx, bookPath, true, pipeline.RebillConsent{Given: true}, false, 0, 0); err != nil {
|
||
t.Fatalf("`translate --resnapshot --accept-rebill` must proceed: %v", err)
|
||
}
|
||
if calls != 2 {
|
||
t.Fatalf("the consented run must re-call the provider, calls=%d", calls)
|
||
}
|
||
}
|
||
|
||
// TestRedriveWiresAcceptRebill is the same wiring check for the OTHER money-spending command: redrive
|
||
// carries --resnapshot too, so it carries the consent too. A redrive refused for want of consent must
|
||
// also leave the flagged row it was about to reset intact.
|
||
func TestRedriveWiresAcceptRebill(t *testing.T) {
|
||
calls := 0
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = io.ReadAll(r.Body)
|
||
calls++
|
||
tb, _ := json.Marshal("Извините, я не могу перевести это.")
|
||
fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":"stop"}],
|
||
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`, tb)
|
||
}))
|
||
defer srv.Close()
|
||
bookPath := setupCLIProject(t, srv.URL)
|
||
ctx := context.Background()
|
||
|
||
// The refusal text flags the only chunk (exit 2 is a sentinel, not an infra failure).
|
||
if err := translate(ctx, bookPath, false, pipeline.RebillConsent{}, false, 0, 0); exitCode(err) != 2 {
|
||
t.Fatalf("setup: the refusal fixture must complete with flags (exit 2), got %v", err)
|
||
}
|
||
callsAfterRun1 := calls
|
||
|
||
pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
|
||
raw, err := os.ReadFile(pipePath)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
writeCLIFile(t, pipePath, strings.Replace(string(raw), "prompt_version: v-cli", "prompt_version: v-cli-drift", 1))
|
||
|
||
sel := pipeline.RedriveSelector{Chapter: -1, ChunkIdx: -1}
|
||
if err := redrive(ctx, bookPath, true, pipeline.RebillConsent{}, sel, false, 0); err == nil {
|
||
t.Fatal("`redrive --resnapshot` without consent must be refused over the threshold")
|
||
} else if !strings.Contains(err.Error(), "--accept-rebill") {
|
||
t.Errorf("the redrive refusal must name the flag; got: %v", err)
|
||
}
|
||
if calls != callsAfterRun1 {
|
||
t.Fatalf("a refused redrive must reach no provider, calls=%d", calls)
|
||
}
|
||
|
||
// Consent given → the redrive resets and re-attacks (the mock still refuses, so it ends flagged again
|
||
// — what matters is that it RAN and billed).
|
||
if err := redrive(ctx, bookPath, true, pipeline.RebillConsent{Given: true}, sel, false, 0); exitCode(err) != 2 {
|
||
t.Fatalf("`redrive --resnapshot --accept-rebill` must run (and end flagged), got %v", err)
|
||
}
|
||
if calls <= callsAfterRun1 {
|
||
t.Fatalf("the consented redrive must re-call the provider, calls=%d", calls)
|
||
}
|
||
}
|
||
|
||
// TestTranslateAndRedriveWireTheRunCeiling is the same class as TestTranslateWiresAcceptRebill, for the
|
||
// flag row 145 added: it exists to change what the RUN does, not what the parser returns.
|
||
//
|
||
// It closes an audit finding — deleting `r.CeilingUSD = ceilingUSD` from either command left the whole
|
||
// backend suite green, because TestParseCeilingUSD stops at the parsed invocation and the pipeline-level
|
||
// ceiling tests set the field on the Runner directly. Neither of them touches the wire between the two.
|
||
func TestTranslateAndRedriveWireTheRunCeiling(t *testing.T) {
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = io.ReadAll(r.Body)
|
||
tb, _ := json.Marshal("ЧЕРНОВИК ПЕРЕВОДА")
|
||
fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":"stop"}],
|
||
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`, tb)
|
||
}))
|
||
defer srv.Close()
|
||
ctx := context.Background()
|
||
|
||
// A ceiling far below anything the book can cost. The book's own ceiling is comfortable, so ONLY the
|
||
// argument can produce this stop — and only if the command actually hands it to the runner.
|
||
const tiny = 0.000001
|
||
err := translate(ctx, setupCLIProject(t, srv.URL), false, pipeline.RebillConsent{}, false, tiny, 0)
|
||
if err == nil || !strings.Contains(err.Error(), "ceiling reached") {
|
||
t.Fatalf("translate must carry --ceiling-usd into the run; got %v", err)
|
||
}
|
||
if !strings.Contains(err.Error(), "--ceiling-usd") {
|
||
t.Fatalf("the stop must name the ceiling in force: %v", err)
|
||
}
|
||
|
||
// Same for redrive: it re-attacks with real provider calls, so an unwired flag there is a hole in
|
||
// exactly the surface the row closes. A redrive with no targets never reserves and therefore could not
|
||
// show a ceiling at all — so the book is first driven to a FLAGGED chunk, which gives the redrive
|
||
// something to re-attack and makes the reservation (and the ceiling) reachable.
|
||
refuse := true
|
||
srv2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
body, _ := io.ReadAll(r.Body)
|
||
text := "ЧЕРНОВИК ПЕРЕВОДА"
|
||
if refuse && !strings.Contains(string(body), "Черновик перевода для редактуры") {
|
||
text = "Извините, я не могу перевести это." // soft refusal → the chunk is flagged
|
||
}
|
||
tb, _ := json.Marshal(text)
|
||
fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":"stop"}],
|
||
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`, tb)
|
||
}))
|
||
defer srv2.Close()
|
||
bookPath := setupCLIProject(t, srv2.URL)
|
||
if err := translate(ctx, bookPath, false, pipeline.RebillConsent{}, false, 0, 0); exitCode(err) != 2 {
|
||
t.Fatalf("setup: the run must finish WITH a flagged chunk (exit 2), got %v", err)
|
||
}
|
||
refuse = false
|
||
sel := pipeline.RedriveSelector{Chapter: -1, ChunkIdx: -1}
|
||
err = redrive(ctx, bookPath, false, pipeline.RebillConsent{}, sel, false, tiny)
|
||
if err == nil || !strings.Contains(err.Error(), "ceiling reached") {
|
||
t.Fatalf("redrive must carry --ceiling-usd into the re-attack; got %v", err)
|
||
}
|
||
}
|
||
|
||
// setupCLIProjectMulti is setupCLIProject with a source long enough — and a draft cut fine enough — to
|
||
// produce SEVERAL output units, which is what a volume ceiling needs in order to bite at all.
|
||
func setupCLIProjectMulti(t *testing.T, providerURL string) string {
|
||
t.Helper()
|
||
bookPath := setupCLIProject(t, providerURL)
|
||
dir := filepath.Dir(bookPath)
|
||
writeCLIFile(t, filepath.Join(dir, "source.txt"),
|
||
"静かな図書館の朝。鈴木は本を読んだ。外では雨が降っていた。彼は窓を見た。時間は静かに過ぎた。夜になった。")
|
||
writeCLIFile(t, filepath.Join(dir, "pipeline.yaml"), `
|
||
core: C1
|
||
version: 1
|
||
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
|
||
retries: { regenerate_before_escalate: 0 }
|
||
context: { glossary_injection: selective, glossary_token_budget: 800 }
|
||
segmentation:
|
||
draft_budget_out: 24
|
||
edit_ceiling_out: 48
|
||
fertility: { cjk: 1.1978, other: 0.3852 }
|
||
stages:
|
||
- { name: draft, role: translator, model: fake-model, prompt_override: prompts/translator.md, prompt_version: v-cli, temperature: 0.3, reasoning: "off" }
|
||
`)
|
||
return bookPath
|
||
}
|
||
|
||
// TestTranslateWiresMaxUnits is the same kind of pin as TestTranslateWiresAcceptRebill above, for the
|
||
// other run ceiling: it proves the flag changes what the RUN DOES, not merely what the parser returns.
|
||
//
|
||
// Every other proof of the volume ceiling sets Runner.MaxUnits directly, which leaves exactly one line
|
||
// unverified — `r.MaxUnits = maxUnits` in main.go's translate(). That line is the entire seam between the
|
||
// flag a platform passes and the engine that honours it; dropping it would leave every volume test in the
|
||
// pipeline package green while `tmctl translate --max-units 2` translated the whole book.
|
||
func TestTranslateWiresMaxUnits(t *testing.T) {
|
||
var mu sync.Mutex
|
||
calls := 0
|
||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||
_, _ = io.ReadAll(r.Body)
|
||
mu.Lock()
|
||
calls++
|
||
mu.Unlock()
|
||
tb, _ := json.Marshal("ЧЕРНОВИК ПЕРЕВОДА")
|
||
fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":"stop"}],
|
||
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`, tb)
|
||
}))
|
||
defer srv.Close()
|
||
bookPath := setupCLIProjectMulti(t, srv.URL)
|
||
ctx := context.Background()
|
||
|
||
// Unbounded first, on a throwaway copy of the project, to learn how many units this book really has —
|
||
// so the bounded assertion below is against a measured number rather than a guessed one.
|
||
fullPath := setupCLIProjectMulti(t, srv.URL)
|
||
if err := translate(ctx, fullPath, false, pipeline.RebillConsent{}, false, 0, 0); err != nil {
|
||
t.Fatalf("the unbounded baseline must complete: %v", err)
|
||
}
|
||
mu.Lock()
|
||
total := calls
|
||
calls = 0
|
||
mu.Unlock()
|
||
if total < 3 {
|
||
t.Fatalf("fixture is too thin to bound: the whole book took %d call(s), so a ceiling of 2 proves nothing", total)
|
||
}
|
||
|
||
// Now the same book through the CLI entry point WITH the flag.
|
||
if err := translate(ctx, bookPath, false, pipeline.RebillConsent{}, false, 0, 2); err != nil {
|
||
t.Fatalf("a volume-bounded run is a completion, not an error: %v", err)
|
||
}
|
||
mu.Lock()
|
||
bounded := calls
|
||
mu.Unlock()
|
||
if bounded != 2 {
|
||
t.Fatalf("`translate --max-units 2` made %d provider call(s) on a %d-unit book: the flag did not reach the engine (main.go's `r.MaxUnits = maxUnits`)", bounded, total)
|
||
}
|
||
}
|