package app
import (
"errors"
"io"
"regexp"
"strings"
"testing"
"time"
"unicode/utf8"
"charm.land/bubbles/v2/spinner"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
"tobi/internal/session"
"tobi/internal/termcolor"
)
var ansi = regexp.MustCompile("\\x1b\\[[0-9;]*m")
func TestViewNeverExceedsWidth(t *testing.T) {
for _, width := range []int{0, 24, 30, 40, 60, 76, 100, 300} {
f := textinput.New()
f.SetWidth(max(0, min(width, maxUIWidth)-8))
f.SetValue(strings.Repeat("nomatch", 20)) // a filter nothing matches, to cover the empty state
m := pickerModel{width: width, filter: f, th: newTheme(true), spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), rows: []hostRow{
{name: "selfish", local: true, sessions: []session.Session{{ID: "a", Command: "hx", Cwd: "/tmp", StartedAt: time.Now()}}},
{name: "caladon", sessions: []session.Session{{ID: "b", Command: "nu", Cwd: "/x", StartedAt: time.Now()}}},
{name: "pupos", unreachable: true}, {name: "homura", peerOffline: true},
}}
m.move(1) // the cursor has to survive a filter that left nothing to pick
for n, line := range strings.Split(m.view(), "\n") {
if got, max := utf8.RuneCountInString(ansi.ReplaceAllString(line, "")), min(width, maxUIWidth); got > max {
t.Fatalf("width %d line %d: %d > %d", width, n, got, max)
}
}
}
}
func TestKillFlow(t *testing.T) {
a := session.Session{ID: "a", Host: "caladon", Command: "hx", StartedAt: time.Now()}
m := pickerModel{width: 80, filter: textinput.New(), th: newTheme(true), spinner: spinner.New(spinner.WithSpinner(spinner.Dot)),
rows: []hostRow{{name: "caladon", sessions: []session.Session{a}}}}
m.move(1)
next, cmd := m.Update(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl})
if m = next.(pickerModel); cmd == nil || !m.killing[a.Canonical()] {
t.Fatal("ctrl+x should fire the kill")
}
if _, cmd = m.Update(tea.KeyPressMsg{Code: 'x', Mod: tea.ModCtrl}); cmd != nil {
t.Fatal("a kill in flight should not fire again")
}
next, _ = m.Update(killResult{a, errors.New("remote tobi is\ntoo old")})
if m = next.(pickerModel); m.killing[a.Canonical()] || !strings.Contains(m.view(), "remote tobi is too old") {
t.Fatal("a failed kill should clear the flight and show the error on one line")
}
next, _ = m.Update(killResult{a, nil})
if m = next.(pickerModel); len(m.rows[0].sessions) != 0 {
t.Fatal("a killed session should leave its row")
}
}
func TestRunProbeConsumesRepliesNotInput(t *testing.T) {
reads := make(chan stdinRead, 8)
reads <- stdinRead{[]byte("\x1b]11;rgb:2222/3333/4444\x1b\\"), nil}
reads <- stdinRead{[]byte("\x1b[?62;1cx"), nil}
reads <- stdinRead{[]byte("x"), nil}
var pushed []string
colors := runProbe(reads, io.Discard, func(b []byte) { pushed = append(pushed, string(b)) }, 50*time.Millisecond)
if colors[termcolor.Background] != (termcolor.Color{R: 0x2222, G: 0x3333, B: 0x4444}) {
t.Fatalf("probe colors = %v", colors)
}
if len(pushed) != 1 || pushed[0] != "x" {
t.Fatalf("probe pushed %q, want only the keys after the sentinel", pushed)
}
}
func TestRunProbeWritesQueryAndTimesOut(t *testing.T) {
reads := make(chan stdinRead, 8)
reads <- stdinRead{[]byte("\x1b]10;rgb:0102/0304/0506\x1b\\"), nil}
var w strings.Builder
colors := runProbe(reads, &w, func([]byte) { t.Fatal("nothing to push on timeout") }, 50*time.Millisecond)
if w.String() != termcolor.ProbeQuery {
t.Fatalf("probe wrote %q", w.String())
}
if colors[termcolor.Foreground] != (termcolor.Color{R: 0x0102, G: 0x0304, B: 0x0506}) {
t.Fatalf("probe colors = %v", colors)
}
}