// Two invariants the e2e suite cannot reach, because each lives inside the
// tui package or only shows up mid-flight:
//
// - layout: at every terminal size the viewer must fit its rows and columns
// and keep the highlighted session visible — no daemon run observes a
// given width/height pair;
// - preview timing: a debounce, a stale tick for a row no longer
// highlighted, and the cache stamp that decides when a new reply
// invalidates a preview are race-dependent state a scripted scenario
// reaches only flakily.
package tui
import (
"albedo/cli/internal/daemon"
"errors"
"strings"
"testing"
"time"
tea "charm.land/bubbletea/v2"
"github.com/charmbracelet/x/ansi"
)
func TestSessionViewerResponsiveViewport(t *testing.T) {
for _, size := range [][2]int{{170, 34}, {110, 28}, {80, 24}, {46, 10}, {25, 7}, {12, 5}} {
m := NewSessionViewer("/work/current")
m.SetSize(size[0], size[1])
sessions := []daemon.Session{
{ID: "one", Title: "A very long session title that cannot fit on a single line", Workspace: "/work/alpha"},
{ID: "two", Title: "Selected session", Workspace: "/work/beta"},
}
m.SetSessions(sessions, nil)
m.Cursor = len(m.Filtered) - 1
view := m.View()
lines := strings.Split(view, "\n")
if len(lines) > size[1] {
t.Errorf("%dx%d: %d lines", size[0], size[1], len(lines))
}
for _, line := range lines {
if ansi.StringWidth(line) > size[0] {
t.Errorf("%dx%d: line too wide: %q", size[0], size[1], line)
}
}
if !strings.Contains(ansi.Strip(view), "Selected session") && size[0] >= 25 {
t.Errorf("%dx%d: selected row not visible", size[0], size[1])
}
}
}
func viewerAt(now time.Time) SessionViewer {
m := NewSessionViewer("/work/current")
m.now = func() time.Time { return now }
return m
}
func TestSessionViewerPreviewFetchesOnSettleAndRenders(t *testing.T) {
now := time.Date(2026, 9, 23, 12, 0, 0, 0, time.Local)
stamp := now.Add(-time.Minute).Unix()
sessions := []daemon.Session{
{ID: "one", Title: "First", Model: "sonnet", LastAssistantAt: &stamp},
{ID: "two", Title: "Second", Model: "opus", LastAssistantAt: &stamp},
}
var fetched []string
m := viewerAt(now)
m.Fetch = func(id string) tea.Cmd {
fetched = append(fetched, id)
return nil
}
m.SetSize(120, 30)
m.SetSessions(sessions, nil)
m, cmd := m.Update(tea.KeyPressMsg{Code: tea.KeyDown})
if cmd == nil {
t.Fatal("moving did not schedule a preview")
}
// A stale tick for a row no longer highlighted is ignored.
m, _ = m.Update(sessionPreviewTickMsg{ID: "one"})
m, _ = m.Update(sessionPreviewTickMsg{ID: "two"})
m, _ = m.Update(sessionPreviewTickMsg{ID: "two"})
if strings.Join(fetched, " ") != "two" {
t.Fatalf("fetched: %v", fetched)
}
m, _ = m.Update(SessionPreviewMsg{ID: "two", Preview: daemon.SessionPreview{Total: 9, Items: []daemon.PreviewItem{
{Type: "user", Preview: "please fix the flaky test"},
{Type: "tool", Preview: "read"}, {Type: "tool", Preview: "read"}, {Type: "tool", Preview: "bash"},
{Type: "assistant", Preview: "fixed the race in the watcher"},
}}})
view := ansi.Strip(m.View())
for _, want := range []string{"please fix the flaky test", "read ×2, bash", "fixed the race", "9 messages", "opus"} {
if !strings.Contains(view, want) {
t.Errorf("preview missing %q:\n%s", want, view)
}
}
// A newer reply invalidates the cached preview; a failure shows a hint.
newer := stamp + 60
sessions[1].LastAssistantAt = &newer
m.SetSessions(sessions, nil)
if m.PreviewCmd() == nil {
t.Fatal("new reply did not refresh the preview")
}
m.fetchPreview("two")
m, _ = m.Update(SessionPreviewMsg{ID: "two", Err: errors.New("unknown operation")})
if preview := m.previews["two"]; preview == nil || preview.loading || !preview.err {
t.Fatal("failed preview did not leave loading state")
}
}