1688 lines
40 KiB
Go
1688 lines
40 KiB
Go
package huh
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
tea "charm.land/bubbletea/v2"
|
|
"charm.land/lipgloss/v2"
|
|
"github.com/charmbracelet/x/ansi"
|
|
"github.com/charmbracelet/x/xpty"
|
|
)
|
|
|
|
const text = "Huh"
|
|
|
|
var pretty = lipgloss.NewStyle().
|
|
Width(60).
|
|
Border(lipgloss.NormalBorder()).
|
|
MarginTop(1).
|
|
Padding(1, 3, 1, 2)
|
|
|
|
func TestForm(t *testing.T) {
|
|
type Taco struct {
|
|
Shell string
|
|
Base string
|
|
Toppings []string
|
|
}
|
|
|
|
type Order struct {
|
|
Taco Taco
|
|
Name string
|
|
Instructions string
|
|
Discount bool
|
|
}
|
|
|
|
var taco Taco
|
|
order := Order{Taco: taco}
|
|
|
|
f := NewForm(
|
|
NewGroup(
|
|
NewSelect[string]().
|
|
Options(NewOptions("Soft", "Hard")...).
|
|
Title("Shell?").
|
|
Description("Our tortillas are made fresh in-house every day.").
|
|
Validate(func(t string) error {
|
|
if t == "Hard" {
|
|
return fmt.Errorf("we're out of hard shells, sorry")
|
|
}
|
|
return nil
|
|
}).
|
|
Value(&order.Taco.Shell),
|
|
|
|
NewSelect[string]().
|
|
Options(NewOptions("Chicken", "Beef", "Fish", "Beans")...).
|
|
Value(&order.Taco.Base).
|
|
Title("Base"),
|
|
),
|
|
|
|
// Prompt for toppings and special instructions.
|
|
// The customer can ask for up to 4 toppings.
|
|
NewGroup(
|
|
NewMultiSelect[string]().
|
|
Title("Toppings").
|
|
Description("Choose up to 4.").
|
|
Options(
|
|
NewOption("Lettuce", "lettuce").Selected(true),
|
|
NewOption("Tomatoes", "tomatoes").Selected(true),
|
|
NewOption("Corn", "corn"),
|
|
NewOption("Salsa", "salsa"),
|
|
NewOption("Sour Cream", "sour cream"),
|
|
NewOption("Cheese", "cheese"),
|
|
).
|
|
Validate(func(t []string) error {
|
|
if len(t) <= 0 {
|
|
return fmt.Errorf("at least one topping is required")
|
|
}
|
|
return nil
|
|
}).
|
|
Value(&order.Taco.Toppings).
|
|
Filterable(true).
|
|
Limit(4),
|
|
),
|
|
|
|
// Gather final details for the order.
|
|
NewGroup(
|
|
NewInput().
|
|
Value(&order.Name).
|
|
Title("What's your name?").
|
|
Placeholder("Margaret Thatcher").
|
|
Description("For when your order is ready."),
|
|
|
|
NewText().
|
|
Value(&order.Instructions).
|
|
Placeholder("Just put it in the mailbox please").
|
|
Title("Special Instructions").
|
|
Description("Anything we should know?").
|
|
CharLimit(400),
|
|
|
|
NewConfirm().
|
|
Title("Would you like 15% off?").
|
|
Value(&order.Discount).
|
|
Affirmative("Yes!").
|
|
Negative("No."),
|
|
),
|
|
)
|
|
|
|
f.Update(f.Init())
|
|
|
|
view := viewModel(f)
|
|
|
|
//
|
|
// ┃ Shell?
|
|
// ┃ Our tortillas are made fresh in-house every day.
|
|
// ┃ > Soft
|
|
// ┃ Hard
|
|
//
|
|
// Base
|
|
// > Chicken
|
|
// Beef
|
|
// Fish
|
|
// Beans
|
|
//
|
|
// ↑ up • ↓ down • / filter • enter select
|
|
//
|
|
|
|
if !strings.Contains(view, "┃ Shell?") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to contain Shell? title")
|
|
}
|
|
|
|
if !strings.Contains(view, "Our tortillas are made fresh in-house every day.") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to contain tortilla description")
|
|
}
|
|
|
|
if !strings.Contains(view, "Base") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to contain Base title")
|
|
}
|
|
|
|
// Attempt to select hard shell and retrieve error.
|
|
m := batchUpdate(f.Update(keypress('j')))
|
|
m = batchUpdate(m.Update(codeKeypress(tea.KeyTab)))
|
|
view = viewModel(m)
|
|
|
|
if !strings.Contains(view, "* we're out of hard shells, sorry") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to show out of hard shells error")
|
|
}
|
|
|
|
// select back the soft shell
|
|
m = batchUpdate(m.Update(keypress('k')))
|
|
m = batchUpdate(m.Update(codeKeypress(tea.KeyEnter)))
|
|
|
|
view = viewModel(m)
|
|
|
|
if !strings.Contains(view, "┃ > Chicken") {
|
|
t.Log(pretty.Render(view))
|
|
t.Fatal("Expected form to continue to base group")
|
|
}
|
|
|
|
// batchMsg + nextGroup
|
|
m = batchUpdate(m.Update(codeKeypress(tea.KeyEnter)))
|
|
view = viewModel(m)
|
|
|
|
//
|
|
// ┃ Toppings
|
|
// ┃ Choose up to 4.
|
|
// ┃ > ✓ Lettuce
|
|
// ┃ ✓ Tomatoes
|
|
// ┃ • Corn
|
|
// ┃ • Salsa
|
|
// ┃ • Sour Cream
|
|
// ┃ • Cheese
|
|
//
|
|
// x toggle • ↑ up • ↓ down • enter confirm • shift+tab back
|
|
//
|
|
if !strings.Contains(view, "Toppings") {
|
|
t.Log(pretty.Render(view))
|
|
t.Fatal("Expected form to show toppings group")
|
|
}
|
|
|
|
if !strings.Contains(view, "Choose up to 4.") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to show toppings description")
|
|
}
|
|
|
|
if !strings.Contains(view, "> ✓ Lettuce ") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to preselect lettuce")
|
|
}
|
|
|
|
if !strings.Contains(view, " ✓ Tomatoes") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to preselect tomatoes")
|
|
}
|
|
|
|
m = batchUpdate(m.Update(keypress('j')))
|
|
m = batchUpdate(m.Update(keypress('j')))
|
|
view = viewModel(m)
|
|
|
|
if !strings.Contains(view, "> • Corn") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to change selection to corn")
|
|
}
|
|
|
|
m = batchUpdate(m.Update(keypress('x')))
|
|
view = viewModel(m)
|
|
|
|
if !strings.Contains(view, "> ✓ Corn") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to change selection to corn")
|
|
}
|
|
|
|
m = batchUpdate(m.Update(codeKeypress(tea.KeyEnter)))
|
|
view = viewModel(m)
|
|
|
|
if !strings.Contains(view, "What's your name?") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to prompt for name")
|
|
}
|
|
|
|
if !strings.Contains(view, "Special Instructions") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to prompt for special instructions")
|
|
}
|
|
|
|
if !strings.Contains(view, "Would you like 15% off?") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to prompt for discount")
|
|
}
|
|
|
|
//
|
|
// ┃ What's your name?
|
|
// ┃ For when your order is ready.
|
|
// ┃ > Margaret Thatcher
|
|
//
|
|
// Special Instructions
|
|
// Anything we should know?
|
|
// Just put it in the mailbox please
|
|
//
|
|
// Would you like 15% off?
|
|
//
|
|
// Yes! No.
|
|
//
|
|
// enter next • shift+tab back
|
|
//
|
|
typeText(m, "Glen")
|
|
view = viewModel(m)
|
|
if !strings.Contains(view, "Glen") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected form to accept user input")
|
|
}
|
|
|
|
if order.Taco.Shell != "Soft" {
|
|
t.Error("Expected order shell to be Soft")
|
|
}
|
|
|
|
if order.Taco.Base != "Chicken" {
|
|
t.Error("Expected order shell to be Chicken")
|
|
}
|
|
|
|
if len(order.Taco.Toppings) != 3 {
|
|
t.Error("Expected order to have 3 toppings")
|
|
}
|
|
|
|
if order.Name != "Glen" {
|
|
t.Error("Expected order name to be Glen")
|
|
}
|
|
|
|
// TODO: Finish and submit form.
|
|
}
|
|
|
|
func TestInput(t *testing.T) {
|
|
field := NewInput()
|
|
f := NewForm(NewGroup(field))
|
|
f.Update(f.Init())
|
|
|
|
view := viewModel(f)
|
|
|
|
if !strings.Contains(view, ">") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain prompt.")
|
|
}
|
|
|
|
// Type Huh in the form.
|
|
f = typeText(f, text)
|
|
view = viewModel(f)
|
|
|
|
if !strings.Contains(view, text) {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain " + text)
|
|
}
|
|
|
|
if !strings.Contains(view, "enter submit") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain help.")
|
|
}
|
|
|
|
if field.GetValue() != text {
|
|
t.Error("Expected field value to be " + text)
|
|
}
|
|
}
|
|
|
|
func TestPasteNotDuplicated(t *testing.T) {
|
|
field := NewInput().Title("Name")
|
|
f := NewForm(NewGroup(field))
|
|
f.Update(f.Init())
|
|
|
|
f = batchUpdate(f.Update(tea.PasteMsg{Content: "hello"})).(*Form)
|
|
|
|
if field.GetValue() != "hello" {
|
|
t.Errorf("Expected field value to be %q, got %q (paste was duplicated)", "hello", field.GetValue())
|
|
}
|
|
}
|
|
|
|
func TestInlineInput(t *testing.T) {
|
|
field := NewInput().
|
|
Title("Input ").
|
|
Prompt(": ").
|
|
Description("Description").
|
|
Inline(true)
|
|
|
|
f := NewForm(NewGroup(field)).WithWidth(40)
|
|
f.Update(f.Init())
|
|
|
|
view := viewModel(f)
|
|
|
|
if !strings.Contains(view, "┃ Input Description:") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain inline input.")
|
|
}
|
|
|
|
// Type Huh in the form.
|
|
f = typeText(f, text)
|
|
view = viewModel(f)
|
|
|
|
if !strings.Contains(view, text) {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain " + text)
|
|
}
|
|
|
|
if !strings.Contains(view, "enter submit") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain help.")
|
|
}
|
|
|
|
if !strings.Contains(view, "┃ Input Description: "+text) {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain help.")
|
|
}
|
|
|
|
if field.GetValue() != text {
|
|
t.Error("Expected field value to be " + text)
|
|
}
|
|
}
|
|
|
|
func TestText(t *testing.T) {
|
|
field := NewText()
|
|
f := NewForm(NewGroup(field))
|
|
f.Update(f.Init())
|
|
|
|
// Type Huh in the form.
|
|
f = typeText(f, text)
|
|
view := viewModel(f)
|
|
|
|
if !strings.Contains(view, text) {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain " + text)
|
|
}
|
|
|
|
if !strings.Contains(view, "alt+enter / ctrl+j new line • ctrl+e open editor • enter submit") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain help.")
|
|
}
|
|
|
|
if field.GetValue() != text {
|
|
t.Error("Expected field value to be " + text)
|
|
}
|
|
}
|
|
|
|
func TestTextExternalEditorHidden(t *testing.T) {
|
|
field := NewText().ExternalEditor(false)
|
|
f := NewForm(NewGroup(field))
|
|
f.Update(f.Init())
|
|
|
|
// Type Huh in the form.
|
|
f = typeText(f, text)
|
|
view := viewModel(f)
|
|
|
|
if !strings.Contains(view, text) {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain " + text)
|
|
}
|
|
|
|
if strings.Contains(view, "ctrl+e open editor") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain help without ctrl+e.")
|
|
}
|
|
|
|
if field.GetValue() != text {
|
|
t.Error("Expected field value to be " + text)
|
|
}
|
|
}
|
|
|
|
func TestConfirm(t *testing.T) {
|
|
field := NewConfirm().Title("Are you sure?")
|
|
f := NewForm(NewGroup(field))
|
|
f.Update(f.Init())
|
|
view := viewModel(f)
|
|
|
|
if !strings.Contains(view, "Yes") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain Yes.")
|
|
}
|
|
|
|
if !strings.Contains(view, "No") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain No.")
|
|
}
|
|
|
|
if !strings.Contains(view, "Are you sure?") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain Are you sure?.")
|
|
}
|
|
|
|
if !strings.Contains(view, "←/→ toggle • enter submit") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain help.")
|
|
}
|
|
|
|
if field.GetValue() != false {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field value to be false")
|
|
}
|
|
|
|
// Toggle left
|
|
f.Update(codeKeypress(tea.KeyLeft))
|
|
|
|
if field.GetValue() != true {
|
|
t.Error("Expected field value to be true")
|
|
}
|
|
|
|
// Toggle right
|
|
f.Update(codeKeypress(tea.KeyRight))
|
|
|
|
if field.GetValue() != false {
|
|
t.Error("Expected field value to be false")
|
|
}
|
|
}
|
|
|
|
func TestSelect(t *testing.T) {
|
|
field := NewSelect[string]().
|
|
Options(NewOptions(
|
|
"Foo\nLine 2",
|
|
"Bar\nLine 2",
|
|
"Baz\nLine 2",
|
|
"Ban\nLine 2",
|
|
)...).
|
|
Title("Which one?")
|
|
f := NewForm(NewGroup(field)).WithHeight(5)
|
|
f.Update(f.Init())
|
|
|
|
view := viewModel(f)
|
|
|
|
if !strings.Contains(view, "Foo") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain Foo.")
|
|
}
|
|
|
|
if !strings.Contains(view, "Which one?") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain Which one?.")
|
|
}
|
|
|
|
if !strings.Contains(view, "> Foo") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected cursor to be on Foo.")
|
|
}
|
|
|
|
// Move selection cursor down
|
|
f = batchUpdate(f.Update(codeKeypress(tea.KeyDown))).(*Form)
|
|
|
|
view = viewModel(f)
|
|
|
|
if got, ok := field.Hovered(); !ok || got != "Bar\nLine 2" {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected cursor to be on Bar.")
|
|
}
|
|
|
|
if strings.Contains(view, "> Foo") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected cursor to be on Bar.")
|
|
}
|
|
|
|
if !strings.Contains(view, "> Bar") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected cursor to be on Bar.")
|
|
}
|
|
|
|
if !strings.Contains(view, "↑ up • ↓ down • / filter • enter submit") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain help.")
|
|
}
|
|
|
|
// Submit
|
|
f.Update(codeKeypress(tea.KeyEnter))
|
|
|
|
if field.GetValue() != "Bar\nLine 2" {
|
|
t.Error("Expected field value to be Bar")
|
|
}
|
|
}
|
|
|
|
// doAllUpdates updates the form with the given command, then continues updating it with any resultant commands from the update until no more are returned.
|
|
func doAllUpdates(f *Form, cmd tea.Cmd) {
|
|
if cmd == nil {
|
|
return
|
|
}
|
|
var cmds []tea.Cmd
|
|
switch msg := cmd().(type) {
|
|
case tea.BatchMsg:
|
|
for _, subcommand := range msg {
|
|
doAllUpdates(f, subcommand)
|
|
}
|
|
return
|
|
default:
|
|
_, result := f.Update(msg)
|
|
cmds = append(cmds, result)
|
|
}
|
|
doAllUpdates(f, tea.Batch(cmds...))
|
|
}
|
|
|
|
func TestSelectDynamic(t *testing.T) {
|
|
trigger := "initial"
|
|
|
|
field1 := NewSelect[string]().
|
|
TitleFunc(func() string {
|
|
return "field1 title " + trigger
|
|
}, &trigger).
|
|
DescriptionFunc(func() string {
|
|
return "field1 desc " + trigger
|
|
}, &trigger).
|
|
OptionsFunc(func() []Option[string] {
|
|
return []Option[string]{NewOption("field1 opt "+trigger, "field1 opt "+trigger)}
|
|
}, &trigger)
|
|
field2 := NewSelect[string]().
|
|
TitleFunc(func() string {
|
|
return "field2 title " + trigger
|
|
}, &trigger).
|
|
DescriptionFunc(func() string {
|
|
return "field2 desc " + trigger
|
|
}, &trigger).
|
|
OptionsFunc(func() []Option[string] {
|
|
return []Option[string]{NewOption("field2 opt "+trigger, "field2 opt "+trigger)}
|
|
}, &trigger)
|
|
field1.WithHeight(5)
|
|
field2.WithHeight(5)
|
|
f := NewForm(NewGroup(field1, field2)).WithHeight(10)
|
|
|
|
doAllUpdates(f, f.Init())
|
|
|
|
view := viewModel(f)
|
|
|
|
expectedStrings := []string{
|
|
"field1 title initial",
|
|
"field1 desc initial",
|
|
"field1 opt initial",
|
|
"field2 title initial",
|
|
"field2 desc initial",
|
|
"field2 opt initial",
|
|
}
|
|
for _, expected := range expectedStrings {
|
|
if !strings.Contains(view, expected) {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected view to contain " + expected)
|
|
}
|
|
}
|
|
|
|
if field1.GetValue() != "field1 opt initial" {
|
|
t.Errorf("Expected field1 value to be field1 opt initial but was %s", field1.GetValue())
|
|
}
|
|
if field2.GetValue() != "field2 opt initial" {
|
|
t.Errorf("Expected field2 value to be field2 opt initial but was %s", field2.GetValue())
|
|
}
|
|
|
|
trigger = "updated"
|
|
_, cmd := f.Update(nil)
|
|
doAllUpdates(f, cmd)
|
|
view = viewModel(f)
|
|
|
|
expectedStrings = []string{
|
|
"field1 title updated",
|
|
"field1 desc updated",
|
|
"field1 opt updated",
|
|
"field2 title updated",
|
|
"field2 desc updated",
|
|
"field2 opt updated",
|
|
}
|
|
for _, expected := range expectedStrings {
|
|
if !strings.Contains(view, expected) {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected view to contain " + expected)
|
|
}
|
|
}
|
|
|
|
if field1.GetValue() != "field1 opt updated" {
|
|
t.Errorf("Expected field1 value to be field1 opt updated but was %s", field1.GetValue())
|
|
}
|
|
if field2.GetValue() != "field2 opt updated" {
|
|
t.Errorf("Expected field2 value to be field2 opt updated but was %s", field1.GetValue())
|
|
}
|
|
}
|
|
|
|
func TestMultiSelect(t *testing.T) {
|
|
field := NewMultiSelect[string]().
|
|
Options(NewOptions(
|
|
"Foo\nLine2",
|
|
"Bar\nLine2",
|
|
"Baz\nLine2",
|
|
"Ban\nLine2",
|
|
)...).
|
|
Title("Which one?")
|
|
f := NewForm(NewGroup(field)).
|
|
WithHeight(5)
|
|
f.Update(f.Init())
|
|
|
|
view := viewModel(f)
|
|
|
|
if !strings.Contains(view, "Foo") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain Foo.")
|
|
}
|
|
|
|
if !strings.Contains(view, "Which one?") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain Which one?.")
|
|
}
|
|
|
|
if !strings.Contains(view, "> • Foo") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected cursor to be on Foo.")
|
|
}
|
|
|
|
// Move selection cursor down
|
|
m := batchUpdate(f.Update(keypress('j')))
|
|
view = viewModel(m)
|
|
|
|
if got, ok := field.Hovered(); !ok || got != "Bar\nLine2" {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected cursor to be on Bar.")
|
|
}
|
|
|
|
if strings.Contains(view, "> • Foo") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected cursor to be on Bar.")
|
|
}
|
|
|
|
if !strings.Contains(view, "> • Bar") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected cursor to be on Bar.")
|
|
}
|
|
|
|
// Toggle
|
|
m = batchUpdate(f.Update(keypress('x')))
|
|
view = viewModel(m)
|
|
|
|
if !strings.Contains(view, "> ✓ Bar") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected cursor to be on Bar.")
|
|
}
|
|
|
|
if !strings.Contains(view, "x toggle • ↑ up • ↓ down • / filter • enter submit") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected field to contain help.")
|
|
}
|
|
|
|
// Submit
|
|
f.Update(codeKeypress(tea.KeyEnter))
|
|
|
|
value := field.GetValue()
|
|
v, ok := value.([]string)
|
|
if !ok {
|
|
t.Error("Expected field value to a slice of string")
|
|
return
|
|
}
|
|
if len(v) != 1 {
|
|
t.Error("Expected field value length to be 1")
|
|
} else {
|
|
if v[0] != "Bar\nLine2" {
|
|
t.Error("Expected first field value to be Bar")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMultiSelectFiltering(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
filtering bool
|
|
}{
|
|
{"Filtering off", false},
|
|
{"Filtering on", true},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
field := NewMultiSelect[string]().Options(NewOptions("Foo", "Bar", "Baz")...).Title("Which one?").Filterable(tc.filtering)
|
|
f := NewForm(NewGroup(field))
|
|
f.Update(f.Init())
|
|
// Filter for values starting with a 'B' only.
|
|
f.Update(keypress('/'))
|
|
f.Update(keypress('B'))
|
|
|
|
view := viewModel(f)
|
|
// When we're filtering, the list should change.
|
|
if tc.filtering && strings.Contains(view, "Foo") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Foo should not in filtered list.")
|
|
}
|
|
// When we're not filtering, the list shouldn't change.
|
|
if !tc.filtering && !strings.Contains(view, "Foo") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected list to contain Foo.")
|
|
}
|
|
})
|
|
}
|
|
t.Run("Remove filter option from help menu.", func(t *testing.T) {
|
|
field := NewMultiSelect[string]().Options(NewOptions("Foo", "Bar", "Baz")...).Title("Which one?").Filterable(false)
|
|
f := NewForm(NewGroup(field))
|
|
f.Update(f.Init())
|
|
view := viewModel(f)
|
|
if strings.Contains(view, "filter") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected list to hide filtering in help menu.")
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestSelectFilteringShowsMatchesAboveTheCursor(t *testing.T) {
|
|
// Bar and Baz sit at opposite ends of the list, so filtering for "ba"
|
|
// while the cursor is at the bottom leaves Bar above the visible window.
|
|
opts := NewOptions("Bar", "Qux", "Quux", "Foo", "Corge", "Grault", "Garply", "Waldo", "Fred", "Baz")
|
|
|
|
field := NewSelect[string]().Options(opts...).Title("Choose")
|
|
f := NewForm(NewGroup(field)).WithHeight(6)
|
|
f.Update(f.Init())
|
|
|
|
// Move to the end of the list, scrolling the viewport away from the top.
|
|
batchUpdate(f.Update(keypress('G')))
|
|
|
|
batchUpdate(f.Update(keypress('/')))
|
|
batchUpdate(f.Update(keypress('b')))
|
|
m := batchUpdate(f.Update(keypress('a')))
|
|
|
|
view := viewModel(m)
|
|
for _, want := range []string{"Bar", "Baz"} {
|
|
if !strings.Contains(view, want) {
|
|
t.Log(pretty.Render(view))
|
|
t.Errorf("filtered list is missing %q, which matches the filter", want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSelectPageNavigation(t *testing.T) {
|
|
opts := NewOptions(
|
|
"Qux",
|
|
"Quux",
|
|
"Foo",
|
|
"Bar",
|
|
"Baz",
|
|
"Corge",
|
|
"Grault",
|
|
"Garply",
|
|
"Waldo",
|
|
"Fred",
|
|
"Plugh",
|
|
"Xyzzy",
|
|
"Thud",
|
|
"Norf",
|
|
"Blip",
|
|
"Flob",
|
|
"Zorp",
|
|
"Smurf",
|
|
"Bloop",
|
|
"Ping",
|
|
)
|
|
|
|
reFirst := regexp.MustCompile(`>( •)? Qux`)
|
|
reLast := regexp.MustCompile(`>( •)? Ping`)
|
|
reHalfDown := regexp.MustCompile(`>( •)? Baz`)
|
|
|
|
for name, field := range map[string]Field{
|
|
"multiselect": NewMultiSelect[string]().Options(opts...).Title("Choose"),
|
|
"select": NewSelect[string]().Options(opts...).Title("Choose"),
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
f := NewForm(NewGroup(field)).WithHeight(10)
|
|
f.Update(f.Init())
|
|
|
|
view := viewModel(f)
|
|
if !reFirst.MatchString(view) {
|
|
t.Log(pretty.Render(view))
|
|
t.Errorf("Wrong item selected, should have matched %q (first item)", reFirst.String())
|
|
}
|
|
|
|
m := batchUpdate(f.Update(keypress('G')))
|
|
// if name == "multiselect" {
|
|
// mm := field.(*MultiSelect[string])
|
|
// t.Logf("AQUI: height=%d offset=%d", mm.viewport.Height(), mm.viewport.YOffset())
|
|
// t.Log("LOOK AT THIS SHIT", ansi.Strip(mm.viewport.View()))
|
|
// }
|
|
view = viewModel(m)
|
|
if !reLast.MatchString(view) {
|
|
t.Log(pretty.Render(view))
|
|
t.Errorf("Wrong item selected, should have matched %q (last item)", reLast.String())
|
|
}
|
|
|
|
m = batchUpdate(f.Update(keypress('g')))
|
|
view = viewModel(m)
|
|
if !reFirst.MatchString(view) {
|
|
t.Log(pretty.Render(view))
|
|
t.Errorf("Wrong item selected, should have matched %q (first item)", reFirst.String())
|
|
}
|
|
|
|
m = batchUpdate(f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'd'})))
|
|
view = viewModel(m)
|
|
if !reHalfDown.MatchString(view) {
|
|
t.Log(pretty.Render(view))
|
|
t.Errorf("Wrong item selected, should have matched %q (half down item)", reHalfDown.String())
|
|
}
|
|
|
|
// sends multiple to verify it stays within boundaries
|
|
for range 10 {
|
|
m = batchUpdate(f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'u'})))
|
|
}
|
|
view = viewModel(m)
|
|
if !reFirst.MatchString(view) {
|
|
t.Log(pretty.Render(view))
|
|
t.Errorf("Wrong item selected, should have matched %q (first item)", reFirst.String())
|
|
}
|
|
|
|
// verify it stays within boundaries
|
|
for range 10 {
|
|
m = batchUpdate(f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'd'})))
|
|
}
|
|
view = viewModel(m)
|
|
if !reLast.MatchString(view) {
|
|
t.Log(pretty.Render(view))
|
|
t.Errorf("Wrong item selected, should have matched %q (last item)", reLast.String())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestFile(t *testing.T) {
|
|
field := NewFilePicker().Title("Which file?")
|
|
cmd := field.Init()
|
|
field.Update(cmd())
|
|
|
|
view := viewModel(field)
|
|
|
|
if !strings.Contains(view, "No file selected") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected file picker to show no file selected.")
|
|
}
|
|
|
|
if !strings.Contains(view, "Which file?") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected file picker to show title.")
|
|
}
|
|
}
|
|
|
|
func TestHideGroup(t *testing.T) {
|
|
f := NewForm(
|
|
NewGroup(NewNote().Description("Foo")).
|
|
WithHide(true),
|
|
NewGroup(NewNote().Description("Bar")),
|
|
NewGroup(NewNote().Description("Baz")),
|
|
NewGroup(NewNote().Description("Qux")).
|
|
WithHideFunc(func() bool { return false }).
|
|
WithHide(true),
|
|
)
|
|
|
|
f = batchUpdate(f, f.NextGroup()).(*Form)
|
|
|
|
if v := f.View(); !strings.Contains(v, "Bar") {
|
|
t.Log(pretty.Render(v))
|
|
t.Error("expected Bar to be visible")
|
|
}
|
|
|
|
// should have no effect as previous group is hidden
|
|
f.Update(prevGroup())
|
|
|
|
if v := f.View(); !strings.Contains(v, "Bar") {
|
|
t.Log(pretty.Render(v))
|
|
t.Error("expected Bar to be visible")
|
|
}
|
|
|
|
f.Update(nextGroup())
|
|
|
|
if v := f.View(); !strings.Contains(v, "Baz") {
|
|
t.Log(pretty.Render(v))
|
|
t.Error("expected Baz to be visible")
|
|
}
|
|
|
|
f.Update(nextGroup())
|
|
|
|
if v := f.View(); strings.Contains(v, "Qux") {
|
|
t.Log(pretty.Render(v))
|
|
t.Error("expected Qux to be hidden")
|
|
}
|
|
|
|
if v := f.State; v != StateCompleted {
|
|
t.Error("should have been completed")
|
|
}
|
|
}
|
|
|
|
func TestHideGroupLastAndFirstGroupsNotHidden(t *testing.T) {
|
|
f := NewForm(
|
|
NewGroup(NewNote().Description("Bar")),
|
|
NewGroup(NewNote().Description("Foo")).
|
|
WithHide(true),
|
|
NewGroup(NewNote().Description("Baz")),
|
|
)
|
|
|
|
f = batchUpdate(f, f.Init()).(*Form)
|
|
|
|
if v := ansi.Strip(f.View()); !strings.Contains(v, "Bar") {
|
|
t.Log(pretty.Render(v))
|
|
t.Error("expected Bar to not be hidden")
|
|
}
|
|
|
|
// should have no effect as there isn't any
|
|
f.Update(prevGroup())
|
|
|
|
if v := f.View(); !strings.Contains(v, "Bar") {
|
|
t.Log(pretty.Render(v))
|
|
t.Error("expected Bar to not be hidden")
|
|
}
|
|
|
|
f.Update(nextGroup())
|
|
|
|
if v := ansi.Strip(f.View()); !strings.Contains(v, "Baz") {
|
|
t.Log(pretty.Render(v))
|
|
t.Error("expected Baz to not be hidden")
|
|
}
|
|
|
|
// should submit the form
|
|
f.Update(nextGroup())
|
|
if v := f.State; v != StateCompleted {
|
|
t.Error("should have been completed")
|
|
}
|
|
}
|
|
|
|
func TestEmptyGroup(t *testing.T) {
|
|
// fields can all be filtered out before the group is built, e.g. when
|
|
// every value was already given on the command line.
|
|
var fields []Field
|
|
|
|
f := NewForm(NewGroup(fields...))
|
|
f = batchUpdate(f, f.Init()).(*Form)
|
|
|
|
if v := ansi.Strip(f.View()); strings.TrimSpace(v) != "" {
|
|
t.Log(pretty.Render(v))
|
|
t.Error("expected an empty group to render nothing")
|
|
}
|
|
|
|
if v := f.GetFocusedField(); v != nil {
|
|
t.Error("expected no focused field")
|
|
}
|
|
|
|
f.Update(nextGroup())
|
|
|
|
if v := f.State; v != StateCompleted {
|
|
t.Error("should have been completed")
|
|
}
|
|
}
|
|
|
|
func TestEmptyForm(t *testing.T) {
|
|
// a form with no groups at all: nothing to ask, so it is already done.
|
|
f := NewForm()
|
|
f = batchUpdate(f, f.Init()).(*Form)
|
|
|
|
if v := ansi.Strip(f.View()); strings.TrimSpace(v) != "" {
|
|
t.Log(pretty.Render(v))
|
|
t.Error("expected an empty form to render nothing")
|
|
}
|
|
|
|
if v := f.GetFocusedField(); v != nil {
|
|
t.Error("expected no focused field")
|
|
}
|
|
|
|
if v := f.KeyBinds(); v != nil {
|
|
t.Error("expected no keybinds")
|
|
}
|
|
|
|
if v := f.Errors(); v != nil {
|
|
t.Error("expected no errors")
|
|
}
|
|
|
|
if v := f.State; v != StateCompleted {
|
|
t.Error("should have been completed")
|
|
}
|
|
|
|
if err := f.Run(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
}
|
|
|
|
func TestEmptyGroupIsSkipped(t *testing.T) {
|
|
f := NewForm(
|
|
NewGroup(),
|
|
NewGroup(NewNote().Description("Bar")),
|
|
)
|
|
|
|
f = batchUpdate(f, f.Init()).(*Form)
|
|
f.Update(nextGroup())
|
|
|
|
if v := ansi.Strip(f.View()); !strings.Contains(v, "Bar") {
|
|
t.Log(pretty.Render(v))
|
|
t.Error("expected Bar to be visible")
|
|
}
|
|
}
|
|
|
|
func TestPrevGroup(t *testing.T) {
|
|
f := NewForm(
|
|
NewGroup(NewNote().Description("Bar")),
|
|
NewGroup(NewNote().Description("Foo")),
|
|
NewGroup(NewNote().Description("Baz")),
|
|
)
|
|
|
|
f = batchUpdate(f, f.Init()).(*Form)
|
|
f.Update(nextGroup())
|
|
f.Update(nextGroup())
|
|
f.Update(prevGroup())
|
|
f.Update(prevGroup())
|
|
|
|
if v := ansi.Strip(f.View()); !strings.Contains(v, "Bar") {
|
|
t.Log(pretty.Render(v))
|
|
t.Error("expected Bar to not be hidden")
|
|
}
|
|
}
|
|
|
|
func TestNote(t *testing.T) {
|
|
field := NewNote().
|
|
Title("Taco").
|
|
Description("How may we take your order?").
|
|
Next(true)
|
|
f := NewForm(NewGroup(field))
|
|
f.Update(f.Init())
|
|
|
|
view := viewModel(f)
|
|
|
|
if !strings.Contains(view, "Taco") {
|
|
t.Log(view)
|
|
t.Error("Expected field to contain Taco title.")
|
|
}
|
|
|
|
if !strings.Contains(view, "order?") {
|
|
t.Log(view)
|
|
t.Error("Expected field to contain Taco description.")
|
|
}
|
|
|
|
if !strings.Contains(view, "Next") {
|
|
t.Log(view)
|
|
t.Error("Expected field to contain next button")
|
|
}
|
|
|
|
const expect = 7
|
|
if h := lipgloss.Height(ansi.Strip(view)); h != expect {
|
|
t.Log(view)
|
|
t.Errorf("Expected field to have height %d, got %d", expect, h)
|
|
}
|
|
|
|
if !strings.Contains(view, "enter submit") {
|
|
t.Log(view)
|
|
t.Error("Expected field to contain help.")
|
|
}
|
|
}
|
|
|
|
func TestDynamicHelp(t *testing.T) {
|
|
f := NewForm(
|
|
NewGroup(
|
|
NewInput().Title("Dynamic Help"),
|
|
NewInput().Title("Dynamic Help"),
|
|
NewInput().Title("Dynamic Help"),
|
|
),
|
|
)
|
|
f.Update(f.Init())
|
|
|
|
view := viewModel(f)
|
|
|
|
if !strings.Contains(view, "Dynamic Help") {
|
|
t.Log(pretty.Render(view))
|
|
t.Fatal("Expected help to contain title.")
|
|
}
|
|
|
|
if strings.Contains(view, "shift+tab") || strings.Contains(view, "submit") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected help not to contain shift+tab or submit.")
|
|
}
|
|
}
|
|
|
|
func TestSkip(t *testing.T) {
|
|
f := NewForm(
|
|
NewGroup(
|
|
NewInput().Title("First"),
|
|
NewNote().Title("Skipped"),
|
|
NewNote().Title("Skipped"),
|
|
NewInput().Title("Second"),
|
|
),
|
|
).WithWidth(25)
|
|
|
|
f = batchUpdate(f, f.Init()).(*Form)
|
|
view := viewModel(f)
|
|
|
|
if !strings.Contains(view, "┃ First") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected first field to be focused")
|
|
}
|
|
|
|
// next field should skip both of the notes and proceed to the last input.
|
|
f.Update(NextField())
|
|
view = viewModel(f)
|
|
|
|
if strings.Contains(view, "┃ First") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected first field to be blurred")
|
|
}
|
|
|
|
if !strings.Contains(view, "┃ Second") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected second field to be focused")
|
|
}
|
|
|
|
// previous field should skip both of the notes and focus the first input.
|
|
f.Update(PrevField())
|
|
view = viewModel(f)
|
|
|
|
if strings.Contains(view, "┃ Second") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected second field to be blurred")
|
|
}
|
|
|
|
if !strings.Contains(view, "┃ First") {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected first field to be focused")
|
|
}
|
|
}
|
|
|
|
func TestTimeout(t *testing.T) {
|
|
// This test requires a real program, so make sure it doesn't interfere with our test runner.
|
|
f := formProgram()
|
|
|
|
// Test that the form times out after 1ms and returns a timeout error.
|
|
err := f.WithTimeout(100 * time.Millisecond).Run()
|
|
if err == nil || !errors.Is(err, ErrTimeout) {
|
|
t.Errorf("expected timeout error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestAbort(t *testing.T) {
|
|
// This test requires a real program, so make sure it doesn't interfere with our test runner.
|
|
f := formProgram()
|
|
|
|
// Test that the form aborts without throwing a timeout error when explicitly told to abort.
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
// Since the context is cancelled, the program should exit immediately.
|
|
cancel()
|
|
// Tell the form to abort.
|
|
f.Update(tea.KeyPressMsg(tea.Key{Mod: tea.ModCtrl, Code: 'c'}))
|
|
// Run the program.
|
|
err := f.RunWithContext(ctx)
|
|
if err == nil || !errors.Is(err, ErrUserAborted) {
|
|
t.Errorf("expected user aborted error, got %v", err)
|
|
}
|
|
}
|
|
|
|
const (
|
|
title = "A Title"
|
|
description = "A Description"
|
|
)
|
|
|
|
var titleAndDescTests = map[string]struct {
|
|
Empty interface{ View() string }
|
|
EmptyHeight int
|
|
Title interface{ View() string }
|
|
Description interface{ View() string }
|
|
}{
|
|
"Group": {
|
|
NewGroup(NewInput()),
|
|
1, // >
|
|
NewGroup(NewInput()).Title(title),
|
|
NewGroup(NewInput()).Description(description),
|
|
},
|
|
"Confirm": {
|
|
NewConfirm(),
|
|
1, // yes | no
|
|
NewConfirm().Title(title),
|
|
NewConfirm().Description(description),
|
|
},
|
|
"FilePicker": {
|
|
NewFilePicker(),
|
|
1, // "no file selected"
|
|
NewFilePicker().Title(title),
|
|
NewFilePicker().Description(description),
|
|
},
|
|
"Input": {
|
|
NewInput(),
|
|
1, // >
|
|
NewInput().Title(title),
|
|
NewInput().Description(description),
|
|
},
|
|
"Note": {
|
|
NewNote(),
|
|
1, // |
|
|
NewNote().Title(title),
|
|
NewNote().Description(description),
|
|
},
|
|
"Text": {
|
|
NewText(),
|
|
6, // textarea
|
|
NewText().Title(title),
|
|
NewText().Description(description),
|
|
},
|
|
"Select": {
|
|
NewSelect[string](),
|
|
1, // >
|
|
NewSelect[string]().Title(title),
|
|
NewSelect[string]().Description(description),
|
|
},
|
|
"MultiSelect": {
|
|
NewMultiSelect[string](),
|
|
1, // >
|
|
NewMultiSelect[string]().Title(title),
|
|
NewMultiSelect[string]().Description(description),
|
|
},
|
|
}
|
|
|
|
func TestNoTitleOrDescription(t *testing.T) {
|
|
for name, tt := range titleAndDescTests {
|
|
t.Run(name, func(t *testing.T) {
|
|
view := tt.Empty.View()
|
|
got := lipgloss.Height(ansi.Strip(view))
|
|
want := tt.EmptyHeight
|
|
if got != want {
|
|
t.Log(pretty.Render(view))
|
|
t.Fatalf("got != want; height should be %d, got %d", want, got)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTitleRowRender(t *testing.T) {
|
|
for name, tt := range titleAndDescTests {
|
|
t.Run(name, func(t *testing.T) {
|
|
view := tt.Title.View()
|
|
if !strings.Contains(view, title) {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected title to be visible")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDescriptionRowRender(t *testing.T) {
|
|
for name, tt := range titleAndDescTests {
|
|
t.Run(name, func(t *testing.T) {
|
|
view := tt.Description.View()
|
|
if !strings.Contains(view, description) {
|
|
t.Log(pretty.Render(view))
|
|
t.Error("Expected description to be visible")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetFocusedField(t *testing.T) {
|
|
f := NewForm(
|
|
NewGroup(
|
|
NewInput().Title("First").Key("First"),
|
|
NewInput().Title("Second").Key("Second"),
|
|
NewInput().Title("Third").Key("Third"),
|
|
),
|
|
).WithWidth(25)
|
|
f = batchUpdate(f, f.Init()).(*Form)
|
|
|
|
f.NextField()
|
|
field := f.GetFocusedField()
|
|
|
|
if field.GetKey() != "Second" {
|
|
t.Error("Expected Second field to be focused but was '" + field.GetKey() + "'")
|
|
}
|
|
}
|
|
|
|
// formProgram returns a new Form with a nil input and output, so it can be used as a test program.
|
|
func formProgram() *Form {
|
|
return NewForm(NewGroup(NewInput().Title("Foo"))).
|
|
WithInput(nil).
|
|
WithOutput(io.Discard).
|
|
WithAccessible(false)
|
|
}
|
|
|
|
func batchUpdate(m Model, cmd tea.Cmd) Model {
|
|
if cmd == nil {
|
|
return m
|
|
}
|
|
msg := cmd()
|
|
m, cmd = m.Update(msg)
|
|
if cmd == nil {
|
|
return m
|
|
}
|
|
msg = cmd()
|
|
m, _ = m.Update(msg)
|
|
return m
|
|
}
|
|
|
|
func codeKeypress(r rune) tea.KeyPressMsg {
|
|
return tea.KeyPressMsg(tea.Key{
|
|
Code: r,
|
|
})
|
|
}
|
|
|
|
func keypress(r rune) tea.KeyPressMsg {
|
|
return tea.KeyPressMsg(tea.Key{
|
|
Text: string(r),
|
|
Code: r,
|
|
ShiftedCode: r,
|
|
})
|
|
}
|
|
|
|
func typeText[T Model](m T, s string) T {
|
|
var tm Model = m
|
|
for _, r := range s {
|
|
tm, _ = tm.Update(keypress(r))
|
|
}
|
|
return tm.(T)
|
|
}
|
|
|
|
func TestAccessibleForm(t *testing.T) {
|
|
var out bytes.Buffer
|
|
|
|
f := NewForm(
|
|
NewGroup(
|
|
NewInput().Title("Hello:"),
|
|
),
|
|
).
|
|
WithAccessible(true).
|
|
WithOutput(&out).
|
|
WithInput(strings.NewReader("carlos\n"))
|
|
|
|
if err := f.Run(); err != nil {
|
|
t.Error(err)
|
|
}
|
|
|
|
if !strings.Contains(out.String(), "Hello:") {
|
|
t.Error("invalid output:\n", out.String())
|
|
}
|
|
}
|
|
|
|
func TestAccessibleFields(t *testing.T) {
|
|
for name, test := range map[string]struct {
|
|
Field Field
|
|
FieldFn func() Field
|
|
Input string
|
|
CheckOutput func(tb testing.TB, output string)
|
|
CheckValue func(tb testing.TB, value any)
|
|
}{
|
|
"input": {
|
|
Field: NewInput(),
|
|
Input: "Hello",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Input:")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, "Hello", value.(string))
|
|
},
|
|
},
|
|
"input with charlimit": {
|
|
Field: NewInput().CharLimit(2),
|
|
Input: "Hello",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Input cannot exceed 2 characters")
|
|
},
|
|
},
|
|
"input with default": {
|
|
FieldFn: func() Field {
|
|
v := "hi"
|
|
return NewInput().Value(&v)
|
|
},
|
|
Input: "\n",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Input:")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, "hi", value.(string))
|
|
},
|
|
},
|
|
"confirm": {
|
|
Field: NewConfirm(),
|
|
Input: "Y",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Choose [y/N]")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, true, value.(bool))
|
|
},
|
|
},
|
|
"confirm with default": {
|
|
FieldFn: func() Field {
|
|
v := true
|
|
return NewConfirm().Value(&v)
|
|
},
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Choose [Y/n]")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, true, value.(bool))
|
|
},
|
|
},
|
|
"confirm with default choose": {
|
|
FieldFn: func() Field {
|
|
v := true
|
|
return NewConfirm().Value(&v)
|
|
},
|
|
Input: "n",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Y/n")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, false, value.(bool))
|
|
},
|
|
},
|
|
"filepicker": {
|
|
Field: NewFilePicker(),
|
|
Input: "huh_test.go",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Choose a file:")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, "huh_test.go", value.(string))
|
|
},
|
|
},
|
|
"filepicker with default": {
|
|
FieldFn: func() Field {
|
|
v := "huh_test.go"
|
|
return NewFilePicker().Value(&v)
|
|
},
|
|
Input: "\n",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Choose a file:")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, "huh_test.go", value.(string))
|
|
},
|
|
},
|
|
"multiselect": {
|
|
Field: NewMultiSelect[string]().Options(NewOptions("a", "b")...),
|
|
Input: "2",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "2. ✓ b")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
got := value.([]string)
|
|
requireEqual(tb, 1, len(got))
|
|
requireEqual(tb, "b", got[0])
|
|
},
|
|
},
|
|
"multiselect default value": {
|
|
FieldFn: func() Field {
|
|
v := []string{"b", "c"}
|
|
return NewMultiSelect[string]().Options(NewOptions("a", "b", "c", "d")...).Value(&v)
|
|
},
|
|
Input: "\n",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "2. ✓ b")
|
|
requireContains(tb, output, "3. ✓ c")
|
|
},
|
|
},
|
|
"select": {
|
|
Field: NewSelect[string]().Options(NewOptions("a", "b")...),
|
|
Input: "2",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Select:")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, "b", value.(string))
|
|
},
|
|
},
|
|
"select default value": {
|
|
FieldFn: func() Field {
|
|
v := "c"
|
|
return NewSelect[string]().
|
|
Options(NewOptions("a", "b", "c", "d")...).
|
|
Value(&v)
|
|
},
|
|
Input: "\n",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Select:")
|
|
requireContains(tb, output, "Enter a number between 1 and 4")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, "c", value.(string))
|
|
},
|
|
},
|
|
"select no input": {
|
|
Field: NewSelect[string]().Options(NewOptions("a", "b")...),
|
|
Input: "\n2\n",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Select:")
|
|
requireContains(tb, output, "Enter a number between 1 and 2")
|
|
requireContains(tb, output, "Invalid: must be a number between 1 and 2")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, "b", value.(string))
|
|
},
|
|
},
|
|
"select single option": {
|
|
Field: NewSelect[string]().Options(NewOptions("a")...),
|
|
Input: "\n1\n",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Select:")
|
|
requireContains(tb, output, "There is only one option available; enter the number 1:")
|
|
requireContains(tb, output, "Invalid: must be 1")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, "a", value.(string))
|
|
},
|
|
},
|
|
"note": {
|
|
Field: NewNote().Title("Hi").Description("there"),
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Hi")
|
|
requireContains(tb, output, "there")
|
|
},
|
|
},
|
|
"text": {
|
|
Field: NewText().Title("Text:"),
|
|
Input: "hello world",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Text:")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, "hello world", value.(string))
|
|
},
|
|
},
|
|
"text with limit": {
|
|
Field: NewText().CharLimit(2).Title("Text"),
|
|
Input: "hello world",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Input cannot exceed 2 characters")
|
|
},
|
|
},
|
|
"text default value": {
|
|
FieldFn: func() Field {
|
|
v := "test"
|
|
return NewText().Title("Text:").Value(&v)
|
|
},
|
|
Input: "\n",
|
|
CheckOutput: func(tb testing.TB, output string) {
|
|
tb.Helper()
|
|
requireContains(tb, output, "Text:")
|
|
},
|
|
CheckValue: func(tb testing.TB, value any) {
|
|
tb.Helper()
|
|
requireEqual(tb, "test", value.(string))
|
|
},
|
|
},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
field := test.Field
|
|
if test.FieldFn != nil {
|
|
field = test.FieldFn()
|
|
}
|
|
|
|
var out bytes.Buffer
|
|
if err := field.RunAccessible(
|
|
&out,
|
|
strings.NewReader(test.Input),
|
|
); err != nil {
|
|
t.Error(err)
|
|
}
|
|
if test.CheckOutput != nil {
|
|
test.CheckOutput(t, out.String())
|
|
}
|
|
if test.CheckValue != nil {
|
|
test.CheckValue(t, field.GetValue())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInputPasswordAccessible(t *testing.T) {
|
|
t.Run("not a tty", func(t *testing.T) {
|
|
var out bytes.Buffer
|
|
if err := NewInput().
|
|
EchoMode(EchoModeNone).
|
|
RunAccessible(&out, bytes.NewReader(nil)); err == nil {
|
|
t.Error("expected it to error")
|
|
}
|
|
if err := NewInput().
|
|
EchoMode(EchoModePassword).
|
|
RunAccessible(&out, bytes.NewReader(nil)); err == nil {
|
|
t.Error("expected it to error")
|
|
}
|
|
})
|
|
|
|
t.Run("is a tty", func(t *testing.T) {
|
|
var out bytes.Buffer
|
|
pty, err := xpty.NewPty(50, 30)
|
|
if err != nil {
|
|
t.Skipf("could not open pty: %v", err)
|
|
}
|
|
upty, ok := pty.(*xpty.UnixPty)
|
|
if !ok {
|
|
t.Skipf("test only works on unix")
|
|
}
|
|
|
|
input := NewInput().EchoMode(EchoModePassword)
|
|
|
|
errs := make(chan error, 1)
|
|
go func() {
|
|
errs <- input.RunAccessible(&out, upty.Slave())
|
|
}()
|
|
|
|
_, _ = upty.Master().Write([]byte("a password\n"))
|
|
|
|
if err := <-errs; err != nil {
|
|
t.Errorf("expected no error, got %v", err)
|
|
}
|
|
|
|
t.Logf("%q", out.String())
|
|
requireContains(t, out.String(), "Password:")
|
|
requireEqual(t, "a password", input.GetValue().(string))
|
|
})
|
|
}
|
|
|
|
func requireEqual[T comparable](tb testing.TB, a, b T) {
|
|
tb.Helper()
|
|
if a != b {
|
|
tb.Fatalf("expected %v to be equal to %v", a, b)
|
|
}
|
|
}
|
|
|
|
func requireContains(tb testing.TB, s, subtr string) {
|
|
tb.Helper()
|
|
if !strings.Contains(s, subtr) {
|
|
tb.Fatalf("%q does not contain %q", s, subtr)
|
|
}
|
|
}
|
|
|
|
func viewModel(m Model) string { return ansi.Strip(m.View()) }
|