Skip to content

Commit f57756a

Browse files
[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 <[email protected]>
1 parent 6a8b505 commit f57756a

File tree

5 files changed

+103
-100
lines changed

5 files changed

+103
-100
lines changed

src/Illuminate/Console/Concerns/ConfiguresPrompts.php

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
namespace Illuminate\Console\Concerns;
44

5-
use Illuminate\Console\PromptOption;
65
use Illuminate\Console\PromptValidationException;
76
use Laravel\Prompts\ConfirmPrompt;
87
use Laravel\Prompts\MultiSearchPrompt;
@@ -231,11 +230,13 @@ protected function restorePrompts()
231230
*/
232231
private function selectFallback($label, $options, $default = null)
233232
{
234-
if ($default !== null) {
235-
$default = array_search($default, array_is_list($options) ? $options : array_keys($options));
233+
$answer = $this->components->choice($label, $options, $default);
234+
235+
if (! array_is_list($options) && $answer === (string) (int) $answer) {
236+
return (int) $answer;
236237
}
237238

238-
return PromptOption::unwrap($this->components->choice($label, PromptOption::wrap($options), $default));
239+
return $answer;
239240
}
240241

241242
/**
@@ -249,24 +250,28 @@ private function selectFallback($label, $options, $default = null)
249250
*/
250251
private function multiselectFallback($label, $options, $default = [], $required = false)
251252
{
252-
$options = PromptOption::wrap($options);
253+
$default = $default !== [] ? implode(',', $default) : null;
253254

254-
if ($required === false) {
255-
$options = [new PromptOption(null, 'None'), ...$options];
255+
if ($required === false && ! $this->laravel->runningUnitTests()) {
256+
$options = array_is_list($options)
257+
? ['None', ...$options]
258+
: ['' => 'None'] + $options;
256259

257-
if ($default === []) {
258-
$default = [null];
260+
if ($default === null) {
261+
$default = 'None';
259262
}
260263
}
261264

262-
$default = $default !== []
263-
? implode(',', array_keys(array_filter($options, fn ($option) => in_array($option->value, $default))))
264-
: null;
265+
$answers = $this->components->choice($label, $options, $default, null, true);
265266

266-
$answers = PromptOption::unwrap($this->components->choice($label, $options, $default, multiple: true));
267+
if (! array_is_list($options)) {
268+
$answers = array_map(fn ($value) => $value === (string) (int) $value ? (int) $value : $value, $answers);
269+
}
267270

268271
if ($required === false) {
269-
return array_values(array_filter($answers, fn ($value) => $value !== null));
272+
return array_is_list($options)
273+
? array_values(array_filter($answers, fn ($value) => $value !== 'None'))
274+
: array_filter($answers, fn ($value) => $value !== '');
270275
}
271276

272277
return $answers;

src/Illuminate/Console/PromptOption.php

Lines changed: 0 additions & 60 deletions
This file was deleted.

src/Illuminate/Console/View/Components/Choice.php

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,29 @@ public function render($question, $choices, $default = null, $attempts = null, $
2020
{
2121
return $this->usingQuestionHelper(
2222
fn () => $this->output->askQuestion(
23-
(new ChoiceQuestion($question, $choices, $default))
23+
$this->getChoiceQuestion($question, $choices, $default)
2424
->setMaxAttempts($attempts)
2525
->setMultiselect($multiple)
2626
),
2727
);
2828
}
29+
30+
/**
31+
* Get a ChoiceQuestion instance that handles array keys like Prompts.
32+
*
33+
* @param string $question
34+
* @param array $choices
35+
* @param mixed $default
36+
* @return \Symfony\Component\Console\Question\ChoiceQuestion
37+
*/
38+
protected function getChoiceQuestion($question, $choices, $default)
39+
{
40+
return new class($question, $choices, $default) extends ChoiceQuestion
41+
{
42+
protected function isAssoc(array $array): bool
43+
{
44+
return ! array_is_list($array);
45+
}
46+
};
47+
}
2948
}

tests/Console/ConfiguresPromptsTest.php

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use Illuminate\Console\Command;
77
use Illuminate\Console\OutputStyle;
88
use Illuminate\Console\View\Components\Factory;
9+
use Laravel\Prompts\Prompt;
910
use Mockery as m;
1011
use PHPUnit\Framework\Attributes\DataProvider;
1112
use PHPUnit\Framework\TestCase;
@@ -23,8 +24,10 @@ protected function tearDown(): void
2324
}
2425

2526
#[DataProvider('selectDataProvider')]
26-
public function testSelectFallback($prompt, $expectedDefault, $selection, $expectedReturn)
27+
public function testSelectFallback($prompt, $expectedOptions, $expectedDefault, $return, $expectedReturn)
2728
{
29+
Prompt::fallbackWhen(true);
30+
2831
$command = new class($prompt) extends Command
2932
{
3033
public $answer;
@@ -42,8 +45,8 @@ public function handle()
4245

4346
$this->runCommand($command, fn ($components) => $components
4447
->expects('choice')
45-
->withArgs(fn ($question, $options, $default) => $default === $expectedDefault)
46-
->andReturnUsing(fn ($question, $options, $default) => $options[$selection])
48+
->with('Test', $expectedOptions, $expectedDefault)
49+
->andReturn($return)
4750
);
4851

4952
$this->assertSame($expectedReturn, $command->answer);
@@ -52,18 +55,20 @@ public function handle()
5255
public static function selectDataProvider()
5356
{
5457
return [
55-
'list with no default' => [fn () => select('foo', ['a', 'b', 'c']), null, 1, 'b'],
56-
'numeric keys with no default' => [fn () => select('foo', [1 => 'a', 2 => 'b', 3 => 'c']), null, 1, 2],
57-
'assoc with no default' => [fn () => select('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C']), null, 1, 'b'],
58-
'list with default' => [fn () => select('foo', ['a', 'b', 'c'], 'b'), 1, 1, 'b'],
59-
'numeric keys with default' => [fn () => select('foo', [1 => 'a', 2 => 'b', 3 => 'c'], 2), 1, 1, 2],
60-
'assoc with default' => [fn () => select('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], 'b'), 1, 1, 'b'],
58+
'list with no default' => [fn () => select('Test', ['a', 'b', 'c']), ['a', 'b', 'c'], null, 'b', 'b'],
59+
'numeric keys with no default' => [fn () => select('Test', [1 => 'a', 2 => 'b', 3 => 'c']), [1 => 'a', 2 => 'b', 3 => 'c'], null, '2', 2],
60+
'assoc with no default' => [fn () => select('Test', ['a' => 'A', 'b' => 'B', 'c' => 'C']), ['a' => 'A', 'b' => 'B', 'c' => 'C'], null, 'b', 'b'],
61+
'list with default' => [fn () => select('Test', ['a', 'b', 'c'], 'b'), ['a', 'b', 'c'], 'b', 'b', 'b'],
62+
'numeric keys with default' => [fn () => select('Test', [1 => 'a', 2 => 'b', 3 => 'c'], 2), [1 => 'a', 2 => 'b', 3 => 'c'], 2, '2', 2],
63+
'assoc with default' => [fn () => select('Test', ['a' => 'A', 'b' => 'B', 'c' => 'C'], 'b'), ['a' => 'A', 'b' => 'B', 'c' => 'C'], 'b', 'b', 'b'],
6164
];
6265
}
6366

6467
#[DataProvider('multiselectDataProvider')]
65-
public function testMultiselectFallback($prompt, $expectedDefault, $selection, $expectedReturn)
68+
public function testMultiselectFallback($prompt, $expectedOptions, $expectedDefault, $return, $expectedReturn)
6669
{
70+
Prompt::fallbackWhen(true);
71+
6772
$command = new class($prompt) extends Command
6873
{
6974
public $answer;
@@ -81,8 +86,8 @@ public function handle()
8186

8287
$this->runCommand($command, fn ($components) => $components
8388
->expects('choice')
84-
->withArgs(fn ($question, $options, $default, $multiple) => $default === $expectedDefault && $multiple === true)
85-
->andReturnUsing(fn ($question, $options, $default, $multiple) => array_values(array_filter($options, fn ($index) => in_array($index, $selection), ARRAY_FILTER_USE_KEY)))
89+
->with('Test', $expectedOptions, $expectedDefault, null, true)
90+
->andReturn($return)
8691
);
8792

8893
$this->assertSame($expectedReturn, $command->answer);
@@ -91,18 +96,18 @@ public function handle()
9196
public static function multiselectDataProvider()
9297
{
9398
return [
94-
'list with no default' => [fn () => multiselect('foo', ['a', 'b', 'c']), '0', [2, 3], ['b', 'c']],
95-
'numeric keys with no default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c']), '0', [2, 3], [2, 3]],
96-
'assoc with no default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C']), '0', [2, 3], ['b', 'c']],
97-
'list with default' => [fn () => multiselect('foo', ['a', 'b', 'c'], ['b', 'c']), '2,3', [2, 3], ['b', 'c']],
98-
'numeric keys with default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c'], [2, 3]), '2,3', [2, 3], [2, 3]],
99-
'assoc with default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], ['b', 'c']), '2,3', [2, 3], ['b', 'c']],
100-
'required list with no default' => [fn () => multiselect('foo', ['a', 'b', 'c'], required: true), null, [1, 2], ['b', 'c']],
101-
'required numeric keys with no default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c'], required: true), null, [1, 2], [2, 3]],
102-
'required assoc with no default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], required: true), null, [1, 2], ['b', 'c']],
103-
'required list with default' => [fn () => multiselect('foo', ['a', 'b', 'c'], ['b', 'c'], required: true), '1,2', [1, 2], ['b', 'c']],
104-
'required numeric keys with default' => [fn () => multiselect('foo', [1 => 'a', 2 => 'b', 3 => 'c'], [2, 3], required: true), '1,2', [1, 2], [2, 3]],
105-
'required assoc with default' => [fn () => multiselect('foo', ['a' => 'A', 'b' => 'B', 'c' => 'C'], ['b', 'c'], required: true), '1,2', [1, 2], ['b', 'c']],
99+
'list with no default' => [fn () => multiselect('Test', ['a', 'b', 'c']), ['None', 'a', 'b', 'c'], 'None', ['None'], []],
100+
'numeric keys with no default' => [fn () => multiselect('Test', [1 => 'a', 2 => 'b', 3 => 'c']), ['' => 'None', 1 => 'a', 2 => 'b', 3 => 'c'], 'None', [''], []],
101+
'assoc with no default' => [fn () => multiselect('Test', ['a' => 'A', 'b' => 'B', 'c' => 'C']), ['' => 'None', 'a' => 'A', 'b' => 'B', 'c' => 'C'], 'None', [''], []],
102+
'list with default' => [fn () => multiselect('Test', ['a', 'b', 'c'], ['b', 'c']), ['None', 'a', 'b', 'c'], 'b,c', ['b', 'c'], ['b', 'c']],
103+
'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]],
104+
'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']],
105+
'required list with no default' => [fn () => multiselect('Test', ['a', 'b', 'c'], required: true), ['a', 'b', 'c'], null, ['b', 'c'], ['b', 'c']],
106+
'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]],
107+
'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']],
108+
'required list with default' => [fn () => multiselect('Test', ['a', 'b', 'c'], ['b', 'c'], required: true), ['a', 'b', 'c'], 'b,c', ['b', 'c'], ['b', 'c']],
109+
'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]],
110+
'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']],
106111
];
107112
}
108113

