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) } } }) }