package editor import ( "math/rand" "testing" "pad/internal/io/pool/types" "pad/internal/ui" ) // TestScrollVisualDecompose_ViewportIdentity is the regression test for the // word-wrap scroll jump: when the viewport top crosses the bottom of a // wrapped logical line, the view jumped past the wrapped remainder instead of // moving pixel-by-pixel with the finger. // // The invariant under test: the document visual offset at the viewport top is // ALWAYS exactly the scroll offset s. If k is the logical line at the viewport // top with sub-line shift r, the viewport top sits V(k)*lh + r into the // document (V(k) = visual lines before line k, each lh tall, plus r into the // next). Any mapping that does not satisfy V(k)*lh + r == s silently skips or // re-shows content — the jump. // // Before the fix, all mapping sites assumed V(k) == k (1 logical line = 1 // visual line), so for any wrapped file the identity failed by exactly // (V(k)-k)*lh once the first wrapped line had been scrolled past. func TestScrollVisualDecompose_ViewportIdentity(t *testing.T) { const n = 500 // logical lines rng := rand.New(rand.NewSource(11)) 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) lh := float64(EffectiveLineHeight()) w := cb.WrapIndex totalVisuals := float64(w.TotalVisuals()) maxS := (totalVisuals - 1) * lh // stay inside the last visual line check := func(s ui.Dp, label string) { TheState.ScrollOffset = s k, r := scrollVisualDecompose() if k < 0 || k >= n { t.Fatalf("%s: k = %d out of range [0,%d)", label, k, n) } // r = s - V(k)*lh places the viewport top r below the TOP of line k; // for a wrapped line the top may be several visual lines above the // viewport, so r spans [0, count(k)*lh). if r < -1e-9 || r >= float64(w.Get(k))*lh+1e-9 { t.Fatalf("%s: r = %f outside [0, count(k)*lh = %f*lh)", label, r, float64(w.Get(k))) } // The viewport top must lie inside line k's visual span. vk := float64(w.VisualsBefore(k)) if vk*lh > float64(s)+1e-9 { t.Fatalf("%s: s = %f is above line k's span start V(k)*lh = %f", label, float64(s), vk*lh) } if k+1 < n && float64(s) >= float64(w.VisualsBefore(k+1))*lh-1e-9 { t.Fatalf("%s: s = %f is at/past line %d's span start (k should be >= %d)", label, float64(s), k+1, k+1) } // The identity: the viewport top is exactly s into the document's // visual space. got := vk*lh + r if d := got - float64(s); d < -1e-9 || d > 1e-9 { t.Fatalf("%s: s = %f: V(k)*lh + r = %f, want s = %f (off by %f — a scroll jump)", label, float64(s), got, float64(s), d) } // k must be the logical line containing the viewport-top visual line. v0 := int32(float64(s) / lh) if want := w.LineForVisual(v0); k != want { t.Fatalf("%s: s = %f: k = %d, want LineForVisual(%d) = %d", label, float64(s), k, v0, want) } } // Sub-line sweep in fine steps across the whole document. steps := 4000 for i := 0; i <= steps; i++ { s := ui.Dp(maxS * float64(i) / float64(steps)) check(s, "sweep") } // Random offsets (arbitrary sub-line positions). for i := 0; i < 2000; i++ { s := ui.Dp(rng.Float64() * maxS) check(s, "random") } } // TestScrollVisualDecompose_LegacyIdentity pins the legacy behavior: with an // all-ones WrapIndex (no wrap corrections applied, i.e. pre-shaping) or no // WrapIndex at all, the mapping must be exactly the old 1:1 mapping, so // non-wrapped files and pre-shaping frames are unchanged. func TestScrollVisualDecompose_LegacyIdentity(t *testing.T) { const n = 100 cb := newTestBufferForWrap(n, nil) // all ones cb.WrapIndex.ResetAll() lh := float64(EffectiveLineHeight()) maxS := float64(n)*lh - lh/2 rng := rand.New(rand.NewSource(13)) check := func(s ui.Dp, label string) { TheState.ScrollOffset = s k, r := scrollVisualDecompose() v0, r0 := scrollDecompose(s, ui.Dp(lh)) if float64(k) != float64(v0) || r != r0 { t.Fatalf("%s: s = %f: (k,r) = (%d,%f), want legacy (v0,r0) = (%d,%f)", label, float64(s), k, r, v0, r0) } } steps := 500 for i := 0; i <= steps; i++ { check(ui.Dp(maxS*float64(i)/float64(steps)), "sweep") } for i := 0; i < 500; i++ { check(ui.Dp(rng.Float64()*maxS), "random") } // No WrapIndex at all (the String-Buffer fallback / pre-build state). cb.WrapIndex = nil for i := 0; i < 100; i++ { check(ui.Dp(rng.Float64()*maxS), "no-wi") } } // TestScrollVisualDecompose_WrappedBoundarySweep walks the scroll offset // across every wrapped-line boundary and checks that the viewport top moves // pixel-by-pixel: the logical line at the top advances by exactly the number // of visual lines the previous line occupies, and the fixed-line position // identity T(i) = V(i)*lh - s holds across the boundary (no skip, no repeat). func TestScrollVisualDecompose_WrappedBoundarySweep(t *testing.T) { counts := []int32{1, 3, 1, 2, 4, 1} cb := newTestBufferForWrap(len(counts), counts) lh := float64(EffectiveLineHeight()) w := cb.WrapIndex eps := lh / 8 var prevK int var prevS float64 for v := int32(0); v < w.TotalVisuals(); v++ { for _, s := range []ui.Dp{ui.Dp(float64(v)*lh - eps/2), ui.Dp(float64(v) * lh), ui.Dp(float64(v)*lh + eps/2)} { if float64(s) < 0 { continue } TheState.ScrollOffset = s k, r := scrollVisualDecompose() if k < prevK { t.Fatalf("s = %f: k decreased (%d -> %d): content re-shown", float64(s), prevK, k) } // A fixed line moves exactly finger-speed across the boundary: // T(i) = V(i)*lh - s for the line that was at the top before. if i, ok := firstVisualToLogical(w, v-1); ok && k > i { Tbefore := float64(w.VisualsBefore(i))*lh - prevS Tafter := float64(w.VisualsBefore(i))*lh - float64(s) if d := (Tbefore - Tafter) - (float64(s) - prevS); d < -1e-9 || d > 1e-9 { t.Fatalf("s = %f: fixed line moved by %f for scroll delta %f (jump!)", float64(s), Tbefore-Tafter, float64(s)-prevS) } } if k == prevK && float64(s) > prevS { // Same top line: the sub-line shift must advance by the delta. if d := (r - (prevS - float64(w.VisualsBefore(k))*lh)) - (float64(s) - prevS); d < -1e-9 || d > 1e-9 { t.Fatalf("s = %f: r advanced by %f for delta %f", float64(s), d, float64(s)-prevS) } } _ = r prevK, prevS = k, float64(s) } } } // firstVisualToLogical maps visual line v to its logical line (mirrors // LineForVisual; the test wants a named helper for readability). func firstVisualToLogical(w *WrapIndex, v int32) (int, bool) { if v < 0 { return 0, false } return w.LineForVisual(v), true } // newTestBufferForWrap builds a minimal editor State backed by a chunked // buffer whose WrapIndex is seeded with the given per-line visual counts // (nil = all ones). It points TheState at it for the scroll mapping under // test. The buffer content is one short line per logical line; only the // WrapIndex matters to the mapping. func newTestBufferForWrap(n int, counts []int32) *ChunkedBuffer { var buf []byte for i := 0; i < n; i++ { buf = append(buf, 'x') if i < n-1 { buf = append(buf, '\n') } } cb := NewChunkedBuffer("/wrap-map.txt", DefaultChunkSize, nil, "") cb.SetContent(buf) offsets := []int32{0} for i := 0; i < len(buf); i++ { if buf[i] == '\n' { offsets = append(offsets, int32(i+1)) } } cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(buf))) w := NewWrapIndex(len(offsets)) if counts != nil { for i, c := range counts { w.Set(i, c) } } cb.WrapIndex = w TheState = NewState() TheState.Editor.ChunkedBuffer = cb TheState.Editor.GlyphLayout = ui.GlyphLayout{} // LineHeight 0 -> EffectiveLineHeight TheState.ScrollOffset = 0 return cb }