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
16const sessionDialogTestTimeout = 10 * time.Second
17
18func TestFormatSessionRowPlainSanitizesControlBytes(t *testing.T) {
19 row := formatSessionRowPlain(core.SessionSummary{
20 Provider: "test\x1b]0;bad\a",
21 Model: "model\x1b[31m",
22 MessageCount: 1,
23 FirstUserText: "hello\x1b[2J\nworld",
24 }, 120)
25 if strings.IndexFunc(row, unicode.IsControl) >= 0 {
26 t.Fatalf("session row contains control characters: %q", row)
27 }
28 if !strings.Contains(row, "hello world") {
29 t.Fatalf("sanitized session text missing: %q", row)
30 }
31}
32
33func TestSessionDialogLoadsEntriesWithoutBlockingOpen(t *testing.T) {
34 root := t.TempDir()
35 cwd := t.TempDir()
36 session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
37 if err != nil {
38 t.Fatal(err)
39 }
40 if err := session.AppendMessage(provider.Message{
41 Role: provider.RoleUser,
42 Content: []provider.Content{provider.TextBlock{Text: "load this session"}},
43 }); err != nil {
44 t.Fatal(err)
45 }
46 if err := session.Close(); err != nil {
47 t.Fatal(err)
48 }
49
50 d := newSessionDialog()
51 events := d.Open(context.Background(), root, cwd)
52 t.Cleanup(d.Close)
53 if !d.Active() || !d.Loading() {
54 t.Fatalf("dialog state after Open = active %v, loading %v; want active and loading", d.Active(), d.Loading())
55 }
56 loadingText := strings.Join(d.Render(tui.Dark, 100), "\n")
57 if !strings.Contains(loadingText, "Loading sessions") {
58 t.Fatalf("loading render = %q, want spinner status", loadingText)
59 }
60 act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
61 if act.Select || !d.Active() {
62 t.Fatalf("enter while loading = %+v, active %v; want no selection and active dialog", act, d.Active())
63 }
64
65 for event := range events {
66 d.ApplyLoad(event)
67 }
68
69 if d.Loading() {
70 t.Fatal("dialog still loading after final result")
71 }
72 if len(d.sessions) != 1 || d.sessions[0].Path != session.Path {
73 t.Fatalf("loaded sessions = %+v, want %q", d.sessions, session.Path)
74 }
75 loadedText := strings.Join(d.Render(tui.Dark, 100), "\n")
76 if strings.Contains(loadedText, "Loading sessions") {
77 t.Fatalf("completed render still shows loading spinner: %q", loadedText)
78 }
79 if !strings.Contains(loadedText, "load this session") {
80 t.Fatalf("completed render missing session text: %q", loadedText)
81 }
82}
83
84func TestSessionDialogCanceledParentDoesNotRemainLoading(t *testing.T) {
85 ctx, cancel := context.WithCancel(context.Background())
86 cancel()
87
88 d := newSessionDialog()
89 events := d.Open(ctx, t.TempDir(), t.TempDir())
90 if d.Loading() {
91 t.Fatal("dialog remained loading with an already-canceled parent")
92 }
93 if d.Active() {
94 t.Fatal("dialog remained active with an already-canceled parent")
95 }
96 if _, ok := <-events; ok {
97 t.Fatal("canceled dialog emitted a load event")
98 }
99}
100
101func TestSessionDialogCancellationFinishesLoading(t *testing.T) {
102 ctx, cancel := context.WithCancel(context.Background())
103 d := newSessionDialog()
104 events := d.Open(ctx, t.TempDir(), t.TempDir())
105 cancel()
106 for range events {
107 }
108 d.ApplyLoadClosed()
109 if d.Loading() {
110 t.Fatal("dialog remained loading after parent cancellation")
111 }
112 if d.Active() {
113 t.Fatal("dialog remained active after parent cancellation")
114 }
115}
116
117func TestSessionDialogCancellationDoesNotEmitFinished(t *testing.T) {
118 root := t.TempDir()
119 cwd := t.TempDir()
120 session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
121 if err != nil {
122 t.Fatal(err)
123 }
124 if err := session.AppendMessage(provider.Message{
125 Role: provider.RoleUser,
126 Content: []provider.Content{provider.TextBlock{Text: "cancel this load"}},
127 }); err != nil {
128 t.Fatal(err)
129 }
130 if err := session.Close(); err != nil {
131 t.Fatal(err)
132 }
133
134 entered := make(chan struct{})
135 d := newSessionDialog()
136 d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
137 close(entered)
138 <-ctx.Done()
139 return core.SessionSummary{Path: path}
140 }
141 ctx, cancel := context.WithCancel(context.Background())
142 events := d.Open(ctx, root, cwd)
143 t.Cleanup(func() {
144 cancel()
145 d.Close()
146 })
147
148 select {
149 case event, ok := <-events:
150 if !ok || event.kind != sessionLoadStarted {
151 t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
152 }
153 d.ApplyLoad(event)
154 case <-time.After(sessionDialogTestTimeout):
155 t.Fatal("timed out waiting for sessionLoadStarted")
156 }
157 select {
158 case <-entered:
159 case <-time.After(sessionDialogTestTimeout):
160 t.Fatal("timed out waiting for the session worker")
161 }
162
163 cancel()
164 for {
165 select {
166 case event, ok := <-events:
167 if !ok {
168 d.ApplyLoadClosed()
169 if d.Loading() || d.Active() {
170 t.Fatalf("dialog after cancellation = loading %v, active %v; want both false", d.Loading(), d.Active())
171 }
172 return
173 }
174 if event.kind == sessionLoadFinished {
175 t.Fatal("canceled load emitted sessionLoadFinished")
176 }
177 d.ApplyLoad(event)
178 case <-time.After(sessionDialogTestTimeout):
179 t.Fatal("timed out waiting for canceled session load to close")
180 }
181 }
182}
183
184func TestSessionDialogPreCanceledOpenPreservesPreviousLoadBarrier(t *testing.T) {
185 root := t.TempDir()
186 cwd := t.TempDir()
187 session, err := core.NewSession(root, cwd, "test", "test-model", "test-version")
188 if err != nil {
189 t.Fatal(err)
190 }
191 if err := session.AppendMessage(provider.Message{
192 Role: provider.RoleUser,
193 Content: []provider.Content{provider.TextBlock{Text: "wait for the prior load"}},
194 }); err != nil {
195 t.Fatal(err)
196 }
197 if err := session.Close(); err != nil {
198 t.Fatal(err)
199 }
200
201 entered := make(chan struct{})
202 release := make(chan struct{})
203 var enteredOnce sync.Once
204 var releaseOnce sync.Once
205 releaseLoad := func() { releaseOnce.Do(func() { close(release) }) }
206 d := newSessionDialog()
207 d.describeSession = func(ctx context.Context, path string) core.SessionSummary {
208 enteredOnce.Do(func() { close(entered) })
209 <-release
210 return core.SessionSummary{Path: path, MessageCount: 1}
211 }
212 firstCtx, firstCancel := context.WithCancel(context.Background())
213 firstEvents := d.Open(firstCtx, root, cwd)
214 t.Cleanup(func() {
215 firstCancel()
216 releaseLoad()
217 d.Close()
218 })
219
220 select {
221 case event, ok := <-firstEvents:
222 if !ok || event.kind != sessionLoadStarted {
223 t.Fatalf("first load event = %+v, open %v; want sessionLoadStarted", event, ok)
224 }
225 d.ApplyLoad(event)
226 case <-time.After(sessionDialogTestTimeout):
227 t.Fatal("timed out waiting for the first load to start")
228 }
229 select {
230 case <-entered:
231 case <-time.After(sessionDialogTestTimeout):
232 t.Fatal("timed out waiting for the first worker")
233 }
234
235 preCanceled, cancelPreCanceled := context.WithCancel(context.Background())
236 cancelPreCanceled()
237 preCanceledEvents := d.Open(preCanceled, root, cwd)
238 if _, ok := <-preCanceledEvents; ok {
239 t.Fatal("pre-canceled open emitted a load event")
240 }
241
242 thirdCtx, cancelThird := context.WithCancel(context.Background())
243 thirdEvents := d.Open(thirdCtx, root, cwd)
244 select {
245 case event, ok := <-thirdEvents:
246 t.Fatalf("third open started before the first load finished: %+v, open %v", event, ok)
247 case <-time.After(100 * time.Millisecond):
248 }
249
250 releaseLoad()
251 firstCancel()
252 select {
253 case event, ok := <-thirdEvents:
254 if !ok || event.kind != sessionLoadStarted {
255 t.Fatalf("third load event = %+v, open %v; want sessionLoadStarted after barrier", event, ok)
256 }
257 d.ApplyLoad(event)
258 case <-time.After(sessionDialogTestTimeout):
259 t.Fatal("timed out waiting for third load after releasing the first")
260 }
261 cancelThird()
262 for event := range thirdEvents {
263 d.ApplyLoad(event)
264 }
265}
266
267func TestSessionDialogEscapeCancelsLoading(t *testing.T) {
268 d := newSessionDialog()
269 events := d.Open(context.Background(), t.TempDir(), t.TempDir())
270 act := d.HandleKey(tui.Key{Kind: tui.KeyEsc})
271 if !act.Close || d.Active() || d.Loading() {
272 t.Fatalf("escape action = %+v, active %v, loading %v; want closed dialog", act, d.Active(), d.Loading())
273 }
274 for range events {
275 }
276}
277
278func TestSessionDialogAppliesLoadedEntriesIncrementallyInPathOrder(t *testing.T) {
279 d := newSessionDialog()
280 d.active = true
281 d.loading = true
282 d.loadGeneration = 7
283
284 d.ApplyLoad(sessionLoadEvent{
285 kind: sessionLoadStarted,
286 generation: 7,
287 total: 4,
288 })
289 d.ApplyLoad(sessionLoadEvent{
290 kind: sessionLoadEntry,
291 generation: 7,
292 index: 2,
293 summary: core.SessionSummary{Path: "third", MessageCount: 1},
294 })
295 if len(d.sessions) != 0 {
296 t.Fatalf("older out-of-order entry appeared before newest entry: %+v", d.sessions)
297 }
298 d.ApplyLoad(sessionLoadEvent{
299 kind: sessionLoadEntry,
300 generation: 7,
301 index: 1,
302 summary: core.SessionSummary{Path: "hidden", MessageCount: 1, HideFromSessions: true},
303 })
304 d.ApplyLoad(sessionLoadEvent{
305 kind: sessionLoadEntry,
306 generation: 7,
307 index: 3,
308 summary: core.SessionSummary{Path: "empty"},
309 })
310 d.ApplyLoad(sessionLoadEvent{
311 kind: sessionLoadEntry,
312 generation: 7,
313 index: 0,
314 summary: core.SessionSummary{Path: "first", MessageCount: 1},
315 })
316 if !d.Loading() {
317 t.Fatal("dialog stopped loading before the terminal event")
318 }
319 if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
320 t.Fatalf("incremental sessions = %+v, want first then third", d.sessions)
321 }
322
323 d.ApplyLoad(sessionLoadEvent{kind: sessionLoadFinished, generation: 7})
324
325 if d.Loading() {
326 t.Fatal("dialog still loading after final result")
327 }
328 if len(d.sessions) != 2 || d.sessions[0].Path != "first" || d.sessions[1].Path != "third" {
329 t.Fatalf("loaded sessions = %+v, want first then third", d.sessions)
330 }
331}
332
333func TestSessionDialogCanSelectLoadedEntryWhileLoading(t *testing.T) {
334 d := newSessionDialog()
335 d.active = true
336 d.loading = true
337 d.loadGeneration = 9
338
339 d.ApplyLoad(sessionLoadEvent{
340 kind: sessionLoadStarted,
341 generation: 9,
342 total: 2,
343 })
344 d.ApplyLoad(sessionLoadEvent{
345 kind: sessionLoadEntry,
346 generation: 9,
347 index: 0,
348 summary: core.SessionSummary{Path: "newest", MessageCount: 1, FirstUserText: "recent work"},
349 })
350
351 if !d.Loading() {
352 t.Fatal("dialog stopped loading after first entry")
353 }
354 rendered := strings.Join(d.Render(tui.Dark, 100), "\n")
355 if !strings.Contains(rendered, "recent work") {
356 t.Fatalf("incremental render missing newest entry: %q", rendered)
357 }
358
359 act := d.HandleKey(tui.Key{Kind: tui.KeyEnter})
360 if !act.Select || act.Path != "newest" || d.Active() {
361 t.Fatalf("select while loading = %+v, active %v; want newest selected and dialog closed", act, d.Active())
362 }
363}
364
365func TestSessionDialogAllScopeIncludesOtherCWDBuckets(t *testing.T) {
366 root := t.TempDir()
367 cwdA := t.TempDir()
368 cwdB := t.TempDir()
369 for _, cwd := range []string{cwdA, cwdB} {
370 session, err := core.NewSession(root, cwd, "test", "model", "test")
371 if err != nil {
372 t.Fatal(err)
373 }
374 if err := session.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "scope"}}}); err != nil {
375 t.Fatal(err)
376 }
377 if err := session.Close(); err != nil {
378 t.Fatal(err)
379 }
380 }
381 d := newSessionDialog()
382 events := d.Open(context.Background(), root, cwdA, true)
383 defer d.Close()
384 for event := range events {
385 d.ApplyLoad(event)
386 }
387 if !d.allScope || len(d.sessions) != 2 {
388 t.Fatalf("all scope = %v, sessions = %#v", d.allScope, d.sessions)
389 }
390 if action := d.HandleKey(tui.Key{Kind: tui.KeyTab}); !action.ToggleScope {
391 t.Fatalf("tab action = %#v, want scope toggle", action)
392 }
393}
394
395func TestSessionDialogSearchShowsCountsAndExcerpt(t *testing.T) {
396 d := newSessionDialog()
397 d.active = true
398 d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1}, {Path: "two", MessageCount: 1}}
399 d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
400 d.query = "alpha"
401 d.searchReady = true
402 d.searchGeneration = 1
403 d.searchMatches = matchSessionSearchSegments(context.Background(), d.query, []core.SessionSearchSegment{
404 {Path: "one", Text: "alpha implementation details"},
405 {Path: "one", Text: "alpha follow-up"},
406 {Path: "two", Text: "unrelated text"},
407 })
408 d.applySearchFilter()
409 if len(d.sessions) != 1 || d.sessions[0].Path != "one" {
410 t.Fatalf("search sessions = %#v", d.sessions)
411 }
412 match := d.searchMatches["one"]
413 if match.count != 2 || match.excerpt == "" {
414 t.Fatalf("search match = %#v", match)
415 }
416 rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n"))
417 if !strings.Contains(rendered, "2 matches") || !strings.Contains(rendered, "alpha") {
418 t.Fatalf("search render = %q", rendered)
419 }
420}
421
422func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) {
423 segments := []core.SessionSearchSegment{
424 {Path: "scattered", Text: "sizzle then harp, again remote"},
425 {Path: "exact", Text: "resume the sharm project"},
426 }
427
428 if matches := matchSessionSearchSegments(context.Background(), "s", segments); matches != nil {
429 t.Fatalf("one-character search = %#v, want no search", matches)
430 }
431 matches := matchSessionSearchSegments(context.Background(), "sharm", segments)
432 if len(matches) != 1 {
433 t.Fatalf("matches = %#v, want only exact session", matches)
434 }
435 match, ok := matches["exact"]
436 if !ok {
437 t.Fatalf("matches = %#v, missing exact session", matches)
438 }
439 if match.count != 1 || match.excerpt != "resume the sharm project" {
440 t.Fatalf("exact match = %#v", match)
441 }
442 if want := []int{11, 12, 13, 14, 15}; len(match.indexes) != len(want) || match.indexes[0] != want[0] || match.indexes[1] != want[1] || match.indexes[2] != want[2] || match.indexes[3] != want[3] || match.indexes[4] != want[4] {
443 t.Fatalf("highlight indexes = %v, want %v", match.indexes, want)
444 }
445}
446
447func TestSessionSearchCountsRepeatedSegmentMatches(t *testing.T) {
448 matches := matchSessionSearchSegments(context.Background(), "sharm", []core.SessionSearchSegment{
449 {Path: "repeated", Text: "sharm then sharm"},
450 })
451 match, ok := matches["repeated"]
452 if !ok {
453 t.Fatalf("matches = %#v, missing repeated session", matches)
454 }
455 if match.count != 2 {
456 t.Fatalf("match count = %d, want 2", match.count)
457 }
458 if match.excerpt != "sharm then sharm" {
459 t.Fatalf("match excerpt = %q", match.excerpt)
460 }
461 if want := []int{0, 1, 2, 3, 4}; len(match.indexes) != len(want) || match.indexes[0] != want[0] || match.indexes[1] != want[1] || match.indexes[2] != want[2] || match.indexes[3] != want[3] || match.indexes[4] != want[4] {
462 t.Fatalf("highlight indexes = %v, want %v", match.indexes, want)
463 }
464}
465
466func TestSessionDialogDefersSearchUntilSecondCharacter(t *testing.T) {
467 d := newSessionDialog()
468 d.active = true
469 d.baseSessions = []core.SessionSummary{{Path: "one"}, {Path: "two"}}
470 d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
471
472 if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}); action.StartSearch || !d.searchRequested {
473 t.Fatalf("slash action = %#v, search requested = %v", action, d.searchRequested)
474 }
475 if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 's'}); action.StartSearch || d.query != "s" {
476 t.Fatalf("one-character action = %#v, query = %q", action, d.query)
477 }
478 if len(d.sessions) != len(d.baseSessions) {
479 t.Fatalf("one-character search filtered sessions: %#v", d.sessions)
480 }
481 if events := d.StartSearch(context.Background()); events != nil || d.searchEvents != nil {
482 t.Fatal("one-character search started the corpus reader")
483 }
484 if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'h'}); !action.StartSearch || d.query != "sh" {
485 t.Fatalf("two-character action = %#v, query = %q", action, d.query)
486 }
487}
488
489func TestSessionDialogClearsMatchesBelowSearchMinimum(t *testing.T) {
490 d := newSessionDialog()
491 d.active = true
492 d.searchRequested = true
493 d.searchReady = true
494 d.query = "sh"
495 d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1, FirstUserText: "session"}}
496 d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
497 d.searchMatches = map[string]sessionSearchMatch{"one": {count: 1}}
498
499 d.HandleKey(tui.Key{Kind: tui.KeyBackspace})
500 if d.searchMatches != nil {
501 t.Fatalf("matches after shortening query = %#v, want nil", d.searchMatches)
502 }
503 rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n"))
504 if strings.Contains(rendered, "1 match") {
505 t.Fatalf("one-character search rendered stale match count: %q", rendered)
506 }
507}
508
509func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
510 first := newSessionDialog()
511 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
512 firstGeneration := first.loadGeneration
513
514 secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
515 secondGeneration := first.loadGeneration
516 if secondGeneration == firstGeneration {
517 t.Fatal("reopening picker did not advance load generation")
518 }
519 first.ApplyLoad(sessionLoadEvent{
520 kind: sessionLoadStarted,
521 generation: secondGeneration,
522 total: 1,
523 })
524
525 first.ApplyLoad(sessionLoadEvent{
526 kind: sessionLoadEntry,
527 generation: firstGeneration,
528 index: 0,
529 summary: core.SessionSummary{
530 Path: "stale",
531 MessageCount: 1,
532 },
533 })
534 if len(first.sessions) != 0 {
535 t.Fatalf("stale results repopulated picker: %+v", first.sessions)
536 }
537
538 first.Close()
539 for range firstEvents {
540 }
541 for range secondEvents {
542 }
543}