test(bonfire): flags, exit codes and the widget matrix
- 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
This commit is contained in:
255
modules/bonfire/live_test.go
Normal file
255
modules/bonfire/live_test.go
Normal file
@@ -0,0 +1,255 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user