@@ -112,7 +117,7 @@ protected function runCommand($command, $expectations)
112117

113118
$application->shouldReceive('make')->withArgs(fn ($abstract) => $abstract === OutputStyle::class)->andReturn($outputStyle = m::mock(OutputStyle::class));
114119
$application->shouldReceive('make')->withArgs(fn ($abstract) => $abstract === Factory::class)->andReturn($factory = m::mock(Factory::class));
115-
$application->shouldReceive('runningUnitTests')->andReturn(true);
120+
$application->shouldReceive('runningUnitTests')->andReturn(false);
116121
$application->shouldReceive('call')->with([$command, 'handle'])->andReturnUsing(fn ($callback) => call_user_func($callback));
117122
$outputStyle->shouldReceive('newLinesWritten')->andReturn(1);
118123

tests/Integration/Console/PromptsAssertionTest.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,4 +162,38 @@ public function handle()
162162
->expectsChoice('Which names do you like?', ['John', 'Jane'], ['John', 'Jane', 'Sally', 'Jack'])
163163
->expectsOutput('You like John, Jane.');
164164
}
165+
166+
public function testAssertionForOptionalMultiselectPrompt()
167+
{
168+
$this->app[Kernel::class]->registerCommand(
169+
new class extends Command
170+
{
171+
protected $signature = 'test:multiselect';
172+
173+
public function handle()
174+
{
175+
$names = multiselect(
176+
label: 'Which names do you like?',
177+
options: ['John', 'Jane', 'Sally', 'Jack'],
178+
);
179+
180+
if (empty($names)) {
181+
$this->line('You like nobody.');
182+
} else {
183+
$this->line(sprintf('You like %s.', implode(', ', $names)));
184+
}
185+
}
186+
}
187+
);
188+
189+
$this
190+
->artisan('test:multiselect')
191+
->expectsChoice('Which names do you like?', ['John', 'Jane'], ['John', 'Jane', 'Sally', 'Jack'])
192+
->expectsOutput('You like John, Jane.');
193+
194+
$this
195+
->artisan('test:multiselect')
196+
->expectsChoice('Which names do you like?', ['None'], ['John', 'Jane', 'Sally', 'Jack'])
197+
->expectsOutput('You like nobody.');
198+
}
165199
}

0 commit comments

Comments
 (0)