Pad/internal/editor/wrap_index_test.go
Greg Pomerantz 83f7affee9 Fix word-wrap scroll jump: visual-line mapping via WrapIndex
Every scroll<->content mapping site (window start, sub-line shift, tap
mapping, max-scroll clamp, selection menu/handle positions) assumed
1 logical line = 1 visual line. When the viewport top crossed the
bottom of a wrapped line, the view jumped past the wrapped remainder
(jump magnitude (count-1)*lh) instead of moving pixel-by-pixel.

- WrapIndex (internal/editor/wrap_index.go): Fenwick tree of
  per-logical-line visual-line counts, parallel to the LineIndex;
  built at index-build time, bookkept by the same
  UpdateLineIndexAfter{Insert,Delete} hooks (never under-stale: every
  touched line resets to the estimate, the next shaping pass
  re-corrects it).
- scrollVisualDecompose: the scroll offset lives in visual-line space:
  k = LineForVisual(floor(s/lh)), r = s - V(k)*lh. All mapping sites
  go through it, so the viewport top is always exactly s into the
  document's visual space (V(k)*lh + r = s) — the jump invariant.
  All-ones index reduces to the legacy 1:1 mapping (pre-shaping and
  non-wrapped behavior unchanged by construction).
- Correction pipeline: the renderer's per-frame VisualLineStarts are
  grouped per logical line and written back (applyWrapCounts). The
  layout feedback now carries the exact window text the layout was
  shaped for (carried in the frame) plus the window start line and the
  content-edit counter; corrections apply only on edit-counter match,
  and grouping over the current window text (wrong after a scroll moved
  the window) is no longer possible.
- bytePosToScreenXY now applies the sub-line shift and the scaled line
  pitch: the selection menu/handles were off by up to a full line.
- maxScroll uses TotalVisuals() with the effective (font-scaled) line
  height; the bottom clamp lands exactly on the file end for wrapped
  content.
- VisibleByteRange returns the real start line (was hardcoded 0).
- emitFrame: replace the unread handoff frame with the newer snapshot
  instead of dropping it — a dropped final frame was never re-emitted
  (emission is event-driven), leaving the consumer one state behind
  forever; fixes the pre-existing TestRealFile_ShiftSelectionInsert
  failure. Still non-blocking.

Tests (mutation-verified where practical): wrap_index_test.go (Fenwick
vs naive model, 3000 ops), wrap_bookkeeping_test.go (edit hooks vs
shadow-string oracle, 400 ops — caught a real m=0 under-marking),
wrap_mapping_test.go (the jump regression: V(k)*lh + r == s over sweeps
+ random offsets; legacy-identity pin; boundary sweep), wrap_apply_test.go
(VisualLineStarts grouping + guards — the first version exposed the
always-true WindowStartByte guard that blocked all post-scroll
corrections). go test -race ./... green.

On-device (emulator, 60 wrapped lines): dp sweep 0/17/50/67/134/340
lands on LINE000-vl0/1/3, LINE001-vl0, LINE002-vl0, LINE005-vl0 —
pixel-exact 1:1, no jump (dp 134 is where the old code jumped to
LINE008); bottom clamp exact.

Docs: architecture.md §6.2 (visual-line space invariant),
development_plan.md (Phase 13), spec.md (wrap + clamp lines).
2026-08-17 19:46:47 -04:00

162 lines
4.4 KiB
Go

