okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
1697fee
main
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 ) { 30for _ ,size := range [][2 ]int {{170 ,34 }, {110 ,28 }, {80 ,24 }, {46 ,10 }, {25 ,7 }, {12 ,5 }} { 31m := NewSessionViewer ("/work/current" ) 32m .SetSize (size [0 ],size [1 ]) 33sessions := []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 } 37m .SetSessions (sessions ,nil ) 38m .Cursor = len (m .Filtered )- 1 39view := m .View () 40lines := strings .Split (view ,"\n" ) 41if len (lines )> size [1 ] { 42t .Errorf ("%dx%d: %d lines" ,size [0 ],size [1 ],len (lines )) 43 } 44for _ ,line := range lines { 45if ansi .StringWidth (line )> size [0 ] { 46t .Errorf ("%dx%d: line too wide: %q" ,size [0 ],size [1 ],line ) 47 } 48 } 49if ! strings .Contains (ansi .Strip (view ),"Selected session" )&& size [0 ]>= 25 { 50t .Errorf ("%dx%d: selected row not visible" ,size [0 ],size [1 ]) 51 } 52 } 53} 54 55func TestSessionViewerPutsWarmSessionsFirst (t * testing.T ) { 56now := time .Date (2026 ,9 ,23 ,12 ,0 ,0 ,0 ,time .Local ) 57newest ,recent ,stale := now .Add (- time .Minute ).Unix (),now .Add (- 10 * time .Minute ).Unix (),now .Add (- 2 * time .Hour ).Unix () 58m := viewerAt (now ) 59m .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 62m .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 ) 67view := ansi .Strip (m .View ()) 68warmAt ,reapedAt ,coldAt := strings .Index (view ,"Warm session" ),strings .Index (view ,"Reaped session" ),strings .Index (view ,"Cold session" ) 69if warmAt < 0 || reapedAt < warmAt || coldAt < reapedAt { 70t .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 { 75m := NewSessionViewer ("/work/current" ) 76m .now = func () time.Time {return now } 77return m 78} 79 80func TestSessionViewerPreviewFetchesOnSettleAndRenders (t * testing.T ) { 81now := time .Date (2026 ,9 ,23 ,12 ,0 ,0 ,0 ,time .Local ) 82stamp := now .Add (- time .Minute ).Unix () 83sessions := []daemon.Session { 84 {ID :"one" ,Title :"First" ,Model :"sonnet" ,LastAssistantAt :& stamp }, 85 {ID :"two" ,Title :"Second" ,Model :"opus" ,LastAssistantAt :& stamp ,TranscriptCount :9 }, 86 } 87var fetched []string 88m := viewerAt (now ) 89m .Fetch = func (id string ) tea.Cmd { 90fetched = append (fetched ,id ) 91return nil 92 } 93m .SetSize (120 ,30 ) 94m .SetSessions (sessions ,nil ) 95 96m ,cmd := m .Update (tea.KeyPressMsg {Code :tea .KeyDown }) 97if cmd == nil { 98t .Fatal ("moving did not schedule a preview" ) 99 } 100// A stale tick for a row no longer highlighted is ignored. 101m ,_ = m .Update (sessionPreviewTickMsg {ID :"one" }) 102m ,_ = m .Update (sessionPreviewTickMsg {ID :"two" }) 103m ,_ = m .Update (sessionPreviewTickMsg {ID :"two" }) 104if strings .Join (fetched ," " )!= "two" { 105t .Fatalf ("fetched: %v" ,fetched ) 106 } 107m ,_ = 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 }}}) 112view := ansi .Strip (m .View ()) 113for _ ,want := range []string {"please fix the flaky test" ,"read ×2, bash" ,"fixed the race" ,"9 messages" ,"opus" } { 114if ! strings .Contains (view ,want ) { 115t .Errorf ("preview missing %q:\n%s" ,want ,view ) 116 } 117 } 118 119// A newer reply invalidates the cached preview; a failure shows a hint. 120newer := stamp + 60 121sessions [1 ].LastAssistantAt = & newer 122m .SetSessions (sessions ,nil ) 123if m .PreviewCmd ()== nil { 124t .Fatal ("new reply did not refresh the preview" ) 125 } 126m .fetchPreview ("two" ) 127m ,_ = m .Update (SessionPreviewMsg {ID :"two" ,Err :errors .New ("unknown operation" )}) 128if preview := m .previews ["two" ];preview == nil || preview .loading || ! preview .err { 129t .Fatal ("failed preview did not leave loading state" ) 130 } 131} 132 133func TestSessionViewerArchiveKeepsCursorSlot (t * testing.T ) { 134m := NewSessionViewer ("/work/current" ) 135m .SetSize (120 ,30 ) 136m .SetSessions ([]daemon.Session {{ID :"a" }, {ID :"b" }, {ID :"c" }, {ID :"d" }},nil ) 137m .focus ("b" ) 138slot := m .Cursor 139m .raw [1 ].Archived = true 140m .rebuild () 141if item ,_ := m .Highlighted ();item .ID != "c" || m .Cursor != slot { 142t .Fatalf ("cursor %d on %q, want slot %d on c" ,m .Cursor ,item .ID ,slot ) 143 } 144m .focus ("d" ) 145m .raw [3 ].Archived = true 146m .rebuild () 147if item ,_ := m .Highlighted ();item .ID != "c" { 148t .Fatalf ("archiving the last session left the cursor on %q, want c" ,item .ID ) 149 } 150} 151 152func TestSessionViewerFilterMatchesSubstrings (t * testing.T ) { 153m := NewSessionViewer ("/work/current" ) 154m .SetSize (120 ,30 ) 155m .SetSessions ([]daemon.Session {{ID :"a" ,Title :"Fix the parser" ,Pinned :true }, {ID :"b" ,Title :"yeh" }, {ID :"c" ,Title :"yellow elephant hat" }},nil ) 156for _ ,r := range "yeh" { 157m ,_ = m .Update (tea.KeyPressMsg {Code :r ,Text :string (r )}) 158 } 159if got := filteredIDs (m .PickerModel );! slices .Equal (got , []string {"b" }) { 160t .Fatalf ("search kept %v, want only the session titled yeh" ,got ) 161 } 162} 163 164func TestSessionViewerDeleteAsksThenTakesEnterOnly (t * testing.T ) { 165m := NewSessionViewer ("/work/current" ) 166m .SetSize (120 ,30 ) 167m .SetSessions ([]daemon.Session {{ID :"gone" ,Title :"Old" ,Archived :true ,ETag :"e1" }},nil ) 168m .OpenArchive () 169m ,_ = m .Update (tea.KeyPressMsg {Code :'d' ,Mod :tea .ModCtrl }) 170if ! m .confirm .asking ()|| ! strings .Contains (ansi .Strip (m .View ()),"enter delete" ) { 171t .Fatalf ("ctrl+d did not ask:\n%s" ,ansi .Strip (m .View ())) 172 } 173m ,cmd := m .Update (tea.KeyPressMsg {Code :'x' ,Text :"x" }) 174if cmd != nil || ! m .confirm .asking () { 175t .Fatal ("a stray key changed the question" ) 176 } 177m ,_ = m .Update (tea.KeyPressMsg {Code :tea .KeyEscape }) 178if m .confirm .asking ()|| ! m .ArchiveView { 179t .Fatal ("esc did not cancel the question alone" ) 180 } 181m ,_ = m .Update (tea.KeyPressMsg {Code :'d' ,Mod :tea .ModCtrl }) 182m ,cmd = m .Update (tea.KeyPressMsg {Code :tea .KeyEnter }) 183deleted ,ok := cmd ().(SessionDeleteMsg ) 184if ! ok || deleted .ID != "gone" || deleted .Condition .ETag != "e1" { 185t .Fatalf ("enter did not delete the asked session: %#v" ,deleted ) 186 } 187}