package pipeline import ( "context" "os" "path/filepath" "strings" "testing" "textmachine/backend/internal/obs" ) // export_test.go pins the read-only export surface (D39 слой 6 / D39.2-open №1): `tmctl export` must // emit each chunk's FINAL text EXACTLY as `tmctl translate` ships it (export-normalised), for BOTH a // fresh run and a $0 resume — the whole point being that the polygon extractor reads the SAME bytes // the backend does, not the RAW stored checkpoint (which skips the export contract for a clean chunk). // TestExportMatchesTranslateFinalText runs a real 3-chunk book across the three export outcomes and // asserts Export() reproduces BookResult's FinalText byte-for-byte — and that a clean chunk carrying a // normalize-relevant artifact is exported NORMALISED (not the raw checkpoint), which is the fix. func TestExportMatchesTranslateFinalText(t *testing.T) { rec := &reqRec{} // Three chapters (\f-separated) → three 1-chunk chapters. The editor (final stage) emits: // ch1: clean prose carrying a U+3000 indent + fullwidth digits → OK, but export must NORMALISE it; // ch2: a sparse contentless CJK-leak run (below the echo threshold) → cosmetic sanitizer strip; // ch3: a leaked service preamble → SUBSTANTIVE sanitizer defect → dropped (FinalText ""). const ( src1 = "ГЛАВААА" src2 = "ГЛАВАББ" src3 = "ГЛАВАВВ" ) editCleanArtifact := "Судзуки шёл по коридору. 123 шага до двери." // U+3000 + «123» editCosmeticLeak := "Он поднял кулак 羅漢 и ударил врага." // sparse Han run → strip editPreamble := "Вот перевод фрагмента:\nОн молча ушёл в туман." // preamble → dropped srv := newJSONProvider(rec, func(body string) (string, string) { if !isEditBody(body) { return "черновик перевода этой главы.", "stop" // any draft — clean, non-echo } switch { case strings.Contains(body, src1): return editCleanArtifact, "stop" case strings.Contains(body, src2): return editCosmeticLeak, "stop" default: // src3 return editPreamble, "stop" } }) defer srv.Close() // Enable the output-sanitizer so the cosmetic-strip (ch2) and substantive-drop (ch3) paths engage. bookPath := setupProjectOpts(t, srv.URL, projectOpts{ source: src1 + "\f" + src2 + "\f" + src3, gatesYAML: "gates:\n sanitizer:\n enabled: true\n", }) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r1 := newRunner(t, bookPath) res, err := r1.TranslateBook(ctx) if err != nil { t.Fatal(err) } if len(res.Chunks) != 3 { t.Fatalf("want 3 chunks, got %d: %+v", len(res.Chunks), res.Chunks) } // The authoritative FinalText per chunk, straight from the translate result. type want struct { final string disp Disposition flag FlagReason } byChunk := map[[2]int]want{} for _, ch := range res.Chunks { byChunk[[2]int{ch.Chapter, ch.ChunkIdx}] = want{ch.FinalText, ch.Disposition, ch.FlagReason} } // The clean chunk MUST be export-normalised, not raw: no U+3000, no fullwidth digits survive. ch1 := byChunk[[2]int{1, 0}] if strings.ContainsRune(ch1.final, ' ') || strings.Contains(ch1.final, "123") { t.Fatalf("ch1 FinalText was not export-normalised: %q", ch1.final) } if ch1.final != "Судзуки шёл по коридору. 123 шага до двери." { t.Fatalf("ch1 FinalText = %q, want the normalised form", ch1.final) } // The cosmetic-leak chunk is flagged-stripped with the Han run removed but the prose kept. ch2 := byChunk[[2]int{2, 0}] if ch2.disp != DispFlagged || ch2.flag != FlagSanitizerStripped || strings.Contains(ch2.final, "羅漢") || ch2.final == "" { t.Fatalf("ch2 expected stripped-non-empty flagged export, got %+v", ch2) } // The preamble chunk is a substantive drop: flagged with an EMPTY export. ch3 := byChunk[[2]int{3, 0}] if ch3.disp != DispFlagged || ch3.final != "" { t.Fatalf("ch3 expected substantive drop with empty export, got %+v", ch3) } assertExportEquals := func(tag string, r *Runner) { t.Helper() exp, err := r.Export(false) if err != nil { t.Fatalf("%s: Export: %v", tag, err) } if len(exp.Chunks) != 3 { t.Fatalf("%s: want 3 export chunks, got %d", tag, len(exp.Chunks)) } for _, ce := range exp.Chunks { w, ok := byChunk[[2]int{ce.Chapter, ce.ChunkIdx}] if !ok { t.Fatalf("%s: export emitted an unknown chunk %d/%d", tag, ce.Chapter, ce.ChunkIdx) } if ce.FinalText != w.final { t.Errorf("%s: ch%d/%d export text = %q, want translate FinalText %q", tag, ce.Chapter, ce.ChunkIdx, ce.FinalText, w.final) } if ce.Disposition != string(w.disp) || ce.FlagReason != string(w.flag) { t.Errorf("%s: ch%d/%d export disp/flag = %s/%s, want %s/%s", tag, ce.Chapter, ce.ChunkIdx, ce.Disposition, ce.FlagReason, w.disp, w.flag) } } } assertExportEquals("fresh", r1) r1.Close() // Resume (a new process): the book is served from checkpoints at $0, and Export must reproduce the // identical bytes — the derived stripped checkpoint and the raw ones both re-normalise deterministically. callsBefore := rec.count() r2 := newRunner(t, bookPath) defer r2.Close() res2, err := r2.TranslateBook(ctx) if err != nil { t.Fatal(err) } if rec.count() != callsBefore { t.Fatalf("resume must not call the provider again: %d -> %d", callsBefore, rec.count()) } if res2.TotalUSD != 0 { t.Fatalf("resume must be $0, got %v", res2.TotalUSD) } assertExportEquals("resume", r2) } // TestExportGlossaryGateWithheld pins the chunk-level glossary post-check gate case (adversarial-review // finding): the gate flips a chunk to flagged/glossary_miss AFTER the stage loop and never writes it to // chunk_status, so a raw chunk_status read would report the chunk as clean ok and ship the text // `translate` withheld. Export must re-derive the gate flag from retrieval_state (like Status) so it // matches translate byte-for-byte: flagged, glossary_miss, EMPTY export. func TestExportGlossaryGateWithheld(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { return "ОТРЕДАКТИРОВАННЫЙ.", "stop" // final text drops the approved name → confirmed miss } return "Некто пошёл в библиотеку.", "stop" }) defer srv.Close() // Opt-in hard gate + a seed whose approved 鈴木→Судзуки is missed in the final. bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed, postcheckGate: true}) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r := newRunner(t, bookPath) defer r.Close() res, err := r.TranslateBook(ctx) if err != nil { t.Fatal(err) } // Precondition: translate withheld this chunk (flagged glossary_miss, empty text). if len(res.Chunks) != 1 || res.Chunks[0].FlagReason != FlagGlossaryMiss || res.Chunks[0].FinalText != "" { t.Fatalf("precondition: want 1 chunk flagged glossary_miss with empty text, got %+v", res.Chunks) } exp, err := r.Export(false) if err != nil { t.Fatalf("Export: %v", err) } if len(exp.Chunks) != 1 { t.Fatalf("want 1 export chunk, got %d", len(exp.Chunks)) } ce := exp.Chunks[0] // Export MUST match translate — NOT ship the withheld text as clean ok. if ce.Disposition != string(DispFlagged) || ce.FlagReason != string(FlagGlossaryMiss) || ce.FinalText != "" { t.Fatalf("export leaked a gate-withheld chunk: disp=%s reason=%s final=%q (want flagged/glossary_miss/\"\")", ce.Disposition, ce.FlagReason, ce.FinalText) } } // TestExportManifestPending pins F4: a PARTIAL book (a ceiling stopped the run mid-way) exports the // untranslated chunks as explicit "pending" rows against the $0 manifest — not silently as a complete // book (the D37-skew the polygon extractor must not inherit). Chunks is non-nil. func TestExportManifestPending(t *testing.T) { srv := newJSONProvider(&reqRec{}, draftEdit) defer srv.Close() // Three chapters; a book ceiling that pays for ~one chapter (2 calls ≈ $0.0036) then denies. bookPath := setupProjectOpts(t, srv.URL, projectOpts{ source: "ГЛАВАА\fГЛАВАБ\fГЛАВАВ", regenerate: 0, bookUSD: 0.005, }) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r := newRunner(t, bookPath) defer r.Close() // The ceiling stop is an infra error — expected; we only need the partial store state it leaves. _, _ = r.TranslateBook(ctx) exp, err := r.Export(false) if err != nil { t.Fatalf("Export: %v", err) } if exp.TotalChunks != 3 { t.Fatalf("manifest must be 3 chunks, got total=%d", exp.TotalChunks) } if exp.PendingChunks < 1 { t.Fatalf("a ceiling-stopped book must have pending chunks, got pending=%d (%+v)", exp.PendingChunks, exp.Chunks) } if len(exp.Chunks) != exp.TotalChunks { t.Fatalf("every manifest chunk must have a row: len=%d total=%d", len(exp.Chunks), exp.TotalChunks) } var pending int for _, ce := range exp.Chunks { if ce.Disposition == "pending" { pending++ if ce.FinalText != "" { t.Errorf("a pending chunk must export no text: %+v", ce) } } } if pending != exp.PendingChunks { t.Errorf("pending rows (%d) must match the counter (%d)", pending, exp.PendingChunks) } } // TestExportDetectsConfigDrift pins F3: after a wire-affecting config edit (a prompt_version bump) since // the run, Export flags ConfigDrift — the gate/stage re-derivation may not match what translate did. func TestExportDetectsConfigDrift(t *testing.T) { srv := newJSONProvider(&reqRec{}, draftEdit) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{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() // Unchanged config → no drift. r2 := newRunner(t, bookPath) exp, err := r2.Export(false) if err != nil { t.Fatal(err) } if exp.ConfigDrift { t.Fatalf("an unchanged config must not drift (current=%s)", exp.CurrentSnapshot) } r2.Close() // Bump a wire-affecting field → 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") } writeFile(t, filepath.Join(dir, "pipeline.yaml"), changed) r3 := newRunner(t, bookPath) defer r3.Close() exp3, err := r3.Export(false) if err != nil { t.Fatal(err) } if !exp3.ConfigDrift || exp3.CurrentSnapshot == "" { t.Errorf("a prompt_version bump since the run must surface as ConfigDrift: %+v", exp3) } } // TestExportFailLoudOnInconsistentStore pins F9: a DispOK final row with an EMPTY final_hash is an // inconsistent store; Export fails LOUD rather than silently exporting "". func TestExportFailLoudOnInconsistentStore(t *testing.T) { srv := newJSONProvider(&reqRec{}, draftEdit) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0}) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r := newRunner(t, bookPath) defer r.Close() res, err := r.TranslateBook(ctx) if err != nil { t.Fatal(err) } // Corrupt the final-stage row: DispOK but no final_hash (simulating store inconsistency). lastStage := r.Pipeline.Stages[len(r.Pipeline.Stages)-1].Name ch := res.Chunks[0] cs, err := r.Store.GetChunkStatus(r.Book.BookID, ch.Chapter, ch.ChunkIdx, lastStage) if err != nil || cs == nil { t.Fatalf("read final row: %v cs=%v", err, cs) } cs.FinalHash = "" if err := r.Store.UpsertChunkStatus(*cs); err != nil { t.Fatal(err) } if _, err := r.Export(false); err == nil { t.Fatal("Export must fail loud on a DispOK row with an empty final_hash") } else if !strings.Contains(err.Error(), "empty final_hash") { t.Fatalf("unexpected error: %v", err) } }