From f57756ae5b233337ecb3ba50fbb3ec161d316b2f Mon Sep 17 00:00:00 2001 From: Jess Archer Date: Wed, 17 Apr 2024 00:20:14 +1000 Subject: [PATCH] [11.x] Fix `expectsChoice` assertion with optional `multiselect` prompts. (#51078) * Replace `PromptOption` with customized `ChoiceQuestion` * Fix expectsChoice assertion for optional `multiselect` * Fix prompt fallback test * Update Choice.php --------- Co-authored-by: Taylor Otwell --- .../Console/Concerns/ConfiguresPrompts.php | 33 +++++----- src/Illuminate/Console/PromptOption.php | 60 ------------------- .../Console/View/Components/Choice.php | 21 ++++++- tests/Console/ConfiguresPromptsTest.php | 55 +++++++++-------- .../Console/PromptsAssertionTest.php | 34 +++++++++++ 5 files changed, 103 insertions(+), 100 deletions(-) delete mode 100644 src/Illuminate/Console/PromptOption.php diff --git a/src/Illuminate/Console/Concerns/ConfiguresPrompts.php b/src/Illuminate/Console/Concerns/ConfiguresPrompts.php index 76ecb3dfcb9c..34a215aeee39 100644 --- a/src/Illuminate/Console/Concerns/ConfiguresPrompts.php +++ b/src/Illuminate/Console/Concerns/ConfiguresPrompts.php @@ -2,7 +2,6 @@ namespace Illuminate\Console\Concerns; -use Illuminate\Console\PromptOption; use Illuminate\Console\PromptValidationException; use Laravel\Prompts\ConfirmPrompt; use Laravel\Prompts\MultiSearchPrompt; @@ -231,11 +230,13 @@ protected function restorePrompts() */ private function selectFallback($label, $options, $default = null) { - if ($default !== null) { - $default = array_search($default, array_is_list($options) ? $options : array_keys($options)); + $answer = $this->components->choice($label, $options, $default); + + if (! array_is_list($options) && $answer === (string) (int) $answer) { + return (int) $answer; } - return PromptOption::unwrap($this->components->choice($label, PromptOption::wrap($options), $default)); + return $answer; } /** @@ -249,24 +250,28 @@ private function selectFallback($label, $options, $default = null) */ private function multiselectFallback($label, $options, $default = [], $required = false) { - $options = PromptOption::wrap($options); + $default = $default !== [] ? implode(',', $default) : null; - if ($required === false) { - $options = [new PromptOption(null, 'None'), ...$options]; + if ($required === false && ! $this->laravel->runningUnitTests()) { + $options = array_is_list($options) + ? ['None', ...$options] + : ['' => 'None'] + $options; - if ($default === []) { - $default = [null]; + if ($default === null) { + $default = 'None'; } } - $default = $default !== [] - ? implode(',', array_keys(array_filter($options, fn ($option) => in_array($option->value, $default)))) - : null; + $answers = $this->components->choice($label, $options, $default, null, true); - $answers = PromptOption::unwrap($this->components->choice($label, $options, $default, multiple: true)); + if (! array_is_list($options)) { + $answers = array_map(fn ($value) => $value === (string) (int) $value ? (int) $value : $value, $answers); + } if ($required === false) { - return array_values(array_filter($answers, fn ($value) => $value !== null)); + return array_is_list($options) + ? array_values(array_filter($answers, fn ($value) => $value !== 'None')) + : array_filter($answers, fn ($value) => $value !== ''); } return $answers; diff --git a/src/Illuminate/Console/PromptOption.php b/src/Illuminate/Console/PromptOption.php deleted file mode 100644 index bef4541db229..000000000000 --- a/src/Illuminate/Console/PromptOption.php +++ /dev/null @@ -1,60 +0,0 @@ -label; - } - - /** - * Wrap the given options in PromptOption objects. - * - * @param array $options - * @return array - */ - public static function wrap($options) - { - return array_map( - fn ($label, $value) => new static(array_is_list($options) ? $label : $value, $label), - $options, - array_keys($options) - ); - } - - /** - * Unwrap the given option(s). - * - * @param static|string|int|array $option - * @return string|int|array - */ - public static function unwrap($option) - { - if (is_array($option)) { - return array_map(static::unwrap(...), $option); - } - - return $option instanceof static ? $option->value : $option; - } -} diff --git a/src/Illuminate/Console/View/Components/Choice.php b/src/Illuminate/Console/View/Components/Choice.php index 8ef25be46044..ed215527ae8e 100644 --- a/src/Illuminate/Console/View/Components/Choice.php +++ b/src/Illuminate/Console/View/Components/Choice.php @@ -20,10 +20,29 @@ public function render($question, $choices, $default = null, $attempts = null, $ { return $this->usingQuestionHelper( fn () => $this->output->askQuestion( - (new ChoiceQuestion($question, $choices, $default)) + $this->getChoiceQuestion($question, $choices, $default) ->setMaxAttempts($attempts) ->setMultiselect($multiple) ), ); } + + /** + * Get a ChoiceQuestion instance that handles array keys like Prompts. + * + * @param string $question + * @param array $choices + * @param mixed $default + * @return \Symfony\Component\Console\Question\ChoiceQuestion + */ + protected function getChoiceQuestion($question, $choices, $default) + { + return new class($question, $choices, $default) extends ChoiceQuestion + { + protected function isAssoc(array $array): bool + { + return ! array_is_list($array); + } + }; + } } diff --git a/tests/Console/ConfiguresPromptsTest.php b/tests/Console/ConfiguresPromptsTest.php index 84f73aaae245..ba2f267b1e6a 100644 --- a/tests/Console/ConfiguresPromptsTest.php +++ b/tests/Console/ConfiguresPromptsTest.php @@ -6,6 +6,7 @@ use Illuminate\Console\Command; use Illuminate\Console\OutputStyle; use Illuminate\Console\View\Components\Factory; +use Laravel\Prompts\Prompt; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -23,8 +24,10 @@ protected function tearDown(): void } #[DataProvider('selectDataProvider')] - public function testSelectFallback($prompt, $expectedDefault, $selection, $expectedReturn) + public function testSelectFallback($prompt, $expectedOptions, $expectedDefault, $return, $expectedReturn) { + Prompt::fallbackWhen(true); + $command = new class($prompt) extends Command { public $answer; @@ -42,8 +45,8 @@ public function handle() $this->runCommand($command, fn ($components) => $components ->expects('choice') - ->withArgs(fn ($question, $options, $default) => $default === $expectedDefault) - ->andReturnUsing(fn ($question, $options, $default) => $options[$selection]) + ->with('Test', $expectedOptions, $expectedDefault) + ->andReturn($return) ); $this->assertSame($expectedReturn, $command->answer); @@ -52,18 +55,20 @@ public function handle() public static function selectDataProvider() { return [ - 'list with no default' => [fn () => select('foo', ['a', 'b', 'c']), null, 1, 'b'], - 'numeric keys with no default' => [fn () => select('foo', [1 => 'a', 2 => 'b', 3 => 'c']), null, 1, 2], - 'assoc with no default' => [fn () => select('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C']), null, 1, 'b'], - 'list with default' => [fn () => select('foo', ['a', 'b', 'c'], 'b'), 1, 1, 'b'], - 'numeric keys with default' => [fn () => select('foo', [1 => 'a', 2 => 'b', 3 => 'c'], 2), 1, 1, 2], - 'assoc with default' => [fn () => select('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], 'b'), 1, 1, 'b'], + 'list with no default' => [fn () => select('Test', ['a', 'b', 'c']), ['a', 'b', 'c'], null, 'b', 'b'], + 'numeric keys with no default' => [fn () => select('Test', [1 => 'a', 2 => 'b', 3 => 'c']), [1 => 'a', 2 => 'b', 3 => 'c'], null, '2', 2], + 'assoc with no default' => [fn () => select('Test', ['a' => 'A', 'b' => 'B', 'c' => 'C']), ['a' => 'A', 'b' => 'B', 'c' => 'C'], null, 'b', 'b'], + 'list with default' => [fn () => select('Test', ['a', 'b', 'c'], 'b'), ['a', 'b', 'c'], 'b', 'b', 'b'], + 'numeric keys with default' => [fn () => select('Test', [1 => 'a', 2 => 'b', 3 => 'c'], 2), [1 => 'a', 2 => 'b', 3 => 'c'], 2, '2', 2], + 'assoc with default' => [fn () => select('Test', ['a' => 'A', 'b' => 'B', 'c' => 'C'], 'b'), ['a' => 'A', 'b' => 'B', 'c' => 'C'], 'b', 'b', 'b'], ]; } #[DataProvider('multiselectDataProvider')] - public function testMultiselectFallback($prompt, $expectedDefault, $selection, $expectedReturn) + public function testMultiselectFallback($prompt, $expectedOptions, $expectedDefault, $return, $expectedReturn) { + Prompt::fallbackWhen(true); + $command = new class($prompt) extends Command { public $answer; @@ -81,8 +86,8 @@ public function handle() $this->runCommand($command, fn ($components) => $components ->expects('choice') - ->withArgs(fn ($question, $options, $default, $multiple) => $default === $expectedDefault && $multiple === true) - ->andReturnUsing(fn ($question, $options, $default, $multiple) => array_values(array_filter($options, fn ($index) => in_array($index, $selection), ARRAY_FILTER_USE_KEY))) + ->with('Test', $expectedOptions, $expectedDefault, null, true) + ->andReturn($return) ); $this->assertSame($expectedReturn, $command->answer); @@ -91,18 +96,18 @@ public function handle() public static function multiselectDataProvider() { return [ - 'list with no default' => [fn () => multiselect('foo', ['a', 'b', 'c']), '0', [2, 3], ['b', 'c']], - 'numeric keys with no default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c']), '0', [2, 3], [2, 3]], - 'assoc with no default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C']), '0', [2, 3], ['b', 'c']], - 'list with default' => [fn () => multiselect('foo', ['a', 'b', 'c'], ['b', 'c']), '2,3', [2, 3], ['b', 'c']], - 'numeric keys with default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c'], [2, 3]), '2,3', [2, 3], [2, 3]], - 'assoc with default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], ['b', 'c']), '2,3', [2, 3], ['b', 'c']], - 'required list with no default' => [fn () => multiselect('foo', ['a', 'b', 'c'], required: true), null, [1, 2], ['b', 'c']], - 'required numeric keys with no default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c'], required: true), null, [1, 2], [2, 3]], - 'required assoc with no default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], required: true), null, [1, 2], ['b', 'c']], - 'required list with default' => [fn () => multiselect('foo', ['a', 'b', 'c'], ['b', 'c'], required: true), '1,2', [1, 2], ['b', 'c']], - 'required numeric keys with default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c'], [2, 3], required: true), '1,2', [1, 2], [2, 3]], - 'required assoc with default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], ['b', 'c'], required: true), '1,2', [1, 2], ['b', 'c']], + 'list with no default' => [fn () => multiselect('Test', ['a', 'b', 'c']), ['None', 'a', 'b', 'c'], 'None', ['None'], []], + 'numeric keys with no default' => [fn () => multiselect('Test', [1 => 'a', 2 => 'b', 3 => 'c']), ['' => 'None', 1 => 'a', 2 => 'b', 3 => 'c'], 'None', [''], []], + 'assoc with no default' => [fn () => multiselect('Test', ['a' => 'A', 'b' => 'B', 'c' => 'C']), ['' => 'None', 'a' => 'A', 'b' => 'B', 'c' => 'C'], 'None', [''], []], + 'list with default' => [fn () => multiselect('Test', ['a', 'b', 'c'], ['b', 'c']), ['None', 'a', 'b', 'c'], 'b,c', ['b', 'c'], ['b', 'c']], + 'numeric keys with default' => [fn () => multiselect('Test', [1 => 'a', 2 => 'b', 3 => 'c'], [2, 3]), ['' => 'None', 1 => 'a', 2 => 'b', 3 => 'c'], '2,3', ['2', '3'], [2, 3]], + 'assoc with default' => [fn () => multiselect('Test', ['a' => 'A', 'b' => 'B', 'c' => 'C'], ['b', 'c']), ['' => 'None', 'a' => 'A', 'b' => 'B', 'c' => 'C'], 'b,c', ['b', 'c'], ['b', 'c']], + 'required list with no default' => [fn () => multiselect('Test', ['a', 'b', 'c'], required: true), ['a', 'b', 'c'], null, ['b', 'c'], ['b', 'c']], + 'required numeric keys with no default' => [fn () => multiselect('Test', [1 => 'a', 2 => 'b', 3 => 'c'], required: true), [1 => 'a', 2 => 'b', 3 => 'c'], null, ['2', '3'], [2, 3]], + 'required assoc with no default' => [fn () => multiselect('Test', ['a' => 'A', 'b' => 'B', 'c' => 'C'], required: true), ['a' => 'A', 'b' => 'B', 'c' => 'C'], null, ['b', 'c'], ['b', 'c']], + 'required list with default' => [fn () => multiselect('Test', ['a', 'b', 'c'], ['b', 'c'], required: true), ['a', 'b', 'c'], 'b,c', ['b', 'c'], ['b', 'c']], + 'required numeric keys with default' => [fn () => multiselect('Test', [1 => 'a', 2 => 'b', 3 => 'c'], [2, 3], required: true), [1 => 'a', 2 => 'b', 3 => 'c'], '2,3', ['2', '3'], [2, 3]], + 'required assoc with default' => [fn () => multiselect('Test', ['a' => 'A', 'b' => 'B', 'c' => 'C'], ['b', 'c'], required: true), ['a' => 'A', 'b' => 'B', 'c' => 'C'], 'b,c', ['b', 'c'], ['b', 'c']], ]; } @@ -112,7 +117,7 @@ protected function runCommand($command, $expectations) $application->shouldReceive('make')->withArgs(fn ($abstract) => $abstract === OutputStyle::class)->andReturn($outputStyle = m::mock(OutputStyle::class)); $application->shouldReceive('make')->withArgs(fn ($abstract) => $abstract === Factory::class)->andReturn($factory = m::mock(Factory::class)); - $application->shouldReceive('runningUnitTests')->andReturn(true); + $application->shouldReceive('runningUnitTests')->andReturn(false); $application->shouldReceive('call')->with([$command, 'handle'])->andReturnUsing(fn ($callback) => call_user_func($callback)); $outputStyle->shouldReceive('newLinesWritten')->andReturn(1); diff --git a/tests/Integration/Console/PromptsAssertionTest.php b/tests/Integration/Console/PromptsAssertionTest.php index f585f2b05663..2bec3a2340e7 100644 --- a/tests/Integration/Console/PromptsAssertionTest.php +++ b/tests/Integration/Console/PromptsAssertionTest.php @@ -162,4 +162,38 @@ public function handle() ->expectsChoice('Which names do you like?', ['John', 'Jane'], ['John', 'Jane', 'Sally', 'Jack']) ->expectsOutput('You like John, Jane.'); } + + public function testAssertionForOptionalMultiselectPrompt() + { + $this->app[Kernel::class]->registerCommand( + new class extends Command + { + protected $signature = 'test:multiselect'; + + public function handle() + { + $names = multiselect( + label: 'Which names do you like?', + options: ['John', 'Jane', 'Sally', 'Jack'], + ); + + if (empty($names)) { + $this->line('You like nobody.'); + } else { + $this->line(sprintf('You like %s.', implode(', ', $names))); + } + } + } + ); + + $this + ->artisan('test:multiselect') + ->expectsChoice('Which names do you like?', ['John', 'Jane'], ['John', 'Jane', 'Sally', 'Jack']) + ->expectsOutput('You like John, Jane.'); + + $this + ->artisan('test:multiselect') + ->expectsChoice('Which names do you like?', ['None'], ['John', 'Jane', 'Sally', 'Jack']) + ->expectsOutput('You like nobody.'); + } }