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:
1 parent
93a468b435
commit
03a6040360
1 file changed
+379
@@ -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
|
||||||
|
}
|
||||||
Reference in new issue
Block a user