142 lines
6.7 KiB
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)
|
|
}
|
|
}
|