package sync import ( "archive/zip" "os" "strings" "testing" "golang.org/x/net/html" ) func TestParseCREXPointer(t *testing.T) { tests := []struct { input string wantFrag int wantPath int wantChar int }{ {"/body/DocFragment[0]/body/div[4]/p[38]/text().541", 0, 2, 541}, {"/body/DocFragment[2]/body/div/p[5]/text()[2].16", 2, 2, 16}, {"/body/DocFragment[1]/body", 1, 0, 0}, {"/body/DocFragment[5]/body/div[3]/p[28]", 5, 2, 0}, } for _, tt := range tests { xp, err := ParseCREXPointer(tt.input) if err != nil { t.Errorf("ParseCREXPointer(%q) error: %v", tt.input, err) continue } if xp.FragmentIndex != tt.wantFrag { t.Errorf("FragmentIndex = %d, want %d", xp.FragmentIndex, tt.wantFrag) } if len(xp.ElementPath) != tt.wantPath { t.Errorf("len(ElementPath) = %d, want %d (got %+v)", len(xp.ElementPath), tt.wantPath, xp.ElementPath) } if xp.CharOffset != tt.wantChar { t.Errorf("CharOffset = %d, want %d", xp.CharOffset, tt.wantChar) } } } func TestParseCREXPointerInvalid(t *testing.T) { _, err := ParseCREXPointer("epubcfi(/6/4!/4/2/1:0)") if err == nil { t.Error("expected error for standard epubcfi") } _, err = ParseCREXPointer("") if err == nil { t.Error("expected error for empty string") } } func TestIsCREXPointer(t *testing.T) { if !IsCREXPointer("/body/DocFragment[0]/body/div/p") { t.Error("should recognize CRE XPointer") } if !IsCREXPointer("#_doc_fragment_5_ link2HCH0002") { t.Error("should recognize CRE fragment ID") } if IsCREXPointer("epubcfi(/6/4!/4/2/1:0)") { t.Error("should not recognize standard epubcfi as CRE") } } func TestIsCREFragmentID(t *testing.T) { if !IsCREFragmentID("#_doc_fragment_5_ link2HCH0002") { t.Error("should recognize fragment ID") } if IsCREFragmentID("/body/DocFragment[2]/body") { t.Error("should not recognize XPointer as fragment ID") } } func TestParseCREFragmentID(t *testing.T) { tests := []struct { input string wantSpine int wantAnchor string }{ {"#_doc_fragment_5_ link2HCH0002", 5, "link2HCH0002"}, {"#_doc_fragment_0_", 0, ""}, {"#_doc_fragment_12_someid123", 12, "someid123"}, } for _, tt := range tests { frag, err := ParseCREFragmentID(tt.input) if err != nil { t.Errorf("ParseCREFragmentID(%q) error: %v", tt.input, err) continue } if frag.SpineIndex != tt.wantSpine { t.Errorf("SpineIndex = %d, want %d", frag.SpineIndex, tt.wantSpine) } if frag.Anchor != tt.wantAnchor { t.Errorf("Anchor = %q, want %q", frag.Anchor, tt.wantAnchor) } } } func TestParseCREFragmentIDInvalid(t *testing.T) { _, err := ParseCREFragmentID("/body/DocFragment[2]/body") if err == nil { t.Error("expected error for XPointer input") } _, err = ParseCREFragmentID("#_doc_fragment_") if err == nil { t.Error("expected error for missing index") } } func TestIsStandardEPUBCFI(t *testing.T) { if !IsStandardEPUBCFI("epubcfi(/6/4!/4/2/1:0)") { t.Error("should recognize standard epubcfi") } if IsStandardEPUBCFI("/body/DocFragment[0]/body") { t.Error("should not recognize CRE as standard") } } // writeTestEPUB builds a minimal, deterministic EPUB in a temp dir so the // conversion tests exercise the real zip→OPF→spine→document pipeline // without depending on books in a particular machine's uploads/ tree. // // Spine: doc1..doc6. doc2 carries the Dashwood sentence used for exact and // text-search anchoring; doc6 has an id anchor for fragment-ID conversion. func writeTestEPUB(t *testing.T) string { t.Helper() type spineDoc struct { name string body string } docs := []spineDoc{ {"doc1.xhtml", "

