list_test.go

  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}