test(sync): add manual repro harness for CRE-to-CFI highlight conversion

TestManualCleanRepro replays a real device highlight export through the
same conversion path the sync handler uses, diffs the output against
the CFIs stored at ingest time, and computes a ground-truth oracle by
locating each selection in the flattened document itself. Classifies
every row as healthy / diff-base / backwards for both the converter
and the stored data. Skips automatically when fixture files are not
present on the machine (manual test, like cfi_verify_manual_test.go).

REPRO_VERBOSE=1 prints per-row detail; REPRO_EMIT_SQL=<path> emits
idempotent UPDATE statements that heal database rows written by the
old converter from the preserved raw device positions - used to
backfill dev and validated against production data.
This commit is contained in:
John O'Keefe committed 2026-10-12 20:46:08 -04:00
1 parent 93a468b435
commit 03a6040360
1 file changed
+379
+379
View File
@@ -0,0 +1,379 @@
package sync
// Manual repro harness for the CRE→CFI highlight conversion defects found in
// "Clean" (Rosser K Powitzky): 28 device highlights whose stored web CFIs are
// structurally inconsistent (mismatched start/end base paths, backwards
// ranges). This test mirrors handlers.convertHighlightPositions exactly —
// pos0 converted WITH the selection text as context, pos1 WITHOUT any — then
// reports the result alongside the CFIs stored at ingest time and a
// ground-truth oracle computed by locating the selection text in the document
// itself. Skips silently when the book/export are not on this machine, like
// cfi_verify_manual_test.go.
import (
"fmt"
"encoding/json"
"os"
"strings"
"testing"
"golang.org/x/net/html"
)
const (
reproExport = "/home/nymusicman/Code/bookhoard/export/media_highlights.json"
reproEpub = "/home/nymusicman/Code/bookhoard/uploads/Ebooks/Rosser K Powitzky/Clean_ How the Jewish Roots of Atone (77)/Clean_ How the Jewish Roots of - Rosser K Powitzky.epub"
)
type reproRow struct {
ID string `json:"id"`
StartPosition string `json:"start_position"`
EndPosition string `json:"end_position"`
StoredStart string `json:"epubcfi_start"`
StoredEnd string `json:"epubcfi_end"`
SelectionText string `json:"selection_text"`
PctStart float64 `json:"percentage_start"`
}
// webUsable mirrors handlers.webUsableCFI (unexported there; cannot import
// the handlers package from here without a cycle).
func webUsable(precision, cfi string) string {
if precision != "structural" && precision != "exact" {
return ""
}
if !strings.HasPrefix(cfi, "epubcfi(") || !strings.HasSuffix(cfi, ")") {
return ""
}
return cfi
}
// reproExtendCFIByLength mirrors handlers.extendCFIByLength: advance the
// trailing character offset of a point CFI by the UTF-16 length of text.
func reproExtendCFIByLength(cfi, text string) string {
if cfi == "" || text == "" {
return ""
}
if !strings.HasSuffix(cfi, ")") {
return ""
}
inner := cfi[:len(cfi)-1]
idx := strings.LastIndex(inner, ":")
if idx < 0 {
return cfi
}
var off int
if _, err := fmtSscanInt(inner[idx+1:], &off); err != nil {
return cfi
}
return inner[:idx+1] + itoa(off+utf16Len(text)) + ")"
}
// cfiBase splits "epubcfi(...)" into (base, offset): base is everything
// before the final ":N" character-offset terminator.
func cfiBase(cfi string) (string, int, bool) {
if !strings.HasSuffix(cfi, ")") {
return "", 0, false
}
inner := cfi[:len(cfi)-1]
idx := strings.LastIndex(inner, ":")
if idx < 0 {
return inner, 0, true
}
off := 0
if _, err := fmtSscanInt(inner[idx+1:], &off); err != nil {
return inner, 0, true
}
return inner[:idx], off, true
}
func fmtSscanInt(s string, out *int) (int, error) {
n := 0
ok := false
for _, r := range s {
if r < '0' || r > '9' {
break
}
n = n*10 + int(r-'0')
ok = true
}
if !ok {
return 0, errReproNoInt
}
*out = n
return len(s), nil
}
var errReproNoInt = errReproNoIntErr{}
type errReproNoIntErr struct{}
func (errReproNoIntErr) Error() string { return "no integer" }
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := n < 0
if neg {
n = -n
}
var b [20]byte
i := len(b)
for n > 0 {
i--
b[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
b[i] = '-'
}
return string(b[i:])
}
// oracleCFIs locates the selection text in the document named by the start
// xpointer's fragment and returns ground-truth (startCFI, endCFI, spineIndex).
// Whitespace runs are collapsed on both sides before matching, so device
// text with reflowed newlines still lands.
func oracleCFIs(t *testing.T, conv *CFIConverter, row reproRow) (string, string, string) {
xp, err := ParseCREXPointer(row.StartPosition)
if err != nil {
return "", "", "unparseable start xpointer"
}
doc, href, err := conv.getContentDoc(xp.FragmentIndex)
if err != nil {
return "", "", "content doc missing: " + err.Error()
}
body := findBody(doc)
if body == nil {
return "", "", "no body"
}
// Flatten every text node in document order.
var segs []textSegment
var walk func(*html.Node)
walk = func(n *html.Node) {
if n.Type == html.TextNode {
segs = append(segs, textSegment{node: n, runes: []rune(n.Data)})
}
for c := n.FirstChild; c != nil; c = c.NextSibling {
walk(c)
}
}
walk(body)
var flat []rune
for _, s := range segs {
flat = append(flat, s.runes...)
}
needle := []rune(normalizeWhitespace(row.SelectionText))
if len(needle) == 0 {
return "", "", "empty selection text"
}
// Collapse whitespace in `flat` while mapping each kept rune back to a
// flat index.
var norm []rune
var normToFlat []int
lastWS := false
for i, r := range flat {
isWS := r == ' ' || r == '\t' || r == '\n' || r == '\r' || r == ' ' || r == '​'
if isWS {
if lastWS {
continue
}
norm = append(norm, ' ')
normToFlat = append(normToFlat, i)
lastWS = true
continue
}
norm = append(norm, r)
normToFlat = append(normToFlat, i)
lastWS = false
}
// Trim wrapped-quote artifacts KOReader adds to partial-paragraph text.
trimmed := needle
for len(trimmed) > 0 && (trimmed[0] == '{' || trimmed[0] == '}') {
trimmed = trimmed[1:]
}
for len(trimmed) > 0 && (trimmed[len(trimmed)-1] == '{' || trimmed[len(trimmed)-1] == '}') {
trimmed = trimmed[:len(trimmed)-1]
}
pos := indexRunes(norm, trimmed)
if pos < 0 {
return "", "", "selection text not found in doc " + href
}
flatStart := normToFlat[pos]
flatEnd := normToFlat[pos+len(trimmed)-1] + 1 // end is exclusive
nodeAt := func(flatIdx int) (*html.Node, int) {
acc := 0
for _, s := range segs {
n := len(s.runes)
if flatIdx < acc+n {
return s.node, flatIdx - acc
}
acc += n
}
return nil, 0
}
startNode, startOff := nodeAt(flatStart)
endNode, endOff := nodeAt(flatEnd)
if startNode == nil || endNode == nil {
return "", "", "flat index out of range"
}
spineIndex := xp.FragmentIndex - 1
startCFI, err := buildCFI(spineIndex, startNode, startOff)
if err != nil {
return "", "", "buildCFI start: " + err.Error()
}
endCFI, err := buildCFI(spineIndex, endNode, endOff)
if err != nil {
return "", "", "buildCFI end: " + err.Error()
}
return startCFI, endCFI, ""
}
func indexRunes(hay, needle []rune) int {
if len(needle) == 0 || len(needle) > len(hay) {
return -1
}
for i := 0; i+len(needle) <= len(hay); i++ {
match := true
for j := range needle {
if hay[i+j] != needle[j] {
match = false
break
}
}
if match {
return i
}
}
return -1
}
func TestManualCleanRepro(t *testing.T) {
raw, err := os.ReadFile(reproExport)
if err != nil {
t.Skipf("export json not present: %v", err)
}
if _, err := os.Stat(reproEpub); err != nil {
t.Skipf("book epub not present: %v", err)
}
var rows []reproRow
if err := json.Unmarshal(raw, &rows); err != nil {
t.Fatalf("unmarshal export: %v", err)
}
conv := cachedConverter(reproEpub)
stats := map[string]int{}
for _, row := range rows {
// Mirror handlers.convertHighlightPositions (text-anchored primary
// path, legacy per-endpoint fallback).
startLoc := ConvertHighlightRangeToCanonical(reproEpub, "reflowable", row.StartPosition, row.EndPosition, row.SelectionText, row.PctStart)
startCFI := webUsable(startLoc.Precision, startLoc.CFI)
endCFI := ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = webUsable(startLoc.Precision, startLoc.EndCFI)
}
if startCFI == "" {
startLoc = ConvertToCanonical(LocatorSourceKOReader, row.StartPosition, row.PctStart, row.SelectionText, "reflowable", reproEpub, "")
startCFI = webUsable(startLoc.Precision, startLoc.CFI)
endCFI = ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = startLoc.EndCFI
}
if endCFI == "" && row.EndPosition != "" {
endLoc := ConvertToCanonical(LocatorSourceKOReader, row.EndPosition, row.PctStart, "", "reflowable", reproEpub, "")
endCFI = webUsable(endLoc.Precision, endLoc.CFI)
}
if endCFI == "" && startCFI != "" && row.SelectionText != "" {
endCFI = reproExtendCFIByLength(startCFI, row.SelectionText)
}
}
oracleStart, oracleEnd, oracleNote := oracleCFIs(t, conv, row)
stats["oracleMiss:"+statusOr(oracleNote)]++
cls := func(a, b string) string {
ba, oa, okA := cfiBase(a)
bb, ob, okB := cfiBase(b)
switch {
case !okA || !okB || a == "" || b == "":
return "missing"
case ba != bb:
return "diff-base"
case ob <= oa:
return "backwards"
default:
return "healthy"
}
}
cur := cls(startCFI, endCFI)
sto := cls(row.StoredStart, row.StoredEnd)
ora := cls(oracleStart, oracleEnd)
stats["current:"+cur]++
stats["stored:"+sto]++
stats["oracle:"+ora]++
if os.Getenv("REPRO_VERBOSE") != "" || cur != "healthy" || sto != "healthy" {
t.Logf("row %s", row.ID[:8])
t.Logf(" raw: %s -> %s", row.StartPosition, row.EndPosition)
t.Logf(" ctx: %q", truncateRunes(row.SelectionText, 60))
t.Logf(" stored: %-38s %-38s [%s]", row.StoredStart, row.StoredEnd, sto)
t.Logf(" current: %-38s %-38s [%s]", startCFI, endCFI, cur)
t.Logf(" oracle: %-38s %-38s [%s] %s", oracleStart, oracleEnd, ora, oracleNote)
}
}
t.Logf("summary: %v", stats)
// REPRO_EMIT_SQL=<path>: emit UPDATE statements carrying the NEW
// converter output into the database rows this export came from
// (one-off healing of rows written by the broken converter).
if out := os.Getenv("REPRO_EMIT_SQL"); out != "" {
var b strings.Builder
b.WriteString("BEGIN;\n")
for _, row := range rows {
startLoc := ConvertHighlightRangeToCanonical(reproEpub, "reflowable", row.StartPosition, row.EndPosition, row.SelectionText, row.PctStart)
startCFI := webUsable(startLoc.Precision, startLoc.CFI)
endCFI := ""
if startCFI != "" && startLoc.EndCFI != "" {
endCFI = webUsable(startLoc.Precision, startLoc.EndCFI)
}
if startCFI == "" {
continue // leave untouched; legacy fallback rows keep old CFIs
}
b.WriteString(fmt.Sprintf(
"UPDATE media_highlights SET epubcfi_start=%s, epubcfi_end=%s WHERE id='%s';\n",
pgQuote(startCFI), pgQuote(endCFI), row.ID,
))
}
b.WriteString("COMMIT;\n")
if err := os.WriteFile(out, []byte(b.String()), 0o600); err != nil {
t.Fatalf("write sql: %v", err)
}
t.Logf("wrote %s", out)
}
}
func pgQuote(s string) string {
if s == "" {
return "''"
}
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
func statusOr(s string) string {
if s == "" {
return "found"
}
return s
}