package chunk import ( "os" "path/filepath" "strings" "testing" "textmachine/backend/internal/chunk/chunktest" "textmachine/backend/internal/lang" "textmachine/backend/internal/text" ) // ch is a content document with enough prose to be a chapter. func ch(id, href, body string) chunktest.Chapter { return chunktest.Chapter{ID: id, Href: href, Body: "

" + body + "

"} } func refs(ids ...string) []chunktest.SpineRef { out := make([]chunktest.SpineRef, 0, len(ids)) for _, id := range ids { out = append(out, chunktest.SpineRef{ID: id}) } return out } // TestEPUBNavDrawsChaptersNotTheSpine is the pack's central claim: the spine is reading ORDER and the nav is // the book's own statement about chapters. Six documents, three nav targets ⇒ three chapters, and the // documents between targets are FOLDED IN rather than becoming chapters of their own. func TestEPUBNavDrawsChaptersNotTheSpine(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ ch("c1", "c1.xhtml", "one a"), ch("c1b", "c1b.xhtml", "one b"), ch("c2", "c2.xhtml", "two a"), ch("c2b", "c2b.xhtml", "two b"), ch("c3", "c3.xhtml", "three a"), ch("c3b", "c3b.xhtml", "three b"), }, Spine: refs("c1", "c1b", "c2", "c2b", "c3", "c3b"), Nav: &chunktest.Nav{TOC: []string{"c1.xhtml", "c2.xhtml", "c3.xhtml"}}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if len(doc.Chapters) != 3 { t.Fatalf("chapters = %d, want 3 (the nav named three)", len(doc.Chapters)) } if doc.Structure != StructureDeclared { t.Fatalf("structure = %q, want %q — a nav IS the format declaring its chapters", doc.Structure, StructureDeclared) } // ⛔ No text was dropped: every document's prose survives, in its own chapter. for i, want := range []string{"one a", "one b"} { if !strings.Contains(doc.Chapters[0], want) { t.Fatalf("chapter 1 lost %q (part %d): %q", want, i, doc.Chapters[0]) } } if doc.DocumentsAttached != 3 { t.Fatalf("documents attached = %d, want 3 (one folded into each chapter)", doc.DocumentsAttached) } } // TestEPUB2NCXDrawsChaptersWhenThereIsNoNav is the EPUB 2 half. An EPUB 2 has no navigation document at all, // so a reader that knows only about nav falls straight through to the spine and reports the wrong chapters. func TestEPUB2NCXDrawsChaptersWhenThereIsNoNav(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ ch("c1", "c1.xhtml", "one a"), ch("c1b", "c1b.xhtml", "one b"), ch("c2", "c2.xhtml", "two a"), }, Spine: refs("c1", "c1b", "c2"), NCX: []string{"c1.xhtml", "c2.xhtml"}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if len(doc.Chapters) != 2 || doc.Structure != StructureDeclared { t.Fatalf("chapters = %d structure = %q, want 2 / %q", len(doc.Chapters), doc.Structure, StructureDeclared) } if !strings.Contains(doc.Chapters[0], "one b") { t.Fatalf("the document between NCX targets was lost: %q", doc.Chapters[0]) } } // TestEPUBSpineIsDelimitedNotDeclared pins the vocabulary fix: with no nav and no NCX the engine still cuts // on the spine, but it no longer calls that the format declaring its chapters. func TestEPUBSpineIsDelimitedNotDeclared(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ch("c1", "c1.xhtml", "one"), ch("c2", "c2.xhtml", "two")}, Spine: refs("c1", "c2"), }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if doc.Structure != StructureDelimited { t.Fatalf("structure = %q, want %q — a spine is reading order, not a chapter list", doc.Structure, StructureDelimited) } if len(doc.Chapters) != 2 { t.Fatalf("chapters = %d, want 2", len(doc.Chapters)) } } // TestEPUB3LandmarksExcludeTocAndCover: EPUB 3 declares service pages inside the navigation document. func TestEPUB3LandmarksExcludeTocAndCover(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ ch("cov", "cover.xhtml", "COVERPAGE"), ch("toc", "toc.xhtml", "TOCPAGE"), ch("c1", "c1.xhtml", "one"), ch("c2", "c2.xhtml", "two"), }, Spine: refs("cov", "toc", "c1", "c2"), Nav: &chunktest.Nav{ TOC: []string{"c1.xhtml", "c2.xhtml"}, Landmarks: map[string]string{"cover.xhtml": "cover", "toc.xhtml": "toc"}, InSpine: true, }, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if len(doc.Chapters) != 2 { t.Fatalf("chapters = %d, want 2", len(doc.Chapters)) } for _, bad := range []string{"COVERPAGE", "TOCPAGE"} { for i, c := range doc.Chapters { if strings.Contains(c, bad) { t.Fatalf("service page %q leaked into chapter %d", bad, i+1) } } } // The nav document itself is in the spine here and is excluded on its `properties`, so three pages went. if len(doc.Excluded) != 3 { t.Fatalf("documents excluded = %d, want 3 (cover + toc + the nav document)", len(doc.Excluded)) } } // TestEPUB2GuideExcludesTocAndCover is the case a landmarks-only reader gets wrong. An EPUB 2 has no // navigation document, so the ONLY declaration of "this page is the toc" is the OPF . Miss it and a // text table of contents is glued to chapter one — the mass case, not a corner one. func TestEPUB2GuideExcludesTocAndCover(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ ch("cov", "cover.xhtml", "COVERPAGE"), ch("toc", "toc.xhtml", "TOCPAGE"), ch("c1", "c1.xhtml", "one"), ch("c2", "c2.xhtml", "two"), }, Spine: refs("cov", "toc", "c1", "c2"), NCX: []string{"c1.xhtml", "c2.xhtml"}, Guide: []chunktest.GuideRef{{Type: "cover", Href: "cover.xhtml"}, {Type: "toc", Href: "toc.xhtml"}}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if len(doc.Chapters) != 2 { t.Fatalf("chapters = %d, want 2", len(doc.Chapters)) } if strings.Contains(doc.Chapters[0], "TOCPAGE") { t.Fatal("the EPUB 2 table-of-contents page was glued to chapter one — was not read") } if len(doc.Excluded) != 2 { t.Fatalf("documents excluded = %d, want 2", len(doc.Excluded)) } } // TestEPUBCoverImagePropertyExcludesNothing: `properties="cover-image"` sits on an IMAGE, not on a document. // A reader that greps the attribute for "cover" would drop a real chapter. func TestEPUBCoverImagePropertyExcludesNothing(t *testing.T) { chapters := []chunktest.Chapter{ch("c1", "c1.xhtml", "one"), ch("c2", "c2.xhtml", "two")} chapters[0].Properties = "cover-image" p := chunktest.EPUB{Chapters: chapters, Spine: refs("c1", "c2")}.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if len(doc.Chapters) != 2 || len(doc.Excluded) != 0 { t.Fatalf("chapters = %d excluded = %d, want 2 / 0 — cover-image is not the cover DOCUMENT", len(doc.Chapters), len(doc.Excluded)) } } // TestEPUBNonLinearNeverStartsAChapter: linear="no" marks appendices and footnote pages. They are text and // are kept, but they do not open a chapter even when the nav points at them. func TestEPUBNonLinearNeverStartsAChapter(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ ch("c1", "c1.xhtml", "one"), ch("notes", "notes.xhtml", "FOOTNOTES"), ch("c2", "c2.xhtml", "two"), }, Spine: []chunktest.SpineRef{{ID: "c1"}, {ID: "notes", Linear: "no"}, {ID: "c2"}}, Nav: &chunktest.Nav{TOC: []string{"c1.xhtml", "notes.xhtml", "c2.xhtml"}}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if len(doc.Chapters) != 2 { t.Fatalf("chapters = %d, want 2 — the non-linear page must not open one", len(doc.Chapters)) } if !strings.Contains(doc.Chapters[0], "FOOTNOTES") { t.Fatalf("the non-linear page was DROPPED instead of attached: %q", doc.Chapters[0]) } } // TestEPUBUncoveredBeforeFirstTargetJoinsChapterOne mirrors the txt preamble rule: a title page the nav does // not mention joins chapter one rather than becoming a chapter or vanishing. func TestEPUBUncoveredBeforeFirstTargetJoinsChapterOne(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ ch("title", "title.xhtml", "TITLEPAGE"), ch("c1", "c1.xhtml", "one"), ch("c2", "c2.xhtml", "two"), }, Spine: refs("title", "c1", "c2"), Nav: &chunktest.Nav{TOC: []string{"c1.xhtml", "c2.xhtml"}}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if len(doc.Chapters) != 2 { t.Fatalf("chapters = %d, want 2", len(doc.Chapters)) } if !strings.Contains(doc.Chapters[0], "TITLEPAGE") { t.Fatalf("the uncovered title page was lost: %q", doc.Chapters[0]) } } // TestEPUBNavWithNoResolvableTargetsIsAbsent: a nav pointing at documents that are not in the spine is a // BROKEN nav. It must fall through to the next witness, not produce zero chapters. func TestEPUBNavWithNoResolvableTargetsIsAbsent(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ch("c1", "c1.xhtml", "one"), ch("c2", "c2.xhtml", "two")}, Spine: refs("c1", "c2"), Nav: &chunktest.Nav{TOC: []string{"gone-a.xhtml", "gone-b.xhtml"}}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if len(doc.Chapters) != 2 || doc.Structure != StructureDelimited { t.Fatalf("chapters = %d structure = %q, want 2 / %q", len(doc.Chapters), doc.Structure, StructureDelimited) } if doc.TOCUnresolved != 2 { t.Fatalf("unresolved targets = %d, want 2 — the count is the whole alarm", doc.TOCUnresolved) } } // TestEPUBDuplicateNavTargetsCollapse: several nav entries pointing into ONE document (chapter + its // sections) are one boundary, not several empty ones. func TestEPUBDuplicateNavTargetsCollapse(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ch("c1", "c1.xhtml", "one"), ch("c2", "c2.xhtml", "two")}, Spine: refs("c1", "c2"), Nav: &chunktest.Nav{TOC: []string{"c1.xhtml", "c1.xhtml#s2", "c2.xhtml"}}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if len(doc.Chapters) != 2 { t.Fatalf("chapters = %d, want 2 — a fragment into the same document is the same boundary", len(doc.Chapters)) } if doc.TargetsCollapsed != 1 { t.Fatalf("targets collapsed = %d, want 1", doc.TargetsCollapsed) } // ⛔ AND THE PROVENANCE IS DOWNGRADED. The nav named three chapters; this package hands back two, because // it groups whole documents and does not slice one by its anchors. Reporting that as `declared` would put // the engine's own coarser answer out under the format's name — the very lie the spine used to tell. if doc.Structure != StructureDelimited { t.Fatalf("structure = %q, want %q — the format named more chapters than the cut returned", doc.Structure, StructureDelimited) } } // TestEPUBNavOracleReproducesTheTxtCut is the code oracle the format work is measured by: ground truth with // no hand annotation and no model call. The nav DECLARES where chapters begin; unroll the same book into a // plain txt whose chapters open with source headers, and the txt path must recover the same boundaries. // Two independent readers, one answer. func TestEPUBNavOracleReproducesTheTxtCut(t *testing.T) { bodies := []string{"第一章\n" + strings.Repeat("蛊", 200), "第二章\n" + strings.Repeat("蛊", 200), "第三章\n" + strings.Repeat("蛊", 200)} p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ ch("c1", "c1.xhtml", bodies[0]), ch("c2", "c2.xhtml", bodies[1]), ch("c3", "c3.xhtml", bodies[2]), }, Spine: refs("c1", "c2", "c3"), Nav: &chunktest.Nav{TOC: []string{"c1.xhtml", "c2.xhtml", "c3.xhtml"}}, }.Build(t) fromEPUB, err := ingest(p) if err != nil { t.Fatal(err) } txt := writeTempTXT(t, strings.Join(bodies, "\n")) fromTXT, err := ingest(txt) if err != nil { t.Fatal(err) } if len(fromTXT.Chapters) != len(fromEPUB.Chapters) { t.Fatalf("txt cut %d chapters, the nav declared %d", len(fromTXT.Chapters), len(fromEPUB.Chapters)) } for i := range fromEPUB.Chapters { if fromTXT.Chapters[i] != fromEPUB.Chapters[i] { t.Fatalf("chapter %d differs:\n txt: %.60q\nepub: %.60q", i+1, fromTXT.Chapters[i], fromEPUB.Chapters[i]) } } if fromEPUB.Structure != StructureDeclared || fromTXT.Structure != StructureDetected { t.Fatalf("provenance should differ even where the cut agrees: epub=%q txt=%q", fromEPUB.Structure, fromTXT.Structure) } } // writeTempTXT writes body to a temp .txt and returns its path. func writeTempTXT(t *testing.T, body string) string { t.Helper() p := filepath.Join(t.TempDir(), "src.txt") if err := os.WriteFile(p, []byte(body), 0o644); err != nil { t.Fatal(err) } return p } // TestEPUBNavTargetsFollowSpineOrderNotTocOrder: a table of contents may list its entries in any order (an // appendix first, a foreword last). Boundaries are spine POSITIONS, so a mis-ordered toc must not interleave // the chapters or hand a later document to an earlier chapter. func TestEPUBNavTargetsFollowSpineOrderNotTocOrder(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ ch("c1", "c1.xhtml", "ONE"), ch("c1b", "c1b.xhtml", "ONEB"), ch("c2", "c2.xhtml", "TWO"), ch("c2b", "c2b.xhtml", "TWOB"), }, Spine: refs("c1", "c1b", "c2", "c2b"), Nav: &chunktest.Nav{TOC: []string{"c2.xhtml", "c1.xhtml"}}, // declared out of reading order }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if len(doc.Chapters) != 2 { t.Fatalf("chapters = %d, want 2", len(doc.Chapters)) } if !strings.Contains(doc.Chapters[0], "ONE") || !strings.Contains(doc.Chapters[0], "ONEB") { t.Fatalf("chapter 1 is not the FIRST span of the book: %q", doc.Chapters[0]) } if !strings.Contains(doc.Chapters[1], "TWO") || !strings.Contains(doc.Chapters[1], "TWOB") { t.Fatalf("chapter 2 is not the SECOND span of the book: %q", doc.Chapters[1]) } } // TestEPUBPartiallyUnresolvedNavStillCuts: a scrape whose toc points half at real documents and half at // nothing must use what resolved and COUNT the rest, not fall back and not fail. func TestEPUBPartiallyUnresolvedNavStillCuts(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ch("c1", "c1.xhtml", "one"), ch("c2", "c2.xhtml", "two")}, Spine: refs("c1", "c2"), Nav: &chunktest.Nav{TOC: []string{"c1.xhtml", "gone.xhtml", "c2.xhtml"}}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if len(doc.Chapters) != 2 || doc.Structure != StructureDeclared { t.Fatalf("chapters = %d structure = %q, want 2 / %q", len(doc.Chapters), doc.Structure, StructureDeclared) } if doc.TOCUnresolved != 1 { t.Fatalf("unresolved = %d, want 1", doc.TOCUnresolved) } } // TestEPUBAllServiceSpineFailsLoud: a book whose every spine document is a declared service page has no text // to translate. That must be a loud refusal, never an empty book that costs money to discover. func TestEPUBAllServiceSpineFailsLoud(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ch("cov", "cover.xhtml", "COVER"), ch("toc", "toc.xhtml", "TOC")}, Spine: refs("cov", "toc"), NCX: []string{"cover.xhtml"}, Guide: []chunktest.GuideRef{{Type: "cover", Href: "cover.xhtml"}, {Type: "toc", Href: "toc.xhtml"}}, }.Build(t) if _, err := ingest(p); err == nil { t.Fatal("a spine of nothing but service pages must fail loud, not return an empty book") } } // TestEPUBSingleDocumentChapterIsByteIdenticalToTheOldPath guards the refactor itself: grouping now JOINS a // chapter's documents, and the one-document case must go through that join unchanged — otherwise every // existing EPUB book silently re-cuts and re-pays. func TestEPUBSingleDocumentChapterIsByteIdenticalToTheOldPath(t *testing.T) { body := "

