package editor import ( "strings" "testing" "pad/internal/io/pool/types" "pad/internal/ui" ) // TestApplyWrapCounts_Grouping verifies that a shaped window's // VisualLineStarts (window-relative byte offsets, one per visual line, // including the initial 0) are attributed to the correct logical lines: // a wrapped logical line gets its true visual-line count, and lines the // layout does not cover keep their previous counts (over-stale is safe, // under-attributing is not). func TestApplyWrapCounts_Grouping(t *testing.T) { // Whole-file buffer; the shaped window covers lines 5..8. var lines []string for i := 0; i < 12; i++ { lines = append(lines, "line-content-"+strings.Repeat("x", i%7)) } // The windowed lines get distinctive shapes. lines[5] = "A very long line that wraps three times" lines[6] = "short" lines[7] = "" // empty line lines[8] = "another line that wraps twice" content := strings.Join(lines, "\n") + "\n" cb := newTestBufferForWrapWholeFile(content) // Distinct seed counts so any (un)set line is distinguishable. for i := 0; i < cb.WrapIndex.Len(); i++ { cb.WrapIndex.Set(i, int32(9)) } // The shaped window covers lines 5..8 (their content plus terminators). winStart := byteOffsetOfLine(content, 5) windowText := strings.Join(lines[5:9], "\n") + "\n" off := map[string]int{} crs := 0 for _, l := range lines[5:9] { off[l] = crs crs += len(l) + 1 } // Fabricated visual line starts (window-relative), as the shaper would // emit them: 0 first, then one per wrapped continuation, then one per // following logical line start. starts := []int{ 0, // line 5 visual 1 20, // line 5 wraps 35, // line 5 wraps again off["short"], // line 6 off[""], // line 7 (empty) off["another line that wraps twice"], // line 8 off["another line that wraps twice"] + 19, // line 8 wraps } TheState = NewState() TheState.Editor.ChunkedBuffer = cb TheState.Editor.IMEWindowText = windowText TheState.Editor.IMEWindowStartByte = winStart TheState.Editor.EditSeq = 42 fb := ui.LayoutFeedback{ GlyphLayout: ui.GlyphLayout{VisualLineStarts: starts, LineHeight: 20}, WindowText: windowText, WindowStartByte: winStart, WindowStartLine: 5, EditSeq: 42, } TheState.applyWrapCounts(fb) want := map[int]int32{5: 3, 6: 1, 7: 1, 8: 2} for line, wc := range want { if got := cb.WrapIndex.Get(line); got != wc { t.Errorf("line %d count = %d, want %d", line, got, wc) } } // Untouched lines keep their seed count. for _, line := range []int{0, 1, 2, 3, 4, 9, 10, 11} { if got := cb.WrapIndex.Get(line); got != 9 { t.Errorf("line %d count = %d, want untouched 9", line, got) } } } // TestApplyWrapCounts_TrailingNewline covers a window whose last line ends // with '\n' at the window end: the shaper may emit a visual-line start at // exactly len(winText) for the (empty) trailing line; it must not be // attributed to the last real line. func TestApplyWrapCounts_TrailingNewline(t *testing.T) { content := "short\nlong line that wraps once here\nlast line ends with newline\n" cb := newTestBufferForWrapWholeFile(content) for i := 0; i < cb.WrapIndex.Len(); i++ { cb.WrapIndex.Set(i, 7) } TheState = NewState() TheState.Editor.ChunkedBuffer = cb TheState.Editor.IMEWindowText = content TheState.Editor.IMEWindowStartByte = 0 TheState.Editor.EditSeq = 1 // Line 0: "short" (1); line 1: wraps (2); line 2: "last line..." (1); // plus a trailing-empty-line start at len(content). starts := []int{0, 6, 6 + 18, len("short\nlong line that wraps once here\n"), len(content)} fb := ui.LayoutFeedback{ GlyphLayout: ui.GlyphLayout{VisualLineStarts: starts}, WindowText: content, WindowStartByte: 0, WindowStartLine: 0, EditSeq: 1, } TheState.applyWrapCounts(fb) if got := cb.WrapIndex.Get(0); got != 1 { t.Errorf("line 0 = %d, want 1", got) } if got := cb.WrapIndex.Get(1); got != 2 { t.Errorf("line 1 = %d, want 2", got) } // Line 2 keeps the seed: the entry at len(content) belongs to the empty // trailing line, which is already at the estimate. if got := cb.WrapIndex.Get(2); got != 1 && got != 7 { t.Errorf("line 2 = %d, want 1 (or untouched 7)", got) } } // TestApplyWrapCounts_Guards pins the early-outs: no buffer, no wrap index, // negative window start, empty window text, empty starts, and a window start // byte past the window text. func TestApplyWrapCounts_Guards(t *testing.T) { content := "abc\ndef\n" cb := newTestBufferForWrapWholeFile(content) cb.WrapIndex.Set(0, 5) cb.WrapIndex.Set(1, 5) cb.WrapIndex.Set(2, 5) base := func() *ui.LayoutFeedback { return &ui.LayoutFeedback{ GlyphLayout: ui.GlyphLayout{VisualLineStarts: []int{0, 4}}, WindowText: content, WindowStartByte: 0, WindowStartLine: 0, EditSeq: 0, } } run := func(mut func(*State, *ui.LayoutFeedback)) { TheState = NewState() cbc := newTestBufferForWrapWholeFile(content) cbc.WrapIndex.Set(0, 5) cbc.WrapIndex.Set(1, 5) cbc.WrapIndex.Set(2, 5) TheState.Editor.ChunkedBuffer = cbc TheState.Editor.IMEWindowText = content TheState.Editor.IMEWindowStartByte = 0 fb := base() wi := cbc.WrapIndex // capture before mut (some cases nil it) mut(TheState, fb) TheState.applyWrapCounts(*fb) for i := 0; i < 3; i++ { if got := wi.Get(i); got != 5 { t.Errorf("guard case: line %d = %d, want untouched 5", i, got) } } } run(func(s *State, fb *ui.LayoutFeedback) { s.Editor.ChunkedBuffer = nil }) run(func(s *State, fb *ui.LayoutFeedback) { s.Editor.ChunkedBuffer.WrapIndex = nil }) run(func(s *State, fb *ui.LayoutFeedback) { fb.WindowStartLine = -1 }) run(func(s *State, fb *ui.LayoutFeedback) { fb.WindowText = "" }) run(func(s *State, fb *ui.LayoutFeedback) { fb.GlyphLayout.VisualLineStarts = nil }) _ = cb } // newTestBufferForWrapWholeFile builds a chunked buffer + LineIndex + // all-ones WrapIndex over the given content (the LineIndex convention: a // trailing "\n" opens an empty trailing line). func newTestBufferForWrapWholeFile(content string) *ChunkedBuffer { cb := NewChunkedBuffer("/wrap-group.txt", DefaultChunkSize, nil, "") cb.SetContent([]byte(content)) offsets := []int32{0} for i := 0; i < len(content); i++ { if content[i] == '\n' { offsets = append(offsets, int32(i+1)) } } cb.LineIndex = types.NewLineIndex(offsets, 0, int64(len(content))) cb.WrapIndex = NewWrapIndex(len(offsets)) return cb } // byteOffsetOfLine returns the byte offset of the start of 0-based line i // under the LineIndex convention. func byteOffsetOfLine(content string, i int) int { o := 0 for line := 0; line < i; line++ { nx := strings.IndexByte(content[o:], '\n') if nx < 0 { return len(content) } o += nx + 1 } return o }