- PeekGlobals table, Args round trip, hook and PeekGlobals agree (clusters too) - reserved flags, duplicate names, Call runs no OnGlobals hook - exit codes 0/1/2, Reported silence, scrubbed errors, wrong-directory hint - widget matrix: not a terminal byte for byte, -q, --no-ansi, -n, terminal - live models through Update and View and one program run into a buffer - prompts under -n, Secret from piped stdin, ConfirmProceed, PrintAbove
256 lines
8.6 KiB
Go
256 lines
8.6 KiB
Go
package bonfire
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"github.com/charmbracelet/colorprofile"
|
|
"github.com/charmbracelet/x/ansi"
|
|
)
|
|
|
|
// TestLiveModels: the spinner, progress, process and selector models
|
|
// render the right frames through Update and View, without a terminal; one
|
|
// program runs end to end into a buffer with input off.
|
|
func TestLiveModels(t *testing.T) {
|
|
p := DefaultPalette()
|
|
size := tea.WindowSizeMsg{Width: 80, Height: 24}
|
|
|
|
t.Run("spinner", func(t *testing.T) {
|
|
var m tea.Model = newSpinnerModel(p, "Syncing")
|
|
if m.Init() == nil {
|
|
t.Fatal("spinner does not tick")
|
|
}
|
|
first := frame(m)
|
|
m, cmd := m.Update(m.(spinnerModel).spin.Tick())
|
|
if cmd == nil || frame(m) == first {
|
|
t.Errorf("tick did not advance the spinner: %q -> %q", first, frame(m))
|
|
}
|
|
m, _ = m.Update(itemMsg{done: 1, total: 3, item: "post-1"})
|
|
m, _ = m.Update(itemMsg{done: 2, total: 3, item: "post-2", err: errors.New("x")})
|
|
v := frame(m)
|
|
for _, want := range []string{"Syncing 2/3", "post-2…", "! 1 failed"} {
|
|
if !strings.Contains(v, want) {
|
|
t.Errorf("spinner frame lacks %q: %q", want, v)
|
|
}
|
|
}
|
|
done, cmd := m.Update(finishMsg{})
|
|
if cmd == nil || frame(done) != symCheck+" Syncing\n" {
|
|
t.Errorf("success frame = %q", frame(done))
|
|
}
|
|
failed, _ := m.Update(finishMsg{err: errors.New("no")})
|
|
if frame(failed) != symCross+" Syncing\n" {
|
|
t.Errorf("failure frame = %q", frame(failed))
|
|
}
|
|
})
|
|
|
|
t.Run("progress", func(t *testing.T) {
|
|
var m tea.Model = newProgressModel(p, 10)
|
|
if m.Init() != nil {
|
|
t.Error("progress has an init command")
|
|
}
|
|
m, _ = m.Update(tea.WindowSizeMsg{Width: 20, Height: 5})
|
|
m, _ = m.Update(itemMsg{done: 0, total: 10})
|
|
if v := frame(m); !strings.Contains(v, "0/10") {
|
|
t.Errorf("empty bar = %q", v)
|
|
}
|
|
m, _ = m.Update(itemMsg{done: 4, total: 10, item: "row 4"})
|
|
m, _ = m.Update(itemMsg{done: 5, total: 10})
|
|
if v := frame(m); !strings.Contains(v, "5/10") || !strings.Contains(v, "row 4") {
|
|
t.Errorf("Current kept across steps: %q", v)
|
|
}
|
|
m, _ = m.Update(itemMsg{done: 5, total: 10, item: "row 5", err: errors.New("bad")})
|
|
if v := frame(m); !strings.Contains(v, "! 1 failed") || !strings.Contains(v, "row 5") {
|
|
t.Errorf("Fail frame = %q", v)
|
|
}
|
|
m, cmd := m.Update(finishMsg{})
|
|
v := frame(m)
|
|
if cmd == nil || strings.Contains(v, "row 5") || !strings.HasSuffix(v, "\n") {
|
|
t.Errorf("final frame = %q", v)
|
|
}
|
|
zero := newProgressModel(p, 0)
|
|
if v := frame(zero); !strings.Contains(v, "0/0") {
|
|
t.Errorf("zero total frame = %q", v)
|
|
}
|
|
})
|
|
|
|
t.Run("process", func(t *testing.T) {
|
|
var m tea.Model = newProcessModel(p, "", []string{"blog", "shop", "forum"})
|
|
m, _ = m.Update(size)
|
|
v := frame(m)
|
|
for _, name := range []string{"blog", "shop", "forum"} {
|
|
if !strings.Contains(v, symWaiting+" "+name) {
|
|
t.Errorf("pending row %s missing: %q", name, v)
|
|
}
|
|
}
|
|
if !strings.Contains(v, "Processing 0/3") || !strings.Contains(v, "0%") {
|
|
t.Errorf("default operation label missing: %q", v)
|
|
}
|
|
m, _ = m.Update(stepMsg{item: "blog", state: itemRunning, done: 0})
|
|
if v := frame(m); strings.Contains(v, symWaiting+" blog") {
|
|
t.Errorf("running row still pending: %q", v)
|
|
}
|
|
m, _ = m.Update(stepMsg{item: "blog", state: itemDone, done: 1})
|
|
m, _ = m.Update(stepMsg{item: "blog", state: itemRunning, done: 1})
|
|
m, _ = m.Update(stepMsg{item: "shop", state: itemFailed, err: errors.New("rejected"), done: 2})
|
|
v = frame(m)
|
|
for _, want := range []string{symCheck + " blog", symCross + " shop rejected", symWaiting + " forum", "Processing 2/3", "66%"} {
|
|
if !strings.Contains(v, want) {
|
|
t.Errorf("process frame lacks %q:\n%s", want, v)
|
|
}
|
|
}
|
|
m, cmd := m.Update(finishMsg{})
|
|
if cmd == nil || !strings.HasSuffix(frame(m), "\n") {
|
|
t.Errorf("final process frame = %q", frame(m))
|
|
}
|
|
|
|
// Ten rows on a six-line terminal: three rows around the running one.
|
|
var items []string
|
|
for i := range 10 {
|
|
items = append(items, fmt.Sprintf("item-%d", i))
|
|
}
|
|
var big tea.Model = newProcessModel(p, "Pushing", items)
|
|
big, _ = big.Update(tea.WindowSizeMsg{Width: 80, Height: 6})
|
|
big, _ = big.Update(stepMsg{item: "item-6", state: itemRunning, done: 6})
|
|
v = frame(big)
|
|
for _, want := range []string{"item-5", "item-6", "item-7"} {
|
|
if !strings.Contains(v, want) {
|
|
t.Errorf("window lacks %s:\n%s", want, v)
|
|
}
|
|
}
|
|
for _, gone := range []string{"item-0", "item-4", "item-8"} {
|
|
if strings.Contains(v, gone+"\n") || strings.Contains(v, gone+" ") {
|
|
t.Errorf("window shows %s:\n%s", gone, v)
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("selector", func(t *testing.T) {
|
|
key := func(m tea.Model, msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m.Update(msg) }
|
|
up, down := tea.KeyPressMsg{Code: tea.KeyUp}, tea.KeyPressMsg{Code: tea.KeyDown}
|
|
enter, esc := tea.KeyPressMsg{Code: tea.KeyEnter}, tea.KeyPressMsg{Code: tea.KeyEscape}
|
|
ctrlC := tea.KeyPressMsg{Code: 'c', Mod: tea.ModCtrl}
|
|
|
|
var m tea.Model = newSelectorModel(p, "Pick a plugin", []string{"blog", "shop", "forum"}, 1)
|
|
m, _ = m.Update(size)
|
|
if v := frame(m); !strings.Contains(v, "> shop") || !strings.Contains(v, "? Pick a plugin") {
|
|
t.Errorf("default cursor frame = %q", v)
|
|
}
|
|
m, _ = key(m, up)
|
|
m, _ = key(m, up)
|
|
m, _ = key(m, tea.KeyPressMsg{Code: 'k', Text: "k"})
|
|
if c := m.(selectorModel).cursor; c != 0 {
|
|
t.Errorf("cursor moved above the top: %d", c)
|
|
}
|
|
for range 5 {
|
|
m, _ = key(m, down)
|
|
}
|
|
if c := m.(selectorModel).cursor; c != 2 {
|
|
t.Errorf("cursor moved below the bottom: %d", c)
|
|
}
|
|
m, _ = key(m, enter)
|
|
if v := frame(m); !strings.Contains(v, "Selected:") || !strings.Contains(v, "forum") || !strings.Contains(v, "esc back") {
|
|
t.Errorf("confirmation frame = %q", v)
|
|
}
|
|
m, _ = key(m, esc)
|
|
if m.(selectorModel).confirming || m.(selectorModel).cancelled {
|
|
t.Error("esc on the confirmation screen did not go back")
|
|
}
|
|
m, _ = key(m, tea.KeyPressMsg{Code: 'k', Text: "k"})
|
|
m, _ = key(m, enter)
|
|
m, cmd := key(m, enter)
|
|
sel := m.(selectorModel)
|
|
if cmd == nil || sel.chosen != 1 || sel.cancelled {
|
|
t.Fatalf("selector chose %d (cancelled %v)", sel.chosen, sel.cancelled)
|
|
}
|
|
if v := frame(m); v != "? Pick a plugin shop\n" {
|
|
t.Errorf("summary line = %q", v)
|
|
}
|
|
|
|
for name, keys := range map[string][]tea.KeyPressMsg{
|
|
"esc in the list": {esc},
|
|
"ctrl+c in the list": {ctrlC},
|
|
"ctrl+c on confirmation": {enter, ctrlC},
|
|
} {
|
|
var s tea.Model = newSelectorModel(p, "Pick", []string{"a", "b"}, 9)
|
|
var cmd tea.Cmd
|
|
for _, k := range keys {
|
|
s, cmd = key(s, k)
|
|
}
|
|
if !s.(selectorModel).cancelled || cmd == nil || s.(selectorModel).chosen != -1 {
|
|
t.Errorf("%s: not cancelled", name)
|
|
}
|
|
if v := frame(s); v != "? Pick\n" {
|
|
t.Errorf("%s: summary line = %q", name, v)
|
|
}
|
|
}
|
|
|
|
// A short terminal shows a window that follows the cursor.
|
|
var long []string
|
|
for i := range 20 {
|
|
long = append(long, fmt.Sprintf("choice-%02d", i))
|
|
}
|
|
var w tea.Model = newSelectorModel(p, "Pick", long, 0)
|
|
w, _ = w.Update(tea.WindowSizeMsg{Width: 80, Height: 8})
|
|
for range 10 {
|
|
w, _ = key(w, down)
|
|
}
|
|
v := frame(w)
|
|
if !strings.Contains(v, "> choice-10") || strings.Contains(v, "choice-00") || strings.Contains(v, "choice-11") {
|
|
t.Errorf("selector window:\n%s", v)
|
|
}
|
|
})
|
|
|
|
t.Run("print above", func(t *testing.T) {
|
|
var m tea.Model = liveModel{inner: newSpinnerModel(p, "x")}
|
|
before := frame(m)
|
|
m, cmd := m.Update(printAboveMsg("a log line"))
|
|
if cmd == nil || frame(m) != before {
|
|
t.Error("printAboveMsg did not become a Println command or reached the inner model")
|
|
}
|
|
m, _ = m.Update(itemMsg{done: 1, total: 2})
|
|
if !strings.Contains(frame(m), "1/2") {
|
|
t.Errorf("other messages do not reach the inner model: %q", frame(m))
|
|
}
|
|
})
|
|
|
|
t.Run("program end to end", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
prog := tea.NewProgram(liveModel{inner: newSpinnerModel(p, "Importing posts")},
|
|
tea.WithInput(nil),
|
|
tea.WithOutput(&buf),
|
|
tea.WithoutSignalHandler(),
|
|
tea.WithWindowSize(80, 24),
|
|
tea.WithColorProfile(colorprofile.NoTTY),
|
|
)
|
|
done := make(chan error, 1)
|
|
go func() {
|
|
_, err := prog.Run()
|
|
done <- err
|
|
}()
|
|
prog.Send(itemMsg{done: 1, total: 2, item: "first"})
|
|
prog.Send(printAboveMsg("imported first"))
|
|
prog.Send(itemMsg{done: 2, total: 2, item: "second"})
|
|
prog.Send(finishMsg{})
|
|
select {
|
|
case err := <-done:
|
|
if err != nil {
|
|
t.Fatalf("program: %v", err)
|
|
}
|
|
case <-time.After(10 * time.Second):
|
|
prog.Kill()
|
|
t.Fatal("program did not finish")
|
|
}
|
|
out := ansi.Strip(buf.String())
|
|
for _, want := range []string{"imported first", symCheck + " Importing posts"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("program output lacks %q:\n%q", want, out)
|
|
}
|
|
}
|
|
})
|
|
}
|