textmachine/backend/internal/pipeline/status_test.go

588 lines
24 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"
"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")
}
}
// TestRedriveResnapshotAcceptsDrift is the D20.4 counterpart: `redrive --resnapshot` (which the CLI
// now wires into r.Resnapshot — previously the flag was parsed but silently ignored) must SKIP the
// drift guard and accept the re-pin, resetting + re-attacking instead of failing loud. Same drifted
// setup as TestRedriveAbortsOnConfigDriftPreservingTelemetry, but with Resnapshot=true. Reverting the
// `if !r.Resnapshot` guard-skip makes this fail loud like the abort case.
func TestRedriveResnapshotAcceptsDrift(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()
// Drift the config (a prompt_version bump renders a new snapshot).
dir := filepath.Dir(bookPath)
body, err := os.ReadFile(filepath.Join(dir, "pipeline.yaml"))
if err != nil {
t.Fatal(err)
}
writeFile(t, filepath.Join(dir, "pipeline.yaml"),
strings.Replace(string(body), "prompt_version: v-test", "prompt_version: v-test-drift", 1))
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true // <-- what `redrive --resnapshot` sets
sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
if err != nil {
t.Fatalf("redrive --resnapshot must NOT abort on drift, got: %v", err)
}
if !sum.ResetRun || res == nil {
t.Fatalf("redrive --resnapshot must reset + re-run under the new snapshot: resetRun=%v res=%v", sum.ResetRun, res)
}
}
// TestRedriveResnapshotSeedCollisionAbortsBeforeReset is the self-review CONFIRMED (fixes-safety):
// even under --resnapshot, a seed-FILE edit that introduces an approved shared-key COLLISION must
// fail loud BEFORE the destructive reset, not after (which would delete the flag telemetry, then the
// re-seed would fail loud — the external-review 1c torn state, re-opened for the resnapshot path by
// wiring r.Resnapshot into redrive). Reverting the seedGlossary hoist (nesting it back inside
// `if !r.Resnapshot`) deletes the flagged row here.
func TestRedriveResnapshotSeedCollisionAbortsBeforeReset(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()
clean := "terms:\n - src: Гуюэ\n dst: Гу\n status: approved\n"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0, glossarySeed: clean})
ctx := context.Background()
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
// Introduce an approved shared-key collision: same ELIGIBLE firing src (phonetic ≥3), different
// sense+dst, overlapping windows → the D16.1 polysemy livelock class → seedGlossary fails loud.
dir := filepath.Dir(bookPath)
writeFile(t, filepath.Join(dir, "glossary-seed.yaml"),
"terms:\n - src: Гуюэ\n dst: Гу\n sense: a\n status: approved\n - src: Гуюэ\n dst: Губец\n sense: b\n status: approved\n")
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true // --resnapshot: skips the snapshot COMPARISON, but NOT the seed-error-before-reset guard
sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
if err == nil {
t.Fatal("a seed collision must make redrive --resnapshot fail loud (before the reset)")
}
if res != nil || sum.ResetRun {
t.Errorf("a seed-collision-aborted redrive must not re-run: res=%v resetRun=%v", res, sum.ResetRun)
}
// The whole point: the reset never fired, so the flag telemetry is intact.
if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != "flagged" {
t.Fatalf("the flagged row must survive a seed-collision-aborted redrive --resnapshot, got: %+v", cs)
}
}
// 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")
}
}