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", "

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("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)