package pipeline import ( "context" "math" "os" "path/filepath" "strings" "sync" "testing" ) // TestStatusAndRedrive exercises the D15.3 read-model + targeted re-attack end-to-end against // the mock provider ($0, no real LLM): a book with one flagged chapter and one ok chapter → // status projects the honest N/M + per-chapter passports + money; redrive dry-run identifies // the flagged chunk without touching it; a real redrive resets ONLY the flagged stages, re-runs // with a fresh budget (ok work resumes at $0), and the previously-spent money stays committed. func TestStatusAndRedrive(t *testing.T) { rec := &reqRec{} var mu sync.Mutex refuse := true // ch1 draft refuses until flipped srv := newJSONProvider(rec, func(body string) (string, string) { mu.Lock() r := refuse mu.Unlock() if r && strings.Contains(body, "ОТКАЗНАЯГЛАВА") && !isEditBody(body) { return "Извините, я не могу перевести это.", "stop" // soft refusal → flagged, non-retryable } return draftEdit(body) }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗНАЯГЛАВА\fНОРМАЛЬНАЯГЛАВА", regenerate: 0}) ctx := context.Background() // --- Run 1: ch1 flagged (soft_refusal), ch2 done. --- r1 := newRunner(t, bookPath) res1, err := r1.TranslateBook(ctx) if err != nil { t.Fatal(err) } if res1.Flagged != 1 { t.Fatalf("want 1 flagged chunk, got %d", res1.Flagged) } callsAfterRun1 := rec.count() // draft(ch1)=refusal, draft(ch2)+edit(ch2)=ok → 3 if callsAfterRun1 != 3 { t.Fatalf("want 3 provider calls after run1, got %d", callsAfterRun1) } // --- STATUS projection (read-only). --- rep, err := r1.Status(ctx) if err != nil { t.Fatal(err) } if rep.TotalChunks != 2 || rep.Done != 1 || rep.Flagged != 1 || rep.Pending != 0 || rep.InProgress != 0 { t.Fatalf("status counts wrong: %+v", rep) } if math.Abs(rep.PercentDone-50) > 1e-9 { t.Errorf("percent_done = %v, want 50", rep.PercentDone) } if len(rep.Chapters) != 2 { t.Fatalf("want 2 chapter passports, got %d", len(rep.Chapters)) } if rep.Chapters[0].Verdict != "attention" || rep.Chapters[0].WorstFlagReason != string(FlagSoftRefusal) || rep.Chapters[0].ChunksFlagged != 1 { t.Errorf("ch1 passport = %+v (want attention/soft_refusal/1 flagged)", rep.Chapters[0]) } if rep.Chapters[1].Verdict != "pass" || rep.Chapters[1].ChunksDone != 1 { t.Errorf("ch2 passport = %+v (want pass/1 done)", rep.Chapters[1]) } wantCommitted := 3 * fakeCallUSD if math.Abs(rep.CommittedUSD-wantCommitted) > 1e-9 { t.Errorf("committed = %v, want %v", rep.CommittedUSD, wantCommitted) } // Projected extrapolates the per-processed-chunk average over the whole book (here both // chunks are processed, so it equals committed). if math.Abs(rep.ProjectedBookUSD-wantCommitted) > 1e-9 { t.Errorf("projected = %v, want %v", rep.ProjectedBookUSD, wantCommitted) } r1.Close() if rec.count() != callsAfterRun1 { t.Fatalf("status made provider calls: %d -> %d", callsAfterRun1, rec.count()) } // --- REDRIVE dry-run: identify the target, mutate nothing. --- r2 := newRunner(t, bookPath) sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, Reason: string(FlagSoftRefusal), DryRun: true}) if err != nil { t.Fatal(err) } if res != nil || sum.ResetRun { t.Error("dry-run must not re-run the book") } if len(sum.Targets) != 1 || sum.Targets[0].Chapter != 1 || sum.Targets[0].ChunkIdx != 0 { t.Fatalf("dry-run targets = %+v", sum.Targets) } // The flagged stage AND its skipped downstream are both selected for reset. if strings.Join(sum.Targets[0].Stages, ",") != "draft,edit" { t.Errorf("dry-run reset stages = %v, want draft,edit", sum.Targets[0].Stages) } if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" { t.Fatalf("dry-run mutated chunk_status: %+v", cs) } r2.Close() if rec.count() != callsAfterRun1 { t.Fatal("dry-run made provider calls") } // --- REDRIVE real: flip the provider to success, reset ch1, re-run. --- mu.Lock() refuse = false mu.Unlock() r3 := newRunner(t, bookPath) defer r3.Close() sum3, res3, err := r3.Redrive(ctx, RedriveSelector{Chapter: 1, ChunkIdx: 0, Reason: ""}) if err != nil { t.Fatal(err) } if !sum3.ResetRun || res3 == nil { t.Fatal("real redrive should reset and re-run") } if res3.Flagged != 0 { t.Fatalf("redrive should have fixed the flag, still %d flagged", res3.Flagged) } // Exactly two FRESH calls (ch1 draft + ch1 edit); ch2 resumes at $0. if got := rec.count() - callsAfterRun1; got != 2 { t.Fatalf("redrive should make exactly 2 fresh calls (ch1 draft+edit), made %d", got) } if cs, _ := r3.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "ok" { t.Fatalf("redrive did not fix ch1 draft: %+v", cs) } // MONEY (documented): the discarded refusal's spend STAYS committed; redrive bills the two // new calls on top → committed reflects total lifetime spend (5 calls), ceilings stay honest. committed, _, _ := r3.Store.SpentUSD("test-book") if math.Abs(committed-5*fakeCallUSD) > 1e-9 { t.Errorf("committed after redrive = %v, want %v (3 original + 2 redrive)", committed, 5*fakeCallUSD) } // A clean status now: everything done. rep2, err := r3.Status(ctx) if err != nil { t.Fatal(err) } if rep2.Done != 2 || rep2.Flagged != 0 { t.Errorf("post-redrive status: done=%d flagged=%d, want 2/0", rep2.Done, rep2.Flagged) } } // TestStatusSurfacesGlossaryMissGate covers self-review finding #2: the post-check GATE flags a // chunk at the CHUNK level (never a chunk_status row), so status must promote a gate-on chunk // with a CONFIRMED post-check miss to flagged/glossary_miss — otherwise it reports done/pass // while `tmctl translate` exits 2, contradicting the run. func TestStatusSurfacesGlossaryMissGate(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { return "ОТРЕДАКТИРОВАННЫЙ.", "stop" } return "Некто пошёл в библиотеку.", "stop" // drops the approved name → CONFIRMED post-check miss }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed, postcheckGate: true}) ctx := context.Background() r := newRunner(t, bookPath) defer r.Close() res, err := r.TranslateBook(ctx) if err != nil { t.Fatal(err) } if res.Flagged != 1 { t.Fatalf("translate must flag the glossary_miss chunk: flagged=%d", res.Flagged) } rep, err := r.Status(ctx) if err != nil { t.Fatal(err) } if rep.Flagged != 1 || rep.Done != 0 { t.Fatalf("status must match translate (surface glossary_miss): done=%d flagged=%d, want 0/1", rep.Done, rep.Flagged) } // The gate-promoted flag is counted as a glossary_miss (NOT re-drivable) so the CLI advises a // seed fix + --resnapshot, not a redrive dead-end (minor 1d). Flagged − GlossaryMissFlagged = 0 // re-drivable here. if rep.GlossaryMissFlagged != 1 { t.Fatalf("the gate flag must be counted as glossary_miss: GlossaryMissFlagged=%d, want 1", rep.GlossaryMissFlagged) } if len(rep.Chapters) != 1 || rep.Chapters[0].WorstFlagReason != string(FlagGlossaryMiss) { t.Fatalf("passport must show glossary_miss: %+v", rep.Chapters) } if rep.Chapters[0].Verdict == "pass" { t.Error("a chapter with a glossary_miss must not be 'pass'") } } // TestStatusDetectsConfigDrift covers self-review finding #3: after a wire-affecting config edit // (a prompt_version bump) since the last run, status must flag ConfigDrift — the current config // renders a different snapshot than the stored rows, which translate would --resnapshot (re-bill). // Without it status shows a misleading "done/consistent". func TestStatusDetectsConfigDrift(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0}) ctx := context.Background() r1 := newRunner(t, bookPath) if _, err := r1.TranslateBook(ctx); err != nil { t.Fatal(err) } r1.Close() // No config change → no drift. r2 := newRunner(t, bookPath) rep, err := r2.Status(ctx) if err != nil { t.Fatal(err) } if rep.ConfigDrift { t.Fatalf("an unchanged config must not drift (current=%s)", rep.CurrentSnapshot) } r2.Close() // Bump a wire-affecting field (draft prompt_version) → the current config renders a new // snapshot → drift. dir := filepath.Dir(bookPath) body, err := os.ReadFile(filepath.Join(dir, "pipeline.yaml")) if err != nil { t.Fatal(err) } changed := strings.Replace(string(body), "prompt_version: v-test", "prompt_version: v-test2", 1) if changed == string(body) { t.Fatal("setup: prompt_version token not found to change") } writeFile(t, filepath.Join(dir, "pipeline.yaml"), changed) r3 := newRunner(t, bookPath) defer r3.Close() rep3, err := r3.Status(ctx) if err != nil { t.Fatal(err) } if !rep3.ConfigDrift || rep3.CurrentSnapshot == "" { t.Errorf("a prompt_version bump since the run must surface as ConfigDrift: %+v", rep3) } } // TestRedriveResetsOnlyFlaggedStageNotDispOK mutation-locks the D12 invariant "a redrive NEVER // resets DispOK work" against a mutant that resets EVERY stage (external-review 1c). The existing // end-to-end test flags the FIRST stage (draft), so its reset set == the full set and a // reset-everything mutant survives. Here draft is OK and only EDIT is flagged, so the reset set is // {edit} alone: the mutant (reset draft too) is caught by both the plan (Stages == [edit]) and the // re-run cost (draft must resume at $0 → exactly ONE fresh edit call). Reverting the // Flagged||Skipped stage filter to `true` makes draft re-bill and Stages == [draft,edit]. func TestRedriveResetsOnlyFlaggedStageNotDispOK(t *testing.T) { rec := &reqRec{} var mu sync.Mutex refuseEdit := true // the EDIT stage refuses until flipped; draft is always OK srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { mu.Lock() r := refuseEdit mu.Unlock() if r { return "Извините, я не могу продолжить.", "stop" // soft refusal (matches the blacklist) → edit flagged, non-retryable } return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" } return "ЧЕРНОВИК ПЕРЕВОДА", "stop" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОДНАГЛАВА", regenerate: 0}) ctx := context.Background() r1 := newRunner(t, bookPath) if _, err := r1.TranslateBook(ctx); err != nil { t.Fatal(err) } callsAfterRun1 := rec.count() // draft(ok) + edit(refusal) = 2 if callsAfterRun1 != 2 { t.Fatalf("want 2 provider calls after run1, got %d", callsAfterRun1) } // draft is OK, edit is flagged. if cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "ok" { t.Fatalf("draft must be ok: %+v", cs) } if cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "edit"); cs == nil || cs.Disposition != "flagged" { t.Fatalf("edit must be flagged: %+v", cs) } r1.Close() // Dry-run plan: only the EDIT stage is reset — the DispOK draft is untouched (the invariant). r2 := newRunner(t, bookPath) sum, _, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, DryRun: true}) if err != nil { t.Fatal(err) } if len(sum.Targets) != 1 { t.Fatalf("want 1 target, got %+v", sum.Targets) } if got := strings.Join(sum.Targets[0].Stages, ","); got != "edit" { t.Fatalf("reset plan = %q, want just \"edit\" (draft is DispOK and must not be reset)", got) } r2.Close() // Real redrive with the editor fixed: only edit re-runs (1 fresh call); draft resumes at $0. mu.Lock() refuseEdit = false mu.Unlock() r3 := newRunner(t, bookPath) defer r3.Close() sum3, res3, err := r3.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1}) if err != nil { t.Fatal(err) } if !sum3.ResetRun || res3 == nil || res3.Flagged != 0 { t.Fatalf("redrive should reset+re-run and clear the flag: %+v", res3) } if got := rec.count() - callsAfterRun1; got != 1 { t.Fatalf("redrive must make exactly ONE fresh call (edit only; draft resumes $0), made %d", got) } // The draft checkpoint was never discarded — its cost is not re-billed. committed, _, _ := r3.Store.SpentUSD("test-book") if math.Abs(committed-3*fakeCallUSD) > 1e-9 { t.Errorf("committed = %v, want %v (draft + refused-edit + redriven-edit)", committed, 3*fakeCallUSD) } } // TestRedriveReasonSelectorMismatch locks the --reason selector: a redrive whose reason does not // match the flagged chunk's flag_reason must find NO target and touch nothing (mutating // RedriveSelector.matches to ignore Reason would wrongly re-attack the chunk). The matching reason // then does select it (so the test also proves the selector is live, not a no-op that always misses). func TestRedriveReasonSelectorMismatch(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) { return "Извините, я не могу перевести это.", "stop" // soft_refusal } return draftEdit(body) }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0}) ctx := context.Background() r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(ctx); err != nil { t.Fatal(err) } calls := rec.count() // Non-matching reason → no target, no reset, no re-run. sum, res, err := r.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, Reason: string(FlagLength)}) if err != nil { t.Fatal(err) } if len(sum.Targets) != 0 || sum.ResetRun || res != nil { t.Fatalf("a non-matching --reason must select nothing, got %+v", sum) } if rec.count() != calls { t.Fatal("non-matching redrive made provider calls") } if cs, _ := r.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" { t.Fatalf("the flagged row must be untouched after a non-matching redrive: %+v", cs) } // The MATCHING reason does select it (dry-run, so nothing is mutated) — proves the selector fires. sum2, _, err := r.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, Reason: string(FlagSoftRefusal), DryRun: true}) if err != nil { t.Fatal(err) } if len(sum2.Targets) != 1 { t.Fatalf("the matching --reason must select the flagged chunk, got %+v", sum2.Targets) } } // TestRedriveAbortsOnConfigDriftPreservingTelemetry locks Task 1c: a redrive under a DRIFTED config // must refuse loudly BEFORE the destructive reset, leaving the flag telemetry intact. The old code // reset (deleted) the flagged chunk_status + checkpoints first, then TranslateBook failed loud on // the snapshot mismatch — so the flagged row was gone and status read the chunk as pending/pass. // Reverting the guard deletes the flagged draft row here; the "still flagged" assertion catches it. func TestRedriveAbortsOnConfigDriftPreservingTelemetry(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 := context.Background() r1 := newRunner(t, bookPath) if _, err := r1.TranslateBook(ctx); err != nil { t.Fatal(err) } r1.Close() callsAfterRun := rec.count() // Drift the config: bump the draft prompt_version → the current config renders a new snapshot. dir := filepath.Dir(bookPath) body, err := os.ReadFile(filepath.Join(dir, "pipeline.yaml")) if err != nil { t.Fatal(err) } changed := strings.Replace(string(body), "prompt_version: v-test", "prompt_version: v-test-drift", 1) if changed == string(body) { t.Fatal("setup: prompt_version token not found") } writeFile(t, filepath.Join(dir, "pipeline.yaml"), changed) r2 := newRunner(t, bookPath) defer r2.Close() sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1}) if err == nil { t.Fatal("a drifted config must make redrive fail loud") } if !strings.Contains(err.Error(), "snapshot") && !strings.Contains(err.Error(), "снапшот") { t.Errorf("drift error should mention the snapshot mismatch, got: %v", err) } if res != nil || sum.ResetRun { t.Errorf("a drift-aborted redrive must not re-run: res=%v resetRun=%v", res, sum.ResetRun) } // The whole point: the flag telemetry is intact (the row was NOT reset). if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" { t.Fatalf("the flagged row must survive a drift-aborted redrive, got: %+v", cs) } if rec.count() != callsAfterRun { t.Fatal("a drift-aborted redrive must make no provider calls") } } // TestRedriveAbortsOnSeedFileDriftPreservingTelemetry locks the self-review MAJOR: the redrive guard // must catch a SEED-FILE edit (not just a config-file edit), because that is exactly what the // glossary_miss CLI hint tells the operator to make. The guard re-seeds (like TranslateBook will) so // an approved-term change shifts the snapshot and the redrive aborts BEFORE the destructive reset — // leaving the flag telemetry intact. A read-only stored-glossary projection would miss it, reset, // then fail loud on the re-seed AFTER deleting the flagged rows. func TestRedriveAbortsOnSeedFileDriftPreservingTelemetry(t *testing.T) { seed := "terms:\n - src: 蛊\n dst: Гу\n status: approved\n" 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, glossarySeed: seed}) ctx := context.Background() r1 := newRunner(t, bookPath) if _, err := r1.TranslateBook(ctx); err != nil { t.Fatal(err) } r1.Close() // Edit the seed FILE — change the approved dst → different memoryVersion → different snapshot. dir := filepath.Dir(bookPath) writeFile(t, filepath.Join(dir, "glossary-seed.yaml"), "terms:\n - src: 蛊\n dst: Гу-изменённый\n status: approved\n") r2 := newRunner(t, bookPath) defer r2.Close() sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1}) if err == nil { t.Fatal("a seed-FILE edit must make redrive fail loud (re-seed drift guard)") } if res != nil || sum.ResetRun { t.Errorf("a seed-drift-aborted 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 != "flagged" { t.Fatalf("the flagged row must survive a seed-drift-aborted redrive, got: %+v", cs) } } // TestRedriveNoTargets confirms a selector that matches nothing is a clean no-op (no re-run). func TestRedriveNoTargets(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАОДИН\fГЛАВАДВА"}) ctx := context.Background() r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(ctx); err != nil { t.Fatal(err) } calls := rec.count() // Nothing is flagged → redrive finds no targets and never re-runs. sum, res, err := r.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1}) if err != nil { t.Fatal(err) } if len(sum.Targets) != 0 || sum.ResetRun || res != nil { t.Fatalf("redrive with no flagged chunks must be a no-op: %+v", sum) } if rec.count() != calls { t.Fatal("no-target redrive made provider calls") } }