Chapter one opening page.

"}, {"doc2.xhtml", "

The family of Dashwood had long been settled in Sussex.

Their estate was large, and their residence was at Norland Park.

"}, {"doc3.xhtml", "

Chapter three contents.

"}, {"doc4.xhtml", "

Chapter four contents.

"}, {"doc5.xhtml", "

Chapter five contents.

"}, {"doc6.xhtml", "

He was neither fit to be a husband nor a father.

"}, } containerXML := ` ` manifest := "" spineRefs := "" for _, d := range docs { id := d.name[:len(d.name)-len(".xhtml")] manifest += " \n" spineRefs += " \n" } opf := ` test-bookhoard-fixture Fixture ` + manifest + ` ` + spineRefs + ` ` path := t.TempDir() + "/fixture.epub" f, err := os.Create(path) if err != nil { t.Fatal(err) } defer f.Close() zw := zip.NewWriter(f) write := func(name, content string) { w, err := zw.Create(name) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte(content)); err != nil { t.Fatal(err) } } write("META-INF/container.xml", containerXML) write("OEBPS/content.opf", opf) for _, d := range docs { write("OEBPS/"+d.name, "\n"+d.body+"\n") } if err := zw.Close(); err != nil { t.Fatal(err) } return path } const fixtureSentence = "The family of Dashwood had long been settled in Sussex." func TestConvertXPointerToCFI(t *testing.T) { c := NewCFIConverter(writeTestEPUB(t)) xp := "/body/DocFragment[2]/body/div[1]/p[1]/text().10" result, err := c.ConvertCREToStandard(xp, 0.05, "") if err != nil { t.Fatalf("ConvertCREToStandard error: %v", err) } t.Logf("Input: %s", xp) t.Logf("EPUBCFI: %s", result.EPUBCFI) t.Logf("Precision: %s", result.Precision) if result.Precision == "percentage" { t.Error("expected better than percentage precision") } if result.EPUBCFI == "" { t.Error("expected non-empty epubcfi") } } func TestConvertFragmentID(t *testing.T) { c := NewCFIConverter(writeTestEPUB(t)) frag := "#_doc_fragment_5_ link2HCH0002" result, err := c.ConvertCREToStandard(frag, 0.9, "") if err != nil { t.Fatalf("ConvertCREToStandard error: %v", err) } t.Logf("Input: %s", frag) t.Logf("Href: %s", result.Href) t.Logf("Precision: %s", result.Precision) if result.Precision != "element" { t.Errorf("expected element precision, got %s", result.Precision) } if result.Href == "" { t.Error("expected non-empty href") } if !strings.Contains(result.Href, "doc6.xhtml#link2HCH0002") { t.Errorf("expected doc6.xhtml#link2HCH0002 href, got %s", result.Href) } } func TestRoundTripXPointer(t *testing.T) { c := NewCFIConverter(writeTestEPUB(t)) originalXP := "/body/DocFragment[2]/body/div[1]/p[1]/text().10" forward, err := c.ConvertCREToStandard(originalXP, 0.05, "") if err != nil { t.Fatalf("forward conversion error: %v", err) } if forward.EPUBCFI == "" { t.Fatal("forward conversion produced empty epubcfi") } t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI) reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, "") if err != nil { t.Fatalf("reverse conversion error: %v", err) } if reverse.XPointer == "" { t.Fatal("reverse conversion produced empty XPointer") } t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer) if reverse.Precision != "exact" { t.Errorf("expected exact precision, got %s", reverse.Precision) } } func TestRoundTripWithContextText(t *testing.T) { c := NewCFIConverter(writeTestEPUB(t)) originalXP := "/body/DocFragment[2]/body/div[1]/p[2]/text().3" forward, err := c.ConvertCREToStandard(originalXP, 0.06, fixtureSentence) if err != nil { t.Fatalf("forward conversion error: %v", err) } if forward.EPUBCFI == "" { t.Fatal("forward conversion produced empty epubcfi") } t.Logf("Forward: %s → %s", originalXP, forward.EPUBCFI) reverse, err := c.ConvertStandardToCRE(forward.EPUBCFI, forward.Percentage, fixtureSentence) if err != nil { t.Fatalf("reverse conversion error: %v", err) } if reverse.XPointer == "" { t.Fatal("reverse conversion produced empty XPointer") } t.Logf("Reverse: %s → %s", forward.EPUBCFI, reverse.XPointer) if reverse.Precision != "exact" { t.Errorf("expected exact precision, got %s", reverse.Precision) } } func TestReverseTextSearchFallback(t *testing.T) { c := NewCFIConverter(writeTestEPUB(t)) // Unresolvable steps in a CFI that still parses to spine doc2 // (spine index 1): the text search must anchor on the sentence. reverse, err := c.ConvertStandardToCRE("epubcfi(/6/4!/4/99999/1:0)", 0.05, fixtureSentence) if err != nil { t.Fatalf("reverse conversion error: %v", err) } t.Logf("Text search fallback XPointer: %s", reverse.XPointer) t.Logf("Precision: %s", reverse.Precision) if reverse.Precision != "exact" { t.Errorf("expected exact precision from text search, got %s", reverse.Precision) } if reverse.XPointer == "" { t.Error("expected non-empty XPointer from text search") } if !strings.Contains(reverse.XPointer, "DocFragment[2]") { t.Errorf("expected fallback into DocFragment[2], got %s", reverse.XPointer) } } func TestReversePercentageFallback(t *testing.T) { c := NewCFIConverter(writeTestEPUB(t)) reverse, err := c.ConvertStandardToCRE("epubcfi(/6/4!/4/99999/1:0)", 0.5, "") if err != nil { t.Fatalf("reverse conversion error: %v", err) } t.Logf("Percentage fallback precision: %s", reverse.Precision) if reverse.Precision != "percentage" { t.Errorf("expected percentage precision, got %s with XPointer %s", reverse.Precision, reverse.XPointer) } if reverse.XPointer != "" { t.Error("expected empty XPointer for percentage fallback") } } func TestParseEPUBCFI(t *testing.T) { tests := []struct { input string wantSpine int wantSteps int }{ {"epubcfi(/6/12!/4/2/90/1:7)", 5, 4}, {"epubcfi(/6/4!/4/2/1:0)", 1, 3}, {"epubcfi(/6/2!/4)", 0, 1}, } for _, tt := range tests { spineIndex, steps, err := parseEPUBCFI(tt.input) if err != nil { t.Errorf("parseEPUBCFI(%q) error: %v", tt.input, err) continue } if spineIndex != tt.wantSpine { t.Errorf("spineIndex = %d, want %d", spineIndex, tt.wantSpine) } if len(steps) != tt.wantSteps { t.Errorf("len(steps) = %d, want %d", len(steps), tt.wantSteps) } } } func TestParseEPUBCFIRange(t *testing.T) { spineIndex, steps, err := parseEPUBCFI("epubcfi(/6/40!/4,/24/20,/40/5:61)") if err != nil { t.Fatalf("parseEPUBCFI range error: %v", err) } if spineIndex != 19 { t.Errorf("spineIndex = %d, want 19", spineIndex) } t.Logf("Range CFI steps: %d", len(steps)) for i, s := range steps { t.Logf(" step %d: index=%d offset=%d hasOffset=%v", i, s.Index, s.Offset, s.HasOffset) } } func TestParseEPUBCFIInvalid(t *testing.T) { _, _, err := parseEPUBCFI("not-a-cfi") if err == nil { t.Error("expected error for invalid CFI") } _, _, err = parseEPUBCFI("epubcfi(/6/12)") if err == nil { t.Error("expected error for CFI without indirection") } } func TestFindTextInNode_SingleTextNode(t *testing.T) { doc := parseTestHTML(`

