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}