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 }