Hello world this is a test

`) body := findBody(doc) node, offset := findTextInNode(body, "Hello world") if node == nil { t.Fatal("expected to find text") } if offset != 0 { t.Errorf("offset = %d, want 0", offset) } } func TestFindTextInNode_CrossEmElement(t *testing.T) { doc := parseTestHTML(`

the countries Vokalia and Consonantia live here

`) body := findBody(doc) node, offset := findTextInNode(body, "Vokalia and Consonantia") if node == nil { t.Fatal("expected to find text across elements") } if node.Data != "Vokalia" { t.Errorf("expected match in 'Vokalia' text node, got %q", node.Data) } if offset != 0 { t.Errorf("offset = %d, want 0", offset) } } func TestFindTextInNode_CrossStrongElement(t *testing.T) { doc := parseTestHTML(`

Some bold and italic text here

`) body := findBody(doc) node, _ := findTextInNode(body, "bold and italic text") if node == nil { t.Fatal("expected to find text across boundary") } if node.Data != "bold and italic" { t.Errorf("expected match in 'bold and italic' text node, got %q", node.Data) } } func TestFindTextInNode_DoesNotCrossParagraphs(t *testing.T) { doc := parseTestHTML(`

first paragraph

second paragraph

`) body := findBody(doc) node, _ := findTextInNode(body, "paragraph second") if node != nil { t.Error("should not match text across

boundaries") } } func TestFindTextInNode_NestedFormatting(t *testing.T) { doc := parseTestHTML(`

