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 TestSessionDialogSearchShowsAnimatedLoadingFeedback(t *testing.T) {
423 d := newSessionDialog()
424 d.active = true
425 d.loading = true // Keep StartSearch pending; no background read is needed.
426 d.query = "go"
427
428 if events := d.StartSearch(context.Background()); events == nil {
429 t.Fatal("StartSearch returned nil")
430 }
431 defer d.Close()
432 if !d.SearchLoading() {
433 t.Fatal("search is not reported as loading while indexing")
434 }
435 if d.searchSpinner == nil {
436 t.Fatal("search did not initialize the shared spinner")
437 }
438 indexed := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n"))
439 if !strings.Contains(indexed, "Indexing session text") {
440 t.Fatalf("indexing render = %q", indexed)
441 }
442
443 d.searching = false
444 d.matching = true
445 matching := stripANSIBytes(strings.Join(d.Render(tui.Dark, 100), "\n"))
446 if !strings.Contains(matching, "Searching session text") {
447 t.Fatalf("matching render = %q", matching)
448 }
449
450 d.matching = false
451 if d.SearchLoading() {
452 t.Fatal("search remains reported as loading after matching completes")
453 }
454}
455
456func TestSessionDialogSearchIgnoresStaleMatchGeneration(t *testing.T) {
457 d := newSessionDialog()
458 d.active = true
459 d.query = "abc"
460 d.searchGeneration = 1
461 d.matchGeneration = 2
462 d.matching = true
463
464 d.ApplySearch(sessionSearchEvent{
465 kind: sessionSearchMatchesReady,
466 generation: 1,
467 matchGeneration: 1,
468 query: "abc",
469 matches: map[string]sessionSearchMatch{"stale": {count: 1}},
470 })
471 if !d.matching {
472 t.Fatal("stale match result cleared the active search state")
473 }
474 if d.searchMatches != nil {
475 t.Fatalf("stale match result was applied: %#v", d.searchMatches)
476 }
477
478 d.ApplySearch(sessionSearchEvent{
479 kind: sessionSearchMatchesReady,
480 generation: 1,
481 matchGeneration: 2,
482 query: "abc",
483 matches: map[string]sessionSearchMatch{"current": {count: 1}},
484 })
485 if d.matching {
486 t.Fatal("current match result did not finish the active search")
487 }
488 if _, ok := d.searchMatches["current"]; !ok {
489 t.Fatalf("current match result was not applied: %#v", d.searchMatches)
490 }
491}
492
493func TestSessionSearchUsesContiguousQueryAndRequiresTwoCharacters(t *testing.T) {
494 segments := []core.SessionSearchSegment{
495 {Path: "scattered", Text: "sizzle then harp, again remote"},
496 {Path: "exact", Text: "resume the sharm project"},
497 }
498
499 if matches := matchSessionSearchSegments(context.Background(), "s", segments); matches != nil {
500 t.Fatalf("one-character search = %#v, want no search", matches)
501 }
502 matches := matchSessionSearchSegments(context.Background(), "sharm", segments)
503 if len(matches) != 1 {
504 t.Fatalf("matches = %#v, want only exact session", matches)
505 }
506 match, ok := matches["exact"]
507 if !ok {
508 t.Fatalf("matches = %#v, missing exact session", matches)
509 }
510 if match.count != 1 || match.excerpt != "resume the sharm project" {
511 t.Fatalf("exact match = %#v", match)
512 }
513 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] {
514 t.Fatalf("highlight indexes = %v, want %v", match.indexes, want)
515 }
516}
517
518func TestSessionSearchCountsRepeatedSegmentMatches(t *testing.T) {
519 matches := matchSessionSearchSegments(context.Background(), "sharm", []core.SessionSearchSegment{
520 {Path: "repeated", Text: "sharm then sharm"},
521 })
522 match, ok := matches["repeated"]
523 if !ok {
524 t.Fatalf("matches = %#v, missing repeated session", matches)
525 }
526 if match.count != 2 {
527 t.Fatalf("match count = %d, want 2", match.count)
528 }
529 if match.excerpt != "sharm then sharm" {
530 t.Fatalf("match excerpt = %q", match.excerpt)
531 }
532 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] {
533 t.Fatalf("highlight indexes = %v, want %v", match.indexes, want)
534 }
535}
536
537func TestSessionDialogDefersSearchUntilSecondCharacter(t *testing.T) {
538 d := newSessionDialog()
539 d.active = true
540 d.baseSessions = []core.SessionSummary{{Path: "one"}, {Path: "two"}}
541 d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
542
543 if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '/'}); action.StartSearch || !d.searchRequested {
544 t.Fatalf("slash action = %#v, search requested = %v", action, d.searchRequested)
545 }
546 if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 's'}); action.StartSearch || d.query != "s" {
547 t.Fatalf("one-character action = %#v, query = %q", action, d.query)
548 }
549 if len(d.sessions) != len(d.baseSessions) {
550 t.Fatalf("one-character search filtered sessions: %#v", d.sessions)
551 }
552 if events := d.StartSearch(context.Background()); events != nil || d.searchEvents != nil {
553 t.Fatal("one-character search started the corpus reader")
554 }
555 if action := d.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: 'h'}); !action.StartSearch || d.query != "sh" {
556 t.Fatalf("two-character action = %#v, query = %q", action, d.query)
557 }
558}
559
560func TestSessionDialogClearsMatchesBelowSearchMinimum(t *testing.T) {
561 d := newSessionDialog()
562 d.active = true
563 d.searchRequested = true
564 d.searchReady = true
565 d.query = "sh"
566 d.baseSessions = []core.SessionSummary{{Path: "one", MessageCount: 1, FirstUserText: "session"}}
567 d.sessions = append([]core.SessionSummary(nil), d.baseSessions...)
568 d.searchMatches = map[string]sessionSearchMatch{"one": {count: 1}}
569
570 d.HandleKey(tui.Key{Kind: tui.KeyBackspace})
571 if d.searchMatches != nil {
572 t.Fatalf("matches after shortening query = %#v, want nil", d.searchMatches)
573 }
574 rendered := stripANSIBytes(strings.Join(d.Render(tui.Dark, 120), "\n"))
575 if strings.Contains(rendered, "1 match") {
576 t.Fatalf("one-character search rendered stale match count: %q", rendered)
577 }
578}
579
580func TestSessionDialogIgnoresStaleLoadResults(t *testing.T) {
581 first := newSessionDialog()
582 firstEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
583 firstGeneration := first.loadGeneration
584
585 secondEvents := first.Open(context.Background(), t.TempDir(), t.TempDir())
586 secondGeneration := first.loadGeneration
587 if secondGeneration == firstGeneration {
588 t.Fatal("reopening picker did not advance load generation")
589 }
590 first.ApplyLoad(sessionLoadEvent{
591 kind: sessionLoadStarted,
592 generation: secondGeneration,
593 total: 1,
594 })
595
596 first.ApplyLoad(sessionLoadEvent{
597 kind: sessionLoadEntry,
598 generation: firstGeneration,
599 index: 0,
600 summary: core.SessionSummary{
601 Path: "stale",
602 MessageCount: 1,
603 },
604 })
605 if len(first.sessions) != 0 {
606 t.Fatalf("stale results repopulated picker: %+v", first.sessions)
607 }
608
609 first.Close()
610 for range firstEvents {
611 }
612 for range secondEvents {
613 }
614}