The toggle handler's limit guard indexed the unfiltered options slice with the cursor, but the cursor tracks the filtered slice. When a filter was applied the two indices diverged, so the guard inspected the wrong option's selected state and allowed toggling past the configured Limit. Index the option that is actually being toggled instead, and add a regression test that selects up to the limit, filters, then verifies a further toggle is a no-op.
1729 lines
42 KiB
Go
1729 lines
42 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 TestMultiSelectLimitEnforcedWhileFiltered(t *testing.T) {
|
|
// Regression test: the toggle limit guard must inspect the option that is
|
|
// actually being toggled, not the option sharing the cursor index in the
|
|
// unfiltered list. With a filter applied the cursor indexes the filtered
|
|
// slice, so the two diverge and the limit could be exceeded.
|
|
field := NewMultiSelect[string]().
|
|
Options(NewOptions("Red", "Green", "Blue")...).
|
|
Title("Colors").
|
|
Limit(1)
|
|
f := NewForm(NewGroup(field))
|
|
f.Update(f.Init())
|
|
|
|
// Select "Red" (the first option), reaching the limit of one.
|
|
m := batchUpdate(f.Update(keypress('x')))
|
|
|
|
// Filter down to "Blue", which lives at index 2 in the unfiltered list but
|
|
// at index 0 in the filtered list.
|
|
m = batchUpdate(m.Update(keypress('/')))
|
|
for _, r := range "Blue" {
|
|
m = batchUpdate(m.Update(keypress(r)))
|
|
}
|
|
|
|
// Apply the filter: filtering stops but the filtered list remains in place.
|
|
m = batchUpdate(m.Update(codeKeypress(tea.KeyEscape)))
|
|
|
|
// Attempt to toggle "Blue". The limit is already reached, so this must be a
|
|
// no-op rather than selecting a second option.
|
|
batchUpdate(m.Update(keypress('x')))
|
|
|
|
value, ok := field.GetValue().([]string)
|
|
if !ok {
|
|
t.Fatalf("expected []string value, got %T", field.GetValue())
|
|
}
|
|
if len(value) != 1 {
|
|
t.Fatalf("limit of 1 exceeded while filtering: got %d selected options %v", len(value), value)
|
|
}
|
|
if value[0] != "Red" {
|
|
t.Errorf("expected the only selected option to be Red, got %q", value[0])
|
|
}
|
|
}
|
|
|
|
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()) }
|