diff --git a/README.md b/README.md index 3953cbe..d61205e 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ Type | Supported | Supported Actions `Editor` | ✘ | `Input` | ✓ | `Multiline` | ✘ | -`Multiselect` | ✘ | +`Multiselect` | ✓ | `Password` | ✓ | -`Select` | ✓ | +`Select` | ✓ | ### Expect diff --git a/answer.go b/answer.go index 1c4c095..ddf3dfe 100644 --- a/answer.go +++ b/answer.go @@ -10,6 +10,11 @@ import ( // ReactionTime is to create a small delay to simulate human reaction. var ReactionTime = 10 * time.Millisecond +// WaitForReaction creates a small delay to simulate human reaction. +func WaitForReaction() <-chan time.Time { + return time.After(ReactionTime) +} + // Answer is an expectation for answering a question. type Answer interface { Step @@ -135,10 +140,22 @@ func pressArrowDown() *Action { return action(terminal.KeyArrowDown, "ARROW DOWN") } +func pressArrowLeft() *Action { + return action(terminal.KeyArrowLeft, "ARROW LEFT") +} + +func pressArrowRight() *Action { + return action(terminal.KeyArrowRight, "ARROW RIGHT") +} + func pressInterrupt() *Action { return action(terminal.KeyInterrupt, "INTERRUPT") } +func pressSpace() *Action { + return action(terminal.KeySpace, "SPACE") +} + func pressDelete() *Action { return action(terminal.KeyDelete, "DELETE") } @@ -163,7 +180,7 @@ func (a *HelpAction) Do(c Console) error { // String represents the answer as a string. func (a *HelpAction) String() string { - return fmt.Sprintf("press %q", a.icon) + return fmt.Sprintf("press %q and see %q", a.icon, a.help) } func pressHelp(help string, options ...string) *HelpAction { diff --git a/cursor_posix.go b/cursor_posix.go index 51728cc..a5ea0e8 100644 --- a/cursor_posix.go +++ b/cursor_posix.go @@ -2,8 +2,6 @@ package surveyexpect -import "time" - func waitForCursor(c Console) error { // ANSI escape sequence for DSR - Device Status Report // https://en.wikipedia.org/wiki/ANSI_escape_code#CSI_sequences @@ -16,7 +14,7 @@ func waitForCursor(c Console) error { // After rendering the question, the prompt asks for the cursor's size and location (ESC[6n) and expects to receive // `ESC[n;mR` in return before reading the answer. If the addStep answers too fast (so the answer will be in between // `ESC[n;mR` and reading answer), the prompt won't see the answer and hangs indefinitely. - <-time.After(ReactionTime) + <-WaitForReaction() return err } diff --git a/cursor_windosw.go b/cursor_windows.go similarity index 77% rename from cursor_windosw.go rename to cursor_windows.go index 3d3440a..74a42fb 100644 --- a/cursor_windosw.go +++ b/cursor_windows.go @@ -3,7 +3,7 @@ package surveyexpect func waitForCursor(c Console) error { - <-time.After(ReactionTime) + <-WaitForReaction() return nil } diff --git a/expectation.go b/expectation.go index de1115e..397a139 100644 --- a/expectation.go +++ b/expectation.go @@ -5,9 +5,12 @@ import ( "regexp" ) -var indicatorRegex = regexp.MustCompile(`^([^ ]\s+)(.*)`) +var ( + selectIndicatorRegex = regexp.MustCompile(`^([^ ]\s+)(.*)`) + multiselectIndicatorRegex = regexp.MustCompile(`^([^ ]\s+)(\[[x ]].*)`) +) -// SelectExpect expects strings from console. +// SelectExpect expects a select list from console. type SelectExpect []string // Do runs the step. @@ -23,17 +26,62 @@ func (e *SelectExpect) Do(c Console) error { // String represents the answer as a string. func (e *SelectExpect) String() string { - breakdown := make([]map[string]string, 0) + var sb stringsBuilder - var size int + sb.WriteLinef("Expect a select list:") + writeSelectList(&sb, *e, selectIndicatorRegex) + + return sb.String() +} + +func expectSelect(options ...string) *SelectExpect { + e := SelectExpect(options) + + return &e +} +// MultiSelectExpect expects a multiselect list from console. +type MultiSelectExpect []string + +// Do runs the step. +func (e *MultiSelectExpect) Do(c Console) error { for _, o := range *e { + if _, err := c.ExpectString(o); err != nil { + return err + } + } + + return nil +} + +// String represents the answer as a string. +func (e *MultiSelectExpect) String() string { + var sb stringsBuilder + + sb.WriteLinef("Expect a multiselect list:") + writeSelectList(&sb, *e, multiselectIndicatorRegex) + + return sb.String() +} + +func expectMultiSelect(options ...string) *MultiSelectExpect { + e := MultiSelectExpect(options) + + return &e +} + +func breakdownOptions(options []string, indicator *regexp.Regexp) (breakdown []map[string]string, pad string) { + breakdown = make([]map[string]string, 0, len(options)) + + var size int + + for _, o := range options { e := map[string]string{ "prefix": "", "option": "", } - if m := indicatorRegex.FindStringSubmatch(o); m != nil { + if m := indicator.FindStringSubmatch(o); m != nil { e["prefix"] = m[1] e["option"] = m[2] l := len(m[1]) @@ -48,12 +96,11 @@ func (e *SelectExpect) String() string { breakdown = append(breakdown, e) } - var ( - sb stringsBuilder - pad = fmt.Sprintf("%%-%ds", size) - ) + return breakdown, fmt.Sprintf("%%-%ds", size) +} - sb.WriteLinef("Expect a select list:") +func writeSelectList(sb *stringsBuilder, options []string, indicator *regexp.Regexp) { + breakdown, pad := breakdownOptions(options, indicator) for i, o := range breakdown { if i > 0 { @@ -63,12 +110,4 @@ func (e *SelectExpect) String() string { sb.Writef(pad, o["prefix"]). Writef(o["option"]) } - - return sb.String() -} - -func expectSelect(options ...string) *SelectExpect { - e := SelectExpect(options) - - return &e } diff --git a/multiselect.go b/multiselect.go new file mode 100644 index 0000000..b2d9051 --- /dev/null +++ b/multiselect.go @@ -0,0 +1,154 @@ +package surveyexpect + +var _ Prompt = (*MultiSelectPrompt)(nil) + +// MultiSelectPrompt is an expectation of survey.Select. +type MultiSelectPrompt struct { + *basePrompt + + message string + steps *InlineSteps +} + +func (p *MultiSelectPrompt) append(steps ...Step) *MultiSelectPrompt { + p.lock() + defer p.unlock() + + p.steps.Append(steps...) + + return p +} + +// ShowHelp asks for help and asserts the help text. +// +// Survey.ExpectMultiSelect("Select a language:"). +// ShowHelp("Your preferred language") +func (p *MultiSelectPrompt) ShowHelp(help string, options ...string) *MultiSelectPrompt { + return p.append(pressHelp(help, options...)) +} + +// Type sends some text to filter the options. +// +// Survey.ExpectMultiSelect("Select a language:"). +// Type("Eng") +func (p *MultiSelectPrompt) Type(s string) *MultiSelectPrompt { + return p.append(typeAnswer(s)) +} + +// Tab sends the TAB key the indicated times. Default is 1 when omitted. +// +// Survey.ExpectMultiSelect("Select a language:"). +// Type("Eng"). +// Tab() +func (p *MultiSelectPrompt) Tab(times ...int) *MultiSelectPrompt { + return p.append(repeatStep(pressTab(), times...)...) +} + +// Interrupt sends ^C and ends the sequence. +// +// Survey.ExpectMultiSelect("Select a language:"). +// Interrupt() +func (p *MultiSelectPrompt) Interrupt() { + p.append(pressInterrupt()) + p.steps.Close() +} + +// Enter sends the ENTER key and ends the sequence. +// +// Survey.ExpectMultiSelect("Select a language:"). +// Type("Eng"). +// Enter() +func (p *MultiSelectPrompt) Enter() { + p.append(pressEnter()) + p.steps.Close() +} + +// Delete sends the DELETE key the indicated times. Default is 1 when omitted. +// +// Survey.ExpectMultiSelect("Select a language:"). +// Type("Eng"). +// Delete(3) +func (p *MultiSelectPrompt) Delete(times ...int) *MultiSelectPrompt { + return p.append(repeatStep(pressDelete(), times...)...) +} + +// MoveUp sends the ARROW UP key the indicated times. Default is 1 when omitted. +// +// Survey.ExpectMultiSelect("Select a language:"). +// Type("Eng"). +// MoveUp() +func (p *MultiSelectPrompt) MoveUp(times ...int) *MultiSelectPrompt { + return p.append(repeatStep(pressArrowUp(), times...)...) +} + +// MoveDown sends the ARROW DOWN key the indicated times. Default is 1 when omitted. +// +// Survey.ExpectMultiSelect("Select a language:"). +// Type("Eng"). +// MoveDown() +func (p *MultiSelectPrompt) MoveDown(times ...int) *MultiSelectPrompt { + return p.append(repeatStep(pressArrowDown(), times...)...) +} + +// Select selects an option. If the option is selected, it will be deselected. +// +// Survey.ExpectMultiSelect("Select a language:"). +// Type("Eng"). +// Select() +func (p *MultiSelectPrompt) Select() *MultiSelectPrompt { + return p.append(pressSpace()) +} + +// SelectNone deselects all filtered options. +// +// Survey.ExpectMultiSelect("Select a language:"). +// Type("Eng"). +// SelectNone() +func (p *MultiSelectPrompt) SelectNone() *MultiSelectPrompt { + return p.append(pressArrowLeft()) +} + +// SelectAll selects all filtered options. +// +// Survey.ExpectMultiSelect("Select a language:"). +// Type("Eng"). +// SelectAll() +func (p *MultiSelectPrompt) SelectAll() *MultiSelectPrompt { + return p.append(pressArrowRight()) +} + +// ExpectOptions expects a list of options. +// +// Survey.ExpectMultiSelect("Select a language:"). +// Type("Eng"). +// ExpectOptions("English") +func (p *MultiSelectPrompt) ExpectOptions(options ...string) *MultiSelectPrompt { + return p.append(expectMultiSelect(options...)) +} + +// Do runs the step. +func (p *MultiSelectPrompt) Do(c Console) error { + if _, err := c.ExpectString(p.message); err != nil { + return err + } + + return p.steps.Do(c) +} + +// String represents the expectation as a string. +func (p *MultiSelectPrompt) String() string { + var sb stringsBuilder + + return sb.WriteLabelLinef("Expect", "MultiSelect Prompt"). + WriteLabelLinef("Message", "%q", p.message). + WriteString(p.steps.String()). + String() +} + +func newMultiSelect(parent *Survey, message string) *MultiSelectPrompt { + return &MultiSelectPrompt{ + basePrompt: &basePrompt{parent: parent}, + message: message, + steps: inlineSteps(), + } +} diff --git a/multiselect_test.go b/multiselect_test.go new file mode 100644 index 0000000..0a9a16f --- /dev/null +++ b/multiselect_test.go @@ -0,0 +1,271 @@ +package surveyexpect_test + +import ( + "testing" + "time" + + "github.com/AlecAivazis/survey/v2" + "github.com/AlecAivazis/survey/v2/terminal" + "github.com/stretchr/testify/assert" + + "github.com/nhatthm/surveyexpect" + "github.com/nhatthm/surveyexpect/options" +) + +func TestMultiSelectPrompt(t *testing.T) { + t.Parallel() + + testCases := []struct { + scenario string + expectSurvey surveyexpect.Expector + help string + showHelp bool + options []string + expectedAnswer []string + expectedError string + }{ + { + scenario: "enter without taking any other actions", + expectSurvey: surveyexpect.Expect(func(s *surveyexpect.Survey) { + s.ExpectMultiSelect("Select destinations"). + Enter() + }), + }, + { + scenario: "with help and ask for it", + expectSurvey: surveyexpect.Expect(func(s *surveyexpect.Survey) { + s.ExpectMultiSelect("Select destinations [Use arrows to move, space to select, to all, to none, type to filter, ? for more help]"). + ShowHelp("Your favorite countries"). + Select(). + Enter() + }), + help: "Your favorite countries", + showHelp: true, + expectedAnswer: []string{"France"}, + }, + { + scenario: "input is interrupted", + expectSurvey: surveyexpect.Expect(func(s *surveyexpect.Survey) { + s.ExpectMultiSelect("Select destinations"). + Interrupt() + }), + expectedError: "interrupt", + }, + { + scenario: "input is invalid", + expectSurvey: surveyexpect.Expect(func(s *surveyexpect.Survey) { + s.ExpectMultiSelect("Select destinations"). + Type("\033X") + }), + expectedError: `Unexpected Escape Sequence: ['\x1b' 'X']`, + }, + { + scenario: "navigation", + expectSurvey: surveyexpect.Expect(func(s *surveyexpect.Survey) { + s.ExpectMultiSelect("Select destinations"). + Type("United").Delete(2). + ExpectOptions( + "> [ ] United Kingdom", + "[ ] United States", + ). + SelectAll(). + Tab(2).MoveDown().MoveUp(4). + ExpectOptions( + "[ ] Germany", + "[ ] Malaysia", + "[ ] Singapore", + "[ ] Thailand", + "[x] United Kingdom", + "[x] United States", + "> [ ] Vietnam", + ). + MoveDown().Select(). + ExpectOptions( + "[x] France", + "[ ] Germany", + "[ ] Malaysia", + "[ ] Singapore", + "[ ] Thailand", + "[x] United Kingdom", + "[x] United States", + ). + SelectNone(). + Select().Select(). + MoveUp(). + ExpectOptions( + "[ ] Germany", + "[ ] Malaysia", + "[ ] Singapore", + "[ ] Thailand", + "[ ] United Kingdom", + "[ ] United States", + "> [ ] Vietnam", + ). + Select(). + Enter() + }), + expectedAnswer: []string{"Vietnam"}, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.scenario, func(t *testing.T) { + t.Parallel() + + // Prepare the survey. + s := tc.expectSurvey(t) + p := &survey.MultiSelectTemplateData{ + MultiSelect: survey.MultiSelect{ + Message: "Select destinations", + Help: tc.help, + Options: []string{ + "France", + "Germany", + "Malaysia", + "Singapore", + "Thailand", + "United Kingdom", + "United States", + "Vietnam", + }, + }, + ShowHelp: tc.showHelp, + } + + // Start the survey. + s.Start(func(stdio terminal.Stdio) { + var answer []string + err := survey.AskOne(p, &answer, options.WithStdio(stdio)) + + assert.Equal(t, tc.expectedAnswer, answer) + + if tc.expectedError == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tc.expectedError) + } + }) + }) + } +} + +func TestMultiSelectPrompt_NoHelpButStillExpect(t *testing.T) { + t.Parallel() + + testingT := T() + s := surveyexpect.Expect(func(s *surveyexpect.Survey) { + s.WithTimeout(50 * time.Millisecond) + + s.ExpectMultiSelect("Select destinations"). + ShowHelp("Your favorite countries"). + ExpectOptions( + "> [ ] option 1", + "[ ] option 2", + ) + })(testingT) + + expectedError := `there are remaining expectations that were not met: + +Expect : MultiSelect Prompt +Message: "Select destinations" +press "?" and see "Your favorite countries" +Expect a multiselect list: +> [ ] option 1 + [ ] option 2` + + p := &survey.MultiSelect{ + Message: "Select destinations", + Options: []string{ + "option 1", + "option 2", + }, + } + + // Start the survey. + s.Start(func(stdio terminal.Stdio) { + var answer []string + err := survey.AskOne(p, &answer, options.WithStdio(stdio)) + + assert.Empty(t, answer) + assert.NoError(t, err) + }) + + assert.EqualError(t, s.ExpectationsWereMet(), expectedError) + + t.Log(testingT.LogString()) +} + +func TestMultiSelectPrompt_SurveyInterrupted(t *testing.T) { + t.Parallel() + + testCases := []struct { + scenario string + expectSurvey surveyexpect.Expector + expectedError string + }{ + { + scenario: "interrupt", + expectSurvey: surveyexpect.Expect(func(s *surveyexpect.Survey) { + s.ExpectMultiSelect("Select destinations"). + Interrupt() + }), + expectedError: "interrupt", + }, + { + scenario: "invalid input", + expectSurvey: surveyexpect.Expect(func(s *surveyexpect.Survey) { + s.ExpectMultiSelect("Select destinations"). + Type("\033X") + }), + expectedError: `Unexpected Escape Sequence: ['\x1b' 'X']`, + }, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.scenario, func(t *testing.T) { + t.Parallel() + + testingT := T() + s := tc.expectSurvey(testingT) + + questions := []*survey.Question{ + { + Name: "countries", + Prompt: &survey.MultiSelect{Message: "Select destinations", Options: []string{"Germany", "Vietnam"}}, + }, + { + Name: "transports", + Prompt: &survey.MultiSelect{Message: "Select transports", Options: []string{"Train", "Bus"}}, + }, + } + + expectedResult := map[string]interface{}{ + "countries": []string{"Vietnam"}, + "transports": []string{"Bus"}, + } + + // Start the survey. + s.Start(func(stdio terminal.Stdio) { + result := map[string]interface{}{ + "countries": []string{"Vietnam"}, + "transports": []string{"Bus"}, + } + err := survey.Ask(questions, &result, options.WithStdio(stdio)) + + assert.Equal(t, expectedResult, result) + + if tc.expectedError == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tc.expectedError) + } + }) + + assert.Nil(t, s.ExpectationsWereMet()) + + t.Log(testingT.LogString()) + }) + } +} diff --git a/select_test.go b/select_test.go index 00a5edc..3d70c7c 100644 --- a/select_test.go +++ b/select_test.go @@ -135,10 +135,21 @@ func TestSelectPrompt_NoHelpButStillExpect(t *testing.T) { s.WithTimeout(50 * time.Millisecond) s.ExpectSelect("Select a country"). - ShowHelp("Your favorite country") + ShowHelp("Your favorite country"). + ExpectOptions( + "> option 1", + "option 2", + ) })(testingT) - expectedError := "there are remaining expectations that were not met:\n\nExpect : Select Prompt\nMessage: \"Select a country\"\npress \"?\"" + expectedError := `there are remaining expectations that were not met: + +Expect : Select Prompt +Message: "Select a country" +press "?" and see "Your favorite country" +Expect a select list: +> option 1 + option 2` p := &survey.Select{ Message: "Select a country", diff --git a/survey.go b/survey.go index 70af8dc..854c87f 100644 --- a/survey.go +++ b/survey.go @@ -76,6 +76,18 @@ func (s *Survey) ExpectInput(message string) *InputPrompt { return e } +// ExpectMultiSelect expects a MultiSelectPrompt. +// +// Survey.ExpectMultiSelect("Enter password:"). +// Enter() +func (s *Survey) ExpectMultiSelect(message string) *MultiSelectPrompt { + e := newMultiSelect(s, message) + + s.addStep(e) + + return e +} + // ExpectPassword expects a PasswordPrompt. // // Survey.ExpectPassword("Enter password:").