240 lines
10 KiB
Go
240 lines
10 KiB
Go
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)
|
||
}
|
||
}
|