Pad/internal/editor/cursor_test.go
Greg Pomerantz c22c21c872 editor: add HandleEnd window-base regression test
Cover the base-offset conversion in HandleEnd (the path that slices the full
file content by the end-of-line offset), mirroring the tap-to-position
regression test.
2026-08-16 21:03:03 -04:00

222 lines
8.8 KiB
Go

package editor
import (
"testing"
"pad/internal/ui"
)
// TestCursorPositioning_Basic verifies cursor positioning via GlyphLayout.
func TestCursorPositioning_Basic(t *testing.T) {
TheState = NewState()
TheState.Editor.Buffer = "hello\nworld"
// Two lines: "hello\n" (bytes 0-5) and "world" (bytes 6-10)
// Line 0 glyphs at Y=70, Line 1 glyphs at Y=140
TheState.Editor.GlyphLayout = ui.GlyphLayout{
ByteOffsets: []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
X: []ui.Dp{10, 20, 30, 40, 50, 10, 20, 30, 40, 50, 60},
Y: []ui.Dp{70, 70, 70, 70, 70, 70, 140, 140, 140, 140, 140},
Advance: []ui.Dp{10, 10, 10, 10, 10, 10, 10, 10, 10, 10, 10},
}
// Test tapping on first line near X=30 (glyph index 2, byteOffset 2)
SetCursorFromPoint(25, 70)
if TheState.Editor.CursorPosition < 0 || TheState.Editor.CursorPosition > len(TheState.Editor.Buffer) {
t.Errorf("cursor position %d out of bounds after first tap", TheState.Editor.CursorPosition)
}
// Test tapping on second line
SetCursorFromPoint(35, 140)
if TheState.Editor.CursorPosition < 0 || TheState.Editor.CursorPosition > len(TheState.Editor.Buffer) {
t.Errorf("cursor position %d out of bounds after second tap", TheState.Editor.CursorPosition)
}
}
// TestCursorPositioning_EmptyDocument verifies cursor positioning handles empty documents.
func TestCursorPositioning_EmptyDocument(t *testing.T) {
TheState = NewState()
TheState.Editor.Buffer = ""
TheState.Editor.GlyphLayout = ui.GlyphLayout{}
// Should not panic on empty layout
SetCursorFromPoint(50, 50)
if TheState.Editor.CursorPosition != 0 {
t.Errorf("expected cursor at 0 for empty document, got %d", TheState.Editor.CursorPosition)
}
}
// TestCursorPositioning_IncompleteLayout verifies cursor positioning handles missing layout slices.
func TestCursorPositioning_IncompleteLayout(t *testing.T) {
TheState = NewState()
TheState.Editor.Buffer = "test"
// GlyphLayout with ByteOffsets and X but no Advance (partial data)
TheState.Editor.GlyphLayout = ui.GlyphLayout{
ByteOffsets: []int{0, 1, 2, 3},
X: []ui.Dp{10, 20, 30, 40},
Y: []ui.Dp{70, 70, 70, 70},
// Advance intentionally omitted
}
// Should not panic when Advance is missing
SetCursorFromPoint(25, 70)
}
// TestHandleCursorMove_Bounds verifies cursor movement stays within buffer bounds.
func TestHandleCursorMove_Bounds(t *testing.T) {
TheState = NewState()
TheState.Editor.Buffer = "hello"
TheState.Editor.CursorPosition = 0
// Move right 3 times
HandleCursorMove(1)
HandleCursorMove(1)
HandleCursorMove(1)
if TheState.Editor.CursorPosition != 3 {
t.Errorf("expected cursor at 3, got %d", TheState.Editor.CursorPosition)
}
// Move right past end — should clamp to buffer length
HandleCursorMove(10)
if TheState.Editor.CursorPosition != len(TheState.Editor.Buffer) {
t.Errorf("expected cursor at %d (end of buffer), got %d", len(TheState.Editor.Buffer), TheState.Editor.CursorPosition)
}
// Move left past start — should clamp to 0
HandleCursorMove(-100)
if TheState.Editor.CursorPosition != 0 {
t.Errorf("expected cursor at 0, got %d", TheState.Editor.CursorPosition)
}
}
// TestTapLocalY_WindowRelative is a regression test for the tap-to-position-cursor
// coordinate bug: the tap's text-local Y must be WINDOW-relative (layout.Y==0 is
// the top of the visible window), so it must NOT include the full scroll offset.
// The old code added the full ScrollOffset, which made visualLine a huge
// content-line number that clamped the cursor to the bottom of the viewport on
// any large file.
func TestTapLocalY_WindowRelative(t *testing.T) {
lh := float64(EditorLineHeight())
// Tap 100 Dp below a region top that starts at 114 Dp, with a deep scroll
// (a large file scrolled far down).
got := tapLocalY(214, 114, 1539464)
// Expected: (214-114) + (1539464 mod lineHeight) => in [100, 100+lineHeight).
if got < 100 || got >= 100+lh {
t.Errorf("tapLocalY = %v, want in [100, %v) (window-relative)", got, 100+lh)
}
// Regression: the buggy version returned ~1,539,564 (full scroll leaked in).
if got > 1000 {
t.Errorf("tapLocalY = %v, full scroll offset leaked in (must be window-relative)", got)
}
}
// TestTapToPosition_WindowBaseOffset is a regression test for the
// window-relative GlyphLayout bug: the GlyphLayout is shaped from the visible
// window alone, so its ByteOffsets are relative to the window start
// (IMEWindowStartByte). SetCursorFromPoint must add that base to produce an
// absolute file offset. Before the fix, a tap on a scrolled large file set the
// cursor to a window-relative offset (near the file start), so the IME
// selection snapped to the window top on every tap.
func TestTapToPosition_WindowBaseOffset(t *testing.T) {
lh := float64(EditorLineHeight())
const lines = 40
gl := ui.GlyphLayout{LineHeight: EditorLineHeight()}
for i := 0; i < lines; i++ {
for _, x := range []ui.Dp{10, 20} {
gl.ByteOffsets = append(gl.ByteOffsets, i*2) // window-relative
gl.X = append(gl.X, x)
gl.Y = append(gl.Y, ui.Dp(float64(i+1)*lh))
gl.Advance = append(gl.Advance, 10)
}
}
TheState = NewState()
TheState.Editor.GlyphLayout = gl
TheState.Editor.IMEWindowStartByte = 47618 // window starts deep in the file
TheState.ScrollOffset = 16500
// Tap ~3 lines below the top of the window.
regionTop := ui.Dp(114)
ptY := regionTop + ui.Dp(3*lh)
localY := tapLocalY(ptY, regionTop, TheState.ScrollOffset)
SetCursorFromPoint(15, localY)
// Window line 3 is window-relative byte 6, so the absolute cursor must be
// base (47618) + 6 = 47624. Before the fix it would be 6 (window-relative),
// which is the file start, not the tapped line.
want := 47618 + 6
if TheState.Editor.CursorPosition < want-2 || TheState.Editor.CursorPosition > want+2 {
t.Errorf("cursor = %d, want %d (base 47618 + window byte 6); window-relative offset bug?", TheState.Editor.CursorPosition, want)
}
// Regression: the buggy cursor would be ~6 (window-relative, near file start).
if TheState.Editor.CursorPosition < 100 {
t.Errorf("cursor = %d, window-relative offset leaked (should be absolute ~%d)", TheState.Editor.CursorPosition, want)
}
}
// TestHandleEnd_WindowBaseOffset is a regression test for the window-relative
// GlyphLayout bug in HandleEnd. The layout covers only the visible window
// (base = IMEWindowStartByte), so its ByteOffsets are window-relative.
// HandleEnd converts the end-of-line position to an absolute file offset
// (base + window offset) before using it to index the full file content.
// Before the fix it used the window-relative offset, landing on the previous
// line's newline.
func TestHandleEnd_WindowBaseOffset(t *testing.T) {
lh := float64(EditorLineHeight())
// File: line0 "AAAAAAAA\n" (bytes 0-8), line1 "BBBBBBBB\n" (bytes 9-17).
// Window starts at byte 9 (line 1). Window-relative offsets: B=0..7, \n=8.
gl := ui.GlyphLayout{LineHeight: EditorLineHeight()}
for i := 0; i < 9; i++ {
gl.ByteOffsets = append(gl.ByteOffsets, i)
gl.X = append(gl.X, ui.Dp(10+10*i))
gl.Y = append(gl.Y, ui.Dp(lh)) // single visual line
gl.Advance = append(gl.Advance, 10)
}
TheState = NewState()
TheState.Editor.Buffer = "AAAAAAAA\nBBBBBBBB\n"
TheState.Editor.GlyphLayout = gl
TheState.Editor.IMEWindowStartByte = 9
TheState.Editor.CursorPosition = 12 // line 1, 4th B (absolute)
HandleEnd()
// The end of line 1 is its newline at absolute byte 17. Before the fix the
// window-relative offset (8) indexed the previous line's newline (byte 8).
if TheState.Editor.CursorPosition != 17 {
t.Errorf("HandleEnd cursor = %d, want 17 (newline ending line 1); window-relative offset bug?", TheState.Editor.CursorPosition)
}
}
// TestTapToPosition_LargeFileScrolled is the end-to-end regression: with a
// window-relative GlyphLayout and a deep scroll, a tap a few lines below the top
// of the window must land on that window line, NOT be clamped to the last window
// line (the pre-fix behavior on large files).
func TestTapToPosition_LargeFileScrolled(t *testing.T) {
lh := float64(EditorLineHeight())
const lines = 40
gl := ui.GlyphLayout{LineHeight: EditorLineHeight()}
for i := 0; i < lines; i++ {
for _, x := range []ui.Dp{10, 20} {
gl.ByteOffsets = append(gl.ByteOffsets, i*2)
gl.X = append(gl.X, x)
gl.Y = append(gl.Y, ui.Dp(float64(i+1)*lh))
gl.Advance = append(gl.Advance, 10)
}
}
TheState = NewState()
TheState.Editor.GlyphLayout = gl
TheState.ScrollOffset = 1539464 // deep into a large file
// Tap ~3 lines below the top of the window.
regionTop := ui.Dp(114)
ptY := regionTop + ui.Dp(3*lh)
localY := tapLocalY(ptY, regionTop, TheState.ScrollOffset)
SetCursorFromPoint(15, localY)
// Should land on window line 3 (byte offset 6), NOT be clamped to the last
// window line (line 39, byte offset 78).
if TheState.Editor.CursorPosition < 4 || TheState.Editor.CursorPosition > 8 {
t.Errorf("cursor = %d, want window line 3 (byte 6); large-file scroll clamp regression?", TheState.Editor.CursorPosition)
}
}