before bold italic after

`) body := findBody(doc) node, offset := findTextInNode(body, "bold italic after") if node == nil { t.Fatal("expected to find text across nested formatting") } if node.Data != "bold italic" { t.Errorf("expected match in 'bold italic' text node, got %q", node.Data) } _ = offset } func TestFindBlockParent(t *testing.T) { doc := parseTestHTML(`

text inside deep

`) body := findBody(doc) var deepNode *html.Node var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.TextNode && n.Data == "deep" { deepNode = n return } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(body) if deepNode == nil { t.Fatal("could not find 'deep' text node") } block := findBlockParent(deepNode) if block == nil { t.Fatal("expected block parent") } if block.Data != "p" { t.Errorf("block parent = %q, want 'p'", block.Data) } } func TestCollectInlineText(t *testing.T) { doc := parseTestHTML(`

the countries Vokalia and Consonantia live

`) body := findBody(doc) var p *html.Node var walk func(*html.Node) walk = func(n *html.Node) { if n.Type == html.ElementNode && n.Data == "p" { p = n return } for c := n.FirstChild; c != nil; c = c.NextSibling { walk(c) } } walk(body) if p == nil { t.Fatal("could not find

element") } segments := collectInlineText(p) var collected []rune for _, seg := range segments { collected = append(collected, seg.runes...) } flattened := strings.TrimSpace(string(collected)) if flattened != "the countries Vokalia and Consonantia live" { t.Errorf("collected text = %q", flattened) } } func parseTestHTML(s string) *html.Node { doc, err := html.Parse(strings.NewReader(s)) if err != nil { panic(err) } return doc } func TestUsableContextText(t *testing.T) { if ok, _ := usableContextText("C"); ok { t.Error("single char must not be usable context") } if ok, _ := usableContextText("Chapter"); ok { t.Error("single word must not be usable context") } if ok, norm := usableContextText("Convergence of Heaven and Earth"); !ok || norm == "" { t.Error("full header must be usable context") } if ok, _ := usableContextText(""); ok { t.Error("empty must not be usable context") } } // Drop-cap regression:

Convergence of Heaven and Earth

. // A device sitting on that header used to send context "C", which text // search matched at the chapter heading ("Chapter 10") and stored doc-start // (/4/2/1:0). Structural resolution must land in the header paragraph. func writeDropCapEPUB(t *testing.T) string { t.Helper() docs := []struct { name string body string }{ {"ch9.xhtml", `

