305 lines
12 KiB
Go
305 lines
12 KiB
Go
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)
|
||
}
|
||
}
|