Pad/internal/editor/tap_scroll_property_test.go
Greg Pomerantz e761436908 Prove tap-to-position is scroll-offset independent; fix float32 decomposition bug
The screen->line tap mapping only needs the sub-line scroll remainder
(tapLocalY adds r, never the full scroll) because the visible glyph
layout is window-relative and IMEWindowStartByte re-anchors it to the
file. That holds for every scroll offset IF the window start line
k=floor(s/lh) and the sub-line remainder r stay consistent.

Property test (4000 random scroll/tap pairs, asserting against an
independent drawn-geometry ground truth, not the tap code's own math)
exposed a real bug: int(s/lh) in the Dp float32 domain rounds the
quotient to nearest and can round UP across an integer boundary while
the float64 mod still reflects the line below. In a sub-pixel-wide
band of scroll offsets the window started one line too far while the
draw shift lagged by one line - the whole rendered window (and every
tapped line) shifted by one.

Fix: one shared float64 floor decomposition (scrollDecompose) used by
the window start (visibleByteRangePrecise/Estimate), the renderer's
sub-line shift (visibleScrollOffset), the tap mapping (tapLocalY), and
chunk prefetching. Also route the shaper's line height (previously
ignored by visibleByteRangePrecise) through VisibleByteRange.

On-device cross-check: at s=4246.9 (r=13.3) and s=4210.7 (r=10.7),
taps on visually identified lines typed markers that landed on exactly
those lines in the file on disk; profiler ScrollDP, screenshot,
formula, and disk all agreed.

Docs: scroll decomposition invariant in architecture.md §6.2, pipeline
hop 2 in doc/README.md, Phase 10 in development_plan.md.
2026-08-17 09:57:21 -04:00

133 lines
4.6 KiB
Go

package editor
import (
"fmt"
"math"
"math/rand"
"testing"
"pad/internal/io/pool/types"
"pad/internal/ui"
)
// This file proves the scroll-offset independence of tap-to-position.
//
// The invariant under test, for a no-wrap file where content line n occupies
// content-y [n*lh, (n+1)*lh):
//
// For every scroll offset s >= 0 and every tap point ptY inside the visible
// region, the cursor must land on the content line whose DRAWN range
// contains the tap. Drawn range of window display line j is
// [reg.Y - r + j*lh, reg.Y - r + (j+1)*lh), where r = s mod lh (the
// renderer shifts the windowed layout up by exactly r:
// `y := reg.Y - scrollOffset` in drawWrappedText, scrollOffset =
// Mod(ScrollOffset, lineHeight)).
//
// Independent ground truth: content_y = (ptY - reg.Y) + s, so the tapped
// content line is floor(content_y / lh). That formula uses only "the content
// is shifted up by s" — it does NOT reuse tapLocalY.
//
// The code path under test is HandleTapAt -> tapLocalY -> SetCursorFromPoint
// -> textPosFromLocalPoint, with a GlyphLayout fabricated the way the
// windowed layout is (window display line j = content line k+j, baseline
// (j+1)*lh, window-relative byte offsets) and IMEWindowStartByte set to the
// same line k = floor(s/lh) that visibleByteRangePrecise picks
// (startLine := int(scrollOffset / lineHeight)).
//
// If the window-start coupling, the draw shift, or tapLocalY's sub-line
// remainder ever disagree, this test fails for some (s, ptY) pair.
func TestTapPosition_ScrollInvariant_Property(t *testing.T) {
const (
lines = 500
bytesPerLine = 5 // "L123\n"
regionTop = float64(62) // editor region top, app dp (matches layoutFrame)
viewportH = float64(760) // app dp
)
lineHeight := float64(EditorLineHeight())
// Build the file once.
var file string
for i := 0; i < lines; i++ {
file += fmt.Sprintf("L%03d\n", i)
}
cb := NewChunkedBuffer("/prop.txt", DefaultChunkSize, nil, "")
cb.SetContent([]byte(file))
// Build the same line index the BuildLineIndex task produces.
offsets := []int32{0}
for i := 0; i < len(file); i++ {
if file[i] == '\n' {
offsets = append(offsets, int32(i+1))
}
}
cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(file)))
rng := rand.New(rand.NewSource(42))
maxS := float64(lines) * lineHeight
for tc := 0; tc < 4000; tc++ {
// Random scroll offset: a mix of small, large, integer-line and
// fractional-line values.
var s float64
switch tc % 4 {
case 0:
s = rng.Float64() * maxS // anywhere
case 1:
s = float64(int(rng.Float64() * maxS)) // exact integer dp
case 2:
s = float64(rng.Intn(lines)) * lineHeight // exact line boundary
case 3:
s = float64(rng.Intn(lines))*lineHeight + rng.Float64()*(lineHeight-1) // near boundary
}
sOff := ui.Dp(s)
// What layoutFrame/visibleByteRangePrecise does: window starts at
// content line k = floor(s / lh).
start, _, _ := cb.VisibleByteRange(sOff, 0, ui.Dp(viewportH), EditorLineHeight(), false, ui.GlyphLayout{}, nil)
if start%bytesPerLine != 0 {
t.Fatalf("test setup: window start %d not on a line boundary", start)
}
k := start / bytesPerLine
// Fabricate the windowed GlyphLayout exactly as the shaper would for
// a no-wrap file: window display line j = content line k+j.
nWin := int(math.Min(viewportH/lineHeight+2, float64(lines-k)))
var gl ui.GlyphLayout
for j := 0; j < nWin; j++ {
gl.ByteOffsets = append(gl.ByteOffsets, j*bytesPerLine)
gl.X = append(gl.X, 10)
gl.Y = append(gl.Y, ui.Dp(float64(j+1)*lineHeight))
gl.Advance = append(gl.Advance, 10)
}
TheState = NewState()
TheState.Editor.ChunkedBuffer = cb
TheState.Editor.GlyphLayout = gl
TheState.Editor.IMEWindowStartByte = start
TheState.ScrollOffset = sOff
TheState.EditorRegion = ui.Region{X: 10, Y: ui.Dp(regionTop), W: 390, H: ui.Dp(viewportH)}
// Random tap inside the visible region.
a := rng.Float64() * viewportH // tap y relative to region top
ptY := ui.Dp(regionTop + a)
HandleTapAt(15, ptY)
// Independent ground truth: content_y = a + s -> content line.
wantLine := int(math.Floor((a + s) / lineHeight))
if wantLine >= lines {
wantLine = lines - 1
}
// Taps below the last shaped window line clamp to it (no glyphs
// further down), matching textPosFromLocalPoint's documented clamp.
if k+nWin-1 < wantLine {
wantLine = k + nWin - 1
}
got := TheState.Editor.CursorPosition
wantLo, wantHi := wantLine*bytesPerLine, wantLine*bytesPerLine+bytesPerLine
if got < wantLo || got >= wantHi {
t.Fatalf("s=%.2f (k=%d) tap a=%.2f: cursor=%d, want line %d [bytes %d,%d)",
s, k, a, got, wantLine, wantLo, wantHi)
}
}
}