package sync import ( "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") } } func TestConvert1984(t *testing.T) { epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub" c := NewCFIConverter(epubPath) xp := "/body/DocFragment[2]/body/div/p[5]/text().500" result, err := c.ConvertCREToStandard(xp, 0.01, "") if err != nil { t.Fatalf("ConvertCREToStandard error: %v", err) } t.Logf("Input: %s", xp) t.Logf("EPUBCFI: %s", result.EPUBCFI) t.Logf("Href: %s", result.Href) t.Logf("Precision: %s", result.Precision) t.Logf("Percentage: %.4f", result.Percentage) if result.Precision == "percentage" { t.Error("expected better than percentage precision") } } func TestConvertCrimeAndPunishmentFragmentID(t *testing.T) { epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub" c := NewCFIConverter(epubPath) xp := "#_doc_fragment_5_ link2HCH0002" result, err := c.ConvertCREToStandard(xp, 0.0303, "") if err != nil { t.Fatalf("ConvertCREToStandard error: %v", err) } t.Logf("Input: %s", xp) t.Logf("EPUBCFI: %s", result.EPUBCFI) t.Logf("Href: %s", result.Href) t.Logf("Precision: %s", result.Precision) t.Logf("Percentage: %.4f", result.Percentage) if result.Precision == "percentage" { t.Error("expected better than percentage precision") } if result.Href == "" { t.Error("expected non-empty href") } if result.Precision != "element" { t.Errorf("expected element precision, got %s", result.Precision) } } 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 TestRoundTrip1984(t *testing.T) { epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub" c := NewCFIConverter(epubPath) originalXP := "/body/DocFragment[2]/body/div/p[5]/text().500" forward, err := c.ConvertCREToStandard(originalXP, 0.01, "") 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) t.Logf("Reverse precision: %s", reverse.Precision) if reverse.Precision != "exact" { t.Errorf("expected exact precision, got %s", reverse.Precision) } } func TestRoundTripCP(t *testing.T) { epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub" c := NewCFIConverter(epubPath) originalXP := "/body/DocFragment[6]/body/div/p[47]/text().2399" contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l" forward, err := c.ConvertCREToStandard(originalXP, 0.0579, contextText) 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, contextText) 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) t.Logf("Reverse precision: %s", reverse.Precision) if reverse.Precision != "exact" { t.Errorf("expected exact precision, got %s", reverse.Precision) } } func TestReverseTextSearchFallback(t *testing.T) { epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Fyodor Dostoyevsky/Crime and Punishment (103)/Crime and Punishment - Fyodor Dostoyevsky.epub" c := NewCFIConverter(epubPath) contextText := "Raskolnikov was not used to crowds, and, as we said before, he avoided society of every sort, more especially of l" reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/4/99999/1:0)", 0.0579, contextText) 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") } } func TestReversePercentageFallback(t *testing.T) { epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub" c := NewCFIConverter(epubPath) reverse, err := c.ConvertStandardToCRE("epubcfi(/6/12!/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 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 acrossboundaries") } } 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 findelement") } 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 }