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}