textmachine/backend/internal/pipeline/manifesttoc_test.go

142 lines
6.7 KiB
Go

package pipeline
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/chunk/chunktest"
"textmachine/backend/internal/obs"
)
// manifesttoc_test.go: backlog row 312, Д-3 — the alarm the engine already raises reaches the reader who
// decides on it, instead of living in the engine's own log.
//
// A book that DECLARES a table of contents and whose bytes cannot be read falls back to the spine and is
// sold by characters. The count existed (chunk.Document.TOCUnreadable, pinned in the chunk package) and
// went to r.Log.Warn, while the far side of the seam reads the manifest — so `structure: delimited` was
// published with no way to tell «this book never had a table of contents» from «it had one and we could
// not read it».
// manifestOf runs a book and returns the manifest it left, plus the raw bytes: an ABSENT key and a
// present zero are the distinction under test, and they decode to different Go values only because the
// field is a pointer — the bytes are what a reader on the other side actually gets.
func manifestOf(t *testing.T, bookPath string) (BookManifest, []byte) {
t.Helper()
r := newRunner(t, bookPath)
t.Cleanup(func() { _ = r.Close() })
if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(r.manifestPath())
if err != nil {
t.Fatalf("a run must leave a manifest: %v", err)
}
var m BookManifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m, raw
}
// epubBook builds the fixture project and then REPLACES its source with the epub the case needs — the
// shared helper writes a plain one and has no way to declare a table of contents that is missing.
func epubBook(t *testing.T, providerURL string, book chunktest.EPUB) string {
t.Helper()
ids := make([]string, 0, len(book.Chapters))
for _, c := range book.Chapters {
ids = append(ids, c.ID)
}
bookPath := setupProjectOpts(t, providerURL, projectOpts{epub: book.Chapters, spine: ids})
book.BuildAt(t, filepath.Join(filepath.Dir(bookPath), "source.epub"))
return bookPath
}
// TestTheManifestSaysADeclaredTableOfContentsCouldNotBeRead is the landing: the fact crosses the seam.
//
// Mutation this catches: stop threading the count into the manifest and the document goes back to saying
// only `delimited`, which is the state row 312 calls «the most degraded case of the class this pack cured».
func TestTheManifestSaysADeclaredTableOfContentsCouldNotBeRead(t *testing.T) {
chapters := []chunktest.Chapter{
{ID: "c1", Href: "c1.xhtml", Body: "<p>Первая глава.</p>"},
{ID: "c2", Href: "c2.xhtml", Body: "<p>Вторая глава.</p>"},
}
spine := []chunktest.SpineRef{{ID: "c1"}, {ID: "c2"}}
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// (1) The book PROMISED a navigation document and the archive does not carry it.
broken := chunktest.EPUB{Chapters: chapters, Spine: spine,
Nav: &chunktest.Nav{TOC: []string{"c1.xhtml", "c2.xhtml"}, Missing: true}}
m, _ := manifestOf(t, epubBook(t, srv.URL, broken))
if m.Structure != chunk.StructureDelimited {
t.Fatalf("premise broken: this book must fall back to the spine, got structure=%q", m.Structure)
}
if m.TOCUnreadable == nil {
t.Fatal("a manifest this build wrote must answer the question — absent is reserved for a sidecar " +
"written before the field existed")
}
if *m.TOCUnreadable != 1 {
t.Fatalf("the book declared one table of contents and it could not be read: toc_unreadable=%d", *m.TOCUnreadable)
}
// (2) THE OTHER HALF, and it is not optional: a book that never claimed a table of contents reaches
// the same `delimited` by its CORRECT path, and must not be marked as a degradation. Without this the
// assertion above is satisfied by a field that is always 1.
clean := chunktest.EPUB{Chapters: chapters, Spine: spine}
m2, raw2 := manifestOf(t, epubBook(t, srv.URL, clean))
if m2.Structure != m.Structure {
t.Fatalf("premise broken: both books must reach the same structure, or the field is not what "+
"distinguishes them: %q vs %q", m2.Structure, m.Structure)
}
if m2.TOCUnreadable == nil || *m2.TOCUnreadable != 0 {
t.Fatalf("this book declared nothing and lost nothing; the answer is zero and it is PRESENT: %v", m2.TOCUnreadable)
}
// And present it is, in the bytes: «measured, none» has to be visible to a reader that has no struct.
var probe map[string]any
if err := json.Unmarshal(raw2, &probe); err != nil {
t.Fatal(err)
}
if _, ok := probe["toc_unreadable"]; !ok {
t.Fatalf("the key must be in the document even at zero, or «none» and «cannot answer» are one state:\n%s", raw2)
}
}
// TestASidecarOlderThanTheFieldCannotAnswer is the THIRD state, and it is the whole cost of adding the
// field without moving the document version.
//
// The version deliberately does not move for an additive field — moving it would discard every stored
// sidecar and re-cut every book. The consequence, spelled out by this file's neighbour for `price`: a
// document written by an older build passes the version, the key and selfConsistent, comes back as
// «current», and carries no such field. A plain int would render that as «none declared, none unreadable»
// — a confident answer from a document that has none. That class already cost this package one defect
// (readModelPrice, acceptance V2-3), and this is what keeps it from costing a second.
func TestASidecarOlderThanTheFieldCannotAnswer(t *testing.T) {
const older = `{"manifest_version":"tm-manifest-v2","book_id":"b","structure":"delimited","chapters":[]}`
var m BookManifest
if err := json.Unmarshal([]byte(older), &m); err != nil {
t.Fatal(err)
}
if m.TOCUnreadable != nil {
t.Fatalf("a document that never carried the field must not answer for it: %v", *m.TOCUnreadable)
}
// CONTROL, so the nil above is the FIELD answering and not the whole decode failing: the rest of the
// document did arrive.
if m.Structure != chunk.StructureDelimited || m.Version != manifestVersion {
t.Fatalf("control: the older document must otherwise decode normally, got %+v", m)
}
// And a document that DOES carry a zero says so — the two states are different bytes and different
// values, which is the entire point of the pointer.
const current = `{"manifest_version":"tm-manifest-v2","book_id":"b","structure":"delimited","toc_unreadable":0,"chapters":[]}`
var m2 BookManifest
if err := json.Unmarshal([]byte(current), &m2); err != nil {
t.Fatal(err)
}
if m2.TOCUnreadable == nil || *m2.TOCUnreadable != 0 {
t.Fatalf("«measured, none» must decode to a present zero, not to absence: %v", m2.TOCUnreadable)
}
}