Chapter 9

Is the Kaphar of Christ the Gospel?

Opening of chapter nine with some text.

`}, {"ch10.xhtml", `

Chapter 10

The Three C's of the New Covenant

The Cleansing Life of Christ

Present yourselves as slaves for obedience, you are slaves of that same one whom you obey, either of sin resulting in death.

Convergence of Heaven and Earth

The overarching goal of Christ is to converge heaven and earth.

`}, } containerXML := ` ` manifest := "" spineRefs := "" for _, d := range docs { id := d.name[:len(d.name)-len(".xhtml")] manifest += " \n" spineRefs += " \n" } opf := ` dropcap-fixture Dropcap Fixture ` + manifest + ` ` + spineRefs + ` ` path := t.TempDir() + "/dropcap.epub" f, err := os.Create(path) if err != nil { t.Fatal(err) } defer f.Close() zw := zip.NewWriter(f) write := func(name, content string) { w, err := zw.Create(name) if err != nil { t.Fatal(err) } if _, err := w.Write([]byte(content)); err != nil { t.Fatal(err) } } write("META-INF/container.xml", containerXML) write("OEBPS/content.opf", opf) for _, d := range docs { write("OEBPS/"+d.name, "\n"+d.body+"\n") } if err := zw.Close(); err != nil { t.Fatal(err) } return path } func TestDropCapSingleCharDoesNotHitDocStart(t *testing.T) { c := NewCFIConverter(writeDropCapEPUB(t)) // CRE xpointer into the drop-cap header paragraph: // DocFragment[2] = ch10 (1-based), body/p[3] = Convergence header // (same-tag indexing, mirrors buildCREXPointer). xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0" result, err := c.ConvertCREToStandard(xp, 0.52, "C") if err != nil { t.Fatalf("ConvertCREToStandard error: %v", err) } t.Logf("Drop-cap single-char → %s (%s)", result.EPUBCFI, result.Precision) if result.EPUBCFI == "" { t.Fatal("expected non-empty epubcfi") } if strings.HasSuffix(result.EPUBCFI, "/4/2/1:0)") { t.Errorf("single-char context collapsed to doc start: %s", result.EPUBCFI) } } func TestDropCapStructuralLandsInHeader(t *testing.T) { c := NewCFIConverter(writeDropCapEPUB(t)) xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0" result, err := c.ConvertCREToStandard(xp, 0.52, "Convergence of Heaven and Earth") if err != nil { t.Fatalf("ConvertCREToStandard error: %v", err) } t.Logf("Drop-cap structural → %s (%s)", result.EPUBCFI, result.Precision) if result.EPUBCFI == "" { t.Fatal("expected non-empty epubcfi") } if strings.HasSuffix(result.EPUBCFI, "/4/2/1:0)") { t.Errorf("full header context still collapsed to doc start: %s", result.EPUBCFI) } if result.Precision != "structural" && result.Precision != "exact" { t.Errorf("expected structural/exact precision, got %s", result.Precision) } // Round-trip back to CRE must stay in the same fragment (web → mobile). reverse, err := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "Convergence of Heaven and Earth") if err != nil { t.Fatalf("reverse conversion error: %v", err) } if reverse.XPointer == "" { t.Fatal("expected non-empty reverse XPointer") } if !strings.Contains(reverse.XPointer, "DocFragment[2]") { t.Errorf("expected reverse into DocFragment[2], got %s", reverse.XPointer) } } func TestReverseIgnoresSingleCharContext(t *testing.T) { c := NewCFIConverter(writeDropCapEPUB(t)) reverse, err := c.ConvertStandardToCRE("epubcfi(/6/4!/4/99999/1:0)", 0.5, "C") if err != nil { t.Fatalf("reverse conversion error: %v", err) } if reverse.Precision != "percentage" { t.Errorf("single-char reverse context must fall back to percentage, got %s", reverse.Precision) } }