package pipeline import ( "bytes" "context" "errors" "os" "path/filepath" "strconv" "strings" "testing" "textmachine/backend/internal/bookfile" "textmachine/backend/internal/chunk" "textmachine/backend/internal/lang" "textmachine/backend/internal/obs" ) // bookbuild_test.go pins the book writer (backlog row 236) on the runner harness: the circle // «translated → built → read back by the engine's own ingest» closes by EQUALITY of paragraphs, and // each of the five states a file may not lie about is driven through the real projection where a // run can reach it (pending, withheld, ghost, drift) and through a synthetic projection where it // cannot (incomplete, the in-chapter ghost mark, a heading, a book of no units). // ingestParagraphs reads an EPUB back through the engine's reader and splits each chapter the way the // chunker does (blank lines, trimmed, empties dropped — chunk.splitParagraphs). func ingestParagraphs(t *testing.T, epubPath string) [][]string { t.Helper() doc, err := chunk.IngestEncoded(epubPath, "", "", nil) if err != nil { t.Fatalf("ingest of the written EPUB failed: %v", err) } out := make([][]string, len(doc.Chapters)) for i, ch := range doc.Chapters { for _, raw := range strings.Split(ch, "\n\n") { if p := strings.TrimSpace(raw); p != "" { out[i] = append(out[i], p) } } } return out } func assertRoundTrip(t *testing.T, book *bookfile.Book, epubPath string) { t.Helper() got := ingestParagraphs(t, epubPath) if len(got) != len(book.Chapters) { t.Fatalf("ingest read %d chapters back, the book has %d", len(got), len(book.Chapters)) } for i := range book.Chapters { want := book.Blocks(i) if strings.Join(got[i], "\n|") != strings.Join(want, "\n|") { t.Errorf("chapter %d read back differently:\n got %q\nwant %q", i+1, got[i], want) } } } // multiLineEdit makes the editor return prose with BOTH separators the real editor uses (a bare newline // and a blank line), so the paragraph rule is exercised rather than assumed. func multiLineEdit(body string) (string, string) { if isEditBody(body) { return "Первая строка абзаца.\nВторая строка.\n\nТретья, после пустой.", "stop" } return "ЧЕРНОВИК ПЕРЕВОДА", "stop" } func TestBuildBookRoundTripsThroughIngestAndPublishesItsPaths(t *testing.T) { srv := newJSONProvider(&reqRec{}, multiLineEdit) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАА\fГЛАВАБ\fГЛАВАВ", regenerate: 0}) 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) } rep, err := r.BuildBook(BuildOptions{}) if err != nil { t.Fatalf("BuildBook: %v", err) } if !rep.Complete || rep.PendingUnits+rep.WithheldUnits+rep.IncompleteUnits+rep.StaleUnits+rep.GhostRows != 0 || rep.ConfigDrift || rep.StaleUnknown { t.Fatalf("a fully translated book must build complete, with the stale check MADE: %+v", rep) } if rep.Version != buildVersion || rep.TextModified == "" || rep.TextModified == "1970-01-01T00:00:00Z" { t.Errorf("report envelope/stamp: %+v", rep) } dir := filepath.Dir(bookPath) for _, f := range bookfile.Formats { want := filepath.Join(dir, "test-book.db.book."+f) if rep.Files[f] != want { t.Errorf("files[%s] = %q, want %q", f, rep.Files[f], want) } if _, err := os.Stat(want); err != nil { t.Errorf("%s not written: %v", want, err) } } // The status envelope publishes the same places, whether or not a build ran. st, err := r.Status(ctx) if err != nil { t.Fatal(err) } for f, p := range rep.Files { if st.Artifacts.BookFiles[f] != p { t.Errorf("status artifacts.book_files[%s] = %q, the build wrote %q", f, st.Artifacts.BookFiles[f], p) } } // The circle: what the engine reads back is what the writer laid down, chapter by chapter. exp, err := r.Export(false) if err != nil { t.Fatal(err) } book, _, err := assembleBook(exp, nil, r.Book.Title, r.Book.TargetLang, lang.DefaultReaderWords()) if err != nil { t.Fatal(err) } if len(book.Chapters) != 3 || len(book.Chapters[0].Paragraphs) != 3 || book.Chapters[0].Paragraphs[1] != "Вторая строка." { t.Fatalf("paragraph rule: a non-blank line is a paragraph: %+v", book.Chapters[0]) } if book.Chapters[0].Title != "1" || len(book.Notice) != 0 || book.Description != "" { t.Errorf("a headingless complete book: bare-number titles, no notice: %+v", book) } assertRoundTrip(t, book, rep.Files["epub"]) // The text file is the same blocks, with no operator vocabulary. txt, err := os.ReadFile(rep.Files["txt"]) if err != nil { t.Fatal(err) } for _, banned := range []string{"=== CHAPTER", "TEXT MISSING", "pending", "⚠"} { if strings.Contains(string(txt), banned) { t.Errorf("complete book's txt carries %q:\n%s", banned, txt) } } if !strings.HasPrefix(string(txt), "Тест\n\n\n1\n\nПервая строка абзаца.\n\nВторая строка.\n\nТретья, после пустой.\n\n\n2\n") { t.Errorf("txt shape:\n%s", txt) } // Determinism in-process (the two-process proof is the stand's cmp): a rebuild is byte-identical. first, _ := os.ReadFile(rep.Files["epub"]) if _, err := r.BuildBook(BuildOptions{}); err != nil { t.Fatal(err) } second, _ := os.ReadFile(rep.Files["epub"]) if !bytes.Equal(first, second) { t.Error("two builds of one store differ byte-wise") } } func TestBuildBookRefusesAPendingBookAndPartialMarksIt(t *testing.T) { srv := newJSONProvider(&reqRec{}, draftEdit) defer srv.Close() // The ceiling pays for about one chapter, then denies (TestExportManifestPending's setup). 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() _, _ = r.TranslateBook(ctx) // the ceiling stop is expected; the partial store is the fixture _, err := r.BuildBook(BuildOptions{}) if refusalClassOf(err) != RefusalBookIncomplete { t.Fatalf("class = %v", err) } if !strings.Contains(err.Error(), "pending (not yet translated)") || !strings.Contains(err.Error(), "--partial") { t.Errorf("the refusal must list the holes and name the way out: %v", err) } dir := filepath.Dir(bookPath) if _, err := os.Stat(filepath.Join(dir, "test-book.db.book.epub")); !errors.Is(err, os.ErrNotExist) { t.Fatalf("a refusal must write nothing: %v", err) } rep, err := r.BuildBook(BuildOptions{Partial: true}) if err != nil { t.Fatalf("--partial must write: %v", err) } if rep.Complete || rep.PendingUnits < 1 { t.Fatalf("report must say the book is not whole: %+v", rep) } exp, _ := r.Export(false) book, _, err := assembleBook(exp, nil, r.Book.Title, r.Book.TargetLang, lang.DefaultReaderWords()) if err != nil { t.Fatal(err) } // No langpack → the non-verbal form: the notice is «⚠ holes/total», the mark «⚠ chapter.unit». if len(book.Notice) != 1 || !strings.HasPrefix(book.Notice[0], "⚠ ") || !strings.HasSuffix(book.Notice[0], "/3") { t.Errorf("notice = %q", book.Notice) } marked := 0 for _, ce := range exp.Chunks { if ce.Disposition != exportPending { continue } marked++ ch := book.Chapters[ce.Chapter-1] if len(ch.Paragraphs) != 1 || ch.Paragraphs[0] != "⚠ "+strconv.Itoa(ce.Chapter)+"."+strconv.Itoa(ce.ChunkIdx) { t.Errorf("pending chapter %d must carry exactly the mark: %+v", ce.Chapter, ch) } } if marked != rep.PendingUnits { t.Errorf("marked %d pending units, report says %d", marked, rep.PendingUnits) } assertRoundTrip(t, book, rep.Files["epub"]) txt, _ := os.ReadFile(rep.Files["txt"]) if !strings.HasPrefix(string(txt), "Тест\n\n"+book.Notice[0]+"\n\n\n") { t.Errorf("txt must open with the title and the notice:\n%s", txt) } } func TestBuildBookWithheldUnitIsAHole(t *testing.T) { // The editor leaks a service preamble on chapter 3 → substantive sanitizer defect → the unit ships // no text (withheld), while chapters 1–2 are clean (TestExportMatchesTranslateFinalText's ch3 path). srv := newJSONProvider(&reqRec{}, func(body string) (string, string) { if !isEditBody(body) { return "черновик перевода этой главы.", "stop" } if strings.Contains(body, "ГЛАВАВВ") { return "Вот перевод фрагмента:\nОн молча ушёл в туман.", "stop" } return "Чистый абзац.", "stop" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{ source: "ГЛАВААА\fГЛАВАББ\fГЛАВАВВ", gatesYAML: "gates:\n sanitizer:\n enabled: true\n", }) ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r := newRunner(t, bookPath) defer r.Close() _, _ = r.TranslateBook(ctx) // completes with flags exp, err := r.Export(false) if err != nil { t.Fatal(err) } if exp.PendingUnits != 0 { t.Fatalf("the fixture must have NO pending unit — the hole is a withheld one: %+v", exp) } withheld, _ := HoleCounts(exp) if withheld != 1 || UnitHole(exp.Chunks[2]) != HoleWithheld { t.Fatalf("chapter 3 must be withheld: withheld=%d chunks=%+v", withheld, exp.Chunks) } _, err = r.BuildBook(BuildOptions{}) if refusalClassOf(err) != RefusalBookIncomplete || !strings.Contains(err.Error(), "chapter 3 unit 0: withheld") { t.Fatalf("pending_units=0 with a withheld unit must still be refused, naming it: %v", err) } rep, err := r.BuildBook(BuildOptions{Partial: true, Formats: []string{"epub"}}) if err != nil { t.Fatal(err) } if rep.WithheldUnits != 1 || rep.Complete { t.Errorf("report: %+v", rep) } book, _, _ := assembleBook(exp, nil, r.Book.Title, r.Book.TargetLang, lang.DefaultReaderWords()) if got := book.Chapters[2].Paragraphs; len(got) != 1 || got[0] != "⚠ 3.0" { t.Errorf("withheld chapter must carry exactly the mark: %q", got) } assertRoundTrip(t, book, rep.Files["epub"]) if _, ok := rep.Files["txt"]; ok { t.Error("--format epub must not write the txt") } } func TestBuildBookGhostRowsAreAHole(t *testing.T) { srv := newJSONProvider(&reqRec{}, draftEdit) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАА\fГЛАВАБ\fГЛАВАВ", 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() // The source SHRINKS to two chapters: chapter 3's final row is now a GHOST — translated text the // current cut cannot place — while pending stays 0. The «complete» look is exactly the trap. writeFile(t, filepath.Join(filepath.Dir(bookPath), "source.txt"), "ГЛАВАА\fГЛАВАБ") r2 := newRunner(t, bookPath) defer r2.Close() exp, err := r2.Export(false) if err != nil { t.Fatal(err) } if exp.PendingUnits != 0 || exp.GhostRows != 1 || len(exp.GhostUnits) != 1 || exp.GhostUnits[0] != (UnitRef{Chapter: 3, ChunkIdx: 0}) { t.Fatalf("fixture: want pending 0, ghost 1 at 3/0; got %+v", exp) } _, err = r2.BuildBook(BuildOptions{}) if refusalClassOf(err) != RefusalBookIncomplete || !strings.Contains(err.Error(), "ghost rows") || !strings.Contains(err.Error(), " 3/0") { t.Fatalf("ghost rows alone must refuse, naming the row: %v", err) } rep, err := r2.BuildBook(BuildOptions{Partial: true}) if err != nil { t.Fatal(err) } if rep.Complete || rep.GhostRows != 1 { t.Errorf("report: %+v", rep) } book, _, _ := assembleBook(exp, nil, r2.Book.Title, r2.Book.TargetLang, lang.DefaultReaderWords()) // Chapter 3 no longer exists to be marked; the book-level notice carries the count. if len(book.Chapters) != 2 || len(book.Notice) != 1 || book.Notice[0] != "⚠ +1" { t.Errorf("ghost-only book: 2 chapters, one ghost notice: chapters=%d notice=%q", len(book.Chapters), book.Notice) } assertRoundTrip(t, book, rep.Files["epub"]) } func TestBuildBookDriftIsReportedNotMarked(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() dir := filepath.Dir(bookPath) body, _ := os.ReadFile(filepath.Join(dir, "pipeline.yaml")) writeFile(t, filepath.Join(dir, "pipeline.yaml"), strings.Replace(string(body), "prompt_version: v-test", "prompt_version: v-test2", 1)) r2 := newRunner(t, bookPath) defer r2.Close() rep, err := r2.BuildBook(BuildOptions{}) if err != nil { t.Fatalf("drift is not a hole; the default build must write: %v", err) } if !rep.ConfigDrift || !rep.Complete { t.Fatalf("report must carry the drift and call the text complete: %+v", rep) } txt, _ := os.ReadFile(rep.Files["txt"]) if strings.Contains(string(txt), "⚠") || strings.Contains(strings.ToLower(string(txt)), "drift") { t.Errorf("drift must not reach the reader's file:\n%s", txt) } } // TestAssembleBookMarksEveryHoleKind drives the assembler on a synthetic projection: the heading is // stripped and becomes the title, incomplete units keep their text behind the mark, a ghost inside an // existing chapter is marked at the chapter's end, and the words come from the target language's data. func TestAssembleBookMarksEveryHoleKind(t *testing.T) { exp := &BookExport{ BookID: "syn", TotalUnits: 5, PendingUnits: 1, GhostRows: 2, TextModified: "2026-08-30T12:00:00Z", GhostUnits: []UnitRef{{Chapter: 2, ChunkIdx: 4}, {Chapter: 2, ChunkIdx: 6}}, Chunks: []ChunkExport{ {Chapter: 1, ChunkIdx: 0, Disposition: "ok", Heading: "Глава 1", FinalText: "Глава 1\n\nПодзаголовок модели\nПервый.\n\nВторой."}, {Chapter: 1, ChunkIdx: 2, Disposition: "ok", FinalText: "Третий."}, {Chapter: 2, ChunkIdx: 0, Disposition: "flagged", FlagReason: "sanitizer_substantive", FinalText: ""}, {Chapter: 2, ChunkIdx: 2, Disposition: "flagged", FlagReason: "cjk_artifact", DroppedMembers: 1, DroppedReason: "cjk_artifact", FinalText: "Уцелевшая часть."}, {Chapter: 3, ChunkIdx: 0, Disposition: "pending"}, }, } root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "ru"), 0o755); err != nil { t.Fatal(err) } writeFile(t, filepath.Join(root, "ru", "reader.txt"), "hole.pending\tНе переведено.\nhole.withheld\tНе выпущено.\nhole.incomplete\tНедостаёт {dropped}.\nhole.stale\tУстарело.\nhole.ghost\tВне нарезки {ghost}.\nnotice.holes\tПропусков {holes} из {total}.\nnotice.ghost\tВне нарезки всего {ghost}.\n") words, present, err := lang.LoadReaderWords(root, "ru") if err != nil || !present { t.Fatal(err) } book, holes, err := assembleBook(exp, nil, "蛊真人", "ru", words) if err != nil { t.Fatal(err) } if len(holes) != 3 { t.Errorf("holes = %+v", holes) } if book.Identifier != "urn:textmachine:book:syn" || book.Language != "ru" || book.Title != "蛊真人" || bookfile.Modified(book.Modified) != "2026-08-30T12:00:00Z" { t.Errorf("metadata: %+v", book) } want := []bookfile.Chapter{ {Title: "Глава 1", Paragraphs: []string{"Подзаголовок модели", "Первый.", "Второй.", "Третий."}}, {Title: "2", Paragraphs: []string{"Не выпущено.", "Недостаёт 1.", "Уцелевшая часть.", "Вне нарезки 2."}}, {Title: "3", Paragraphs: []string{"Не переведено."}}, } if len(book.Chapters) != len(want) { t.Fatalf("chapters: %+v", book.Chapters) } for i := range want { if book.Chapters[i].Title != want[i].Title || strings.Join(book.Chapters[i].Paragraphs, "|") != strings.Join(want[i].Paragraphs, "|") { t.Errorf("chapter %d = %+v, want %+v", i+1, book.Chapters[i], want[i]) } } if strings.Join(book.Notice, "|") != "Пропусков 3 из 5.|Вне нарезки всего 2." || book.Description != "Пропусков 3 из 5. Вне нарезки всего 2." { t.Errorf("notice = %q description = %q", book.Notice, book.Description) } // The heading is stripped ONLY as the known prefix: a text that merely mentions the heading later // keeps it, and a heading absent from the text strips nothing. exp2 := &BookExport{BookID: "syn", TotalUnits: 1, TextModified: "2026-08-30T12:00:00Z", Chunks: []ChunkExport{{Chapter: 1, ChunkIdx: 0, Disposition: "ok", Heading: "Глава 1", FinalText: "Текст, где Глава 1 упомянута."}}} b2, _, err := assembleBook(exp2, nil, " t ", "ru", words) if err != nil || b2.Chapters[0].Paragraphs[0] != "Текст, где Глава 1 упомянута." || b2.Chapters[0].Title != "Глава 1" || b2.Title != "t" { t.Errorf("prefix rule / trimmed title: %+v %q %v", b2.Chapters, b2.Title, err) } // A book no run has written is stamped with the epoch, and a malformed stamp fails loud. exp2.TextModified = "" if b3, _, err := assembleBook(exp2, nil, "t", "ru", words); err != nil || bookfile.Modified(b3.Modified) != "1970-01-01T00:00:00Z" { t.Errorf("epoch stamp: %v %v", err, b3) } exp2.TextModified = "yesterday" if _, _, err := assembleBook(exp2, nil, "t", "ru", words); err == nil { t.Error("a malformed text_modified must fail loud") } // The writer's own reading of a unit: text of nothing a reader can see is withheld; a bare CR ends a // line like LF; a stale unit shows its mark and not its (outdated) text, ahead of the incomplete rule. exp3 := &BookExport{BookID: "syn", TotalUnits: 3, TextModified: "2026-08-30T12:00:00Z", Chunks: []ChunkExport{ {Chapter: 1, ChunkIdx: 0, Disposition: "ok", FinalText: "\x01\x02"}, {Chapter: 2, ChunkIdx: 0, Disposition: "ok", FinalText: "Первая\rВторая\r\nТретья"}, {Chapter: 3, ChunkIdx: 0, Disposition: "flagged", DroppedMembers: 1, DroppedReason: "x", FinalText: "Старый текст."}, }} b4, holes4, err := assembleBook(exp3, map[UnitRef]bool{{Chapter: 3, ChunkIdx: 0}: true}, "t", "ru", words) if err != nil { t.Fatal(err) } if got := b4.Chapters[0].Paragraphs; len(got) != 1 || got[0] != "Не выпущено." { t.Errorf("control-only text must be a withheld hole, got %q", got) } if got := strings.Join(b4.Chapters[1].Paragraphs, "|"); got != "Первая|Вторая|Третья" { t.Errorf("CR must end a line: %q", got) } if got := b4.Chapters[2].Paragraphs; len(got) != 1 || got[0] != "Устарело." { t.Errorf("a stale unit shows its mark and not its old text, got %q", got) } if len(holes4) != 2 || holes4[0].Kind != HoleWithheld || holes4[1].Kind != HoleStale || b4.Notice[0] != "Пропусков 2 из 3." { t.Errorf("holes = %+v notice = %q", holes4, b4.Notice) } } // TestBuildBookStaleUnitsAreAHole is the final review's blocker: a source edit that keeps the unit keys // (a typo fix) used to yield a byte-identical file under complete=true — the translation of text no // longer in the book. The writer now reads the same fact the resume path acts on (content hash). func TestBuildBookStaleUnitsAreAHole(t *testing.T) { srv := newJSONProvider(&reqRec{}, draftEdit) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАА\fГЛАВАБ", 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) } rep1, err := r1.BuildBook(BuildOptions{}) if err != nil || !rep1.Complete || rep1.StaleUnknown || rep1.StaleUnits != 0 { t.Fatalf("a fresh run must build complete with the stale check MADE: %+v %v", rep1, err) } r1.Close() // The same two chapters, one of them edited in place: keys survive, the text under one does not. writeFile(t, filepath.Join(filepath.Dir(bookPath), "source.txt"), "ГЛАВАА\fГЛАВАБ — исправлено") r2 := newRunner(t, bookPath) _, err = r2.BuildBook(BuildOptions{}) if refusalClassOf(err) != RefusalBookIncomplete || !strings.Contains(err.Error(), "chapter 2 unit 0: stale") || strings.Contains(err.Error(), "chapter 1 unit 0") { t.Fatalf("the edited unit — and only it — must be a stale hole: %v", err) } rep2, err := r2.BuildBook(BuildOptions{Partial: true}) if err != nil || rep2.Complete || rep2.StaleUnits != 1 || rep2.StaleUnknown { t.Fatalf("partial: %+v %v", rep2, err) } exp, _ := r2.Export(false) stale, unknown := r2.staleUnits(exp) book, _, _ := assembleBook(exp, stale, r2.Book.Title, r2.Book.TargetLang, lang.DefaultReaderWords()) if unknown || !stale[UnitRef{Chapter: 2, ChunkIdx: 0}] || strings.Join(book.Chapters[1].Paragraphs, "|") != "⚠ 2.0" { t.Errorf("stale=%v unknown=%v chapter2=%q", stale, unknown, book.Chapters[1].Paragraphs) } assertRoundTrip(t, book, rep2.Files["epub"]) r2.Close() // Re-translating (the engine re-buys the moved unit) makes the book whole again. r3 := newRunner(t, bookPath) defer r3.Close() // ⚠ CONSENT IS EXPLICIT HERE SINCE THE ROW-238 FIX (money-and-honesty pack, 31.08). Editing the source // re-buys already-billed rows, and the re-payment consent gate now SEES that — before the fix its probe // read a manifest the run had just rewritten, so it stayed silent and this scenario passed by leaning // on a defect. Consent is orthogonal to what this test asserts; the guarantee that used to be implied // here — «an edited source proceeds without consent» — was FALSE and now lives, inverted and explicit, // in TestTheCONSENTGateSeesAnInPlaceSourceEdit (rebillsource_test.go). Scenario-only edit, sanctioned // by the orchestrator 31.08 on the acceptance of this pack; no assertion of this test is touched. r3.AcceptRebill = RebillConsent{Given: true} if _, err := r3.TranslateBook(ctx); err != nil { t.Fatal(err) } rep3, err := r3.BuildBook(BuildOptions{}) if err != nil || !rep3.Complete || rep3.StaleUnits != 0 { t.Fatalf("after a re-translate the book must be whole: %+v %v", rep3, err) } txt, _ := os.ReadFile(rep3.Files["txt"]) if strings.Contains(string(txt), "⚠") { t.Errorf("no mark may survive the re-translate:\n%s", txt) } } // TestBuildBookRemovedStageIsDrift: removing the editor stage used to make the draft rows the shipping // rows with no drift — an unedited book that reads as complete. Now a stored row of a stage the config // does not run is drift (export.go), and the writer's stale check says «unknown» under drift. func TestBuildBookRemovedStageIsDrift(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() dir := filepath.Dir(bookPath) body, _ := os.ReadFile(filepath.Join(dir, "pipeline.yaml")) var kept []string for _, line := range strings.Split(string(body), "\n") { if !strings.Contains(line, "name: edit") { kept = append(kept, line) } } writeFile(t, filepath.Join(dir, "pipeline.yaml"), strings.Join(kept, "\n")) r2 := newRunner(t, bookPath) defer r2.Close() rep, err := r2.BuildBook(BuildOptions{}) if err != nil { t.Fatal(err) } if !rep.ConfigDrift || !rep.StaleUnknown { t.Fatalf("a removed stage is drift, and under drift the stale check is unknown: %+v", rep) } } func TestBuildBookRefusesABookOfNoUnitsAndBadOptions(t *testing.T) { srv := newJSONProvider(&reqRec{}, draftEdit) defer srv.Close() r := newRunner(t, setupProject(t, srv.URL)) defer r.Close() _, err := r.buildFromExport(&BookExport{Version: exportVersion, BookID: "empty", Chunks: []ChunkExport{}}, nil, false, bookfile.Formats, BuildOptions{}) if refusalClassOf(err) != RefusalSourceUnreadable { t.Errorf("a book of no units: %v", err) } if _, err := r.BuildBook(BuildOptions{Formats: []string{"pdf"}}); err == nil || strings.Contains(err.Error(), "refus") { t.Errorf("unknown format must be a plain error: %v", err) } if _, err := r.BuildBook(BuildOptions{Out: "x"}); err == nil { t.Error("--out with two formats must be an error") } } // TestBuildBookLeavesOneBuildBesideTheDatabase pins the fix of the mid-work review's blocker: a // `--format` subset must not leave the other format's OLDER copy beside the database, where the // envelope would present it as current — the «silently complete» outcome through a legal sequence // (build; translate more; build --format epub). func TestBuildBookLeavesOneBuildBesideTheDatabase(t *testing.T) { srv := newJSONProvider(&reqRec{}, draftEdit) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАА\fГЛАВАБ", 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) } if _, err := r1.BuildBook(BuildOptions{}); err != nil { t.Fatal(err) } r1.Close() dir := filepath.Dir(bookPath) txtPath := filepath.Join(dir, "test-book.db.book.txt") if _, err := os.Stat(txtPath); err != nil { t.Fatal(err) } // The book grows a chapter (pending) and only the epub is rebuilt, partial. writeFile(t, filepath.Join(dir, "source.txt"), "ГЛАВАА\fГЛАВАБ\fГЛАВАВ") r2 := newRunner(t, bookPath) defer r2.Close() rep, err := r2.BuildBook(BuildOptions{Formats: []string{"epub"}, Partial: true}) if err != nil { t.Fatal(err) } if rep.Complete || rep.PendingUnits != 1 { t.Fatalf("fixture: %+v", rep) } if _, err := os.Stat(txtPath); !errors.Is(err, os.ErrNotExist) { t.Fatalf("the older txt (which reads as a COMPLETE book) must not survive a subset rebuild beside the database: %v", err) } if _, ok := rep.Files["txt"]; ok { t.Error("the report must list only what this build wrote") } // An explicit --out leaves the default place alone (it wrote elsewhere). if _, err := r2.BuildBook(BuildOptions{Formats: []string{"txt"}, Partial: true, Out: filepath.Join(t.TempDir(), "b.txt")}); err != nil { t.Fatal(err) } if _, err := os.Stat(filepath.Join(dir, "test-book.db.book.epub")); err != nil { t.Errorf("--out must not remove the copies beside the database: %v", err) } } func TestBuildBookRefusesAConfigItCannotFileUnder(t *testing.T) { srv := newJSONProvider(&reqRec{}, draftEdit) defer srv.Close() r := newRunner(t, setupProject(t, srv.URL)) defer r.Close() exp := &BookExport{Version: exportVersion, BookID: "b", TotalUnits: 1, Chunks: []ChunkExport{{Chapter: 1, ChunkIdx: 0, Disposition: "ok", FinalText: "x"}}} title := r.Book.Title r.Book.Title = " " if _, err := r.buildFromExport(exp, nil, false, bookfile.Formats, BuildOptions{}); refusalClassOf(err) != RefusalBadConfig || !strings.Contains(err.Error(), "title") { t.Errorf("an empty title is a config refusal naming the key: %v", err) } r.Book.Title = title for _, bad := range []string{"russian", "ru_RU", "", "r", "ru-", "ru-très"} { r.Book.TargetLang = bad if _, err := r.buildFromExport(exp, nil, false, bookfile.Formats, BuildOptions{}); refusalClassOf(err) != RefusalBadConfig || !strings.Contains(err.Error(), "target_lang") { t.Errorf("target_lang %q must be a config refusal naming the key: %v", bad, err) } } for _, good := range []string{"ru", "zh", "pt-BR", "zh-Hant-TW", "en-x-private"} { if !looksLikeLanguageTag(good) { t.Errorf("%q looks like a language tag and must pass", good) } } } // TestBuildBookOutNamesANewFile pins the --out rule (the final review's second blocker: --out used to // replace ANY path, the project database included): an existing file is refused, a missing directory is // refused, a new path is written, and the default place is left alone. func TestBuildBookOutNamesANewFile(t *testing.T) { srv := newJSONProvider(&reqRec{}, draftEdit) defer srv.Close() bookPath := setupProject(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) } dbBefore, _ := os.ReadFile(r.Book.ProjectDB) for name, out := range map[string]string{ "the project database": r.Book.ProjectDB, "the source": r.Book.SourceFile, "an existing file": filepath.Join(t.TempDir(), "taken.epub"), "a missing directory": filepath.Join(t.TempDir(), "nope", "book.epub"), } { if name == "an existing file" { writeFile(t, out, "somebody's bytes") } _, err := r.BuildBook(BuildOptions{Formats: []string{"epub"}, Out: out}) if refusalClassOf(err) != RefusalBadConfig { t.Errorf("--out %s must be refused with the config class: %v", name, err) } } dbAfter, _ := os.ReadFile(r.Book.ProjectDB) if !bytes.Equal(dbBefore, dbAfter) { t.Fatal("the project database was touched by a refused --out") } out := filepath.Join(t.TempDir(), "book.epub") rep, err := r.BuildBook(BuildOptions{Formats: []string{"epub"}, Out: out}) if err != nil { t.Fatal(err) } if rep.Files["epub"] != out { t.Errorf("files = %v", rep.Files) } b, _ := os.ReadFile(out) if !bytes.HasPrefix(b, []byte("PK")) { t.Errorf("the explicit path must hold the EPUB: %q", b[:min(len(b), 16)]) } if _, err := os.Stat(filepath.Join(filepath.Dir(bookPath), "test-book.db.book.epub")); !errors.Is(err, os.ErrNotExist) { t.Error("--out must not also write the default place") } // No temp file survives a refusal or a write, anywhere the writer looked. for _, d := range []string{filepath.Dir(out), filepath.Dir(bookPath)} { entries, _ := os.ReadDir(d) for _, e := range entries { if strings.Contains(e.Name(), ".tmp-") { t.Errorf("temp file left behind: %s/%s", d, e.Name()) } } } }