Первый абзац.

Второй абзац.

" p := chunktest.EPUB{ Chapters: []chunktest.Chapter{{ID: "c1", Href: "c1.xhtml", Body: body}, {ID: "c2", Href: "c2.xhtml", Body: body}}, Spine: refs("c1", "c2"), }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } // The expectation is COMPUTED from the primitives, not guessed: whatever extractXHTML produces for this // document, a one-document chapter must be exactly that, normalized — the join must add nothing. raw, _, err := extractXHTML([]byte(` c ` + body + ``)) if err != nil { t.Fatal(err) } want := text.NormalizeSource(raw) for i, c := range doc.Chapters { if c != want { t.Fatalf("chapter %d = %q, want %q — the join altered a single-document chapter", i+1, c, want) } } } // TestANewLanguageCutsFromItsDataFileAlone answers the project's default review question end to end: a pair // that is NOT in this repository ships one data file and gets a real chapter cut, with no Go change. // // Korean proves both halves. With no file it falls to the embedded CJK default, whose marker 第 never appears // in a Korean book, so the text stays ONE chapter. With ko/structure.txt naming 제 and 장 — the same binary, // the same text — it cuts into three and reports `detected`. func TestANewLanguageCutsFromItsDataFileAlone(t *testing.T) { prose := strings.Repeat("가", 200) body := "제1장 시작\n" + prose + "\n제2장 전개\n" + prose + "\n제3장 결말\n" + prose src := writeTempTXT(t, body) bare, err := lang.LoadSourceStructure("", "ko") if err != nil { t.Fatal(err) } before, err := IngestEncoded(src, "", "ko", bare) if err != nil { t.Fatal(err) } if len(before.Chapters) != 1 { t.Fatalf("without its own grammar a Korean book must stay one chapter, got %d", len(before.Chapters)) } root := t.TempDir() if err := os.MkdirAll(filepath.Join(root, "ko"), 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(root, "ko", "structure.txt"), []byte("marker\t제\nunits\t장\n"), 0o644); err != nil { t.Fatal(err) } st, err := lang.LoadSourceStructure(root, "ko") if err != nil { t.Fatal(err) } after, err := IngestEncoded(src, "", "ko", st) if err != nil { t.Fatal(err) } if len(after.Chapters) != 3 { t.Fatalf("chapters = %d, want 3 — the language's own grammar did not cut", len(after.Chapters)) } if after.Structure != StructureDetected { t.Fatalf("structure = %q, want %q", after.Structure, StructureDetected) } if after.Titles[1] != "제2장 전개" { t.Fatalf("title_raw of chapter 2 = %q, want %q", after.Titles[1], "제2장 전개") } } // TestEPUBServiceRoleWithAFragmentDoesNotDeleteTheDocument is the text-loss guard. // // ⛔ `` says the table of contents BEGINS at that point — inside a // document that may hold prose before and after it. Reading it as "this whole file is the toc" deletes book // text while the chapter count stays plausible and the provenance stays a confident `declared`: nothing // looks wrong. The form is not hypothetical — the owner's Kristoff EPUB carries exactly it // (`type="toc" href="index_split_124.html#filepos2096584"`), and there the split file happened to be pure // front matter. One calibre split the other way and it is a chapter. func TestEPUBServiceRoleWithAFragmentDoesNotDeleteTheDocument(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ ch("c1", "c1.xhtml", "PROSEONE"), ch("c2", "c2.xhtml", "PROSETWO"), ch("c3", "c3.xhtml", "PROSETHREE"), }, Spine: refs("c1", "c2", "c3"), NCX: []string{"c1.xhtml", "c2.xhtml", "c3.xhtml"}, Guide: []chunktest.GuideRef{{Type: "toc", Href: "c2.xhtml#tocpos"}}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } joined := strings.Join(doc.Chapters, "\x00") for _, want := range []string{"PROSEONE", "PROSETWO", "PROSETHREE"} { if !strings.Contains(joined, want) { t.Fatalf("%s was deleted: a role declared at a POINT inside a document excluded the whole file", want) } } if len(doc.Excluded) != 0 { t.Fatalf("documents excluded = %d, want 0 — the declaration named a place, not a document", len(doc.Excluded)) } } // TestEPUBWholeDocumentServiceRoleStillExcludes is the other half: without a fragment the declaration IS // about the document, and the page must still go. func TestEPUBWholeDocumentServiceRoleStillExcludes(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ch("toc", "toc.xhtml", "TOCPAGE"), ch("c1", "c1.xhtml", "PROSE")}, Spine: refs("toc", "c1"), NCX: []string{"c1.xhtml"}, Guide: []chunktest.GuideRef{{Type: "toc", Href: "toc.xhtml"}}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if strings.Contains(strings.Join(doc.Chapters, ""), "TOCPAGE") { t.Fatal("a whole-document toc declaration must still exclude the page") } if len(doc.Excluded) != 1 { t.Fatalf("documents excluded = %d, want 1", len(doc.Excluded)) } } // TestEPUBOrdinaryBookDoesNotReportABrokenTOC: a table of contents that lists the book's own cover resolves // perfectly — the cover is right there in the spine — it is simply not a chapter boundary. Counting that as // "the toc named nothing" makes every ordinary EPUB 2 report a broken table, and an alarm that fires on the // normal case is one nobody reads. func TestEPUBOrdinaryBookDoesNotReportABrokenTOC(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ ch("cov", "cover.xhtml", "COVER"), ch("c1", "c1.xhtml", "one"), ch("c2", "c2.xhtml", "two"), }, Spine: refs("cov", "c1", "c2"), NCX: []string{"cover.xhtml", "c1.xhtml", "c2.xhtml"}, Guide: []chunktest.GuideRef{{Type: "cover", Href: "cover.xhtml"}}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if doc.TOCUnresolved != 0 { t.Fatalf("unresolved = %d, want 0 — every target named a document this book has", doc.TOCUnresolved) } if len(doc.Excluded) != 1 { t.Fatalf("documents excluded = %d, want 1 — the cover is counted ONCE, as an exclusion", len(doc.Excluded)) } if len(doc.Chapters) != 2 { t.Fatalf("chapters = %d, want 2", len(doc.Chapters)) } } // TestEPUBDanglingNavCountSurvivesTheFallToNCX: a nav whose targets all dangle is replaced by the NCX, and // how much of it dangled is still a fact about the book. Overwriting the resolved table wholesale threw that // count away with the table. func TestEPUBDanglingNavCountSurvivesTheFallToNCX(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ch("c1", "c1.xhtml", "one"), ch("c2", "c2.xhtml", "two")}, Spine: refs("c1", "c2"), Nav: &chunktest.Nav{TOC: []string{"gone-a.xhtml", "gone-b.xhtml"}}, NCX: []string{"c1.xhtml", "c2.xhtml"}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if doc.Structure != StructureDeclared || len(doc.Chapters) != 2 { t.Fatalf("structure = %q chapters = %d, want %q / 2 — the NCX must take over", doc.Structure, len(doc.Chapters), StructureDeclared) } if doc.TOCUnresolved != 2 { t.Fatalf("unresolved = %d, want 2 — the broken nav's dangling targets are still a fact", doc.TOCUnresolved) } } // TestEPUBTitleRawComesFromTheNavLabel: the EPUB half of title_raw had no test at all — removing the label // collection entirely left every chapter with an empty title and the battery green. func TestEPUBTitleRawComesFromTheNavLabel(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ ch("c1", "c1.xhtml", "one"), ch("c1b", "c1b.xhtml", "one more"), ch("c2", "c2.xhtml", "two"), }, Spine: refs("c1", "c1b", "c2"), Nav: &chunktest.Nav{TOC: []string{"c1.xhtml", "c2.xhtml"}}, NCX: []string{"c1.xhtml", "c2.xhtml"}, // present but LOSING: nav takes precedence }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } want := []string{"NAV c1.xhtml", "NAV c2.xhtml"} if len(doc.Titles) != len(want) { t.Fatalf("titles = %d, want %d", len(doc.Titles), len(want)) } for i, w := range want { if doc.Titles[i] != w { // A wrong SOURCE is the interesting failure: an "NCX …" here means the losing table supplied it. t.Fatalf("title %d = %q, want %q", i+1, doc.Titles[i], w) } } } // TestEPUB2TitleRawComesFromTheNCXLabel is the EPUB 2 half, where navLabel lives in the NCX. func TestEPUB2TitleRawComesFromTheNCXLabel(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ch("c1", "c1.xhtml", "one"), ch("c2", "c2.xhtml", "two")}, Spine: refs("c1", "c2"), NCX: []string{"c1.xhtml", "c2.xhtml"}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } want := []string{"NCX c1.xhtml", "NCX c2.xhtml"} for i, w := range want { if doc.Titles[i] != w { t.Fatalf("title %d = %q, want %q", i+1, doc.Titles[i], w) } } } // TestEPUBTitleRawFollowsTheChapterOpener: when documents are folded in AHEAD of a boundary, the chapter's // title is the boundary's, not the first document's. The opener is stored rather than derived precisely // because "first element of the group" is the wrong answer exactly here. func TestEPUBTitleRawFollowsTheChapterOpener(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ ch("title", "title.xhtml", "TITLEPAGE"), ch("c1", "c1.xhtml", "one"), ch("c2", "c2.xhtml", "two"), }, Spine: refs("title", "c1", "c2"), Nav: &chunktest.Nav{TOC: []string{"c1.xhtml", "c2.xhtml"}}, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if doc.Titles[0] != "NAV c1.xhtml" { t.Fatalf("chapter 1 title = %q — it must name the BOUNDARY, not the title page folded in ahead of it", doc.Titles[0]) } } // TestEPUB3LandmarkWithAFragmentDoesNotDeleteTheDocument is the EPUB 3 half of the same rule the guide half // keeps: a landmark naming a POINT inside a document marks a place, not the document. func TestEPUB3LandmarkWithAFragmentDoesNotDeleteTheDocument(t *testing.T) { p := chunktest.EPUB{ Chapters: []chunktest.Chapter{ch("c1", "c1.xhtml", "PROSEONE"), ch("c2", "c2.xhtml", "PROSETWO")}, Spine: refs("c1", "c2"), Nav: &chunktest.Nav{ TOC: []string{"c1.xhtml", "c2.xhtml"}, Landmarks: map[string]string{"c1.xhtml#tocstart": "toc"}, }, }.Build(t) doc, err := ingest(p) if err != nil { t.Fatal(err) } if !strings.Contains(strings.Join(doc.Chapters, ""), "PROSEONE") { t.Fatal("a landmark at a POINT inside a document excluded the whole file and deleted its prose") } if len(doc.Excluded) != 0 { t.Fatalf("documents excluded = %d, want 0", len(doc.Excluded)) } if doc.ServiceRolesInsideDocuments != 1 { t.Fatalf("service roles inside documents = %d, want 1 — a refusal must be counted, not silent", doc.ServiceRolesInsideDocuments) } }