1package modes
2
3import (
4 "context"
5 "strings"
6 "sync"
7 "testing"
8 "time"
9 "unicode"
10
11 "github.com/bnema/zut/packages/core"
12 "github.com/bnema/zut/packages/provider"
13 "github.com/bnema/zut/packages/tui"
14)
15
16func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) {
17 row := formatSessionRowPlain(core.SessionSummary{
18 Provider: "test\x1b]0;bad\a",
19 Model: "model\x1b[31m",
20 MessageCount: 1,
21 FirstUserText: "hello\x1b[2J\nworld",
22 }, 120)
23 if strings.IndexFunc(row, unicode.IsControl) >= 0 {
24 t.Fatalf("session row contains control characters: %q", row)
25 }
26 if !strings.Contains(row, "hello world") {
27 t.Fatalf("sanitized session text missing: %q", row)
28 }
29}
30
31func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
32 root := t.TempDir()
33 cwd := t.TempDir()
34 session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
35 if err != nil {
36 t.Fatal(err)
37 }
38 if err := session.AppendMessage(provider.Message{
39 Role: provider.RoleUser,
40 Content: []provider.Content{provider.TextBlock{Text: "load this session"}},
41 }); err != nil {
42 t.Fatal(err)
43 }
44 if err := session.Close(); err != nil {
45 t.Fatal(err)
46 }
47
48 d := newSessionDialog()
49 events := d.Open(context.Background(), root, cwd)
50 t.Cleanup(d.Close)
51 if !d.Active() || !d.Loading() {
52 t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
53 }
54 loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
55 if !strings.Contains(loadingText, "loading sessions") {
56 t.Fatalf("loading render = %q, want spinner status", loadingText)
57 }
58 act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
59 if act.Select || !d.Active() {
60 t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active())
61 }
62
63 for event := range events {
64 d.ApplyLoad(event)
65 }
66
67 if d.Loading() {
68 t.Fatal("dialog still loading after final result")
69 }
70 if len(d.sessions) != 1 || d.sessions[0].Path != session.Path {
71 t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
72 }
73 loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
74 if strings.Contains(loadedText, "loading sessions") {
75 t.Fatalf("completed render still shows loading spinner: %q", loadedText)
76 }
77 if !strings.Contains(loadedText, "load this session") {
78 t.Fatalf("completed render missing session text: %q", loadedText)
79 }
80}
81
82func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) {
83 ctx, cancel := context.WithCancel(context.Background())
84 cancel()
85
86 d := newSessionDialog()
87 events := d.Open(ctx, t.TempDir(), t.TempDir())
88 if d.Loading() {
89 t.Fatal("dialog remained loading with an already-canceled parent")
90 }
91 if d.Active() {
92 t.Fatal("dialog remained active with an already-canceled parent")
93 }
94 if _, ok := <-events; ok {
95 t.Fatal("canceled dialog emitted a load event")
96 }
97}
98
99func TestSessionDialogCancellationFinishesLoading(t *testing.T) {
100 ctx, cancel := context.WithCancel(context.Background())
101 d := newSessionDialog()
102 events := d.Open(ctx, t.TempDir(), t.TempDir())
103 cancel()
104 for range events {
105 }
106 d.ApplyLoadClosed()
107 if d.Loading() {
108 t.Fatal("dialog remained loading after parent cancellation")
109 }
110 if d.Active() {
111 t.Fatal("dialog remained active after parent cancellation")
112 }
113}
114
115func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) {
116 root := t.TempDir()
117 cwd := t.TempDir()
118 session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
119 if err != nil {
120 t.Fatal(err)
121 }
122 if err := session.AppendMessage(provider.Message{
123 Role: provider.RoleUser,
124 Content: []provider.Content{provider.TextBlock{Text: "cancel this load"}},
125 }); err != nil {
126 t.Fatal(err)
127 }
128 if err := session.Close(); err != nil {
129 t.Fatal(err)
130 }
131
132 entered := make(chan struct{})
133 d := newSessionDialog()
134 d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
135 close(entered)
136 <-ctx.Done()
137 return core.SessionSummary{Path: path}
138 }
139 ctx, cancel := context.WithCancel(context.Background())
140 events := d.Open(ctx, root, cwd)
141 t.Cleanup(func() {
142 cancel()
143 d.Close()
144 })
145
146 select {
147 case event, ok := <-events:
148 if !ok || event.kind != sessionLoadStarted {
149 t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
150 }
151 d.ApplyLoad(event)
152 case <-time.After(time.Second):
153 t.Fatal("timed out waiting for sessionLoadStarted")
154 }
155 select {
156 case <-entered:
157 case <-time.After(time.Second):
158 t.Fatal("timed out waiting for the session worker")
159 }
160
161 cancel()
162 for {
163 select {
164 case event, ok := <-events:
165 if !ok {
166 d.ApplyLoadClosed()
167 if d.Loading() || d.Active() {
168 t.Fatalf("dialog after cancellation = loading %v, active %v; want both false", d.Loading(), d.Active())
169 }
170 return
171 }
172 if event.kind == sessionLoadFinished {
173 t.Fatal("canceled load emitted sessionLoadFinished")
174 }
175 d.ApplyLoad(event)
176 case <-time.After(time.Second):
177 t.Fatal("timed out waiting for canceled session load to close")
178 }
179 }
180}
181
182func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T) {
183 root := t.TempDir()
184 cwd := t.TempDir()
185 session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
186 if err != nil {
187 t.Fatal(err)
188 }
189 if err := session.AppendMessage(provider.Message{
190 Role: provider.RoleUser,
191 Content: []provider.Content{provider.TextBlock{Text: "wait for the prior load"}},
192 }); err != nil {
193 t.Fatal(err)
194 }
195 if err := session.Close(); err != nil {
196 t.Fatal(err)
197 }
198
199 entered := make(chan struct{})
200 release := make(chan struct{})
201 var enteredOnce sync.Once
202 var releaseOnce sync.Once
203 releaseLoad := func() { releaseOnce.Do(func() { close(release) }) }
204 d := newSessionDialog()
205 d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
206 enteredOnce.Do(func() { close(entered) })
207 <-release
208 return core.SessionSummary{Path: path, MessageCount: 1}
209 }
210 firstCtx, firstCancel := context.WithCancel(context.Background())
211 firstEvents := d.Open(firstCtx, root, cwd)
212 t.Cleanup(func() {
213 firstCancel()
214 releaseLoad()
215 d.Close()
216 })
217
218 select {
219 case event, ok := <-firstEvents:
220 if !ok || event.kind != sessionLoadStarted {
221 t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
222 }
223 d.ApplyLoad(event)
224 case <-time.After(time.Second):
225 t.Fatal("timed out waiting for the first load to start")
226 }
227 select {
228 case <-entered:
229 case <-time.After(time.Second):
230 t.Fatal("timed out waiting for the first worker")
231 }
232
233 preCanceled, cancelPreCanceled := context.WithCancel(context.Background())
234 cancelPreCanceled()
235 preCanceledEvents := d.Open(preCanceled, root, cwd)
236 if _, ok := <-preCanceledEvents; ok {
237 t.Fatal("pre-canceled open emitted a load event")
238 }
239
240 thirdCtx, cancelThird := context.WithCancel(context.Background())
241 thirdEvents := d.Open(thirdCtx, root, cwd)
242 select {
243 case event, ok := <-thirdEvents:
244 t.Fatalf("third open started before the first load finished: %+v, open %v", event, ok)
245 case <-time.After(100 * time.Millisecond):
246 }
247
248 releaseLoad()
249 firstCancel()
250 select {
251 case event, ok := <-thirdEvents:
252 if !ok || event.kind != sessionLoadStarted {
253 t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok)
254 }
255 d.ApplyLoad(event)
256 case <-time.After(time.Second):
257 t.Fatal("timed out waiting for third load after releasing the first")
258 }
259 cancelThird()
260 for event := range thirdEvents {
261 d.ApplyLoad(event)
262 }
263}
264
265func TestSessionDialogEscapeCancelsLoading(t *testing.T) {
266 d := newSessionDialog()
267 events := d.Open(context.Background(), t.TempDir(), t.TempDir())
268 act := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
269 if !act.Close || d.Active() || d.Loading() {
270 t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading())
271 }
272 for range events {
273 }
274}
275
276func TestSessionDialogAppliesLoadedEntriesIncrementallyInPathOrder(t *testing.T) {
277 d := newSessionDialog()
278 d.active = true
279 d.loading = true
280 d.loadGeneration = 7
281
282 d.ApplyLoad(sessionLoadEvent{
283 kind: sessionLoadStarted,
284 generation: 7,
285 total: 4,
286 })
287 d.ApplyLoad(sessionLoadEvent{
288 kind: sessionLoadEntry,
289 generation: 7,
290 index: 2,
291 summary: core.SessionSummary{Path: "third", MessageCount: 1},
292 })
293 if len(d.sessions) != 0 {
294 t.Fatalf("older out-of-order entry appeared before newest entry: %+v", d.sessions)
295 }
296 d.ApplyLoad(sessionLoadEvent{
297 kind: sessionLoadEntry,
298 generation: 7,
299 index: 1,
300 summary: core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true},
301 })
302 d.ApplyLoad(sessionLoadEvent{
303 kind: sessionLoadEntry,
304 generation: 7,
305 index: 3,
306 summary: core.SessionSummary{Path: "empty"},
307 })
308 d.ApplyLoad(sessionLoadEvent{
309 kind: sessionLoadEntry,
310 generation: 7,
311 index: 0,
312 summary: core.SessionSummary{Path: "first", MessageCount: 1},
313 })
314 if !d.Loading() {
315 t.Fatal("dialog stopped loading before the terminal event")
316 }
317 if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
318 t.Fatalf("incremental sessions = %+v, want first then third", d.sessions)
319 }
320
321 d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})
322
323 if d.Loading() {
324 t.Fatal("dialog still loading after final result")
325 }
326 if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
327 t.Fatalf("loaded sessions = %+v, want first then third", d.sessions)
328 }
329}
330
331func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) {
332 d := newSessionDialog()
333 d.active = true
334 d.loading = true
335 d.loadGeneration = 9
336
337 d.ApplyLoad(sessionLoadEvent{
338 kind: sessionLoadStarted,
339 generation: 9,
340 total: 2,
341 })
342 d.ApplyLoad(sessionLoadEvent{
343 kind: sessionLoadEntry,
344 generation: 9,
345 index: 0,
346 summary: core.SessionSummary{Path: "newest", MessageCount: 1, FirstUserText: "recent work"},
347 })
348
349 if !d.Loading() {
350 t.Fatal("dialog stopped loading after first entry")
351 }
352 rendered := strings.Join(d.Render(tui.Dark, 100), "\n")
353 if !strings.Contains(rendered, "recent work") {
354 t.Fatalf("incremental render missing newest entry: %q", rendered)
355 }
356
357 act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
358 if !act.Select || act.Path != "newest" || d.Active() {
359 t.Fatalf("select while loading = %+v, active %v; want newest selected and dialog closed", act, d.Active())
360 }
361}
362
363func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
364 first := newSessionDialog()
365 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
366 firstGeneration := first.loadGeneration
367
368 secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
369 secondGeneration := first.loadGeneration
370 if secondGeneration == firstGeneration {
371 t.Fatal("reopening picker did not advance load generation")
372 }
373 first.ApplyLoad(sessionLoadEvent{
374 kind: sessionLoadStarted,
375 generation: secondGeneration,
376 total: 1,
377 })
378
379 first.ApplyLoad(sessionLoadEvent{
380 kind: sessionLoadEntry,
381 generation: firstGeneration,
382 index: 0,
383 summary: core.SessionSummary{
384 Path: "stale",
385 MessageCount: 1,
386 },
387 })
388 if len(first.sessions) != 0 {
389 t.Fatalf("stale results repopulated picker: %+v", first.sessions)
390 }
391
392 first.Close()
393 for range firstEvents {
394 }
395 for range secondEvents {
396 }
397}