okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

dawntui: sessions search back to substring match per word, listed order1697fee

main
7.1 KiB187 linesraw
1// Two invariants the e2e suite cannot reach, because each lives inside the
2// tui package or only shows up mid-flight:
3//
4//   - layout: at every terminal size the viewer must fit its rows and columns
5//     and keep the highlighted session visible — no daemon run observes a
6//     given width/height pair;
7//   - preview timing: a debounce, a stale tick for a row no longer
8//     highlighted, and the cache stamp that decides when a new reply
9//     invalidates a preview are race-dependent state a scripted scenario
10//     reaches only flakily;
11//   - grouping: within a group, sessions the daemon still holds lead the
12//     reaped ones;
13//   - keystrokes: the fuzzy search narrows as each key lands, and a delete
14//     asks first and ignores every key but enter and esc.
15package tui
16
17import (
18	"albedo/cli/internal/daemon"
19	"errors"
20	"slices"
21	"strings"
22	"testing"
23	"time"
24
25	tea "charm.land/bubbletea/v2"
26	"github.com/charmbracelet/x/ansi"
27)
28
29func TestSessionViewerResponsiveViewport(t *testing.T) {
30	for _, size := range [][2]int{{170, 34}, {110, 28}, {80, 24}, {46, 10}, {25, 7}, {12, 5}} {
31		m := NewSessionViewer("/work/current")
32		m.SetSize(size[0], size[1])
33		sessions := []daemon.Session{
34			{ID: "one", Title: "A very long session title that cannot fit on a single line", Workspace: "/work/alpha"},
35			{ID: "two", Title: "Selected session", Workspace: "/work/beta"},
36		}
37		m.SetSessions(sessions, nil)
38		m.Cursor = len(m.Filtered) - 1
39		view := m.View()
40		lines := strings.Split(view, "\n")
41		if len(lines) > size[1] {
42			t.Errorf("%dx%d: %d lines", size[0], size[1], len(lines))
43		}
44		for _, line := range lines {
45			if ansi.StringWidth(line) > size[0] {
46				t.Errorf("%dx%d: line too wide: %q", size[0], size[1], line)
47			}
48		}
49		if !strings.Contains(ansi.Strip(view), "Selected session") && size[0] >= 25 {
50			t.Errorf("%dx%d: selected row not visible", size[0], size[1])
51		}
52	}
53}
54
55func TestSessionViewerPutsWarmSessionsFirst(t *testing.T) {
56	now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.Local)
57	newest, recent, stale := now.Add(-time.Minute).Unix(), now.Add(-10*time.Minute).Unix(), now.Add(-2*time.Hour).Unix()
58	m := viewerAt(now)
59	m.SetSize(120, 30)
60	// the daemon lists by activity; a loaded session whose last turn is over
61	// an hour old has a cold cache and keeps the daemon's order
62	m.SetSessions([]daemon.Session{
63		{ID: "reaped", Title: "Reaped session", LastAssistantAt: &newest},
64		{ID: "warm", Title: "Warm session", LastAssistantAt: &recent, Cursor: &daemon.Cursor{}},
65		{ID: "cold", Title: "Cold session", LastAssistantAt: &stale, Cursor: &daemon.Cursor{}},
66	}, nil)
67	view := ansi.Strip(m.View())
68	warmAt, reapedAt, coldAt := strings.Index(view, "Warm session"), strings.Index(view, "Reaped session"), strings.Index(view, "Cold session")
69	if warmAt < 0 || reapedAt < warmAt || coldAt < reapedAt {
70		t.Fatalf("a warm session must lead, and a cold loaded one must not:\n%s", view)
71	}
72}
73
74func viewerAt(now time.Time) SessionViewer {
75	m := NewSessionViewer("/work/current")
76	m.now = func() time.Time { return now }
77	return m
78}
79
80func TestSessionViewerPreviewFetchesOnSettleAndRenders(t *testing.T) {
81	now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.Local)
82	stamp := now.Add(-time.Minute).Unix()
83	sessions := []daemon.Session{
84		{ID: "one", Title: "First", Model: "sonnet", LastAssistantAt: &stamp},
85		{ID: "two", Title: "Second", Model: "opus", LastAssistantAt: &stamp, TranscriptCount: 9},
86	}
87	var fetched []string
88	m := viewerAt(now)
89	m.Fetch = func(id string) tea.Cmd {
90		fetched = append(fetched, id)
91		return nil
92	}
93	m.SetSize(120, 30)
94	m.SetSessions(sessions, nil)
95
96	m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
97	if cmd == nil {
98		t.Fatal("moving did not schedule a preview")
99	}
100	// A stale tick for a row no longer highlighted is ignored.
101	m, _ = m.Update(sessionPreviewTickMsg{ID: "one"})
102	m, _ = m.Update(sessionPreviewTickMsg{ID: "two"})
103	m, _ = m.Update(sessionPreviewTickMsg{ID: "two"})
104	if strings.Join(fetched, " ") != "two" {
105		t.Fatalf("fetched: %v", fetched)
106	}
107	m, _ = m.Update(SessionPreviewMsg{ID: "two", Preview: daemon.SessionPreview{Items: []daemon.PreviewItem{
108		{Type: "user", Preview: "please fix the flaky test"},
109		{Type: "tool", Preview: "read"}, {Type: "tool", Preview: "read"}, {Type: "tool", Preview: "bash"},
110		{Type: "assistant", Preview: "fixed the race in the watcher"},
111	}}})
112	view := ansi.Strip(m.View())
113	for _, want := range []string{"please fix the flaky test", "read ×2, bash", "fixed the race", "9 messages", "opus"} {
114		if !strings.Contains(view, want) {
115			t.Errorf("preview missing %q:\n%s", want, view)
116		}
117	}
118
119	// A newer reply invalidates the cached preview; a failure shows a hint.
120	newer := stamp + 60
121	sessions[1].LastAssistantAt = &newer
122	m.SetSessions(sessions, nil)
123	if m.PreviewCmd() == nil {
124		t.Fatal("new reply did not refresh the preview")
125	}
126	m.fetchPreview("two")
127	m, _ = m.Update(SessionPreviewMsg{ID: "two", Err: errors.New("unknown operation")})
128	if preview := m.previews["two"]; preview == nil || preview.loading || !preview.err {
129		t.Fatal("failed preview did not leave loading state")
130	}
131}
132
133func TestSessionViewerArchiveKeepsCursorSlot(t *testing.T) {
134	m := NewSessionViewer("/work/current")
135	m.SetSize(120, 30)
136	m.SetSessions([]daemon.Session{{ID: "a"}, {ID: "b"}, {ID: "c"}, {ID: "d"}}, nil)
137	m.focus("b")
138	slot := m.Cursor
139	m.raw[1].Archived = true
140	m.rebuild()
141	if item, _ := m.Highlighted(); item.ID != "c" || m.Cursor != slot {
142		t.Fatalf("cursor %d on %q, want slot %d on c", m.Cursor, item.ID, slot)
143	}
144	m.focus("d")
145	m.raw[3].Archived = true
146	m.rebuild()
147	if item, _ := m.Highlighted(); item.ID != "c" {
148		t.Fatalf("archiving the last session left the cursor on %q, want c", item.ID)
149	}
150}
151
152func TestSessionViewerFilterMatchesSubstrings(t *testing.T) {
153	m := NewSessionViewer("/work/current")
154	m.SetSize(120, 30)
155	m.SetSessions([]daemon.Session{{ID: "a", Title: "Fix the parser", Pinned: true}, {ID: "b", Title: "yeh"}, {ID: "c", Title: "yellow elephant hat"}}, nil)
156	for _, r := range "yeh" {
157		m, _ = m.Update(tea.KeyPressMsg{Code: r, Text: string(r)})
158	}
159	if got := filteredIDs(m.PickerModel); !slices.Equal(got, []string{"b"}) {
160		t.Fatalf("search kept %v, want only the session titled yeh", got)
161	}
162}
163
164func TestSessionViewerDeleteAsksThenTakesEnterOnly(t *testing.T) {
165	m := NewSessionViewer("/work/current")
166	m.SetSize(120, 30)
167	m.SetSessions([]daemon.Session{{ID: "gone", Title: "Old", Archived: true, ETag: "e1"}}, nil)
168	m.OpenArchive()
169	m, _ = m.Update(tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl})
170	if !m.confirm.asking() || !strings.Contains(ansi.Strip(m.View()), "enter delete") {
171		t.Fatalf("ctrl+d did not ask:\n%s", ansi.Strip(m.View()))
172	}
173	m, cmd := m.Update(tea.KeyPressMsg{Code: 'x', Text: "x"})
174	if cmd != nil || !m.confirm.asking() {
175		t.Fatal("a stray key changed the question")
176	}
177	m, _ = m.Update(tea.KeyPressMsg{Code: tea.KeyEscape})
178	if m.confirm.asking() || !m.ArchiveView {
179		t.Fatal("esc did not cancel the question alone")
180	}
181	m, _ = m.Update(tea.KeyPressMsg{Code: 'd', Mod: tea.ModCtrl})
182	m, cmd = m.Update(tea.KeyPressMsg{Code: tea.KeyEnter})
183	deleted, ok := cmd().(SessionDeleteMsg)
184	if !ok || deleted.ID != "gone" || deleted.Condition.ETag != "e1" {
185		t.Fatalf("enter did not delete the asked session: %#v", deleted)
186	}
187}