diff --git a/internal/sync/cfi_converter_test.go b/internal/sync/cfi_converter_test.go
index f8cfdd4..035cfc9 100644
--- a/internal/sync/cfi_converter_test.go
+++ b/internal/sync/cfi_converter_test.go
@@ -1,6 +1,8 @@
package sync
import (
+ "archive/zip"
+ "os"
"strings"
"testing"
@@ -9,7 +11,7 @@ import (
func TestParseCREXPointer(t *testing.T) {
tests := []struct {
- input string
+ input string
wantFrag int
wantPath int
wantChar int
@@ -72,9 +74,9 @@ func TestIsCREFragmentID(t *testing.T) {
func TestParseCREFragmentID(t *testing.T) {
tests := []struct {
- input string
- wantSpine int
- wantAnchor string
+ input string
+ wantSpine int
+ wantAnchor string
}{
{"#_doc_fragment_5_ link2HCH0002", 5, "link2HCH0002"},
{"#_doc_fragment_0_", 0, ""},
@@ -116,57 +118,224 @@ func TestIsStandardEPUBCFI(t *testing.T) {
}
}
-func TestConvert1984(t *testing.T) {
- epubPath := "/home/nymusicman/Code/bookhoard/uploads/Ebooks/George Orwell/1984 (126)/1984 - George Orwell.epub"
- c := NewCFIConverter(epubPath)
+// 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()
- xp := "/body/DocFragment[2]/body/div/p[5]/text().500"
- result, err := c.ConvertCREToStandard(xp, 0.01, "")
+ 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", ""},
+ {"doc4.xhtml", ""},
+ {"doc5.xhtml", ""},
+ {"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("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.EPUBCFI == "" {
+ t.Error("expected non-empty epubcfi")
+ }
}
-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)
+func TestConvertFragmentID(t *testing.T) {
+ c := NewCFIConverter(writeTestEPUB(t))
- xp := "#_doc_fragment_5_ link2HCH0002"
- result, err := c.ConvertCREToStandard(xp, 0.0303, "")
+ 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", xp)
- t.Logf("EPUBCFI: %s", result.EPUBCFI)
+ t.Logf("Input: %s", frag)
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.Precision != "element" {
+ t.Errorf("expected element precision, got %s", result.Precision)
}
if result.Href == "" {
t.Error("expected non-empty href")
}
- if result.Precision != "element" {
- t.Errorf("expected element precision, got %s", result.Precision)
+ 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
+ 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},
@@ -213,103 +382,6 @@ func TestParseEPUBCFIInvalid(t *testing.T) {
}
}
-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)