389 lines
15 KiB
Go
389 lines
15 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 layer 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)
|
||
}
|
||
|
||
// TestExportCarriesTheChapterTitleOnTheUnitThatOpensTheChapter pins the export side of the heading seam,
|
||
// which nothing held: the title reaches `tmctl export` — the artifact the platform reads — through
|
||
// u.Members[0], and every other assertion about it lives one projection earlier, on the cut. A unit is a
|
||
// GROUP of chunks, so a unit's leader and a unit's last member are different chunks, and only the leader
|
||
// opens a chapter. The fixture's leading unit is asserted to hold more than one chunk, because on a
|
||
// one-chunk unit those two positions coincide and this test would prove nothing.
|
||
func TestExportCarriesTheChapterTitleOnTheUnitThatOpensTheChapter(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := zhBookWithChapterHeadings(t, srv.URL)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r := newRunner(t, bookPath)
|
||
defer r.Close()
|
||
if _, err := r.TranslateBook(ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
full, err := r.bookChunks()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
units := r.outputUnits(full)
|
||
if len(units) < 3 || len(units[0].Members) < 2 {
|
||
t.Fatalf("degenerate fixture: %d units, the leading unit holds %d chunks — Members[0] and the last member must be different chunks",
|
||
len(units), len(units[0].Members))
|
||
}
|
||
|
||
// BOTH export modes, and they are not the same read. `export --pairs` re-cuts the book (export.go
|
||
// takes r.bookChunks for the source column) while the default joins the stored MANIFEST — so a
|
||
// mutation of the chunker alone leaves the default mode correct and prints «Глава 1» in the middle of
|
||
// chapter 1 under --pairs. One of the two would have proved half of this.
|
||
for _, pairs := range []bool{false, true} {
|
||
assertExportedTitles(t, r, pairs, units)
|
||
}
|
||
}
|
||
|
||
// assertExportedTitles is the heading assertion for one export mode.
|
||
func assertExportedTitles(t *testing.T, r *Runner, pairs bool, units []editUnit) {
|
||
t.Helper()
|
||
mode := "export"
|
||
if pairs {
|
||
mode = "export --pairs"
|
||
}
|
||
exp, err := r.Export(pairs)
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
titles := 0
|
||
for _, ce := range exp.Chunks {
|
||
want := ""
|
||
if ce.ChunkIdx == 0 {
|
||
want = zhNominal(t, ce.Chapter)
|
||
}
|
||
if ce.Heading != want {
|
||
t.Fatalf("%s: chapter %d unit at chunk %d: heading = %q, want %q — a chapter's title belongs to the unit that OPENS it",
|
||
mode, ce.Chapter, ce.ChunkIdx, ce.Heading, want)
|
||
}
|
||
if want == "" {
|
||
if strings.HasPrefix(ce.FinalText, "Глава ") {
|
||
t.Fatalf("%s: chapter %d unit at chunk %d begins with a title it must not carry: %q",
|
||
mode, ce.Chapter, ce.ChunkIdx, firstLine(ce.FinalText))
|
||
}
|
||
continue
|
||
}
|
||
titles++
|
||
if !strings.HasPrefix(ce.FinalText, want+"\n\n") {
|
||
t.Fatalf("%s: chapter %d text does not begin with its title %q: %q", mode, ce.Chapter, want, firstLine(ce.FinalText))
|
||
}
|
||
}
|
||
if titles != 2 {
|
||
t.Fatalf("%s: %d units carry a title, want 2 — one per chapter (units in export: %d)", mode, titles, len(exp.Chunks))
|
||
}
|
||
t.Logf("%s: %d units, leading unit holds %d chunks, %d of them carry a title", mode, len(exp.Chunks), len(units[0].Members), titles)
|
||
}
|
||
|
||
// 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.TotalUnits != 3 {
|
||
t.Fatalf("manifest must be 3 chunks, got total=%d", exp.TotalUnits)
|
||
}
|
||
if exp.PendingUnits < 1 {
|
||
t.Fatalf("a ceiling-stopped book must have pending chunks, got pending=%d (%+v)", exp.PendingUnits, exp.Chunks)
|
||
}
|
||
if len(exp.Chunks) != exp.TotalUnits {
|
||
t.Fatalf("every manifest chunk must have a row: len=%d total=%d", len(exp.Chunks), exp.TotalUnits)
|
||
}
|
||
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.PendingUnits {
|
||
t.Errorf("pending rows (%d) must match the counter (%d)", pending, exp.PendingUnits)
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
// firstLine keeps a failure message readable when the text it quotes is a whole chapter.
|
||
func firstLine(s string) string {
|
||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||
return s[:i] + "…"
|
||
}
|
||
return s
|
||
}
|