package editor
import (
"math/rand"
"testing"
)
// TestWrapIndex_NaiveModel is a property test: a random sequence of
// Set / InsertLines / DeleteLines operations on a WrapIndex must keep the
// Fenwick structure exactly consistent with a naive []int32 model.
func TestWrapIndex_NaiveModel(t *testing.T) {
rng := rand.New(rand.NewSource(7))
w := NewWrapIndex(50)
model := make([]int32, 50)
for i := range model {
model[i] = 1
}
naivePrefix := func(m []int32, k int) int32 {
var s int32
for i := 0; i < k && i < len(m); i++ {
s += m[i]
}
return s
}
naiveLineForVisual := func(m []int32, v int32) int {
if len(m) == 0 {
return 0
}
if v <= 0 {
return 0
}
var s int32
for i, c := range m {
s += c
if s > v {
return i
}
}
return len(m) - 1
}
ops := 0
for i := 0; i < 3000; i++ {
switch rng.Intn(4) {
case 0: // Set
if len(model) == 0 {
continue
}
li := rng.Intn(len(model))
c := int32(1 + rng.Intn(8))
w.Set(li, c)
model[li] = c
case 1: // InsertLines
pos := rng.Intn(len(model) + 1)
n := 1 + rng.Intn(5)
w.InsertLines(pos, n)
inserted := make([]int32, n)
for j := range inserted {
inserted[j] = 1
}
model = append(model[:pos], append(inserted, model[pos:]...)...)
case 2: // DeleteLines
if len(model) == 0 {
continue
}
pos := rng.Intn(len(model))
n := 1 + rng.Intn(min(5, len(model)-pos))
w.DeleteLines(pos, n)
model = append(model[:pos], model[pos+n:]...)
case 3: // SetRange (the shaped-window correction shape)
if len(model) == 0 {
continue
}
pos := rng.Intn(len(model))
n := 1 + rng.Intn(min(10, len(model)-pos))
counts := make([]int32, n)
for j := range counts {
counts[j] = int32(1 + rng.Intn(6))
model[pos+j] = counts[j]
}
w.SetRange(pos, counts)
}
ops++
// Full consistency check after every op.
if w.Len() != len(model) {
t.Fatalf("op %d (%d): Len = %d, model %d", i, ops, w.Len(), len(model))
}
for i := 0; i < w.Len(); i++ {
if w.Get(i) != model[i] {
t.Fatalf("op %d: Get(%d) = %d, model %d", i, i, w.Get(i), model[i])
}
}
// Prefix sums (V(k)) for a sample of k values plus the ends.
for _, k := range []int{0, 1, len(model) / 2, len(model) - 1, len(model), len(model) + 5} {
if got, want := w.VisualsBefore(k), naivePrefix(model, k); got != want {
t.Fatalf("op %d: VisualsBefore(%d) = %d, want %d", i, k, got, want)
}
}
if got, want := w.TotalVisuals(), naivePrefix(model, len(model)); got != want {
t.Fatalf("op %d: TotalVisuals = %d, want %d", i, got, want)
}
// LineForVisual for every visual line (bounded: total is small here).
if total := w.TotalVisuals(); total < 5000 {
for v := int32(0); v < total; v++ {
if got, want := w.LineForVisual(v), naiveLineForVisual(model, v); got != want {
t.Fatalf("op %d: LineForVisual(%d) = %d, want %d", i, v, got, want)
}
}
}
}
}
// TestWrapIndex_AllOnesIsIdentity pins the legacy-mapping property: an
// all-ones index (no wrap corrections applied) must map visual lines to
// logical lines 1:1, so pre-shaping behavior is exactly the old code path.
func TestWrapIndex_AllOnesIsIdentity(t *testing.T) {
w := NewWrapIndex(1000)
for v := int32(0); v < 1000; v++ {
if got := w.LineForVisual(v); got != int(v) {
t.Fatalf("LineForVisual(%d) = %d, want %d (all-ones must be identity)", v, got, v)
}
}
for k := 0; k <= 1000; k++ {
if got := w.VisualsBefore(k); got != int32(k) {
t.Fatalf("VisualsBefore(%d) = %d, want %d", k, got, k)
}
}
if got := w.TotalVisuals(); got != 1000 {
t.Fatalf("TotalVisuals = %d, want 1000", got)
}
}
// TestWrapIndex_LineForVisualWrapped checks the mapping against a hand-built
// wrap pattern: lines wrap as W = [1,3,1,2,1,...] so V(0)=0, V(1)=1,
// V(2)=4, V(3)=5, V(4)=7, V(5)=8.
func TestWrapIndex_LineForVisualWrapped(t *testing.T) {
w := NewWrapIndex(5)
counts := []int32{1, 3, 1, 2, 1}
w.SetRange(0, counts)
// Visual line v belongs to the smallest k with V(k+1) > v.
want := map[int32]int{
0: 0, 1: 1, 2: 1, 3: 1, // line 0: v0; line 1: v1..v3
4: 2, // line 2: v4
5: 3, 6: 3, // line 3: v5..v6
7: 4, // line 4: v7
8: 4, // clamped: v >= total(8) -> last line
99: 4,
}
for v, k := range want {
if got := w.LineForVisual(v); got != k {
t.Errorf("LineForVisual(%d) = %d, want %d", v, got, k)
}
}
if got := w.TotalVisuals(); got != 8 {
t.Errorf("TotalVisuals = %d, want 8", got)
}
}