package editor import ( "math" "math/rand" "testing" "pad/internal/ui" ) // TestFindScrollTarget_PlacesMatchAtViewportTop is the regression test for // "in a long file the viewport is not positioned properly when I search." // // The visual line at which logical line li starts is exactly V(li) = // VisualsBefore(li) — the visual lines of all lines BEFORE it. The old code // computed li + VisualsBefore(li), double-counting li: with the all-ones // pre-shape estimates that is 2*li, so the viewport landed at twice the // intended depth. On small files the MaxScroll clamp masked it (everything // still fit / clamped to the bottom); on long files it was wildly wrong. func TestFindScrollTarget_PlacesMatchAtViewportTop(t *testing.T) { const n = 2000 // a "long" file rng := rand.New(rand.NewSource(3)) counts := make([]int32, n) for i := range counts { counts[i] = int32(1 + rng.Intn(4)) // each line wraps into 1..4 visual lines } cb := newTestBufferForWrap(n, counts) TheState = NewState() TheState.Editor.ChunkedBuffer = cb // Large MaxScroll so the clamp never interferes with the mapping itself. totalVisuals := float64(cb.WrapIndex.TotalVisuals()) lh := float64(EffectiveLineHeight()) TheState.MaxScroll = ui.Dp(totalVisuals * lh) w := cb.WrapIndex // Every line i is "x\n" (2 bytes) except the last ("x"), so line i starts // at byte i*2. for _, li := range []int{0, 1, 137, 1500, n - 2, n - 1} { absByte := li * 2 target, ok := findScrollTarget(absByte) if !ok { t.Fatalf("line %d: findScrollTarget ok=false", li) } want := ui.Dp(math.Ceil(float64(w.VisualsBefore(li)) * lh)) if target != want { t.Fatalf("line %d: target = %v, want ceil(V(%d)*lh) = %v (the visual line at which the line starts)", li, target, li, want) } // The inverse mapping must put the viewport top back on logical line li. TheState.ScrollOffset = target k, _ := scrollVisualDecompose() if k != li { t.Fatalf("line %d: decompose(target) = %d, want %d (round-trip failed)", li, k, li) } // Guard against the old double-count: the buggy value was // (li + V(li))*lh, which for li > 0 is never the correct target. if li > 0 { if buggy := ui.Dp(math.Ceil(float64(int64(li)+int64(w.VisualsBefore(li))) * lh)); target == buggy { t.Fatalf("line %d: target %v equals the old double-count value", li, target) } } } } // TestFindScrollTarget_NoWrapIsIdentity pins the all-ones (pre-shape / // word-wrap-off) case: the target is exactly li*lineHeight, matching the // legacy mapping and the nil-WrapIndex fallback. func TestFindScrollTarget_NoWrapIsIdentity(t *testing.T) { const n = 500 counts := make([]int32, n) for i := range counts { counts[i] = 1 } cb := newTestBufferForWrap(n, counts) TheState = NewState() TheState.Editor.ChunkedBuffer = cb lh := float64(EffectiveLineHeight()) TheState.MaxScroll = ui.Dp(float64(n) * lh) for _, li := range []int{0, 1, 42, n - 1} { target, ok := findScrollTarget(li * 2) if !ok { t.Fatalf("line %d: ok=false", li) } if want := ui.Dp(math.Ceil(float64(li) * lh)); target != want { t.Fatalf("line %d: target = %v, want ceil(li*lh) = %v", li, target, want) } } } // TestFindSettle_CorrectsAfterWrapCounts verifies the post-jump correction: // the search scroll arms a settle with the estimate-based offset; when the // (simulated) shaping pass corrects the wrap counts, findSettle re-scrolls // to the accurate position and then converges. func TestFindSettle_CorrectsAfterWrapCounts(t *testing.T) { const n = 1000 counts := make([]int32, n) for i := range counts { counts[i] = 1 // pre-shape estimates } cb := newTestBufferForWrap(n, counts) TheState = NewState() TheState.Editor.ChunkedBuffer = cb lh := float64(EffectiveLineHeight()) TheState.MaxScroll = ui.Dp(float64(4*n) * lh) const li = 500 absByte := li * 2 e := &TheState.Editor e.Find = FindState{Visible: true} target, ok := findScrollTarget(absByte) if !ok { t.Fatal("findScrollTarget ok=false") } TheState.ScrollOffset = target e.Find.SettleByte = absByte e.Find.SettleScroll = target e.Find.SettlePasses = 4 // Simulate the shaping pass: every line above the target wraps into 3 // visual lines. The estimate-based offset is now far too small. for i := 0; i < li; i++ { cb.WrapIndex.Set(i, 3) } if !e.findSettle() { t.Fatal("findSettle did not re-scroll after the wrap-count correction") } want := ui.Dp(math.Ceil(float64(cb.WrapIndex.VisualsBefore(li)) * lh)) if TheState.ScrollOffset != want { t.Fatalf("settled offset %v, want %v", TheState.ScrollOffset, want) } if k, _ := scrollVisualDecompose(); k != li { t.Fatalf("decompose(settled) = %d, want %d", k, li) } if e.Find.SettlePasses != 3 { t.Fatalf("passes %d, want 3", e.Find.SettlePasses) } // No further correction: the settle converges and disarms. if e.findSettle() { t.Fatal("findSettle did not converge on a stable offset") } if e.Find.SettleByte != -1 { t.Fatalf("SettleByte %d, want -1 after convergence", e.Find.SettleByte) } } // TestFindSettle_CancelsOnUserScroll: if anything other than the settle // moves the viewport, the settle gives up (no yanking the view back). func TestFindSettle_CancelsOnUserScroll(t *testing.T) { const n = 200 counts := make([]int32, n) for i := range counts { counts[i] = 1 } cb := newTestBufferForWrap(n, counts) TheState = NewState() TheState.Editor.ChunkedBuffer = cb lh := float64(EffectiveLineHeight()) TheState.MaxScroll = ui.Dp(float64(n) * lh) const li = 100 e := &TheState.Editor e.Find = FindState{Visible: true} target, _ := findScrollTarget(li * 2) TheState.ScrollOffset = target e.Find.SettleByte = li * 2 e.Find.SettleScroll = target e.Find.SettlePasses = 4 // The user scrolls away. TheState.ScrollOffset = target + ui.Dp(5*lh) if e.findSettle() { t.Fatal("findSettle re-scrolled after a user scroll") } if e.Find.SettleByte != -1 { t.Fatal("settle not cancelled by a user scroll") } }