497 lines
20 KiB
Go
497 lines
20 KiB
Go
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")
|
||
}
|
||
}
|