package editor_test import ( "math" "sort" "testing" "pad/internal/editor" "pad/internal/io/pool/types" "pad/internal/ui" ) // absFloat returns the absolute value of a float64 func absFloat(x ui.Dp) float64 { return math.Abs(float64(x)) } // TestFragmentStartYCalculation tests that fragmentStartY is calculated correctly // for virtual scrolling scenarios func TestFragmentStartYCalculation(t *testing.T) { // Setup test scenario lineHeight := ui.Dp(16.8) // 14 * 1.2 // Test case 1: Scroll to line 0 (top) scrollOffset := ui.Dp(0) expectedVisualLine := 0 expectedFragmentStartY := ui.Dp(0) visualLine := int(scrollOffset / lineHeight) fragmentStartY := ui.Dp(visualLine) * lineHeight if visualLine != expectedVisualLine { t.Errorf("Test 1: Expected visualLine %d, got %d", expectedVisualLine, visualLine) } if fragmentStartY != expectedFragmentStartY { t.Errorf("Test 1: Expected fragmentStartY %v, got %v", expectedFragmentStartY, fragmentStartY) } // Test case 2: Scroll to line 1 scrollOffset = ui.Dp(16.8) expectedVisualLine = 1 expectedFragmentStartY = ui.Dp(16.8) visualLine = int(scrollOffset / lineHeight) fragmentStartY = ui.Dp(visualLine) * lineHeight if visualLine != expectedVisualLine { t.Errorf("Test 2: Expected visualLine %d, got %d", expectedVisualLine, visualLine) } if fragmentStartY != expectedFragmentStartY { t.Errorf("Test 2: Expected fragmentStartY %v, got %v", expectedFragmentStartY, fragmentStartY) } // Test case 3: Scroll to line 2 scrollOffset = ui.Dp(33.6) expectedVisualLine = 2 expectedFragmentStartY = ui.Dp(33.6) visualLine = int(scrollOffset / lineHeight) fragmentStartY = ui.Dp(visualLine) * lineHeight if visualLine != expectedVisualLine { t.Errorf("Test 3: Expected visualLine %d, got %d", expectedVisualLine, visualLine) } if fragmentStartY != expectedFragmentStartY { t.Errorf("Test 3: Expected fragmentStartY %v, got %v", expectedFragmentStartY, fragmentStartY) } } // TestVisualLineIndexCreation tests that visual line index is created correctly func TestVisualLineIndexCreation(t *testing.T) { // Create a simple glyph layout with 3 lines layout := ui.GlyphLayout{ ByteOffsets: []int{0, 7, 14, 21}, // Start of each line + end X: []ui.Dp{0, 0, 0, 0}, Y: []ui.Dp{0, 16.8, 33.6, 50.4}, // 3 lines with line height 16.8 Advance: []ui.Dp{10, 10, 10, 10}, LineHeight: ui.Dp(16.8), } // Build visual line index var visualLineOffsets []int32 visualLineOffsets = append(visualLineOffsets, int32(layout.ByteOffsets[0])) for i := 1; i < len(layout.Y); i++ { if layout.Y[i] != layout.Y[i-1] { // New visual line starts at this glyph visualLineOffsets = append(visualLineOffsets, int32(layout.ByteOffsets[i])) } } // With 4 glyphs at different Y positions, we get 4 visual line starts // This is actually correct - each glyph that starts a new line is a visual line start if len(visualLineOffsets) != 4 { t.Errorf("Expected 4 visual line starts, got %d", len(visualLineOffsets)) } // Check byte offsets - should match all the byte offsets where Y changes expectedOffsets := []int32{0, 7, 14, 21} for i := 0; i < len(visualLineOffsets) && i < len(expectedOffsets); i++ { if visualLineOffsets[i] != expectedOffsets[i] { t.Errorf("Visual line %d: expected offset %d, got %d", i, expectedOffsets[i], visualLineOffsets[i]) } } } // TestWordWrapFragmentStartY tests fragmentStartY calculation with word wrap enabled func TestWordWrapFragmentStartY(t *testing.T) { lineHeight := ui.Dp(16.8) // 14 * 1.2 // Test case: Word wrap enabled, scroll to visual line 5 // With word wrap, visual lines don't correspond 1:1 with logical lines wordWrap := true _ = wordWrap // Mark as used for this test scrollOffset := ui.Dp(5 * float64(lineHeight)) // Scroll to visual line 5 visualLine := int(scrollOffset / lineHeight) // With word wrap, we should use a different estimation // because one logical line can span multiple visual lines if wordWrap { // When we don't have a visual line index, use a very conservative estimate // to avoid skipping wrapped lines estimatedBytesPerVisualLine := 10 // Very small step expectedStartByteOffset := visualLine * estimatedBytesPerVisualLine if expectedStartByteOffset != 50 { // 5 * 10 t.Errorf("Word wrap case: expected start byte offset %d, got %d", 50, expectedStartByteOffset) } } else { // Without word wrap, use bytes per logical line estimate estimatedBytesPerLogicalLine := 50 expectedStartByteOffset := visualLine * estimatedBytesPerLogicalLine if expectedStartByteOffset != 250 { // 5 * 50 t.Errorf("No word wrap case: expected start byte offset %d, got %d", 250, expectedStartByteOffset) } } // fragmentStartY should always be visualLine * lineHeight expectedFragmentStartY := ui.Dp(visualLine) * lineHeight if expectedFragmentStartY != ui.Dp(5*16.8) { t.Errorf("Expected fragmentStartY %v, got %v", ui.Dp(5*16.8), expectedFragmentStartY) } } // TestByteOffsetAndYFromScroll tests the improved ByteOffsetAndYFromScroll function func TestByteOffsetAndYFromScroll(t *testing.T) { // Create a mock chunked buffer with visual line index lineHeight := ui.Dp(16.8) visualLineIndex := &types.VisualLineIndex{ Offsets: []int32{0, 7, 14, 21}, // 4 lines } layout := ui.GlyphLayout{ ByteOffsets: []int{0, 7, 14, 21}, X: []ui.Dp{0, 0, 0, 0}, Y: []ui.Dp{0, 16.8, 33.6, 50.4}, Advance: []ui.Dp{10, 10, 10, 10}, LineHeight: lineHeight, VisualLineIndex: visualLineIndex, } // Test scrolling to different positions testCases := []struct { scrollOffset ui.Dp expectedByte int expectedY ui.Dp }{ {ui.Dp(0), 0, ui.Dp(0)}, // Top of document {ui.Dp(16.8), 7, ui.Dp(16.8)}, // Start of line 1 {ui.Dp(33.6), 14, ui.Dp(33.6)}, // Start of line 2 {ui.Dp(50.4), 21, ui.Dp(50.4)}, // Start of line 3 } for _, tc := range testCases { byteOffset, y := ByteOffsetAndYFromScrollWithLayout(tc.scrollOffset, layout) if byteOffset != tc.expectedByte { t.Errorf("Scroll %v: expected byte offset %d, got %d", tc.scrollOffset, tc.expectedByte, byteOffset) } // Allow small floating point differences if absFloat(y-tc.expectedY) > 0.001 { t.Errorf("Scroll %v: expected Y %v, got %v", tc.scrollOffset, tc.expectedY, y) } } } // ByteOffsetAndYFromScrollWithLayout is a test helper that mimics the improved function func ByteOffsetAndYFromScrollWithLayout(scrollOffset ui.Dp, layout ui.GlyphLayout) (int, ui.Dp) { lineHeight := layout.LineHeight if lineHeight == 0 { lineHeight = editor.EditorLineHeight() } // Calculate which visual line should be at the given scroll offset visualLine := int(scrollOffset / lineHeight) // If we have a visual line index, use it for accurate byte offset if layout.VisualLineIndex != nil && visualLine < len(layout.VisualLineIndex.Offsets) { byteOffset := int(layout.VisualLineIndex.Offsets[visualLine]) lineTop := ui.Dp(visualLine) * lineHeight return byteOffset, lineTop } // Fallback to the original logic using layout.Y values // 1. Find the index of the line whose top is <= scrollOffset idx := sort.Search(len(layout.Y), func(i int) bool { top := layout.Y[i] - lineHeight return top > scrollOffset }) if idx > 0 { idx-- } // 2. Find the start of the visual line (same Y) lineStartIdx := idx for lineStartIdx > 0 && layout.Y[lineStartIdx-1] == layout.Y[idx] { lineStartIdx-- } if lineStartIdx < 0 || lineStartIdx >= len(layout.ByteOffsets) { return 0, 0 } // 3. Calculate the top of this visual line lineTop := layout.Y[lineStartIdx] - lineHeight return layout.ByteOffsets[lineStartIdx], lineTop }