fix(sync): understand cross-block text; never store a guessed locator

Tonight's failures all traced to one blind spot: the converter could
only reason about text within a single block. A position at a chapter
heading sends walk-up context (heading + the paragraphs below, joined by
the plugin's block capture); a selection can span several paragraphs.
Neither shape could be verified (containment compared one block against
a multi-block quote, so the CORRECT structural landing at the heading
was rejected) nor matched by text search (it never crossed block
boundaries). The ladder then fell to the percentage rung — which labeled
its char-count guess Precision "exact" — and that confidently-wrong CFI
was stored: reading positions reopened paragraphs away from the true
spot, and a highlight echo overwrote the row's good web CFIs with a
garbage start anchor that made the highlight unpaintable ("disappeared").

Four changes, all in the forward converter and its consumers:

- Quote verification: after the structural walk lands, read the
  whitespace-normalized document text forward from the landing point
  (crossing block boundaries; inline spans join directly so drop-cap
  splits still read as one word). A usable context must be a prefix of
  that stream — which is exactly what device captures are: the text from
  the position onward, or the selection between two anchors. The old
  single-block containment checks remain as secondary acceptance.
- Cross-block text search: the search rung matches against the whole
  document flattened in reading order, with every rune mapped back to
  its source node and offset. A context spanning blocks now matches, and
  the matched extent yields a true range end (EndEPUBCFI) that
  highlights use as their end anchor, threaded through the facade as
  CanonicalLocator.EndCFI.
- Honest labels: the percentage rung returns Precision "percentage" —
  a char-count estimate must never masquerade as an exact anchor.
- Confident-only storage: progress adopts a converted locator solely at
  structural/exact precision (section hrefs keep their legacy handling;
  anything lower stores percentage only), and highlight conversion
  returns CFIs only at structural/exact precision — a low-confidence
  echo yields empty, which applyLWW coalescing turns into preservation
  of the row's existing web CFIs instead of clobbering them.

Tests: walk-up context at a heading verifies structurally and lands in
the heading; a block-spanning context is found by search with a range
end landing in the following paragraph; the percentage rung is honestly
labeled; all drop-cap guards stay green.
This commit is contained in:
2026-09-09 20:15:12 -04:00
parent ce3ae31ced
commit b6f507b9e5
4 changed files with 293 additions and 43 deletions
+90
View File
@@ -667,6 +667,96 @@ func TestReverseIgnoresSingleCharContext(t *testing.T) {
}
}
// A position at a chapter heading sends walk-up context: the heading text
// concatenated with the paragraphs below (block walk-up in the plugin).
// The structural landing at the heading is correct and must verify — the
// context is a prefix of the document as read from the landing.
func TestWalkUpContextVerifiesAtHeading(t *testing.T) {
c := NewCFIConverter(writeDropCapEPUB(t))
// ch10: h1[1] "Chapter 10", h1[2] "The Three C's of the New Covenant",
// then paragraphs. Position at h1[2]'s text start; the device captured
// the heading plus the following paragraph.
xp := "/body/DocFragment[2]/body/h1[2]/text().0"
ctx := "The Three C's of the New Covenant The Cleansing Life of Christ"
result, err := c.ConvertCREToStandard(xp, 0.52, ctx)
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("walk-up heading → %s (%s)", result.EPUBCFI, result.Precision)
if result.Precision != "structural" {
t.Fatalf("expected structural precision (quote verification), got %s (%s)", result.Precision, result.EPUBCFI)
}
if strings.HasSuffix(result.EPUBCFI, "/4/2/1:0)") {
t.Errorf("collapsed to doc start: %s", result.EPUBCFI)
}
// The landing must be the heading, not a paragraph below it.
reverse, rerr := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "")
if rerr != nil {
t.Fatalf("reverse conversion error: %v", rerr)
}
if !strings.Contains(reverse.XPointer, "h1[2]") {
t.Errorf("expected landing in h1[2], got %s", reverse.XPointer)
}
}
// A selection spanning blocks (heading tail into the next paragraph) must
// be findable by text search across block boundaries, and the matched
// quote's extent gives a true range end.
func TestCrossBlockSearchSpansBlocks(t *testing.T) {
c := NewCFIConverter(writeDropCapEPUB(t))
// No element path → structural rung skipped, text search runs.
xp := "/body/DocFragment[2]/body"
ctx := "New Covenant The Cleansing Life of Christ"
result, err := c.ConvertCREToStandard(xp, 0.52, ctx)
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("cross-block search → %s … %s (%s)", result.EPUBCFI, result.EndEPUBCFI, result.Precision)
if result.Precision != "exact" {
t.Fatalf("expected exact text-search precision, got %s", result.Precision)
}
if result.EPUBCFI == "" || result.EndEPUBCFI == "" {
t.Fatalf("expected range anchors, got %q…%q", result.EPUBCFI, result.EndEPUBCFI)
}
if result.EPUBCFI == result.EndEPUBCFI {
t.Fatalf("range collapsed: %s", result.EPUBCFI)
}
// The start lands in the heading, the end in the following paragraph.
startRev, err1 := c.ConvertStandardToCRE(result.EPUBCFI, result.Percentage, "")
endRev, err2 := c.ConvertStandardToCRE(result.EndEPUBCFI, result.Percentage, "")
if err1 != nil || err2 != nil {
t.Fatalf("reverse conversions failed: %v %v", err1, err2)
}
if !strings.Contains(startRev.XPointer, "h1[2]") {
t.Errorf("expected start in h1[2], got %s", startRev.XPointer)
}
if !strings.Contains(endRev.XPointer, "p[1]") {
t.Errorf("expected end in p[1] (following paragraph), got %s", endRev.XPointer)
}
}
// The percentage rung is a char-count estimate: it must never label its
// landing "exact". Feed it an unmatchable context so the ladder falls all
// the way through.
func TestPercentageFallbackIsHonestlyLabeled(t *testing.T) {
c := NewCFIConverter(writeDropCapEPUB(t))
xp := "/body/DocFragment[2]/body/p[3]/span[1]/text().0"
result, err := c.ConvertCREToStandard(xp, 0.52, "zzz qqq vvv uuu www")
if err != nil {
t.Fatalf("ConvertCREToStandard error: %v", err)
}
t.Logf("unmatchable context → %s (%s)", result.EPUBCFI, result.Precision)
if result.Precision != "percentage" {
t.Errorf("percentage rung must not claim exact, got %s", result.Precision)
}
}
// The bookmark route supplies no context (bookmark text is a display
// label, never book text), so the facade must still resolve the drop-cap
// xpointer structurally instead of collapsing to the document start.