Files
bookhoard/internal/sync/cfi_verify_test.go
T
John O'Keefe aec226af1a feat(sync): server-side position authority — verify/heal progress anchors
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.
2026-09-26 14:43:34 -04:00

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)
}
}