textmachine/backend/internal/pipeline/bookbuild_test.go

718 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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")
}
}
// TestTheBuiltBookCarriesChapterTitlesAndNotTheirNumbers closes the LAST hop of the heading seam — the one
// the seam is named for. assembleBook substitutes the chapter NUMBER when the export carries no title, so a
// title lost anywhere upstream ships as «1», «2» in the finished book; that substitution is what turned a
// green battery into a book with no chapter names. Three positions above this are pinned (the cut, the
// manifest reconstruction, the export in both modes) and this one was prose.
//
// Two guarantees, and they are separate: the title BECOMES the chapter's title, and the same literal is
// STRIPPED from the body so a reader does not meet it twice. The fixture's numbers are not consecutive, so
// «the title came from the header» and «the title came from the counter» are distinguishable here too.
func TestTheBuiltBookCarriesChapterTitlesAndNotTheirNumbers(t *testing.T) {
srv := newJSONProvider(&reqRec{}, 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)
}
rep, err := r.BuildBook(BuildOptions{})
if err != nil {
t.Fatalf("BuildBook: %v", err)
}
if !rep.Complete {
t.Fatalf("the fixture must build complete, else the titles below are read off a partial book: %+v", rep)
}
raw, err := os.ReadFile(rep.Files["txt"])
if err != nil {
t.Fatal(err)
}
book := string(raw)
// The writer puts a chapter title on its own line after a blank run (bookfile.WriteTXT).
for i := 1; i <= len(zhFixtureNominals); i++ {
want := "\n\n\n" + zhNominal(t, i) + "\n"
if !strings.Contains(book, want) {
t.Fatalf("the built book has no chapter titled %q — assembleBook substitutes the chapter NUMBER when the export carries none, which is exactly how a book ships as «1», «2»", strings.TrimSpace(want))
}
// The fallback must NOT be what a reader sees: the ordinal on its own line is that fallback.
if strings.Contains(book, "\n\n\n"+strconv.Itoa(i)+"\n") {
t.Fatalf("the built book carries %q as a chapter title — that is assembleBook's number fallback, not a title", strconv.Itoa(i))
}
}
// And the title is not printed twice: the export glues it onto the text, the writer prints it as the
// heading, and assembleBook strips the known prefix off the body.
for i := 1; i <= len(zhFixtureNominals); i++ {
title := zhNominal(t, i)
if n := strings.Count(book, title); n != 1 {
t.Fatalf("%q appears %d times in the built book, want 1 — the heading prefix is not being stripped from the body it was glued onto", title, n)
}
}
}
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 12 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())
}
}
}
}