- each PrintAbove block is one bubbletea print message sent straight to the program's queue under liveMu, so the event loop prints it in call order instead of a command goroutine racing it back - runLive unregisters, sends finishMsg and waits under liveMu, so a late printer writes after the final frame and no line is lost - TestPrintAboveOrderedNoLoss pins order and no loss for spinner, progress, process and a printer racing the finish (CR-01)
430 lines
13 KiB
Go
430 lines
13 KiB
Go
package bonfire
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"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) {
|
|
// A printed block is one message for the program's queue, not a
|
|
// command bubbletea would run on a goroutine of its own.
|
|
msg := printLine("first\nsecond")
|
|
if msg == nil {
|
|
t.Fatal("printLine returned no message")
|
|
}
|
|
if _, isCmd := msg.(tea.Cmd); isCmd {
|
|
t.Error("printLine returned a command")
|
|
}
|
|
})
|
|
|
|
t.Run("program end to end", func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
prog := tea.NewProgram(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(printLine("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)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
// lockedBuffer is a bytes.Buffer safe for a live program and other
|
|
// goroutines writing at the same time.
|
|
type lockedBuffer struct {
|
|
mu sync.Mutex
|
|
buf bytes.Buffer
|
|
}
|
|
|
|
func (b *lockedBuffer) Write(p []byte) (int, error) {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.Write(p)
|
|
}
|
|
|
|
func (b *lockedBuffer) String() string {
|
|
b.mu.Lock()
|
|
defer b.mu.Unlock()
|
|
return b.buf.String()
|
|
}
|
|
|
|
var numberedLine = regexp.MustCompile(`(line|bg)-\d{3}`)
|
|
|
|
// numbered returns the numbered lines of the ANSI-stripped output in the
|
|
// order they appear.
|
|
func numbered(out string) []string {
|
|
return numberedLine.FindAllString(ansi.Strip(out), -1)
|
|
}
|
|
|
|
// printNumbered prints line-<from> .. line-<to-1> through the console's
|
|
// print paths in turn: Println, Printf, Info and bonfire.PrintAbove.
|
|
func printNumbered(c *console, from, to int) {
|
|
for i := from; i < to; i++ {
|
|
line := fmt.Sprintf("line-%03d", i)
|
|
switch i % 4 {
|
|
case 0:
|
|
c.Println(line)
|
|
case 1:
|
|
c.Printf("%s\n", line)
|
|
case 2:
|
|
c.Info(line)
|
|
case 3:
|
|
if !PrintAbove(line) {
|
|
c.Println(line)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// checkNumbered fails unless out holds line-000 .. line-<n-1> exactly once
|
|
// and in order.
|
|
func checkNumbered(t *testing.T, run int, out string, n int) {
|
|
t.Helper()
|
|
got := numbered(out)
|
|
for i := 0; i < n; i++ {
|
|
want := fmt.Sprintf("line-%03d", i)
|
|
if i >= len(got) {
|
|
t.Fatalf("run %d: %d of %d lines printed, %s missing:\n%s", run, len(got), n, want, ansi.Strip(out))
|
|
}
|
|
if got[i] != want {
|
|
t.Fatalf("run %d: line %d is %s, want %s (lines: %v)", run, i, got[i], want, got)
|
|
}
|
|
}
|
|
if len(got) != n {
|
|
t.Fatalf("run %d: %d lines printed, want %d: %v", run, len(got), n, got)
|
|
}
|
|
}
|
|
|
|
// TestPrintAboveOrderedNoLoss (CR-01): every line printed while a spinner,
|
|
// progress or process view runs appears once and in order; a line printed
|
|
// from another goroutine while the view finishes is not lost.
|
|
func TestPrintAboveOrderedNoLoss(t *testing.T) {
|
|
const runs, lines = 20, 100
|
|
newTTY := func(out io.Writer) *console {
|
|
return newConsole(strings.NewReader(""), out, io.Discard, noEnv, boolPtr(true), boolPtr(true))
|
|
}
|
|
|
|
t.Run("spinner", func(t *testing.T) {
|
|
for run := range runs {
|
|
out := &lockedBuffer{}
|
|
c := newTTY(out)
|
|
if err := c.Spinner("working", func() error {
|
|
printNumbered(c, 0, lines)
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checkNumbered(t, run, out.String(), lines)
|
|
}
|
|
})
|
|
|
|
t.Run("progress", func(t *testing.T) {
|
|
for run := range runs {
|
|
out := &lockedBuffer{}
|
|
c := newTTY(out)
|
|
if err := c.Progress(lines, func(p Progress) error {
|
|
printNumbered(c, 0, 40)
|
|
Failed(p, "item-a", errors.New("first failure"))
|
|
printNumbered(c, 40, 80)
|
|
Failed(p, "item-b", errors.New("second failure"))
|
|
printNumbered(c, 80, lines)
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got := out.String()
|
|
checkNumbered(t, run, got, lines)
|
|
for _, want := range []string{"item-a: first failure", "item-b: second failure"} {
|
|
if !strings.Contains(ansi.Strip(got), want) {
|
|
t.Fatalf("run %d: failure line %q missing:\n%s", run, want, ansi.Strip(got))
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
t.Run("process", func(t *testing.T) {
|
|
items := []string{"a", "b", "c", "d"}
|
|
for run := range runs {
|
|
out := &lockedBuffer{}
|
|
c := newTTY(out)
|
|
next := 0
|
|
err := Process(context.Background(), c, "Pushing", items, func(context.Context, string) error {
|
|
printNumbered(c, next, next+lines/len(items))
|
|
next += lines / len(items)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
checkNumbered(t, run, out.String(), lines)
|
|
}
|
|
})
|
|
|
|
t.Run("concurrent_finish", func(t *testing.T) {
|
|
const bg = 200
|
|
for run := range runs {
|
|
out := &lockedBuffer{}
|
|
c := newTTY(out)
|
|
started := make(chan struct{})
|
|
done := make(chan struct{})
|
|
if err := c.Spinner("working", func() error {
|
|
go func() {
|
|
defer close(done)
|
|
for i := range bg {
|
|
line := fmt.Sprintf("bg-%03d", i)
|
|
if !PrintAbove(line) {
|
|
_, _ = io.WriteString(out, line+"\n")
|
|
}
|
|
if i == 5 {
|
|
close(started)
|
|
}
|
|
}
|
|
}()
|
|
<-started
|
|
return nil
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
<-done
|
|
count := map[string]int{}
|
|
for _, l := range numbered(out.String()) {
|
|
count[l]++
|
|
}
|
|
for i := range bg {
|
|
l := fmt.Sprintf("bg-%03d", i)
|
|
if count[l] != 1 {
|
|
t.Fatalf("run %d: %s printed %d times:\n%s", run, l, count[l], ansi.Strip(out.String()))
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|