718 lines
32 KiB
Go
718 lines
32 KiB
Go
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 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())
|
||
}
|
||
}
|
||
}
|
||
}
|