344 lines
13 KiB
Go
344 lines
13 KiB
Go
package bookfile
|
||
|
||
import (
|
||
"archive/zip"
|
||
"bytes"
|
||
"encoding/xml"
|
||
"io"
|
||
"regexp"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
)
|
||
|
||
// bookfile_test.go pins the writer's two load-bearing properties — determinism and a container the
|
||
// engine's own reader round-trips — and the EPUB 3 shape epubcheck was run against (the stand proof).
|
||
|
||
func sampleBook() *Book {
|
||
return &Book{
|
||
Identifier: "urn:textmachine:book:test",
|
||
Title: "Тест & <проба>",
|
||
Language: "ru",
|
||
Modified: time.Date(2026, 8, 24, 1, 15, 0, 0, time.UTC),
|
||
Chapters: []Chapter{
|
||
{Title: "Глава 1", Paragraphs: []string{"Первый абзац.", "Второй — с «кавычками» & <тегом>."}},
|
||
{Title: "2", Paragraphs: []string{"Одна строка."}},
|
||
},
|
||
}
|
||
}
|
||
|
||
func epubBytes(t *testing.T, b *Book) []byte {
|
||
t.Helper()
|
||
var buf bytes.Buffer
|
||
if err := WriteEPUB(&buf, b); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
return buf.Bytes()
|
||
}
|
||
|
||
func zipEntries(t *testing.T, data []byte) (*zip.Reader, map[string]string) {
|
||
t.Helper()
|
||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
out := map[string]string{}
|
||
for _, f := range zr.File {
|
||
rc, err := f.Open()
|
||
if err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
b, _ := io.ReadAll(rc)
|
||
rc.Close()
|
||
out[f.Name] = string(b)
|
||
}
|
||
return zr, out
|
||
}
|
||
|
||
func TestContainerIsOCFConformant(t *testing.T) {
|
||
data := epubBytes(t, sampleBook())
|
||
zr, entries := zipEntries(t, data)
|
||
// The mimetype's LOCAL header carries its CRC and sizes itself: general-purpose flag bit 3 (a data
|
||
// descriptor after the entry) is clear, and the extra-field length is zero — the plain form a reader
|
||
// that identifies the file by its first bytes expects. Bytes 6–7 are the flags, 28–29 the extra length.
|
||
if flags := uint16(data[6]) | uint16(data[7])<<8; flags&0x8 != 0 {
|
||
t.Errorf("mimetype local header has the data-descriptor flag set (flags=%#x)", flags)
|
||
}
|
||
if extra := uint16(data[28]) | uint16(data[29])<<8; extra != 0 {
|
||
t.Errorf("mimetype local header carries an extra field of %d bytes", extra)
|
||
}
|
||
if string(data[30:38]) != "mimetype" || string(data[38:58]) != "application/epub+zip" {
|
||
t.Errorf("the mimetype entry is not at the fixed OCF offsets: %q", data[30:58])
|
||
}
|
||
if zr.File[0].Name != "mimetype" || zr.File[0].Method != zip.Store {
|
||
t.Fatalf("mimetype must be the first, STORED entry; got %q method %d", zr.File[0].Name, zr.File[0].Method)
|
||
}
|
||
if entries["mimetype"] != "application/epub+zip" {
|
||
t.Fatalf("mimetype = %q", entries["mimetype"])
|
||
}
|
||
if !strings.Contains(entries["META-INF/container.xml"], `full-path="OEBPS/content.opf"`) {
|
||
t.Fatalf("container.xml must point at the OPF:\n%s", entries["META-INF/container.xml"])
|
||
}
|
||
for _, f := range zr.File {
|
||
if !f.Modified.IsZero() && f.Modified.Unix() != -2208988800 { // zero MS-DOS time decodes to 1980-00-00 → 1979/1980 epochs vary; a real clock reading is what is forbidden
|
||
if f.Modified.Year() > 1981 {
|
||
t.Errorf("entry %s carries a clock timestamp %v — the file would differ between builds", f.Name, f.Modified)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
type opfDoc struct {
|
||
UniqueID string `xml:"unique-identifier,attr"`
|
||
Metadata struct {
|
||
Identifier struct {
|
||
ID string `xml:"id,attr"`
|
||
Value string `xml:",chardata"`
|
||
} `xml:"identifier"`
|
||
Title string `xml:"title"`
|
||
Language string `xml:"language"`
|
||
Description string `xml:"description"`
|
||
Meta []struct {
|
||
Property string `xml:"property,attr"`
|
||
Value string `xml:",chardata"`
|
||
} `xml:"meta"`
|
||
} `xml:"metadata"`
|
||
Manifest []struct {
|
||
ID string `xml:"id,attr"`
|
||
Href string `xml:"href,attr"`
|
||
MediaType string `xml:"media-type,attr"`
|
||
Properties string `xml:"properties,attr"`
|
||
} `xml:"manifest>item"`
|
||
Spine []struct {
|
||
IDRef string `xml:"idref,attr"`
|
||
} `xml:"spine>itemref"`
|
||
}
|
||
|
||
func parseOPF(t *testing.T, entries map[string]string) opfDoc {
|
||
t.Helper()
|
||
var doc opfDoc
|
||
if err := xml.Unmarshal([]byte(entries["OEBPS/content.opf"]), &doc); err != nil {
|
||
t.Fatalf("OPF does not parse: %v\n%s", err, entries["OEBPS/content.opf"])
|
||
}
|
||
return doc
|
||
}
|
||
|
||
func TestEPUBCarriesTheFiveRequiredThingsAndKeepsNavOutOfTheSpine(t *testing.T) {
|
||
b := sampleBook()
|
||
b.Description = "⚠ 1/2"
|
||
_, entries := zipEntries(t, epubBytes(t, b))
|
||
doc := parseOPF(t, entries)
|
||
if doc.Metadata.Identifier.Value != b.Identifier || doc.Metadata.Identifier.ID != doc.UniqueID {
|
||
t.Errorf("dc:identifier %+v must be the one unique-identifier=%q names", doc.Metadata.Identifier, doc.UniqueID)
|
||
}
|
||
if doc.Metadata.Title != b.Title {
|
||
t.Errorf("dc:title = %q, want %q (escaped in the file, intact when parsed)", doc.Metadata.Title, b.Title)
|
||
}
|
||
padded := sampleBook()
|
||
padded.Title = " T "
|
||
if _, e := zipEntries(t, epubBytes(t, padded)); !strings.Contains(e["OEBPS/content.opf"], "<dc:title>T</dc:title>") {
|
||
t.Errorf("dc:title must be trimmed like the text file's title line:\n%s", e["OEBPS/content.opf"])
|
||
}
|
||
if doc.Metadata.Language != "ru" {
|
||
t.Errorf("dc:language = %q", doc.Metadata.Language)
|
||
}
|
||
if doc.Metadata.Description != b.Description {
|
||
t.Errorf("dc:description = %q, want %q", doc.Metadata.Description, b.Description)
|
||
}
|
||
modified := ""
|
||
for _, m := range doc.Metadata.Meta {
|
||
if m.Property == "dcterms:modified" {
|
||
modified = m.Value
|
||
}
|
||
}
|
||
if modified != "2026-08-24T01:15:00Z" {
|
||
t.Errorf("dcterms:modified = %q, want the CCYY-MM-DDThh:mm:ssZ render of Book.Modified", modified)
|
||
}
|
||
navID := ""
|
||
for _, it := range doc.Manifest {
|
||
if it.Properties == "nav" {
|
||
navID = it.ID
|
||
if _, ok := entries["OEBPS/"+it.Href]; !ok {
|
||
t.Errorf("the nav item points at %q, which is not in the zip", it.Href)
|
||
}
|
||
}
|
||
}
|
||
if navID == "" {
|
||
t.Fatal("no manifest item carries properties=\"nav\" — EPUB 3 requires a navigation document")
|
||
}
|
||
if len(doc.Spine) != len(b.Chapters) {
|
||
t.Fatalf("spine has %d itemrefs, want one per chapter (%d) and nothing else", len(doc.Spine), len(b.Chapters))
|
||
}
|
||
for _, ref := range doc.Spine {
|
||
if ref.IDRef == navID {
|
||
t.Fatal("the nav document is in the spine — the engine's reader would read it back as a chapter")
|
||
}
|
||
}
|
||
nav := entries["OEBPS/nav.xhtml"]
|
||
for _, ch := range b.Chapters {
|
||
if !strings.Contains(nav, ">"+xmlText(ch.Title)+"</a>") {
|
||
t.Errorf("nav lacks an entry titled %q:\n%s", ch.Title, nav)
|
||
}
|
||
}
|
||
}
|
||
|
||
// blocksOf pulls the text of every <h1>/<p> of a chapter document, in order — what the engine's reader
|
||
// turns into paragraphs.
|
||
var blockRE = regexp.MustCompile(`(?s)<(h1|p)>(.*?)</(?:h1|p)>`)
|
||
|
||
func blocksOf(doc string) []string {
|
||
var out []string
|
||
for _, m := range blockRE.FindAllStringSubmatch(doc, -1) {
|
||
out = append(out, m[2])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func TestChapterDocumentsCarryExactlyBlocks(t *testing.T) {
|
||
b := sampleBook()
|
||
b.Notice = []string{"⚠ 1/2", "⚠ +3"}
|
||
_, entries := zipEntries(t, epubBytes(t, b))
|
||
for i := range b.Chapters {
|
||
got := blocksOf(entries["OEBPS/"+chapterEntry(i)])
|
||
want := b.Blocks(i)
|
||
if len(got) != len(want) {
|
||
t.Fatalf("chapter %d: %d blocks in the document, Blocks() says %d\n%v\n%v", i+1, len(got), len(want), got, want)
|
||
}
|
||
for j := range want {
|
||
if got[j] != xmlText(want[j]) {
|
||
t.Errorf("chapter %d block %d = %q, want %q", i+1, j, got[j], xmlText(want[j]))
|
||
}
|
||
}
|
||
}
|
||
// The notice is in the FIRST document only, ahead of its title.
|
||
if first := blocksOf(entries["OEBPS/ch1.xhtml"]); first[0] != "⚠ 1/2" || first[2] != xmlText("Глава 1") {
|
||
t.Errorf("first document must open with the notice, then the title: %v", first)
|
||
}
|
||
if second := blocksOf(entries["OEBPS/ch2.xhtml"]); second[0] != "2" {
|
||
t.Errorf("second document must open with its title, no notice: %v", second)
|
||
}
|
||
}
|
||
|
||
func TestEPUBAndTXTAreDeterministic(t *testing.T) {
|
||
b := sampleBook()
|
||
first, second := epubBytes(t, b), epubBytes(t, b)
|
||
if !bytes.Equal(first, second) {
|
||
t.Fatal("two EPUB builds of one Book differ")
|
||
}
|
||
var t1, t2 bytes.Buffer
|
||
if err := WriteTXT(&t1, b); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if err := WriteTXT(&t2, b); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if !bytes.Equal(t1.Bytes(), t2.Bytes()) {
|
||
t.Fatal("two TXT builds of one Book differ")
|
||
}
|
||
}
|
||
|
||
func TestTextIsEscapedAndControlCharactersAreDropped(t *testing.T) {
|
||
b := sampleBook()
|
||
b.Chapters[0].Paragraphs = []string{"a < b & c > d \"q\"", "bad\x00\x0bchars\x7f kept"}
|
||
if got := CleanText("bad\x00\x0bchars\x7f kept\xff"); got != "badchars\x7f kept\uFFFD" {
|
||
t.Errorf("CleanText = %q", got)
|
||
}
|
||
_, entries := zipEntries(t, epubBytes(t, b))
|
||
doc := entries["OEBPS/ch1.xhtml"]
|
||
for _, want := range []string{"a < b & c > d "q"", "badchars\x7f kept"} {
|
||
if !strings.Contains(doc, want) {
|
||
t.Errorf("document lacks %q:\n%s", want, doc)
|
||
}
|
||
}
|
||
if strings.ContainsAny(doc, "\x00\x0b") {
|
||
t.Error("an XML-illegal control character reached the document")
|
||
}
|
||
if !strings.Contains(entries["OEBPS/content.opf"], "<dc:title>Тест & <проба></dc:title>") {
|
||
t.Errorf("title not escaped:\n%s", entries["OEBPS/content.opf"])
|
||
}
|
||
}
|
||
|
||
func TestWritersRefuseAnInvalidBook(t *testing.T) {
|
||
cases := map[string]func(*Book){
|
||
"no chapters": func(b *Book) { b.Chapters = nil },
|
||
"empty title": func(b *Book) { b.Chapters[1].Title = " " },
|
||
// Judged after CleanText: a title of control characters only would render as an empty <h1> and
|
||
// an empty nav link — the invalid EPUB validate exists to refuse, not something to write.
|
||
"control-only chapter title": func(b *Book) { b.Chapters[1].Title = "\x01\x02 \x1f" },
|
||
"control-only book title": func(b *Book) { b.Title = "\x03\x0b" },
|
||
"zero modified": func(b *Book) { b.Modified = time.Time{} },
|
||
"no language": func(b *Book) { b.Language = "" },
|
||
"no identifier": func(b *Book) { b.Identifier = "" },
|
||
"empty book title": func(b *Book) { b.Title = "" },
|
||
}
|
||
for name, mutate := range cases {
|
||
b := sampleBook()
|
||
mutate(b)
|
||
if err := WriteEPUB(io.Discard, b); err == nil {
|
||
t.Errorf("%s: WriteEPUB must refuse", name)
|
||
}
|
||
if err := WriteTXT(io.Discard, b); err == nil {
|
||
t.Errorf("%s: WriteTXT must refuse", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestTXTShape(t *testing.T) {
|
||
b := sampleBook()
|
||
b.Notice = []string{"⚠ 1/2"}
|
||
var buf bytes.Buffer
|
||
if err := WriteTXT(&buf, b); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
want := "Тест & <проба>\n\n⚠ 1/2\n\n\nГлава 1\n\nПервый абзац.\n\nВторой — с «кавычками» & <тегом>.\n\n\n2\n\nОдна строка.\n"
|
||
if buf.String() != want {
|
||
t.Fatalf("txt =\n%q\nwant\n%q", buf.String(), want)
|
||
}
|
||
for _, banned := range []string{"=== CHAPTER", "TEXT MISSING", "pending", "flagged"} {
|
||
if strings.Contains(buf.String(), banned) {
|
||
t.Errorf("operator vocabulary %q in the reader's text", banned)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTitlesAreCleanedTheSameInBothFormats pins format parity on the strings the assembler does NOT
|
||
// pre-clean — the book title, the chapter titles, the notice: the EPUB drops a control character through
|
||
// xmlText, and the text file must drop it the same way (acceptance finding: TXT wrote titles raw).
|
||
func TestTitlesAreCleanedTheSameInBothFormats(t *testing.T) {
|
||
b := sampleBook()
|
||
b.Title = "Заглавие\x01книги"
|
||
b.Notice = []string{"⚠\x03 1/2"}
|
||
b.Chapters[0].Title = "Гла\x02ва 1"
|
||
b.Chapters[0].Paragraphs = []string{"Абзац\x1fс контролем."}
|
||
_, entries := zipEntries(t, epubBytes(t, b))
|
||
doc := parseOPF(t, entries)
|
||
if doc.Metadata.Title != "Заглавиекниги" {
|
||
t.Errorf("EPUB dc:title = %q", doc.Metadata.Title)
|
||
}
|
||
if blocks := blocksOf(entries["OEBPS/ch1.xhtml"]); len(blocks) < 3 || blocks[0] != "⚠ 1/2" || blocks[1] != "Глава 1" || blocks[2] != "Абзацс контролем." {
|
||
t.Errorf("EPUB blocks = %q", blocks)
|
||
}
|
||
var txt bytes.Buffer
|
||
if err := WriteTXT(&txt, b); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
want := "Заглавиекниги\n\n⚠ 1/2\n\n\nГлава 1\n\nАбзацс контролем.\n\n\n2\n\nОдна строка.\n"
|
||
if txt.String() != want {
|
||
t.Errorf("TXT =\n%q\nwant\n%q", txt.String(), want)
|
||
}
|
||
for name, out := range map[string]string{"epub opf": entries["OEBPS/content.opf"], "epub nav": entries["OEBPS/nav.xhtml"], "epub ch1": entries["OEBPS/ch1.xhtml"], "txt": txt.String()} {
|
||
if strings.ContainsAny(out, "\x01\x02\x03\x1f") {
|
||
t.Errorf("%s carries a control character the other format drops", name)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestOneChapterBook(t *testing.T) {
|
||
b := &Book{Identifier: "urn:x", Title: "T", Language: "en", Modified: time.Unix(0, 0).UTC(),
|
||
Chapters: []Chapter{{Title: "1"}}} // a chapter with a title and no paragraphs is a legal, empty chapter
|
||
_, entries := zipEntries(t, epubBytes(t, b))
|
||
doc := parseOPF(t, entries)
|
||
if len(doc.Spine) != 1 || len(blocksOf(entries["OEBPS/ch1.xhtml"])) != 1 {
|
||
t.Fatalf("one-chapter book: spine %d, blocks %v", len(doc.Spine), blocksOf(entries["OEBPS/ch1.xhtml"]))
|
||
}
|
||
}
|