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) } } }