Progress submissions now carry (percentage, context_text, epubcfi) and the server becomes the position authority: - VerifyProgressAnchor resolves the submitted standard CFI against the book's own XHTML, extracts the text at the anchor, and cross-checks it with the submitted context_text. A mismatch heals the anchor by text search (percentage disambiguates repeats) instead of storing a bad position. - The anchor's block element is derived as a cssSelector plus a block- relative character offset, and served on progress GET alongside the anchor document's href — readium-native handles that let clients re-open a book without parsing CFIs themselves. - context_text-only submissions (no CFI — the dumb-client tier) are anchored structurally from the context text. Motivation: cross-client progress sync (web foliate CFIs, KOReader CRE xpointers, readium-native apps) previously trusted each client's own locator math; the app's EPUB restore drifted ±pages because readium's paginator does not lay out far-from-viewport columns and the foliate- ported CFI walk ran against readium's mutated WebView DOM. Server-side verification heals both classes at ingest.
159 lines
5.2 KiB
Go
159 lines
5.2 KiB
Go
package sync
|
|
|
|
import (
|
|
"testing"
|
|
)
|
|
|
|
// The fixture (writeTestEPUB in cfi_converter_test.go): 6 spine docs.
|
|
// doc2 = spine index 1 with two paragraphs:
|
|
// p1: "The family of Dashwood had long been settled in Sussex."
|
|
// p2: "Their estate was large, and their residence was at Norland Park."
|
|
// Hand-derived local paths (html→body /4, body→div /4, div→p /4|/6,
|
|
// p→text chunk /1):
|
|
const (
|
|
dashwoodCFI = "epubcfi(/6/4!/4/2/2/1:0)"
|
|
dashwoodText = "The family of Dashwood had long been settled in Sussex."
|
|
estateCFI = "epubcfi(/6/4!/4/2/4/1:0)"
|
|
estateText = "Their estate was large, and their residence was at Norland Park."
|
|
dashwoodSelect = "body>div:nth-child(1)>p:nth-child(1)"
|
|
estateSelect = "body>div:nth-child(1)>p:nth-child(2)"
|
|
)
|
|
|
|
func TestVerifyProgressAnchorAcceptsExact(t *testing.T) {
|
|
path := writeTestEPUB(t)
|
|
finalCFI, sel, _, charOff, healedPct, healed, err := VerifyProgressAnchor(path, dashwoodCFI, dashwoodText, 0.3)
|
|
_ = charOff
|
|
if err != nil {
|
|
t.Fatalf("verify error: %v", err)
|
|
}
|
|
if healed {
|
|
t.Errorf("exact anchor should not heal")
|
|
}
|
|
if healedPct != nil {
|
|
t.Errorf("exact anchor should not carry a healed percentage")
|
|
}
|
|
if finalCFI != dashwoodCFI {
|
|
t.Errorf("finalCFI = %q, want unchanged %q", finalCFI, dashwoodCFI)
|
|
}
|
|
if sel != dashwoodSelect {
|
|
t.Errorf("cssSelector = %q, want %q", sel, dashwoodSelect)
|
|
}
|
|
}
|
|
|
|
func TestVerifyProgressAnchorHealsMismatch(t *testing.T) {
|
|
path := writeTestEPUB(t)
|
|
// Anchored at p1 but the context is p2's text: the classic
|
|
// client-projection bug — the server must heal to the true location.
|
|
finalCFI, _, _, charOff, healedPct, healed, err := VerifyProgressAnchor(path, dashwoodCFI, estateText, 0.3)
|
|
_ = charOff
|
|
if err != nil {
|
|
t.Fatalf("verify error: %v", err)
|
|
}
|
|
if !healed {
|
|
t.Fatalf("expected healing, got none (cfi=%q)", finalCFI)
|
|
}
|
|
if finalCFI != estateCFI {
|
|
t.Errorf("healed CFI = %q, want %q", finalCFI, estateCFI)
|
|
}
|
|
if healedPct == nil || *healedPct <= 0 {
|
|
t.Errorf("healed percentage not recomputed: %v", healedPct)
|
|
}
|
|
|
|
// Healing must converge: re-verifying the healed anchor is a no-op.
|
|
finalCFI2, _, _, _, healedPct2, healed2, err := VerifyProgressAnchor(path, finalCFI, estateText, 0.3)
|
|
if err != nil {
|
|
t.Fatalf("re-verify error: %v", err)
|
|
}
|
|
if healed2 {
|
|
t.Errorf("healed anchor should be stable, got healed again to %q (pct %v)", finalCFI2, healedPct2)
|
|
}
|
|
if finalCFI2 != finalCFI {
|
|
t.Errorf("re-verify CFI = %q, want %q", finalCFI2, finalCFI)
|
|
}
|
|
}
|
|
|
|
func TestVerifyProgressAnchorHealsUnresolvable(t *testing.T) {
|
|
path := writeTestEPUB(t)
|
|
// Element index 99 is out of range in doc2 — the anchor cannot resolve.
|
|
bogus := "epubcfi(/6/4!/4/2/99/1:0)"
|
|
finalCFI, _, _, charOff, healedPct, healed, err := VerifyProgressAnchor(path, bogus, dashwoodText, 0.05)
|
|
_ = charOff
|
|
if err != nil {
|
|
t.Fatalf("verify error: %v", err)
|
|
}
|
|
if !healed {
|
|
t.Fatal("unresolvable anchor should heal from context")
|
|
}
|
|
if finalCFI != dashwoodCFI {
|
|
t.Errorf("healed CFI = %q, want the Dashwood anchor %q", finalCFI, dashwoodCFI)
|
|
}
|
|
if healedPct == nil {
|
|
t.Errorf("healed percentage not recomputed")
|
|
}
|
|
}
|
|
|
|
func TestVerifyProgressAnchorDumbClient(t *testing.T) {
|
|
path := writeTestEPUB(t)
|
|
// No CFI at all — a client that only knows percentage + context is
|
|
// fully supported: the server anchors structurally from the text.
|
|
finalCFI, sel, _, charOff, healedPct, healed, err := VerifyProgressAnchor(path, "", estateText, 0.3)
|
|
_ = charOff
|
|
if err != nil {
|
|
t.Fatalf("verify error: %v", err)
|
|
}
|
|
if !healed {
|
|
t.Fatal("context-only submission should count as anchored-by-heal")
|
|
}
|
|
if finalCFI != estateCFI {
|
|
t.Errorf("anchored CFI = %q, want %q", finalCFI, estateCFI)
|
|
}
|
|
if sel != estateSelect {
|
|
t.Errorf("cssSelector = %q, want %q", sel, estateSelect)
|
|
}
|
|
if healedPct == nil {
|
|
t.Errorf("percentage not recomputed for context-only anchor")
|
|
}
|
|
}
|
|
|
|
func TestContextMatches(t *testing.T) {
|
|
block := "The family of Dashwood had long been settled in Sussex."
|
|
cases := []struct {
|
|
name string
|
|
serverCtx string
|
|
submitted string
|
|
want bool
|
|
}{
|
|
{"exact", block, block, true},
|
|
{"suffix of block", block, "settled in Sussex.", true},
|
|
{"block is suffix", "settled in Sussex.", block, true},
|
|
{"mid-block substring", block, "Dashwood had long been", true},
|
|
{"different text", block, "completely unrelated words here", false},
|
|
{"too short", block, "the", false},
|
|
{"empty submitted", block, "", false},
|
|
{"empty server", "", "some long enough context text", false},
|
|
}
|
|
for _, tc := range cases {
|
|
if got := contextMatches(tc.serverCtx, tc.submitted); got != tc.want {
|
|
t.Errorf("contextMatches(%q, %q) = %v, want %v", tc.serverCtx, tc.submitted, got, tc.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseStandardCFIRange(t *testing.T) {
|
|
// Web progress CFIs are range CFIs; parse must resolve to the start arm.
|
|
spineIndex, localSteps, err := parseEPUBCFI("epubcfi(/6/4!/4/4:0,/4/4:53)")
|
|
if err != nil {
|
|
t.Fatalf("parse error: %v", err)
|
|
}
|
|
if spineIndex != 1 {
|
|
t.Errorf("spineIndex = %d, want 1", spineIndex)
|
|
}
|
|
if len(localSteps) != 4 {
|
|
t.Fatalf("steps = %d, want 4 (parent + start arm)", len(localSteps))
|
|
}
|
|
last := localSteps[len(localSteps)-1]
|
|
if last.Index != 4 || !last.HasOffset || last.Offset != 53 {
|
|
t.Errorf("start-arm step = %+v, want /4:0", last)
|
|
}
|
|
}
|