package pipeline import ( "context" "net/http/httptest" "os" "path/filepath" "strings" "testing" ) // banknote_integration_test.go: the WS4 (д) end-to-end pins for the banknote channel wired into the // runner (banknote ON), covering the load-bearing integration points §4(а): the block is SLICED before // the editor/gates (points 1-4), the cleaned draft is committed as a derived export and final_hash is // re-pointed (point 8), resume re-serves the cleaned draft at $0 (point 7), the echo/length classify // runs over the CLEANED text (point 3), the telemetry is persisted (point 10), and banknoteSnap folds // into the snapshot only when enabled (point 6 / §4в). The banknote-OFF golden fixture stays byte-stable // (the TestGolden guard), so these ON-only cells live here rather than in the shared golden. // bankBlock is a well-formed 2-line banknote block a translator emits after a clean RU translation. const bankBlock = bankSeparator + "\n龙公\tЛун Гун\tname\n蛊\tгу\tterm" func TestBanknoteSliceReTelemetryResume(t *testing.T) { rec := &reqRec{} const cleanDraft = "Тихое утро в библиотеке." srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { // The editor MUST receive the CLEANED draft — the block was sliced off upstream (point 8). if strings.Contains(body, bankSeparator) || strings.Contains(body, "龙公") { t.Errorf("editor request leaked the banknote block:\n%s", body) } return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" } return cleanDraft + "\n" + bankBlock, "stop" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true}) ctx := context.Background() r1 := newRunner(t, bookPath) res1, err := r1.TranslateBook(ctx) if err != nil { t.Fatal(err) } ch := res1.Chunks[0] // The draft stage's usable text is the CLEANED translation (block removed, points 1-3). if got := ch.Stages[0].Text; got != cleanDraft { t.Fatalf("draft text must be the cleaned translation %q, got %q", cleanDraft, got) } if ch.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" { t.Fatalf("final text must come from the edit over the cleaned draft, got %q", ch.FinalText) } // The draft chunk_status.final_hash must be RE-POINTED at the tm-banknote-v1 derived checkpoint (point 8). cs, _ := r1.Store.GetChunkStatus("test-book", 1, 0, "draft") if cs == nil || !strings.HasPrefix(cs.FinalHash, bankDerivedNS+":") { t.Fatalf("draft final_hash must be a tm-banknote-v1 derived checkpoint, got %+v", cs) } // The derived checkpoint holds the CLEANED draft (so export/resume yield it, not the raw block). dcp, err := r1.Store.GetCheckpoint(cs.FinalHash) if err != nil || dcp == nil { t.Fatalf("derived checkpoint missing: %v", err) } if dcp.ResponseText != cleanDraft || dcp.FinishReason != "banknote_export" { t.Fatalf("derived checkpoint = %q/%q, want cleaned draft / banknote_export", dcp.ResponseText, dcp.FinishReason) } // Telemetry (point 10): 2 accepted lines, no parse fail / truncation. rs, _ := r1.Store.GetRetrievalState("test-book", 1, 0) if rs == nil || rs.NBanknoteLines != 2 || rs.BanknoteParseFail != 0 || rs.BanknoteTruncated != 0 { t.Fatalf("banknote telemetry wrong: %+v", rs) } callsAfterRun1 := rec.count() r1.Close() // Resume (new process): $0, NO new provider calls, cleaned draft re-served identically (point 7). r2 := newRunner(t, bookPath) defer r2.Close() res2, err := r2.TranslateBook(ctx) if err != nil { t.Fatal(err) } if rec.count() != callsAfterRun1 { t.Fatalf("resume must not re-call the provider, calls %d -> %d", callsAfterRun1, rec.count()) } if res2.TotalUSD != 0 || res2.Chunks[0].FinalText != res1.Chunks[0].FinalText { t.Fatalf("resume must be $0 and byte-identical, got %+v", res2.Chunks[0]) } } // A clean RU translation followed by a Han-heavy banknote block must classify OK — the echo/CJK check // runs over the CLEANED text (point 3). WITHOUT the slice, the block's Han src column would push // cjk_share over the 15% threshold and false-flag cjk_artifact. func TestBanknoteClassifyOverCleaned(t *testing.T) { rec := &reqRec{} // A short RU translation + a LONG Han-heavy block: raw cjk_share > 0.15, cleaned cjk_share ≈ 0. bigBlock := bankSeparator + "\n" + strings.Repeat("龙公\tЛун Гун\tname\n古月\tГу Юэ\tname\n方源\tФан Юань\tname\n", 3) srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { return "ред", "stop" } return "Он ушёл." + "\n" + bigBlock, "stop" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true}) r := newRunner(t, bookPath) defer r.Close() res, err := r.TranslateBook(context.Background()) if err != nil { t.Fatal(err) } ch := res.Chunks[0] if ch.Stages[0].Disposition != DispOK { t.Fatalf("a clean RU draft + Han banknote block must classify OK over the cleaned text, got %+v", ch.Stages[0]) } if ch.Stages[0].Text != "Он ушёл." { t.Fatalf("draft text must be the cleaned translation, got %q", ch.Stages[0].Text) } } // Enabling the banknote channel folds banknoteSnap into the snapshot (point 6 / §4в); a banknote-OFF // project renders a DIFFERENT snapshot (so a channel flip is a loud --resnapshot). Off omits the field // (omitempty), on adds banknote:{enabled,parser_version}. func TestBanknoteSnapshotFold(t *testing.T) { srv := httptest.NewServer(nil) defer srv.Close() off := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{})) defer off.Close() on := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{banknote: true})) defer on.Close() offID, offPayload, err := off.snapshotID() if err != nil { t.Fatal(err) } onID, onPayload, err := on.snapshotID() if err != nil { t.Fatal(err) } if offID == onID { t.Fatalf("enabling the banknote channel must move the snapshot (loud --resnapshot)") } if strings.Contains(offPayload, "banknote") { t.Fatalf("a banknote-OFF snapshot must omit the field (omitempty), got: %s", offPayload) } if !strings.Contains(onPayload, `"banknote":{"enabled":true,"parser_version":"`+bankParserVersion+`"}`) { t.Fatalf("a banknote-ON snapshot must fold {enabled,parser_version}, got: %s", onPayload) } } // A truncated banknote generation (finish=length) is a length flag (retryable) — the block is not parsed // (finish=stop-only gate §4б), and with the tolerant parser held for robustness it never counts a parse // fail. Here a channel-off run is unaffected; this pins that the stop-only gate does not accept a // truncated block's candidates. With regenerate=0 the length draft flags immediately. func TestBanknoteFinishStopOnlyGate(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { return "ред", "stop" } // A truncated generation: clean text + a partial block, finish=length. return "Незаконченный перевод" + "\n" + bankSeparator + "\n龙公\tЛун", "length" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true, regenerate: 0}) r := newRunner(t, bookPath) defer r.Close() res, err := r.TranslateBook(context.Background()) if err != nil { t.Fatal(err) } ch := res.Chunks[0] // finish=length → flagged length (the block is not accepted; the translation is retried in prod). if ch.Stages[0].Disposition != DispFlagged || ch.Stages[0].FlagReason != FlagLength { t.Fatalf("a truncated generation must flag length (stop-only gate rejects the block), got %+v", ch.Stages[0]) } // No candidates accepted under a non-stop finish → zero telemetry. rs, _ := r.Store.GetRetrievalState("test-book", 1, 0) if rs != nil && rs.NBanknoteLines != 0 { t.Fatalf("a non-stop generation must accept 0 banknote lines, got %+v", rs) } } // The banknote slice + derived-commit + re-point must run AFTER the escalation resolve and consume the // ESCALATED draft's stripped text (WS4 point 8's escalation-ordering note): the primary echoes CJK // (cjk_artifact) → escalates → the fallback returns a clean RU translation + a banknote block, which is // sliced and committed as a derived checkpoint keyed on the ESCALATED attempt's request_hash. func TestBanknoteEscalatedDraftStripped(t *testing.T) { rec := &reqRec{} const fbClean = "Тихое утро в библиотеке." srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { if strings.Contains(body, bankSeparator) { t.Errorf("editor leaked the escalated banknote block:\n%s", body) } return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" } if strings.Contains(body, "fake-fallback") { return fbClean + "\n" + bankBlock, "stop" // the fallback emits a clean translation + banknote } return "静かな図書館の朝。", "stop" // the primary echoes the CJK source → cjk_artifact }) defer srv.Close() bookPath := setupEscalationProject(t, srv.URL, 1.0, false, "") pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml") raw, err := os.ReadFile(pipePath) if err != nil { t.Fatal(err) } writeFile(t, pipePath, string(raw)+"\ngates:\n banknote:\n enabled: true\n") r := newRunner(t, bookPath) defer r.Close() res, err := r.TranslateBook(context.Background()) if err != nil { t.Fatal(err) } ch := res.Chunks[0] draft := ch.Stages[0] if !draft.Escalated || draft.EscalationModel != "fake-fallback" { t.Fatalf("the draft must have escalated to fake-fallback, got %+v", draft) } // The escalated draft's usable text is the CLEANED fallback translation (block sliced). if draft.Text != fbClean { t.Fatalf("escalated draft text must be the cleaned fallback translation %q, got %q", fbClean, draft.Text) } // final_hash re-pointed at a tm-banknote-v1 derived checkpoint holding the cleaned ESCALATED draft. cs, _ := r.Store.GetChunkStatus("test-book", 1, 0, "draft") if cs == nil || !strings.HasPrefix(cs.FinalHash, bankDerivedNS+":") { t.Fatalf("escalated draft final_hash must be a derived checkpoint, got %+v", cs) } dcp, _ := r.Store.GetCheckpoint(cs.FinalHash) if dcp == nil || dcp.ResponseText != fbClean { t.Fatalf("derived checkpoint must hold the cleaned escalated draft, got %+v", dcp) } if ch.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" { t.Fatalf("final text must come from the edit over the cleaned escalated draft, got %q", ch.FinalText) } }