1package list
2
3import (
4 "strconv"
5 "strings"
6 "testing"
7
8 "github.com/stretchr/testify/require"
9)
10
11// trackedItem is a test helper that counts Render calls. The body of
12// Render is the item's content concatenated with the call counter so
13// that "served from cache" vs "freshly rendered" is observable from
14// the rendered string itself.
15type trackedItem struct {
16 *Versioned
17 id string
18 body string
19 finished bool
20 renderHits int
21}
22
23func newTrackedItem(id, body string, finished bool) *trackedItem {
24 return &trackedItem{
25 Versioned: NewVersioned(),
26 id: id,
27 body: body,
28 finished: finished,
29 }
30}
31
32func (t *trackedItem) Render(width int) string {
33 t.renderHits++
34 return t.body + ":w=" + strconv.Itoa(width)
35}
36
37func (t *trackedItem) Finished() bool {
38 return t.finished
39}
40
41// TestList_RenderMemo_PointerKey covers the F6 invariant that the
42// list-level cache is keyed by item pointer, not slice index, so
43// PrependItems and AppendItems do not shift cached entries to the
44// wrong item.
45func TestList_RenderMemo_PointerKey(t *testing.T) {
46 t.Parallel()
47
48 a := newTrackedItem("a", "alpha", false)
49 b := newTrackedItem("b", "bravo", false)
50 c := newTrackedItem("c", "charlie", false)
51
52 l := NewList(a, b, c)
53 l.SetSize(40, 10)
54
55 // First render populates the cache for every item.
56 first := l.Render()
57 require.Equal(t, 1, a.renderHits)
58 require.Equal(t, 1, b.renderHits)
59 require.Equal(t, 1, c.renderHits)
60
61 // Prepending a new item must not shift the existing entries to
62 // the wrong key. The existing items render exactly once more
63 // only if their cache was lost, which would be a bug. Scroll to
64 // the top so the prepended item is visible and gets rendered.
65 z := newTrackedItem("z", "zulu", false)
66 l.PrependItems(z)
67 l.ScrollToTop()
68 _ = l.Render()
69 require.Equal(t, 1, z.renderHits, "prepended item rendered once")
70 require.Equal(t, 1, a.renderHits, "stable item must keep its cached entry across PrependItems")
71 require.Equal(t, 1, b.renderHits, "stable item must keep its cached entry across PrependItems")
72 require.Equal(t, 1, c.renderHits, "stable item must keep its cached entry across PrependItems")
73
74 // AppendItems is symmetric.
75 d := newTrackedItem("d", "delta", false)
76 l.AppendItems(d)
77 _ = l.Render()
78 require.Equal(t, 1, d.renderHits, "appended item rendered once")
79 require.Equal(t, 1, a.renderHits)
80 require.Equal(t, 1, b.renderHits)
81 require.Equal(t, 1, c.renderHits)
82
83 // The output is non-trivial.
84 require.Contains(t, first, "alpha")
85}
86
87// TestList_SetSize_WidthChangeInvalidates covers the F6 invariant
88// that a width change drops every cached entry but a height-only
89// change leaves the cache intact.
90func TestList_SetSize_WidthChangeInvalidates(t *testing.T) {
91 t.Parallel()
92
93 a := newTrackedItem("a", "alpha", false)
94 b := newTrackedItem("b", "bravo", false)
95
96 l := NewList(a, b)
97 l.SetSize(40, 10)
98 _ = l.Render()
99 require.Equal(t, 1, a.renderHits)
100 require.Equal(t, 1, b.renderHits)
101
102 // Height-only change: no invalidation.
103 l.SetSize(40, 20)
104 _ = l.Render()
105 require.Equal(t, 1, a.renderHits, "height-only change must keep cache entries")
106 require.Equal(t, 1, b.renderHits, "height-only change must keep cache entries")
107
108 // Width change: every entry invalidates.
109 l.SetSize(80, 20)
110 _ = l.Render()
111 require.Equal(t, 2, a.renderHits, "width change must invalidate cache entries")
112 require.Equal(t, 2, b.renderHits, "width change must invalidate cache entries")
113}
114
115// TestList_RemoveItem_DropsEntry covers the F6 invariant that
116// RemoveItem drops the cache entry for the removed item but leaves
117// the surviving entries in place.
118func TestList_RemoveItem_DropsEntry(t *testing.T) {
119 t.Parallel()
120
121 a := newTrackedItem("a", "alpha", false)
122 b := newTrackedItem("b", "bravo", false)
123 c := newTrackedItem("c", "charlie", false)
124
125 l := NewList(a, b, c)
126 l.SetSize(40, 10)
127 _ = l.Render()
128 require.Equal(t, 1, a.renderHits)
129 require.Equal(t, 1, b.renderHits)
130 require.Equal(t, 1, c.renderHits)
131
132 l.RemoveItem(1) // remove b
133 _ = l.Render()
134 // a and c still cached.
135 require.Equal(t, 1, a.renderHits, "stable item must keep cached entry across RemoveItem")
136 require.Equal(t, 1, c.renderHits, "stable item must keep cached entry across RemoveItem")
137 // The removed item's entry is dropped โ verify by re-adding b
138 // and confirming it renders as if fresh.
139 l.AppendItems(b)
140 _ = l.Render()
141 require.Equal(t, 2, b.renderHits, "re-added item must re-render")
142}
143
144// TestList_FrozenItem_NotReRendered covers ยง4.5.1: items that report
145// Finished() == true on entry creation are marked frozen after the
146// first render and are never re-rendered until width change or
147// version bump.
148func TestList_FrozenItem_NotReRendered(t *testing.T) {
149 t.Parallel()
150
151 a := newTrackedItem("a", "alpha", true)
152 b := newTrackedItem("b", "bravo", true)
153
154 l := NewList(a, b)
155 l.SetSize(40, 10)
156 _ = l.Render()
157 require.Equal(t, 1, a.renderHits, "frozen items render exactly once on first draw")
158 require.Equal(t, 1, b.renderHits, "frozen items render exactly once on first draw")
159
160 // Many subsequent renders must not re-render frozen items.
161 for range 5 {
162 _ = l.Render()
163 }
164 require.Equal(t, 1, a.renderHits, "frozen items must not re-render across redraws")
165 require.Equal(t, 1, b.renderHits, "frozen items must not re-render across redraws")
166}
167
168// TestList_FrozenItem_TransitionsAfterFinish covers ยง4.5.1: a
169// streaming item that later reports Finished() == true transitions
170// to frozen on the first render after finish.
171func TestList_FrozenItem_TransitionsAfterFinish(t *testing.T) {
172 t.Parallel()
173
174 a := newTrackedItem("a", "alpha", false) // streaming
175 l := NewList(a)
176 l.SetSize(40, 10)
177
178 // While unfinished, every render rebuilds the cache because the
179 // item's Finished() is false.
180 for range 3 {
181 // Bump the version to simulate a streaming delta.
182 a.Bump()
183 _ = l.Render()
184 }
185 require.Equal(t, 3, a.renderHits)
186
187 // Item finishes; on the next render it freezes.
188 a.finished = true
189 a.Bump()
190 _ = l.Render()
191 require.Equal(t, 4, a.renderHits, "post-finish render still happens once")
192
193 for range 5 {
194 _ = l.Render()
195 }
196 require.Equal(t, 4, a.renderHits, "frozen after finish, no further renders")
197}
198
199// TestList_FrozenItem_VersionBumpUnfreezes covers ยง4.5.1: a frozen
200// item that gets a version bump (unexpectedly mutated) is unfrozen
201// and re-rendered โ no stale output.
202func TestList_FrozenItem_VersionBumpUnfreezes(t *testing.T) {
203 t.Parallel()
204
205 a := newTrackedItem("a", "alpha", true)
206 l := NewList(a)
207 l.SetSize(40, 10)
208
209 _ = l.Render()
210 _ = l.Render()
211 require.Equal(t, 1, a.renderHits)
212
213 a.Bump()
214 _ = l.Render()
215 require.Equal(t, 2, a.renderHits, "version bump must invalidate frozen entry")
216
217 // Re-renders without bumping go back to cache hits.
218 _ = l.Render()
219 _ = l.Render()
220 require.Equal(t, 2, a.renderHits, "post-bump render re-freezes")
221}
222
223// TestList_FrozenItem_ResizeUnfreezes covers ยง4.5.1: resize
224// invalidates frozen entries.
225func TestList_FrozenItem_ResizeUnfreezes(t *testing.T) {
226 t.Parallel()
227
228 a := newTrackedItem("a", "alpha", true)
229 l := NewList(a)
230 l.SetSize(40, 10)
231
232 _ = l.Render()
233 require.Equal(t, 1, a.renderHits)
234
235 l.SetSize(80, 10)
236 _ = l.Render()
237 require.Equal(t, 2, a.renderHits, "width change must invalidate frozen entry")
238}
239
240// TestList_FrozenItem_SelectionDragUnfreeze covers ยง4.5.1: an active
241// selection-drag span must un-freeze items inside the range; ending
242// the drag re-freezes them.
243func TestList_FrozenItem_SelectionDragUnfreeze(t *testing.T) {
244 t.Parallel()
245
246 a := newTrackedItem("a", "alpha", true)
247 b := newTrackedItem("b", "bravo", true)
248 c := newTrackedItem("c", "charlie", true)
249
250 l := NewList(a, b, c)
251 l.SetSize(40, 10)
252 _ = l.Render()
253 require.Equal(t, 1, a.renderHits)
254 require.Equal(t, 1, b.renderHits)
255 require.Equal(t, 1, c.renderHits)
256
257 // Begin a selection drag spanning items 0..1. Items inside the
258 // range must re-render (they re-render exactly once because
259 // the un-freeze drops the cached entry, and the selection
260 // suppression keeps them un-frozen until the drag ends).
261 l.BeginSelectionDrag(0, 1)
262 _ = l.Render()
263 require.Equal(t, 2, a.renderHits, "drag-spanned item must re-render once on entering the drag")
264 require.Equal(t, 2, b.renderHits, "drag-spanned item must re-render once on entering the drag")
265 require.Equal(t, 1, c.renderHits, "out-of-range item must remain frozen")
266
267 // While the drag is active, items inside the range are NOT
268 // frozen. Subsequent renders without state changes still
269 // trigger re-renders (because version+width hit but frozen=false
270 // also matches; we still re-use the cache โ no, actually with
271 // our implementation we DO cache unfrozen entries by version).
272 _ = l.Render()
273 require.Equal(t, 2, a.renderHits, "unfrozen but version-stable hits the cache")
274 require.Equal(t, 2, b.renderHits, "unfrozen but version-stable hits the cache")
275
276 // End the drag. Items inside the range re-render once and
277 // re-freeze.
278 l.EndSelectionDrag()
279 _ = l.Render()
280 require.Equal(t, 3, a.renderHits, "post-drag render re-freezes the entry")
281 require.Equal(t, 3, b.renderHits, "post-drag render re-freezes the entry")
282
283 // Subsequent renders are cache hits again.
284 for range 3 {
285 _ = l.Render()
286 }
287 require.Equal(t, 3, a.renderHits, "frozen after drag end")
288 require.Equal(t, 3, b.renderHits, "frozen after drag end")
289}
290
291// TestList_RenderOutputStableAcrossDraws is the F6 byte-equality
292// invariant: rendering the same list multiple times must produce the
293// same bytes.
294func TestList_RenderOutputStableAcrossDraws(t *testing.T) {
295 t.Parallel()
296
297 items := make([]Item, 0, 5)
298 for i := range 5 {
299 items = append(items, newTrackedItem(strconv.Itoa(i), "item-"+strconv.Itoa(i), i%2 == 0))
300 }
301 l := NewList(items...)
302 l.SetSize(40, 20)
303
304 first := l.Render()
305 for range 4 {
306 require.Equal(t, first, l.Render(), "render output must be byte-stable across draws")
307 }
308 // And the output is non-trivial.
309 require.True(t, strings.Contains(first, "item-0"))
310}
311
312// TestList_SetItems_PointerOverlapRetainsCache covers F6 ยง4.5
313// invalidation semantics for SetItems. When the new slice shares
314// some pointers with the previous slice (a typical "swap a few
315// items, keep the rest" scenario), the cache entries for the
316// surviving items must be retained โ re-rendering them would defeat
317// the memo. Entries for the items that were removed must be
318// dropped so they can't serve stale output if the same pointer is
319// re-introduced later.
320func TestList_SetItems_PointerOverlapRetainsCache(t *testing.T) {
321 t.Parallel()
322
323 a := newTrackedItem("a", "alpha", false)
324 b := newTrackedItem("b", "bravo", false)
325 c := newTrackedItem("c", "charlie", false)
326 d := newTrackedItem("d", "delta", false)
327
328 l := NewList(a, b, c)
329 l.SetSize(40, 10)
330 _ = l.Render()
331 require.Equal(t, 1, a.renderHits)
332 require.Equal(t, 1, b.renderHits)
333 require.Equal(t, 1, c.renderHits)
334
335 // Replace the slice with one that shares a and c (b is dropped,
336 // d is added). a and c must keep their cache entries; d renders
337 // once on the next draw.
338 l.SetItems(a, c, d)
339 _ = l.Render()
340 require.Equal(t, 1, a.renderHits, "stable item must keep cached entry across SetItems")
341 require.Equal(t, 1, c.renderHits, "stable item must keep cached entry across SetItems")
342 require.Equal(t, 1, d.renderHits, "new item renders once")
343
344 // Re-introducing b after it was dropped must rebuild its
345 // entry (its previous cache entry was invalidated by SetItems).
346 l.SetItems(a, b, c)
347 _ = l.Render()
348 require.Equal(t, 2, b.renderHits, "re-introduced item must re-render โ its old entry was dropped")
349 // a and c remained throughout both swaps.
350 require.Equal(t, 1, a.renderHits, "stable item retained across multiple SetItems")
351 require.Equal(t, 1, c.renderHits, "stable item retained across multiple SetItems")
352}
353
354// TestList_SetItems_AllNewDropsEveryEntry covers F6 ยง4.5: when the
355// SetItems slice has no pointer overlap with the previous slice,
356// every cache entry from the previous slice is dropped. This is
357// the pure-replace case (e.g. session switch).
358func TestList_SetItems_AllNewDropsEveryEntry(t *testing.T) {
359 t.Parallel()
360
361 a := newTrackedItem("a", "alpha", false)
362 b := newTrackedItem("b", "bravo", false)
363 c := newTrackedItem("c", "charlie", false)
364
365 l := NewList(a, b, c)
366 l.SetSize(40, 10)
367 _ = l.Render()
368 require.Equal(t, 1, a.renderHits)
369 require.Equal(t, 1, b.renderHits)
370 require.Equal(t, 1, c.renderHits)
371
372 // Replace with a fully disjoint slice. Every entry from the
373 // previous slice must be dropped.
374 x := newTrackedItem("x", "xray", false)
375 y := newTrackedItem("y", "yankee", false)
376 l.SetItems(x, y)
377 _ = l.Render()
378 require.Equal(t, 1, x.renderHits, "new item renders once")
379 require.Equal(t, 1, y.renderHits, "new item renders once")
380
381 // Re-introducing the originals must rebuild every entry.
382 l.SetItems(a, b, c)
383 _ = l.Render()
384 require.Equal(t, 2, a.renderHits, "previously-dropped item must re-render")
385 require.Equal(t, 2, b.renderHits, "previously-dropped item must re-render")
386 require.Equal(t, 2, c.renderHits, "previously-dropped item must re-render")
387}
388
389// TestVersioned_BumpMonotonic covers the basic Versioned contract:
390// Version() starts at zero and Bump() advances it monotonically.
391func TestVersioned_BumpMonotonic(t *testing.T) {
392 t.Parallel()
393
394 v := NewVersioned()
395 require.Equal(t, uint64(0), v.Version())
396 v.Bump()
397 require.Equal(t, uint64(1), v.Version())
398 v.Bump()
399 v.Bump()
400 require.Equal(t, uint64(3), v.Version())
401}
402
403// multiLineItem is a test helper whose Render returns a fixed
404// multi-line body. Each line is uniquely identifiable (id:N) so a
405// test can reconstruct the expected visible window by index. F7's
406// byte-identity matrix is built around these.
407type multiLineItem struct {
408 *Versioned
409 id string
410 height int
411}
412
413func newMultiLineItem(id string, height int) *multiLineItem {
414 return &multiLineItem{
415 Versioned: NewVersioned(),
416 id: id,
417 height: height,
418 }
419}
420
421func (m *multiLineItem) Render(_ int) string {
422 if m.height <= 0 {
423 return ""
424 }
425 parts := make([]string, m.height)
426 for i := range m.height {
427 parts[i] = m.id + ":" + strconv.Itoa(i)
428 }
429 return strings.Join(parts, "\n")
430}
431
432func (m *multiLineItem) Finished() bool { return true }
433
434// expectedRender computes what list.Render *should* produce from
435// first principles given the item heights, viewport, offsetIdx,
436// offsetLine, gap, and reverse settings. It mirrors the pre-F7
437// "build everything, trim to height, reverse" semantics so we can
438// assert byte-identity against the new bounded path.
439func expectedRender(items []*multiLineItem, height, offsetIdx, offsetLine, gap int, reverse bool) string {
440 if len(items) == 0 {
441 return ""
442 }
443 budget := max(height, 0)
444 var lines []string
445 currentOffset := offsetLine
446 for idx := offsetIdx; idx < len(items) && len(lines) < budget; idx++ {
447 body := items[idx].Render(0)
448 body = strings.TrimRight(body, "\n")
449 itemLines := strings.Split(body, "\n")
450 itemHeight := len(itemLines)
451
452 if currentOffset >= 0 && currentOffset < itemHeight {
453 lines = append(lines, itemLines[currentOffset:]...)
454 for range gap {
455 lines = append(lines, "")
456 }
457 } else {
458 gapOffset := currentOffset - itemHeight
459 gapRemaining := gap - gapOffset
460 for range max(gapRemaining, 0) {
461 lines = append(lines, "")
462 }
463 }
464 currentOffset = 0
465 }
466 if len(lines) > budget {
467 lines = lines[:budget]
468 }
469 if reverse {
470 for i, j := 0, len(lines)-1; i < j; i, j = i+1, j-1 {
471 lines[i], lines[j] = lines[j], lines[i]
472 }
473 }
474 return strings.Join(lines, "\n")
475}
476
477// TestList_F7_ByteIdentityMatrix is T1 from the F7 plan: a sweep
478// over (item heights ร viewport heights ร offsets ร gaps ร reverse)
479// that asserts list.Render produces output byte-identical to a
480// pre-F7-equivalent reference (build full buffer, trim at end).
481func TestList_F7_ByteIdentityMatrix(t *testing.T) {
482 t.Parallel()
483
484 itemHeights := [][]int{
485 {1},
486 {5},
487 {1, 1, 1},
488 {3, 7, 2},
489 {20, 5, 30},
490 {50, 1, 50, 1},
491 }
492 viewportHeights := []int{1, 3, 5, 10, 25, 100}
493 offsetIdxs := []int{0, 1, 2}
494 offsetLines := []int{0, 1, 4}
495 gaps := []int{0, 1, 3}
496 reverses := []bool{false, true}
497
498 for _, heights := range itemHeights {
499 for _, vh := range viewportHeights {
500 for _, oIdx := range offsetIdxs {
501 if oIdx >= len(heights) {
502 continue
503 }
504 for _, oLine := range offsetLines {
505 maxOffset := heights[oIdx]
506 if oLine >= maxOffset {
507 continue
508 }
509 for _, gap := range gaps {
510 for _, reverse := range reverses {
511 items := make([]*multiLineItem, len(heights))
512 asItems := make([]Item, len(heights))
513 for i, h := range heights {
514 items[i] = newMultiLineItem("i"+strconv.Itoa(i), h)
515 asItems[i] = items[i]
516 }
517 l := NewList(asItems...)
518 l.SetSize(40, vh)
519 l.SetGap(gap)
520 l.SetReverse(reverse)
521 l.offsetIdx = oIdx
522 l.offsetLine = oLine
523
524 got := l.Render()
525 want := expectedRender(items, vh, oIdx, oLine, gap, reverse)
526 require.Equalf(t, want, got,
527 "mismatch heights=%v vh=%d oIdx=%d oLine=%d gap=%d reverse=%v",
528 heights, vh, oIdx, oLine, gap, reverse)
529 }
530 }
531 }
532 }
533 }
534 }
535}
536
537// TestList_F7_GiantItemBoundedRender is T2: a single 10,000-line
538// item with a 50-line viewport. Render must return exactly 50
539// lines โ no off-by-one, no trim issue. This is the F7 win in
540// test form: per-frame work is bounded by viewport, not item
541// height.
542func TestList_F7_GiantItemBoundedRender(t *testing.T) {
543 t.Parallel()
544
545 const itemHeight = 10000
546 const viewport = 50
547
548 giant := newMultiLineItem("giant", itemHeight)
549 l := NewList(giant)
550 l.SetSize(40, viewport)
551
552 out := l.Render()
553 got := strings.Count(out, "\n") + 1
554 require.Equal(t, viewport, got, "render output must be exactly viewport lines for an oversized item")
555
556 // And the lines are the prefix of the item starting at
557 // offset 0.
558 lines := strings.Split(out, "\n")
559 for i, line := range lines {
560 require.Equal(t, "giant:"+strconv.Itoa(i), line, "line %d does not match expected slice", i)
561 }
562}
563
564// TestList_F7_GiantItemWithOffsetBoundedRender complements T2 with
565// a non-zero offsetLine so we exercise both the "skip prefix" and
566// "bound suffix" sides of the slice.
567func TestList_F7_GiantItemWithOffsetBoundedRender(t *testing.T) {
568 t.Parallel()
569
570 const itemHeight = 10000
571 const viewport = 50
572 const offset = 1234
573
574 giant := newMultiLineItem("giant", itemHeight)
575 l := NewList(giant)
576 l.SetSize(40, viewport)
577 l.offsetLine = offset
578
579 out := l.Render()
580 lines := strings.Split(out, "\n")
581 require.Len(t, lines, viewport, "render output must be exactly viewport lines for an oversized item")
582 for i, line := range lines {
583 require.Equal(t, "giant:"+strconv.Itoa(offset+i), line, "line %d does not match expected slice", i)
584 }
585}
586
587// TestList_F7_GapOverflow is T3: viewport height 5, two items each
588// 10 lines, gap of 3. Render returns exactly 5 lines and never
589// includes gap rows beyond the viewport.
590func TestList_F7_GapOverflow(t *testing.T) {
591 t.Parallel()
592
593 a := newMultiLineItem("a", 10)
594 b := newMultiLineItem("b", 10)
595 l := NewList(a, b)
596 l.SetSize(40, 5)
597 l.SetGap(3)
598
599 out := l.Render()
600 lines := strings.Split(out, "\n")
601 require.Len(t, lines, 5, "viewport must clamp output to height even with gap rows pending")
602
603 // Gap rows after item a would only appear if the viewport
604 // extended past the first 10 lines, which it doesn't here.
605 for i, line := range lines {
606 require.Equal(t, "a:"+strconv.Itoa(i), line, "line %d", i)
607 }
608}
609
610// TestList_F7_GapOverflow_BoundaryStraddle exercises a viewport
611// that lands inside the gap region between two items: 12 lines
612// viewport, item a height 10, item b height 10, gap 3 โ first 10
613// lines from a, then 2 of the 3 gap rows, no b lines yet.
614func TestList_F7_GapOverflow_BoundaryStraddle(t *testing.T) {
615 t.Parallel()
616
617 a := newMultiLineItem("a", 10)
618 b := newMultiLineItem("b", 10)
619 l := NewList(a, b)
620 l.SetSize(40, 12)
621 l.SetGap(3)
622
623 out := l.Render()
624 lines := strings.Split(out, "\n")
625 require.Len(t, lines, 12)
626
627 for i := range 10 {
628 require.Equal(t, "a:"+strconv.Itoa(i), lines[i])
629 }
630 require.Equal(t, "", lines[10])
631 require.Equal(t, "", lines[11])
632}
633
634// TestList_F7_ReverseGiantItem is T4: same bounded-slicing
635// invariant in reverse mode. Reverse mode keeps the same final
636// trim semantics; bounded slicing must produce the same window
637// (just reversed).
638func TestList_F7_ReverseGiantItem(t *testing.T) {
639 t.Parallel()
640
641 const itemHeight = 10000
642 const viewport = 50
643
644 giant := newMultiLineItem("giant", itemHeight)
645 l := NewList(giant)
646 l.SetSize(40, viewport)
647 l.SetReverse(true)
648
649 out := l.Render()
650 lines := strings.Split(out, "\n")
651 require.Len(t, lines, viewport)
652
653 // Expected: the same first-50 slice as the non-reverse path
654 // but reversed.
655 for i, line := range lines {
656 expectedIdx := viewport - 1 - i
657 require.Equal(t, "giant:"+strconv.Itoa(expectedIdx), line, "reverse line %d", i)
658 }
659}
660
661// TestList_F7_OffsetLineAtItemBoundary is T5: offsetLine ==
662// itemHeight lands exactly past the last visible line of the item
663// at offsetIdx. The renderer must not address line N (which does
664// not exist); the visible window starts at the gap rows (when gap
665// > 0) or at the next item (when gap == 0).
666func TestList_F7_OffsetLineAtItemBoundary(t *testing.T) {
667 t.Parallel()
668
669 tests := []struct {
670 name string
671 gap int
672 viewport int
673 want []string
674 }{
675 {
676 name: "gap zero jumps straight to next item",
677 gap: 0,
678 viewport: 5,
679 want: []string{
680 "b:0", "b:1", "b:2", "b:3", "b:4",
681 },
682 },
683 {
684 name: "gap two emits gap rows then next item",
685 gap: 2,
686 viewport: 5,
687 want: []string{
688 "", "", "b:0", "b:1", "b:2",
689 },
690 },
691 }
692
693 const itemHeight = 4
694 for _, tc := range tests {
695 t.Run(tc.name, func(t *testing.T) {
696 t.Parallel()
697
698 a := newMultiLineItem("a", itemHeight)
699 b := newMultiLineItem("b", 10)
700 l := NewList(a, b)
701 l.SetSize(40, tc.viewport)
702 l.SetGap(tc.gap)
703 l.offsetIdx = 0
704 l.offsetLine = itemHeight // exactly at the boundary
705
706 out := l.Render()
707 require.Equal(t, strings.Join(tc.want, "\n"), out)
708 })
709 }
710}
711
712// TestList_F7_OffsetLineInsideGap is T6: offsetLine is one row
713// past the end of the item at offsetIdx, landing inside the gap
714// region. The visible window starts at the second gap row and
715// then continues into the next item.
716func TestList_F7_OffsetLineInsideGap(t *testing.T) {
717 t.Parallel()
718
719 const itemHeight = 4
720 const gap = 3
721 const viewport = 5
722
723 a := newMultiLineItem("a", itemHeight)
724 b := newMultiLineItem("b", 10)
725 l := NewList(a, b)
726 l.SetSize(40, viewport)
727 l.SetGap(gap)
728 l.offsetIdx = 0
729 l.offsetLine = itemHeight + 1 // one row into the gap
730
731 out := l.Render()
732 want := strings.Join([]string{
733 "", // second gap row
734 "", // third gap row
735 "b:0", // next item starts
736 "b:1",
737 "b:2",
738 }, "\n")
739 require.Equal(t, want, out)
740}
741
742// TestList_F7_ViewportZeroOrNegative is T7: a non-positive
743// viewport height must produce an empty string with no panic and
744// must normalize l.height to zero (the budget := max(l.height, 0)
745// side effect).
746func TestList_F7_ViewportZeroOrNegative(t *testing.T) {
747 t.Parallel()
748
749 tests := []struct {
750 name string
751 height int
752 }{
753 {name: "height zero", height: 0},
754 {name: "height negative", height: -1},
755 }
756
757 for _, tc := range tests {
758 t.Run(tc.name, func(t *testing.T) {
759 t.Parallel()
760
761 a := newMultiLineItem("a", 5)
762 l := NewList(a)
763 l.SetSize(40, tc.height)
764
765 out := l.Render()
766 require.Equal(t, "", out, "render must be empty for non-positive viewport")
767 require.Equal(t, 0, l.height, "render must normalize height to zero")
768 })
769 }
770}