├── .github └── workflows │ └── run-tests.yml ├── .gitignore ├── LICENSE ├── README.md ├── composer.json ├── src ├── Command │ ├── GeneratorWizard.php │ ├── Generators │ │ ├── GenerateWizardWizard.php │ │ └── Subwizards │ │ │ ├── MultipleChoiceOptionsSubwizard.php │ │ │ └── StepSubwizard.php │ ├── Subwizard.php │ └── Wizard.php ├── Concerns │ └── WizardCore.php ├── Contracts │ ├── RepeatsInvalidSteps.php │ ├── Step.php │ ├── ValidatesWizard.php │ ├── ValidatesWizardSteps.php │ └── Wizard.php ├── DataTransfer │ ├── Specification.php │ ├── StepSpecification.php │ └── WizardSpecification.php ├── Exception │ ├── AbortWizardException.php │ ├── InvalidClassSpecificationException.php │ ├── InvalidStepException.php │ ├── InvalidStepSpecificationException.php │ └── SubwizardException.php ├── LaravelConsoleWizardServiceProvider.php ├── Steps │ ├── BaseMultipleAnswerStep.php │ ├── BaseStep.php │ ├── ChoiceStep.php │ ├── ConfirmStep.php │ ├── MultipleAnswerTextStep.php │ ├── MultipleChoiceStep.php │ ├── OneTimeWizard.php │ ├── RepeatStep.php │ ├── TextStep.php │ └── UniqueMultipleChoiceStep.php └── Templates │ ├── StepTemplate.php │ └── WizardTemplate.php └── tests ├── LaravelConsoleWizardTestsServiceProvider.php ├── TestCase.php ├── TestWizards ├── AbortWizardTest.php ├── BaseTestWizard.php ├── InheritAnswersTestWizard.php ├── RepeatsStepsTestWizard.php ├── StepValidationTestWizard.php ├── Subwizard.php ├── SubwizardTestWizard.php ├── WizardValidationTestWizard.php └── WizardWithOneTimeSubwizard.php ├── Unit ├── GenerateWizardWizardTest.php └── WizardTest.php └── phpunit.xml /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: run-tests 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | run-tests: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | php: [8.1, '8.2'] 15 | laravel: ['10.*', '11.*'] 16 | include: 17 | - testbench: ^8.0 18 | - testbench: ^9.0 19 | exclude: 20 | - laravel: 11.* 21 | php: 8.1 22 | 23 | name: P${{ matrix.php }} - L${{ matrix.laravel }} 24 | 25 | steps: 26 | - name: Update apt 27 | run: sudo apt-get update --fix-missing 28 | 29 | - name: Checkout code 30 | uses: actions/checkout@v2 31 | 32 | - name: Setup PHP 33 | uses: shivammathur/setup-php@v2 34 | with: 35 | php-version: ${{ matrix.php }} 36 | coverage: none 37 | 38 | - name: Install dependencies 39 | run: | 40 | composer require "laravel/framework:${{ matrix.laravel }}" 41 | composer update --prefer-stable --prefer-dist --no-interaction 42 | 43 | - name: Execute tests 44 | run: vendor/bin/phpunit --configuration ./tests/phpunit.xml --bootstrap vendor/autoload.php 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | composer.lock 3 | vendor 4 | tests/.phpunit.result.cache -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Shomisha 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Laravel Console Wizard 2 | 3 | [![Latest Stable Version](https://img.shields.io/packagist/v/shomisha/laravel-console-wizard)](https://packagist.org/packages/shomisha/laravel-console-wizard) 4 | [![Software License](https://img.shields.io/badge/license-MIT-brightgreen.svg?style=flat)](LICENSE.md) 5 | 6 | This package provides a basis for creating multi-step wizards with complex input inside the console. 7 | It is best used for customising generator command output, but can be used for handling any sort of tasks. 8 | 9 | ```php 10 | new TextStep("What's your name?"), 28 | 'age' => new TextStep("How old are you?"), 29 | 'gender' => new ChoiceStep("Your gender?", ["Male", "Female"]), 30 | ]; 31 | } 32 | 33 | public function completed() 34 | { 35 | $this->line(sprintf( 36 | "This is %s and %s is %s years old.", 37 | $this->answers->get('name'), 38 | ($this->answers->get('gender') === 'Male') ? 'he' : 'she', 39 | $this->answers->get('age') 40 | )); 41 | } 42 | } 43 | ``` 44 | The example above shows a simple example of how you can create a wizard with several input prompts and then perform actions using the answers provided by the user. 45 | Running `php artisan wizard:introduction` in your console would execute the above wizard and produce the following output: 46 | 47 | ``` 48 | shomisha:laravel-console-wizard shomisha$ php artisan wizard:introduction 49 | 50 | What's your name?: 51 | > Misa 52 | 53 | How old are you?: 54 | > 25 55 | 56 | Your gender?: 57 | [0] Male 58 | [1] Female 59 | > 0 60 | 61 | This is Misa and he is 25 years old. 62 | ``` 63 | 64 | Take a look at our [wiki pages](https://github.com/shomisha/laravel-console-wizard/wiki) for more instructions and other Wizard features. 65 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shomisha/laravel-console-wizard", 3 | "description": "This package offers a framework for creating interactive console wizards within your Laravel applications.", 4 | "keywords": [ 5 | "laravel", 6 | "console", 7 | "wizard" 8 | ], 9 | "homepage": "https://github.com/shomisha/laravel-console-wizard", 10 | "type": "library", 11 | "license": "MIT", 12 | "support": { 13 | "email": "misa95kovic@gmail.com" 14 | }, 15 | "authors": [ 16 | { 17 | "name": "Miša Ković", 18 | "email": "misa95kovic@gmail.com", 19 | "homepage": "https://github.com/shomisha", 20 | "role": "Author" 21 | } 22 | ], 23 | "require": { 24 | "laravel/framework": "^10.0|^11.0", 25 | "php": "^8.1", 26 | "shomisha/stubless": "^1.4.0" 27 | }, 28 | "require-dev": { 29 | "orchestra/testbench": "^8.0|^9.0", 30 | "phpunit/phpunit": "^9.5|^10.5" 31 | }, 32 | "autoload": { 33 | "psr-4": { 34 | "Shomisha\\LaravelConsoleWizard\\": "src" 35 | } 36 | }, 37 | "autoload-dev": { 38 | "psr-4": { 39 | "Shomisha\\LaravelConsoleWizard\\Test\\": "tests" 40 | } 41 | }, 42 | "extra": { 43 | "laravel": { 44 | "providers": [ 45 | "Shomisha\\LaravelConsoleWizard\\LaravelConsoleWizardServiceProvider" 46 | ] 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Command/GeneratorWizard.php: -------------------------------------------------------------------------------- 1 | handleWizard(); 21 | } catch (AbortWizardException $e) { 22 | return $this->abortWizard($e); 23 | } 24 | 25 | return parent::handle(); 26 | } 27 | 28 | protected function initializeSteps() 29 | { 30 | $this->parentInitializeSteps(); 31 | 32 | $this->steps->prepend($this->getNameStep(), self::NAME_STEP_NAME); 33 | } 34 | 35 | abstract protected function getNameStep(): Step; 36 | 37 | abstract protected function generateTarget(): string; 38 | 39 | final protected function getNameInput() 40 | { 41 | return $this->answers->get(self::NAME_STEP_NAME); 42 | } 43 | 44 | final protected function getClassFullName(): string 45 | { 46 | return $this->qualifyClass($this->getNameInput()); 47 | } 48 | 49 | final protected function getClassShortName(): string 50 | { 51 | $name = $this->getNameInput(); 52 | $class = str_replace($this->getNamespace($name).'\\', '', $name); 53 | 54 | return $class; 55 | } 56 | 57 | final protected function getClassNamespace(): string 58 | { 59 | return $this->getNamespace($this->getClassFullName()); 60 | } 61 | 62 | final protected function buildClass($name) 63 | { 64 | return $this->generateTarget(); 65 | } 66 | 67 | final protected function getStub() 68 | { 69 | return; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Command/Generators/GenerateWizardWizard.php: -------------------------------------------------------------------------------- 1 | new TextStep("Enter the signature for your wizard"), 22 | 'description' => new TextStep("Enter the description for your wizard"), 23 | 'steps' => $this->repeat( 24 | $this->subWizard(new StepSubwizard()) 25 | )->withRepeatPrompt("Do you want to add a wizard step?", true), 26 | ]; 27 | } 28 | 29 | protected function getNameStep(): Step 30 | { 31 | return new TextStep("Enter the class name for your wizard"); 32 | } 33 | 34 | protected function generateTarget(): string 35 | { 36 | $specification = WizardSpecification::fromArray($this->answers->all()) 37 | ->setName($this->getClassShortName()) 38 | ->setNamespace($this->getClassNamespace()); 39 | 40 | return WizardTemplate::bySpecification($specification)->print(); 41 | } 42 | 43 | protected function getDefaultNamespace($rootNamespace) 44 | { 45 | return $rootNamespace . "\Console\Command"; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Command/Generators/Subwizards/MultipleChoiceOptionsSubwizard.php: -------------------------------------------------------------------------------- 1 | $this->repeat(new TextStep("Add option for multiple choice (enter 'stop' to stop)"))->untilAnswerIs('stop')->withoutLastAnswer(), 14 | ]; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Command/Generators/Subwizards/StepSubwizard.php: -------------------------------------------------------------------------------- 1 | TextStep::class, 18 | 'Multiple answer text step' => MultipleAnswerTextStep::class, 19 | 'Choice step' => ChoiceStep::class, 20 | 'Multiple choice step' => MultipleChoiceStep::class, 21 | 'Unique multiple choice step' => UniqueMultipleChoiceStep::class, 22 | 'Confirm step' => ConfirmStep::class, 23 | ]; 24 | 25 | private array $stepSubwizards = [ 26 | ChoiceStep::class => MultipleChoiceOptionsSubwizard::class, 27 | MultipleChoiceStep::class => MultipleChoiceOptionsSubwizard::class, 28 | UniqueMultipleChoiceStep::class => MultipleChoiceOptionsSubwizard::class, 29 | ]; 30 | 31 | function getSteps(): array 32 | { 33 | return [ 34 | 'name' => new TextStep("Enter step name"), 35 | 'question' => new TextStep("Enter step question"), 36 | 'type' => new ChoiceStep("Choose step type", array_keys($this->stepTypes)), 37 | 'has_taking_modifier' => new ConfirmStep("Do you want a 'taking' modifier method for this step?"), 38 | 'has_answered_modifier' => new ConfirmStep("Do you want an 'answered' modifier method for this step?"), 39 | ]; 40 | } 41 | 42 | public function answeredType(Step $step, string $type) 43 | { 44 | $type = $this->stepTypes[$type]; 45 | 46 | if ($followUp = $this->guessFollowUp($type)) { 47 | $this->followUp( 48 | 'step-data', 49 | $this->subWizard($followUp) 50 | ); 51 | } 52 | 53 | return $type; 54 | } 55 | 56 | private function guessFollowUp(string $type): ?Subwizard 57 | { 58 | $followUpClass = $this->stepSubwizards[$type] ?? null; 59 | 60 | if ($followUpClass) { 61 | return new $followUpClass; 62 | } 63 | 64 | return null; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Command/Subwizard.php: -------------------------------------------------------------------------------- 1 | handleWizard(); 23 | } catch (AbortWizardException $e) { 24 | return $this->abortWizard($e); 25 | } 26 | 27 | $this->completed(); 28 | } 29 | 30 | abstract function getSteps(): array; 31 | 32 | abstract function completed(); 33 | } 34 | -------------------------------------------------------------------------------- /src/Concerns/WizardCore.php: -------------------------------------------------------------------------------- 1 | assertStepsAreValid($steps = $this->getSteps()); 36 | 37 | $this->steps = collect($steps); 38 | 39 | $this->taken = collect([]); 40 | $this->followup = collect([]); 41 | $this->skipped = collect([]); 42 | } 43 | 44 | final public function take(Wizard $wizard) 45 | { 46 | while ($this->steps->isNotEmpty()) { 47 | [$name, $step] = $this->getNextStep(); 48 | 49 | $this->taking($step, $name); 50 | 51 | $answer = $this->getStepAnswer($name, $step); 52 | 53 | if ($this->shouldValidateStep($name)) { 54 | try { 55 | $this->validateStep($name, $answer); 56 | } catch (ValidationException $e) { 57 | $this->handleInvalidAnswer($name, $step, $answer, $e); 58 | } 59 | } 60 | 61 | $this->answered($step, $name, $answer); 62 | } 63 | 64 | return $this->answers->toArray(); 65 | } 66 | 67 | final public function initializeWizard() 68 | { 69 | $this->initializeSteps(); 70 | $this->initializeAnswers(); 71 | } 72 | 73 | final protected function handleWizard() 74 | { 75 | $this->initializeWizard(); 76 | 77 | $this->take($this); 78 | 79 | if ($this->shouldValidateWizard()) { 80 | try { 81 | $this->validateWizard(); 82 | } catch (ValidationException $e) { 83 | $this->onWizardInvalid($e->errors()); 84 | } 85 | } 86 | } 87 | 88 | final protected function subWizard(Wizard $wizard) 89 | { 90 | $wizard->output = $this->output; 91 | $wizard->input = $this->input; 92 | 93 | $wizard->initializeWizard(); 94 | 95 | return $wizard; 96 | } 97 | 98 | final protected function repeat(Step $step) 99 | { 100 | return new RepeatStep($step); 101 | } 102 | 103 | final protected function followUp(string $name, Step $step) 104 | { 105 | $this->followup->put($name, $step); 106 | 107 | return $this; 108 | } 109 | 110 | final protected function repeatStep(string $name): ?Step 111 | { 112 | $step = $this->findStep($name); 113 | 114 | if ($step !== null) { 115 | $this->followUp($name, $step); 116 | } 117 | 118 | return $step; 119 | } 120 | 121 | final protected function skip(string $name) 122 | { 123 | $step = $this->steps->pull($name); 124 | 125 | if ($step !== null) { 126 | $this->skipped->put($name, $step); 127 | } 128 | } 129 | 130 | final protected function abort(string $message = null) 131 | { 132 | throw new AbortWizardException($message); 133 | } 134 | 135 | final protected function assertStepsAreValid(array $steps) 136 | { 137 | foreach ($steps as $step) { 138 | if (! ($step instanceof Step)) { 139 | $message = sprintf( 140 | "%s does not implement the %s interface", 141 | get_class($step), 142 | Step::class 143 | ); 144 | throw new InvalidStepException($message); 145 | } 146 | } 147 | } 148 | 149 | private function abortWizard(AbortWizardException $e): void 150 | { 151 | if ($message = $e->getUserMessage()) { 152 | $this->error($message); 153 | } 154 | } 155 | 156 | private function initializeAnswers() 157 | { 158 | $this->answers = collect([]); 159 | } 160 | 161 | private function getNextStep(): array 162 | { 163 | return [ 164 | $this->steps->keys()->first(), 165 | $this->steps->shift(), 166 | ]; 167 | } 168 | 169 | private function taking(Step $step, string $name) 170 | { 171 | if ($this->hasTakingModifier($name)) { 172 | $this->{$this->guessTakingModifier($name)}($step); 173 | } 174 | } 175 | 176 | private function hasTakingModifier(string $name) 177 | { 178 | return method_exists($this, $this->guessTakingModifier($name)); 179 | } 180 | 181 | private function guessTakingModifier(string $name) 182 | { 183 | return sprintf('taking%s', Str::studly($name)); 184 | } 185 | 186 | private function getStepAnswer(string $name, Step $step) 187 | { 188 | if ($this->inheritAnswersFromArguments) { 189 | if ($answer = $this->arguments()[$name] ?? null) { 190 | return $answer; 191 | } 192 | 193 | if ($answer = $this->options()[$name] ?? null) { 194 | return $answer; 195 | } 196 | } 197 | 198 | return $step->take($this); 199 | } 200 | 201 | private function answered(Step $step, string $name, $answer) 202 | { 203 | if ($this->hasAnsweredModifier($name)) { 204 | $answer = $this->{$this->guessAnsweredModifier($name)}($step, $answer); 205 | } 206 | 207 | $this->addAnswer($name, $answer); 208 | 209 | $this->moveStepToTaken($name, $step); 210 | 211 | $this->flushFollowups(); 212 | } 213 | 214 | private function hasAnsweredModifier(string $name) 215 | { 216 | return method_exists($this, $this->guessAnsweredModifier($name)); 217 | } 218 | 219 | private function guessAnsweredModifier(string $name) 220 | { 221 | return sprintf('answered%s', Str::studly($name)); 222 | } 223 | 224 | private function addAnswer(string $name, $answer) 225 | { 226 | $this->answers->put($name, $answer); 227 | } 228 | 229 | private function moveStepToTaken(string $name, Step $step) 230 | { 231 | $this->taken->put($name, $step); 232 | } 233 | 234 | private function flushFollowups() 235 | { 236 | $this->steps = collect(array_merge( 237 | $this->followup->reverse()->toArray(), $this->steps->toArray() 238 | )); 239 | 240 | $this->followup = collect([]); 241 | } 242 | 243 | private function shouldValidateWizard() 244 | { 245 | return $this instanceof ValidatesWizard; 246 | } 247 | 248 | private function validateWizard() 249 | { 250 | return $this->validate($this->answers->toArray(), $this->getRules()); 251 | } 252 | 253 | private function shouldValidateStep(string $name) 254 | { 255 | return $this instanceof ValidatesWizardSteps 256 | && array_key_exists($name, $this->getRules()); 257 | } 258 | 259 | private function validateStep(string $name, $answer) 260 | { 261 | return $this->validate( 262 | [$name => $answer], 263 | [$name => $this->getRules()[$name]] 264 | ); 265 | } 266 | 267 | private function handleInvalidAnswer(string $name, Step $step, $answer, ValidationException $e): void 268 | { 269 | if ($this->hasFailedValidationHandler($name)) { 270 | $this->runFailedValidationHandler($name, $e, $answer); 271 | 272 | return; 273 | } elseif ($this->shouldRepeatInvalidSteps()) { 274 | $this->error($e->errors()[$name][0]); 275 | 276 | $this->followUp($name, $step); 277 | 278 | return; 279 | } 280 | 281 | throw $e; 282 | } 283 | 284 | private function validate(array $data, array $rules) 285 | { 286 | return Validator::make($data, $rules)->validate(); 287 | } 288 | 289 | private function shouldRepeatInvalidSteps(): bool 290 | { 291 | return $this instanceof RepeatsInvalidSteps; 292 | } 293 | 294 | private function hasFailedValidationHandler(string $name) 295 | { 296 | return method_exists($this, $this->guessValidationFailedHandlerName($name)); 297 | } 298 | 299 | private function runFailedValidationHandler(string $name, ValidationException $exception, $answer): void 300 | { 301 | $this->{$this->guessValidationFailedHandlerName($name)}($answer, $exception->errors()[$name]); 302 | } 303 | 304 | private function guessValidationFailedHandlerName(string $name) 305 | { 306 | return sprintf("onInvalid%s", Str::studly($name)); 307 | } 308 | 309 | private function findStep(string $name): ?Step 310 | { 311 | $step = $this->taken->get($name); 312 | 313 | if ($step === null) { 314 | $step = $this->skipped->get($name); 315 | } 316 | 317 | return $step; 318 | } 319 | 320 | public function refill() 321 | { 322 | $this->initializeWizard(); 323 | } 324 | } 325 | -------------------------------------------------------------------------------- /src/Contracts/RepeatsInvalidSteps.php: -------------------------------------------------------------------------------- 1 | assertSpecificationIsValid($specification); 14 | 15 | $this->specification = $specification; 16 | } 17 | 18 | public static function fromArray(array $specification): self 19 | { 20 | return new static($specification); 21 | } 22 | 23 | abstract protected function assertSpecificationIsValid(array $specification); 24 | 25 | protected function extract(string $key) 26 | { 27 | return Arr::get($this->specification, $key); 28 | } 29 | 30 | protected function place(string $key, $value): self 31 | { 32 | $this->specification[$key] = $value; 33 | 34 | return $this; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/DataTransfer/StepSpecification.php: -------------------------------------------------------------------------------- 1 | extract(self::KEY_NAME); 28 | } 29 | 30 | public function getType(): string 31 | { 32 | return $this->extract(self::KEY_TYPE); 33 | } 34 | 35 | public function getQuestion(): string 36 | { 37 | return $this->extract(self::KEY_QUESTION); 38 | } 39 | 40 | public function hasOptions(): bool 41 | { 42 | if (empty($this->getOptions())) { 43 | return false; 44 | } 45 | 46 | return $this->stepShouldHaveOptions(); 47 | } 48 | 49 | public function stepShouldHaveOptions(): bool 50 | { 51 | return in_array($this->getType(), self::STEPS_WITH_OPTIONS); 52 | } 53 | 54 | public function getOptions(): ?array 55 | { 56 | return $this->extract(self::KEY_OPTIONS); 57 | } 58 | 59 | public function hasTakingModifier(): bool 60 | { 61 | return $this->extract(self::KEY_TAKING_MODIFIER); 62 | } 63 | 64 | public function hasAnsweredModifier(): bool 65 | { 66 | return $this->extract(self::KEY_ANSWERED_MODIFIER); 67 | } 68 | 69 | protected function assertSpecificationIsValid(array $specification): void 70 | { 71 | if (!array_key_exists(self::KEY_NAME, $specification)) { 72 | throw InvalidStepSpecificationException::missingName(); 73 | } 74 | 75 | if (!array_key_exists(self::KEY_TYPE, $specification)) { 76 | throw InvalidStepSpecificationException::missingType(); 77 | } 78 | 79 | if (!array_key_exists(self::KEY_QUESTION, $specification)) { 80 | throw InvalidStepSpecificationException::missingQuestion(); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/DataTransfer/WizardSpecification.php: -------------------------------------------------------------------------------- 1 | initializeSteps($this->extract('steps')); 22 | } 23 | 24 | public function getName(): string 25 | { 26 | return $this->extract(self::KEY_CLASS_NAME); 27 | } 28 | 29 | public function setName(string $name): self 30 | { 31 | return $this->place(self::KEY_CLASS_NAME, $name); 32 | } 33 | 34 | public function getSignature(): string 35 | { 36 | return $this->extract(self::KEY_SIGNATURE); 37 | } 38 | 39 | public function getDescription(): ?string 40 | { 41 | return $this->extract(self::KEY_DESCRIPTION); 42 | } 43 | 44 | /** @return \Shomisha\LaravelConsoleWizard\DataTransfer\StepSpecification[] */ 45 | public function getSteps(): array 46 | { 47 | return $this->stepSpecifications; 48 | } 49 | 50 | public function getNamespace(): ?string 51 | { 52 | return $this->extract(self::KEY_NAMESPACE); 53 | } 54 | 55 | public function setNamespace(?string $namespace): self 56 | { 57 | return $this->place(self::KEY_NAMESPACE, $namespace); 58 | } 59 | 60 | protected function assertSpecificationIsValid(array $specification): void 61 | { 62 | if (!array_key_exists(self::KEY_CLASS_NAME, $specification)) { 63 | InvalidClassSpecificationException::missingName(); 64 | } 65 | 66 | if (!array_key_exists(self::KEY_SIGNATURE, $specification)) { 67 | InvalidClassSpecificationException::missingSignature(); 68 | } 69 | } 70 | 71 | private function initializeSteps(array $steps): void 72 | { 73 | $this->stepSpecifications = collect($steps)->mapWithKeys(function (array $step) { 74 | $specification = StepSpecification::fromArray($step); 75 | 76 | return [ 77 | $specification->getName() => $specification 78 | ]; 79 | })->toArray(); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Exception/AbortWizardException.php: -------------------------------------------------------------------------------- 1 | userMessage = $userMessage; 18 | } 19 | 20 | public function getUserMessage() 21 | { 22 | return $this->userMessage; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Exception/InvalidClassSpecificationException.php: -------------------------------------------------------------------------------- 1 | commands([ 13 | GenerateWizardWizard::class, 14 | ]); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Steps/BaseMultipleAnswerStep.php: -------------------------------------------------------------------------------- 1 | endKeyword = $options['end_keyword'] ?? 'Done'; 20 | $this->retainEndKeywordInAnswers = $options['retain_end_keyword'] ?? false; 21 | $this->maxRepetitions = $options['max_repetitions'] ?? null; 22 | } 23 | 24 | protected function loop(callable $callback) 25 | { 26 | $answers = []; 27 | 28 | do { 29 | $newAnswer = $callback(); 30 | 31 | $answers[] = $newAnswer; 32 | 33 | $this->incrementRepetitions(); 34 | } while ($this->shouldKeepLooping($newAnswer)); 35 | 36 | return $answers; 37 | } 38 | 39 | protected function shouldKeepLooping($answer) 40 | { 41 | return strtolower($answer) !== strtolower($this->endKeyword) && !$this->hasExceededMaxRepetitions(); 42 | } 43 | 44 | protected function incrementRepetitions() 45 | { 46 | $this->repetitions++; 47 | 48 | return $this; 49 | } 50 | 51 | protected function hasExceededMaxRepetitions() 52 | { 53 | return $this->maxRepetitions !== null && $this->repetitions >= $this->maxRepetitions; 54 | } 55 | 56 | protected function shouldRemoveEndKeyword(array $answers) 57 | { 58 | return !$this->retainEndKeywordInAnswers && strtolower(last($answers)) === strtolower($this->endKeyword); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Steps/BaseStep.php: -------------------------------------------------------------------------------- 1 | text = $text; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Steps/ChoiceStep.php: -------------------------------------------------------------------------------- 1 | options = $options; 16 | } 17 | 18 | final public function take(Wizard $wizard) 19 | { 20 | return $wizard->choice($this->text, $this->options); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Steps/ConfirmStep.php: -------------------------------------------------------------------------------- 1 | defaultAnswer = $defaultAnswer; 16 | } 17 | 18 | public function take(Wizard $wizard) 19 | { 20 | return $wizard->confirm($this->text, $this->getDefaultAnswer()); 21 | } 22 | 23 | private function getDefaultAnswer(): bool 24 | { 25 | if (is_callable($this->defaultAnswer)) { 26 | return (bool) ($this->defaultAnswer)(); 27 | } 28 | 29 | return (bool) $this->defaultAnswer; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Steps/MultipleAnswerTextStep.php: -------------------------------------------------------------------------------- 1 | line($this->text); 12 | 13 | $answers = $this->loop(function () use ($wizard) { 14 | return readline(); 15 | }); 16 | 17 | if ($this->shouldRemoveEndKeyword($answers)) { 18 | array_pop($answers); 19 | } 20 | 21 | return $answers; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Steps/MultipleChoiceStep.php: -------------------------------------------------------------------------------- 1 | choices = $choices; 16 | } 17 | 18 | final public function take(Wizard $wizard) 19 | { 20 | $options = array_merge($this->choices, [$this->endKeyword]); 21 | $answers = $this->loop(function () use ($wizard, $options) { 22 | return $wizard->choice($this->text, $options); 23 | }); 24 | 25 | if ($this->shouldRemoveEndKeyword($answers)) { 26 | array_pop($answers); 27 | } 28 | 29 | return $answers; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Steps/OneTimeWizard.php: -------------------------------------------------------------------------------- 1 | assertStepsAreValid($steps); 16 | 17 | $this->multiValueSteps = $steps; 18 | } 19 | 20 | function getSteps(): array 21 | { 22 | return $this->multiValueSteps; 23 | } 24 | 25 | function completed() 26 | { 27 | throw new \RuntimeException('One time wizard cannot reach the completed method.'); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Steps/RepeatStep.php: -------------------------------------------------------------------------------- 1 | step = $step; 24 | } 25 | 26 | public function take(Wizard $wizard) 27 | { 28 | if ($this->callback === null) { 29 | throw new InvalidStepException( 30 | "The RepeatStep has not been properly initialized. Please call either RepeatStep::times() or RepeatStep::until() to initialize it." 31 | ); 32 | } 33 | 34 | $this->wizard = $wizard; 35 | 36 | $answers = []; 37 | $answer = null; 38 | 39 | while (call_user_func($this->callback, $answer)) { 40 | $answer = $this->step->take($this->wizard); 41 | 42 | $answers[] = $answer; 43 | 44 | $this->counter++; 45 | 46 | if ($this->shouldRefillStep()) { 47 | $this->refillStep(); 48 | } 49 | } 50 | 51 | if ($this->excludeLast) { 52 | array_pop($answers); 53 | } 54 | 55 | return $answers; 56 | } 57 | 58 | public function times(int $times) 59 | { 60 | return $this->until(function () use ($times) { 61 | return $this->counter == $times; 62 | }); 63 | } 64 | 65 | public function untilAnswerIs($answer, int $maxRepetitions = null) 66 | { 67 | return $this->until(function ($actualAnswer) use ($answer) { 68 | if ($this->isFirstRun()) { 69 | return false; 70 | } 71 | 72 | return $actualAnswer === $answer; 73 | }, $maxRepetitions); 74 | } 75 | 76 | public function withRepeatPrompt(string $question, bool $askOnFirstRun = false, bool $defaultAnswer = false) 77 | { 78 | return $this->until(function ($answer) use ($question, $askOnFirstRun, $defaultAnswer) { 79 | if ($this->isFirstRun() && !$askOnFirstRun) { 80 | return false; 81 | } 82 | 83 | return !(new ConfirmStep($question, $defaultAnswer))->take($this->wizard); 84 | }); 85 | } 86 | 87 | public function until(callable $callback, int $maxRepetitions = null) 88 | { 89 | $this->callback = function ($answer) use ($callback, $maxRepetitions) { 90 | if ($callback($answer)) { 91 | return false; 92 | } 93 | 94 | if ($this->hasExceededMaxRepetitions($maxRepetitions)) { 95 | return false; 96 | } 97 | 98 | return true; 99 | }; 100 | 101 | return $this; 102 | } 103 | 104 | public function withLastAnswer() 105 | { 106 | return $this->setExcludeLast(false); 107 | } 108 | 109 | public function withoutLastAnswer() 110 | { 111 | return $this->setExcludeLast(true); 112 | } 113 | 114 | public function setExcludeLast(bool $excludeLast) 115 | { 116 | $this->excludeLast = $excludeLast; 117 | 118 | return $this; 119 | } 120 | 121 | private function hasExceededMaxRepetitions($maxRepetitions) 122 | { 123 | return $maxRepetitions !== null && $this->counter >= $maxRepetitions; 124 | } 125 | 126 | private function shouldRefillStep() 127 | { 128 | return $this->step instanceof Wizard; 129 | } 130 | 131 | private function refillStep() 132 | { 133 | return $this->step->refill(); 134 | } 135 | 136 | private function isFirstRun() 137 | { 138 | return $this->counter === 0; 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /src/Steps/TextStep.php: -------------------------------------------------------------------------------- 1 | ask($this->text); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Steps/UniqueMultipleChoiceStep.php: -------------------------------------------------------------------------------- 1 | choices = $choices; 16 | } 17 | 18 | final public function take(Wizard $wizard) 19 | { 20 | $options = array_merge($this->choices, [$this->endKeyword]); 21 | $answers = $this->loop(function () use ($wizard, &$options) { 22 | $newAnswer = $wizard->choice($this->text, $options); 23 | 24 | $this->removeChoiceFromOptions($newAnswer, $options); 25 | 26 | return $newAnswer; 27 | }); 28 | 29 | if ($this->shouldRemoveEndKeyword($answers)) { 30 | array_pop($answers); 31 | } 32 | 33 | return $answers; 34 | } 35 | 36 | final protected function removeChoiceFromOptions($choice, &$options) 37 | { 38 | unset($options[array_search($choice, $options)]); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Templates/StepTemplate.php: -------------------------------------------------------------------------------- 1 | getType()); 15 | 16 | $arguments = [ 17 | Value::string($specification->getQuestion()), 18 | ]; 19 | 20 | if ($specification->hasOptions()) { 21 | $arguments[] = Value::array($specification->getOptions()); 22 | } 23 | 24 | return parent::__construct($class, $arguments); 25 | } 26 | 27 | public static function bySpecification(StepSpecification $specification): self 28 | { 29 | return new self($specification); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Templates/WizardTemplate.php: -------------------------------------------------------------------------------- 1 | getName(); 24 | 25 | parent::__construct($name); 26 | 27 | $this->initialize($specification); 28 | } 29 | 30 | public static function bySpecification(WizardSpecification $specification): self 31 | { 32 | return new self($specification); 33 | } 34 | 35 | private function initialize(WizardSpecification $specification): void 36 | { 37 | $this->setNamespace($specification->getNamespace()); 38 | 39 | $this->extends(new Importable(Wizard::class)); 40 | 41 | $this->addProperty( 42 | ClassProperty::name('signature')->value($specification->getSignature())->makeProtected() 43 | ); 44 | 45 | if ($description = $specification->getDescription()) { 46 | $this->addProperty( 47 | ClassProperty::name('description')->value($description)->makeProtected() 48 | ); 49 | } 50 | 51 | $this->initializeSteps($specification); 52 | 53 | $this->addMethod( 54 | $this->getCompletedMethod() 55 | ); 56 | } 57 | 58 | private function initializeSteps(WizardSpecification $specification): void 59 | { 60 | $this->addMethod( 61 | $method = ClassMethod::name('getSteps')->return('array') 62 | ); 63 | 64 | $steps = array_map(function (StepSpecification $stepSpecification) { 65 | $stepTemplate = StepTemplate::bySpecification($stepSpecification); 66 | 67 | if ($stepSpecification->hasTakingModifier()) { 68 | $this->addMethod( 69 | $this->createTakingModifier($stepSpecification) 70 | ); 71 | } 72 | 73 | if ($stepSpecification->hasAnsweredModifier()) { 74 | $this->addMethod( 75 | $this->createAnsweredModifier($stepSpecification) 76 | ); 77 | } 78 | 79 | return $stepTemplate; 80 | }, $specification->getSteps()); 81 | 82 | $method->body( 83 | Block::return(Value::array($steps)) 84 | ); 85 | } 86 | 87 | private function createTakingModifier(StepSpecification $specification): ClassMethod 88 | { 89 | $stepName = "taking" . Str::studly($specification->getName()); 90 | 91 | return ClassMethod::name($stepName)->addArgument( 92 | Argument::name('step')->type(new Importable(Step::class)) 93 | ); 94 | } 95 | 96 | private function createAnsweredModifier(StepSpecification $specification): ClassMethod 97 | { 98 | $stepName = "answered" . Str::studly($specification->getName()); 99 | $argumentName = Str::camel($specification->getName()); 100 | 101 | return ClassMethod::name($stepName)->withArguments([ 102 | Argument::name('step')->type(new Importable(Step::class)), 103 | Argument::name($argumentName) 104 | ])->body( 105 | Block::return(Reference::variable($argumentName)) 106 | ); 107 | } 108 | 109 | private function getCompletedMethod(): ClassMethod 110 | { 111 | return ClassMethod::name('completed')->body( 112 | Block::return( 113 | Block::invokeMethod( 114 | Reference::objectProperty(Reference::this(), 'answers'), 115 | 'all' 116 | ) 117 | ) 118 | ); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /tests/LaravelConsoleWizardTestsServiceProvider.php: -------------------------------------------------------------------------------- 1 | commands( 26 | BaseTestWizard::class, 27 | SubwizardTestWizard::class, 28 | StepValidationTestWizard::class, 29 | WizardValidationTestWizard::class, 30 | WizardWithOneTimeSubwizard::class, 31 | GenerateWizardWizard::class, 32 | RepeatsStepsTestWizard::class, 33 | InheritAnswersTestWizard::class, 34 | AbortWizardTest::class, 35 | ); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | new ConfirmStep('Should I abort?'), 18 | 'why' => new TextStep("Why didn't you abort?"), 19 | ]; 20 | } 21 | 22 | public function answeredAbort(Step $step, bool $abort) 23 | { 24 | if ($abort) { 25 | $this->abort("I am aborting"); 26 | } 27 | 28 | return $abort; 29 | } 30 | 31 | function completed() 32 | { 33 | 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tests/TestWizards/BaseTestWizard.php: -------------------------------------------------------------------------------- 1 | new TextStep("What's your name?"), 18 | 'age' => new TextStep("How old are you?"), 19 | 'preferred-language' => new ChoiceStep( 20 | 'Your favourite programming language', 21 | [ 22 | 'PHP', 23 | 'JavaScript', 24 | 'Python', 25 | 'Java', 26 | 'C#', 27 | 'Go', 28 | ] 29 | ), 30 | ]; 31 | } 32 | 33 | public function askingUnskippable() 34 | { 35 | $this->skip('unskippable'); 36 | } 37 | 38 | public function answeredUnskippable() 39 | { 40 | $this->skip('skip-me'); 41 | } 42 | 43 | public function takingRunAnother() 44 | { 45 | $this->followup('followup', new TextStep("I am a followup.")); 46 | } 47 | 48 | public function answeredRepeatAfterMe(Step $step, $shouldRepeat) 49 | { 50 | $this->repeatStep('repeat_me'); 51 | } 52 | 53 | public function takingMainStep() 54 | { 55 | $this->followup('pre-main-step', new TextStep('I am added before the main step')); 56 | } 57 | 58 | public function answeredMainStep() 59 | { 60 | $this->followup('post-main-step', new TextStep('I am added after the main step')); 61 | } 62 | 63 | public function takingName() 64 | { 65 | } 66 | 67 | public function takingAge() 68 | { 69 | 70 | } 71 | 72 | public function answeredAge(Step $question, $answer) 73 | { 74 | return $answer; 75 | } 76 | 77 | public function answeredPreferredLanguage(Step $question, $answer) 78 | { 79 | return $answer; 80 | } 81 | 82 | public function getAnswers() 83 | { 84 | return $this->answers; 85 | } 86 | 87 | function completed() 88 | { 89 | return $this->answers; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /tests/TestWizards/InheritAnswersTestWizard.php: -------------------------------------------------------------------------------- 1 | new TextStep('Name'), 18 | 'age' => new TextStep('Age'), 19 | ]; 20 | } 21 | 22 | function completed() 23 | { 24 | $this->info(sprintf( 25 | "%s is %s year(s) old", 26 | $this->answers->get('name'), 27 | $this->answers->get('age') 28 | )); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/TestWizards/RepeatsStepsTestWizard.php: -------------------------------------------------------------------------------- 1 | new TextStep("Enter age"), 17 | "birth_year" => new TextStep("Enter birth year"), 18 | ]; 19 | } 20 | 21 | public function getRules(): array 22 | { 23 | return [ 24 | "age" => ["integer", "min:10", "max:20"], 25 | "birth_year" => ["integer", "min:1990", "max:2000"], 26 | ]; 27 | } 28 | 29 | public function onInvalidBirthYear($step, $answer) 30 | { 31 | $this->error("Wrong answer, nimrod"); 32 | } 33 | 34 | function completed() 35 | { 36 | $this->info("Done"); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/TestWizards/StepValidationTestWizard.php: -------------------------------------------------------------------------------- 1 | ['integer', 'min:18'], 17 | 'favourite_colour' => ['string', 'in:red,green,blue'], 18 | ]; 19 | } 20 | 21 | function getSteps(): array 22 | { 23 | return [ 24 | 'name' => new TextStep("What is your name?"), 25 | 'age' => new TextStep("How old are you?"), 26 | 'favourite_colour' => new TextStep("What is your favourite colour?"), 27 | ]; 28 | } 29 | 30 | public function onInvalidAge($answer, $errors) 31 | { 32 | $this->abort("The age you entered is invalid."); 33 | } 34 | 35 | function completed() 36 | { 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/TestWizards/Subwizard.php: -------------------------------------------------------------------------------- 1 | new ConfirmStep("Are you older than 18?"), 15 | 'marital-legality' => new TextStep("Your marital status:"), 16 | ]; 17 | } 18 | 19 | function completed() 20 | { 21 | } 22 | } -------------------------------------------------------------------------------- /tests/TestWizards/SubwizardTestWizard.php: -------------------------------------------------------------------------------- 1 | new TextStep("What's your name?"), 16 | 'legal-status' => $this->subWizard(new Subwizard()), 17 | ]; 18 | } 19 | 20 | function completed() 21 | { 22 | } 23 | } -------------------------------------------------------------------------------- /tests/TestWizards/WizardValidationTestWizard.php: -------------------------------------------------------------------------------- 1 | ['string', 'in:Kings of Leon,Milo Greene'], 17 | 'country' => ['string', 'in:Serbia,England,Croatia,France'], 18 | ]; 19 | } 20 | 21 | public function onWizardInvalid(array $errors) 22 | { 23 | $this->abort("Your wizard is invalid"); 24 | } 25 | 26 | function getSteps(): array 27 | { 28 | return [ 29 | 'name' => new TextStep('What is your name?'), 30 | 'favourite_band' => new TextStep('What is your favourite band?'), 31 | 'country' => new TextStep('Which country do you come from?'), 32 | ]; 33 | } 34 | 35 | function completed() 36 | { 37 | 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /tests/TestWizards/WizardWithOneTimeSubwizard.php: -------------------------------------------------------------------------------- 1 | $this->subWizard(new OneTimeWizard([ 17 | 'first-question' => new TextStep('Answer the first step'), 18 | 'second-question' => new TextStep('Answer the second step'), 19 | ])), 20 | ]; 21 | } 22 | 23 | function completed() 24 | {} 25 | } 26 | -------------------------------------------------------------------------------- /tests/Unit/GenerateWizardWizardTest.php: -------------------------------------------------------------------------------- 1 | disk = Storage::build([ 23 | 'driver' => 'local', 24 | 'root' => $this->path(), 25 | ]); 26 | } 27 | 28 | protected function tearDown(): void 29 | { 30 | $this->app['files']->deleteDirectory($this->path()); 31 | 32 | parent::tearDown(); 33 | } 34 | 35 | /** @test */ 36 | public function command_can_generate_wizard() 37 | { 38 | $this->artisan('wizard:generate') 39 | ->expectsQuestion("Enter the class name for your wizard", 'TestWizard') 40 | ->expectsQuestion("Enter the signature for your wizard", "wizard:test") 41 | ->expectsQuestion('Enter the description for your wizard', 'This is a test wizard.') 42 | ->expectsConfirmation("Do you want to add a wizard step?", 'no'); 43 | 44 | 45 | $generatedWizard = $this->disk->get('Console/Command/TestWizard.php'); 46 | $this->assertIsString($generatedWizard); 47 | 48 | $this->assertStringContainsString('namespace App\Console\Command;', $generatedWizard); 49 | $this->assertStringContainsString('use Shomisha\LaravelConsoleWizard\Command\Wizard;', $generatedWizard); 50 | $this->assertStringContainsString('class TestWizard extends Wizard', $generatedWizard); 51 | 52 | $this->assertStringContainsString("protected \$signature = 'wizard:test';", $generatedWizard); 53 | $this->assertStringContainsString("protected \$description = 'This is a test wizard.';", $generatedWizard); 54 | 55 | $this->assertStringContainsString("public function getSteps() : array\n {\n return array();\n }\n", $generatedWizard); 56 | $this->assertStringContainsString("public function completed()\n {\n return \$this->answers->all();\n }", $generatedWizard); 57 | } 58 | 59 | /** @test */ 60 | public function command_can_generate_wizard_with_steps() 61 | { 62 | $stepTypeChoices = [ 63 | 0 => 'Text step', 64 | 1 => 'Multiple answer text step', 65 | 2 => 'Choice step', 66 | 3 => 'Multiple choice step', 67 | 4 => 'Unique multiple choice step', 68 | 5 => 'Confirm step', 69 | ]; 70 | 71 | 72 | $this->artisan('wizard:generate') 73 | ->expectsQuestion('Enter the class name for your wizard', 'TestWizardWithSteps') 74 | ->expectsQuestion('Enter the signature for your wizard', 'wizard:test-with-steps') 75 | ->expectsQuestion('Enter the description for your wizard', 'This is a test wizard with steps.') 76 | 77 | ->expectsConfirmation('Do you want to add a wizard step?', 'yes') 78 | ->expectsQuestion('Enter step name', 'first-step') 79 | ->expectsQuestion('Enter step question', 'First question') 80 | ->expectsChoice('Choose step type', 'Text step', $stepTypeChoices) 81 | ->expectsConfirmation("Do you want a 'taking' modifier method for this step?", 'yes') 82 | ->expectsConfirmation("Do you want an 'answered' modifier method for this step?", 'no') 83 | 84 | ->expectsConfirmation('Do you want to add a wizard step?', 'yes') 85 | ->expectsQuestion('Enter step name', 'second-step') 86 | ->expectsQuestion('Enter step question', 'Second question') 87 | ->expectsChoice('Choose step type', 'Choice step', $stepTypeChoices) 88 | ->expectsQuestion("Add option for multiple choice (enter 'stop' to stop)", 'First option') 89 | ->expectsQuestion("Add option for multiple choice (enter 'stop' to stop)", 'Second option') 90 | ->expectsQuestion("Add option for multiple choice (enter 'stop' to stop)", 'Third option') 91 | ->expectsQuestion("Add option for multiple choice (enter 'stop' to stop)", 'stop') 92 | ->expectsConfirmation("Do you want a 'taking' modifier method for this step?", 'yes') 93 | ->expectsConfirmation("Do you want an 'answered' modifier method for this step?", 'yes') 94 | 95 | ->expectsConfirmation('Do you want to add a wizard step?', 'no'); 96 | 97 | 98 | $generatedWizard = $this->disk->get('Console/Command/TestWizardWithSteps.php'); 99 | $this->assertIsString($generatedWizard); 100 | 101 | $this->assertStringContainsString('namespace App\Console\Command;', $generatedWizard); 102 | 103 | $this->assertStringContainsString('use Shomisha\LaravelConsoleWizard\Command\Wizard;', $generatedWizard); 104 | $this->assertStringContainsString('use Shomisha\LaravelConsoleWizard\Steps\TextStep', $generatedWizard); 105 | $this->assertStringContainsString('use Shomisha\LaravelConsoleWizard\Steps\ChoiceStep', $generatedWizard); 106 | 107 | $this->assertStringContainsString('class TestWizardWithSteps extends Wizard', $generatedWizard); 108 | 109 | $this->assertStringContainsString("protected \$signature = 'wizard:test-with-steps';", $generatedWizard); 110 | $this->assertStringContainsString("protected \$description = 'This is a test wizard with steps.';", $generatedWizard); 111 | 112 | $this->assertStringContainsString( 113 | "public function getSteps() : array\n {\n return array('first-step' => new TextStep('First question'), 'second-step' => new ChoiceStep('Second question', array('First option', 'Second option', 'Third option')));\n }\n", 114 | $generatedWizard 115 | ); 116 | $this->assertStringContainsString("public function takingFirstStep(Step \$step)\n {\n }", $generatedWizard); 117 | $this->assertStringContainsString("public function takingSecondStep(Step \$step)\n {\n }", $generatedWizard); 118 | $this->assertStringContainsString("public function answeredSecondStep(Step \$step, \$secondStep)\n {\n return \$secondStep;\n }", $generatedWizard); 119 | 120 | $this->assertStringContainsString("public function completed()\n {\n return \$this->answers->all();\n }", $generatedWizard); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /tests/Unit/WizardTest.php: -------------------------------------------------------------------------------- 1 | steps = tap(new \ReflectionProperty($wizardClass, 'steps')) 43 | ->setAccessible(true); 44 | 45 | $this->taken = tap(new \ReflectionProperty($wizardClass, 'taken')) 46 | ->setAccessible(true); 47 | 48 | $this->answers = tap(new \ReflectionProperty($wizardClass, 'answers')) 49 | ->setAccessible(true); 50 | 51 | $this->followup = tap(new \ReflectionProperty($wizardClass, 'followup')) 52 | ->setAccessible(true); 53 | 54 | $wizard = $this->app->get($wizardClass); 55 | 56 | $wizard->initializeWizard(); 57 | 58 | $this->app->instance($wizardClass, $wizard); 59 | 60 | return $wizard; 61 | } 62 | 63 | protected function partiallyMockWizard(string $class, array $methods) 64 | { 65 | $mock = \Mockery::mock(sprintf( 66 | '%s[%s]', 67 | $class, 68 | implode(',', $methods) 69 | )); 70 | 71 | $this->instance($class, $mock); 72 | 73 | return $mock; 74 | } 75 | 76 | protected function runBaseTestWizard() 77 | { 78 | $this->artisan('console-wizard-test:base') 79 | ->expectsQuestion("What's your name?", 'Misa') 80 | ->expectsQuestion("How old are you?", 25) 81 | ->expectsQuestion("Your favourite programming language", 0) 82 | ->run(); 83 | } 84 | 85 | /** @test */ 86 | public function wizard_will_initialize_steps_when_created() 87 | { 88 | $wizard = $this->loadWizard(BaseTestWizard::class); 89 | $steps = $this->steps->getValue($wizard); 90 | 91 | $this->assertInstanceOf(TextStep::class, $steps->get('name')); 92 | $this->assertInstanceOf(TextStep::class, $steps->get('age')); 93 | $this->assertInstanceOf(ChoiceStep::class, $steps->get('preferred-language')); 94 | } 95 | 96 | /** @test */ 97 | public function wizard_will_throw_an_exception_if_an_invalid_step_is_expected() 98 | { 99 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 100 | $mock->shouldReceive('getSteps')->once()->andReturn([ 101 | new TextStep("What's your name?"), 102 | new InvalidStepException(), 103 | ]); 104 | 105 | $this->expectException(InvalidStepException::class); 106 | 107 | $this->artisan('console-wizard-test:base'); 108 | } 109 | 110 | /** @test */ 111 | public function wizard_will_perform_no_actions_prior_to_asserting_all_steps_are_valid() 112 | { 113 | $this->expectException(InvalidStepException::class); 114 | 115 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps', 'take', 'completed']); 116 | 117 | $mock->shouldReceive('getSteps')->once()->andReturn([ 118 | new TextStep("What's your name?"), 119 | new InvalidStepException(), 120 | ]); 121 | 122 | $mock->shouldNotReceive('take'); 123 | $mock->shouldNotReceive('completed'); 124 | 125 | $this->artisan('console-wizard-test:base'); 126 | } 127 | 128 | /** @test */ 129 | public function wizard_will_initialize_an_empty_collection_for_taken_steps() 130 | { 131 | $wizard = $this->loadWizard(BaseTestWizard::class); 132 | $taken = $this->taken->getValue($wizard); 133 | 134 | $this->assertInstanceOf(Collection::class, $taken); 135 | $this->assertEmpty($taken); 136 | } 137 | 138 | /** @test */ 139 | public function wizard_will_initialize_an_empty_collection_for_answers() 140 | { 141 | $wizard = $this->loadWizard(BaseTestWizard::class); 142 | $answers = $this->answers->getValue($wizard); 143 | 144 | $this->assertInstanceOf(Collection::class, $answers); 145 | $this->assertEmpty($answers); 146 | } 147 | 148 | /** @test */ 149 | public function wizard_will_initialize_an_empty_collection_for_followups() 150 | { 151 | $wizard = $this->loadWizard(BaseTestWizard::class); 152 | $followup = $this->followup->getValue($wizard); 153 | 154 | $this->assertInstanceOf(Collection::class, $followup); 155 | $this->assertEmpty($followup); 156 | } 157 | 158 | /** @test */ 159 | public function wizard_will_ask_all_the_defined_steps() 160 | { 161 | $this->runBaseTestWizard(); 162 | } 163 | 164 | /** @test */ 165 | public function wizard_can_override_steps_using_arguments() 166 | { 167 | $this->artisan("wizard-test:inherit-answers Misa") 168 | ->expectsQuestion("Age", 26) 169 | ->expectsOutput("Misa is 26 year(s) old"); 170 | } 171 | 172 | /** @test */ 173 | public function wizard_can_override_steps_using_options() 174 | { 175 | $this->artisan("wizard-test:inherit-answers --age=26") 176 | ->expectsQuestion("Name", "Misa") 177 | ->expectsOutput("Misa is 26 year(s) old"); 178 | } 179 | 180 | /** @test */ 181 | public function wizard_will_not_override_steps_using_arguments_if_that_feature_is_disabled() 182 | { 183 | $wizard = $this->loadWizard(InheritAnswersTestWizard::class); 184 | 185 | $shouldInherit = new \ReflectionProperty(get_class($wizard), 'inheritAnswersFromArguments'); 186 | $shouldInherit->setAccessible(true); 187 | $shouldInherit->setValue($wizard, false); 188 | 189 | $this->artisan("wizard-test:inherit-answers Misa --age=21") 190 | ->expectsQuestion("Name", "Natalija") 191 | ->expectsQuestion("Age", 26) 192 | ->expectsOutput("Natalija is 26 year(s) old"); 193 | } 194 | 195 | /** @test */ 196 | public function wizard_will_ask_all_the_steps_from_a_subwizard() 197 | { 198 | $this->artisan('console-wizard-test:subwizard') 199 | ->expectsQuestion("What's your name?", 'Misa') 200 | ->expectsQuestion("Are you older than 18?", "Yes") 201 | ->expectsQuestion("Your marital status:", 'single'); 202 | } 203 | 204 | /** @test */ 205 | public function subwizard_answers_will_be_present_as_a_subset_of_main_wizard_answers() 206 | { 207 | $wizard = $this->loadWizard(SubwizardTestWizard::class); 208 | 209 | $this->artisan('console-wizard-test:subwizard') 210 | ->expectsQuestion("What's your name?", 'Misa') 211 | ->expectsQuestion("Are you older than 18?", "Yes") 212 | ->expectsQuestion("Your marital status:", 'single'); 213 | 214 | $answers = $this->answers->getValue($wizard); 215 | $this->assertEquals([ 216 | 'name' => 'Misa', 217 | 'legal-status' => [ 218 | 'age-legality' => true, 219 | 'marital-legality' => 'single', 220 | ], 221 | ], $answers->toArray()); 222 | } 223 | 224 | /** @test */ 225 | public function wizard_can_use_one_time_wizard_as_subwizard() 226 | { 227 | $wizard = $this->loadWizard(WizardWithOneTimeSubwizard::class); 228 | 229 | $this->artisan('console-wizard-test:one-time-subwizard') 230 | ->expectsQuestion('Answer the first step', 'First answer') 231 | ->expectsQuestion('Answer the second step', 'Second answer'); 232 | 233 | $answers = $this->answers->getValue($wizard); 234 | $this->assertEquals([ 235 | 'one-time-subwizard' => [ 236 | 'first-question' => 'First answer', 237 | 'second-question' => 'Second answer', 238 | ] 239 | ], $answers->toArray()); 240 | } 241 | 242 | /** @test */ 243 | public function one_time_wizards_completed_method_cannot_be_invoked() 244 | { 245 | $this->expectException(\RuntimeException::class); 246 | 247 | $wizard = new OneTimeWizard([]); 248 | 249 | $wizard->completed(); 250 | } 251 | 252 | /** @test */ 253 | public function wizard_can_validate_answers_on_a_per_step_basis() 254 | { 255 | $mock = $this->partiallyMockWizard(StepValidationTestWizard::class, ['onInvalidAge', 'onInvalidFavouriteColour']); 256 | $invalidAgeHandlerExpectation = $mock->shouldReceive('onInvalidAge'); 257 | $invalidColourHandlerExpectation = $mock->shouldReceive('onInvalidFavouriteColour'); 258 | 259 | 260 | $this->artisan('console-wizard-test:step-validation') 261 | ->expectsQuestion('What is your name?', 'Misa') 262 | ->expectsQuestion('How old are you?', 13) 263 | ->expectsQuestion('What is your favourite colour?', 'red'); 264 | 265 | 266 | $invalidAgeHandlerExpectation->verify(); 267 | $invalidColourHandlerExpectation->verify(); 268 | } 269 | 270 | /** @test */ 271 | public function wizard_will_throw_a_validation_exception_if_a_validation_handler_is_missing() 272 | { 273 | $mock = $this->partiallyMockWizard(StepValidationTestWizard::class, ['onInvalidAge']); 274 | $mock->shouldReceive('onInvalidAge'); 275 | $this->expectException(ValidationException::class); 276 | 277 | $this->artisan('console-wizard-test:step-validation') 278 | ->expectsQuestion('What is your name?', 'Misa') 279 | ->expectsQuestion('How old are you?', 13) 280 | ->expectsQuestion('What is your favourite colour?', 'magenta'); 281 | } 282 | 283 | /** @test */ 284 | public function wizard_can_repeat_invalidly_answered_steps_automatically() 285 | { 286 | $this->artisan('wizard-test:repeat-invalid') 287 | ->expectsQuestion("Enter age", "9") 288 | ->expectsOutput("The age field must be at least 10.") 289 | ->expectsQuestion("Enter age", "21") 290 | ->expectsOutput("The age field must not be greater than 20.") 291 | ->expectsQuestion("Enter age", "13") 292 | ->expectsQuestion("Enter birth year", "1993") 293 | ->expectsOutput("Done"); 294 | } 295 | 296 | /** @test */ 297 | public function wizard_will_not_automatically_repeat_steps_if_they_have_handlers_defined() 298 | { 299 | $mock = $this->partiallyMockWizard(RepeatsStepsTestWizard::class, ['onInvalidIHaveAHandler']); 300 | 301 | $expectation = $mock->shouldReceive('onInvalidIHaveAHandler')->andReturn(true); 302 | 303 | 304 | $this->artisan("wizard-test:repeat-invalid") 305 | ->expectsQuestion("Enter age", "13") 306 | ->expectsQuestion("Enter birth year", "I will not") 307 | ->expectsOutput("Done"); 308 | 309 | 310 | $expectation->verify(); 311 | } 312 | 313 | /** @test */ 314 | public function wizard_can_validate_answers_on_a_complete_wizard_basis() 315 | { 316 | $mock = $this->partiallyMockWizard(WizardValidationTestWizard::class, ['onWizardInvalid']); 317 | $expectation = $mock->shouldReceive('onWizardInvalid')->once(); 318 | 319 | $this->artisan('console-wizard-test:wizard-validation') 320 | ->expectsQuestion("What is your name?", 'Misa') 321 | ->expectsQuestion("What is your favourite band?", 'Invalid answer') 322 | ->expectsQuestion("Which country do you come from?", "Another invalid answer"); 323 | 324 | $expectation->verify(); 325 | } 326 | 327 | /** @test */ 328 | public function wizard_will_invoke_existing_taking_modifiers() 329 | { 330 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['takingName', 'takingAge']); 331 | $mock->shouldReceive('takingName')->once(); 332 | $mock->shouldReceive('takingAge')->once(); 333 | 334 | $this->runBaseTestWizard(); 335 | } 336 | 337 | /** @test */ 338 | public function wizard_will_not_invoke_non_existing_taking_modifiers() 339 | { 340 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['takingPreferredLanguage']); 341 | $mock->shouldNotReceive('takingPreferredLanguage'); 342 | 343 | $this->runBaseTestWizard(); 344 | } 345 | 346 | /** @test */ 347 | public function wizard_will_invoke_existing_answered_modifiers() 348 | { 349 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['answeredAge', 'answeredPreferredLanguage']); 350 | $mock->shouldReceive('answeredAge')->once(); 351 | $mock->shouldReceive('answeredPreferredLanguage')->once(); 352 | 353 | $this->runBaseTestWizard(); 354 | } 355 | 356 | /** @test */ 357 | public function answered_modifier_results_will_be_saved_as_answers() 358 | { 359 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['answeredAge', 'answeredPreferredLanguage']); 360 | $mock->shouldReceive('answeredAge')->once()->andReturn('modified age'); 361 | $mock->shouldReceive('answeredPreferredLanguage')->once()->andReturn('modified programming language'); 362 | 363 | 364 | $this->runBaseTestWizard(); 365 | 366 | $answers = tap(new \ReflectionProperty(BaseTestWizard::class, 'answers')) 367 | ->setAccessible(true) 368 | ->getValue($mock); 369 | 370 | $this->assertEquals('modified age', $answers->get('age')); 371 | $this->assertEquals('modified programming language', $answers->get('preferred-language')); 372 | } 373 | 374 | /** @test */ 375 | public function wizard_will_not_invoke_non_existing_answered_modifiers() 376 | { 377 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['answeredName']); 378 | $mock->shouldNotReceive('answeredName'); 379 | 380 | $this->runBaseTestWizard(); 381 | } 382 | 383 | /** @test */ 384 | public function wizard_can_followup_on_steps() 385 | { 386 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 387 | 388 | $mock->shouldReceive('getSteps')->once()->andReturn([ 389 | 'run-another' => new TextStep('Should I followup on this step?'), 390 | 'i-ran-another' => new TextStep('Is it OK I ran another step?'), 391 | ]); 392 | 393 | $this->artisan('console-wizard-test:base') 394 | ->expectsQuestion('Should I followup on this step?', 'Yes') 395 | ->expectsQuestion("I am a followup.", 'Cool') 396 | ->expectsQuestion('Is it OK I ran another step?', 'Totally'); 397 | } 398 | 399 | /** @test */ 400 | public function followups_will_be_asked_from_the_latest_to_the_earliest() 401 | { 402 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 403 | 404 | $mock->shouldReceive('getSteps')->once()->andReturn([ 405 | 'main-step' => new TextStep("I am the main step"), 406 | ]); 407 | 408 | $this->artisan('console-wizard-test:base') 409 | ->expectsQuestion('I am the main step', 'Cool') 410 | ->expectsQuestion('I am added after the main step', 'Yes, you are') 411 | ->expectsQuestion('I am added before the main step', 'Good for you'); 412 | } 413 | 414 | /** @test */ 415 | public function wizard_can_repeat_steps() 416 | { 417 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 418 | 419 | $mock->shouldReceive('getSteps')->once()->andReturn([ 420 | 'repeat_me' => new TextStep("I should be repeated"), 421 | 'repeat-after-me' => new ConfirmStep("Should I repeat him, though?"), 422 | ]); 423 | 424 | 425 | $this->artisan('console-wizard-test:base') 426 | ->expectsQuestion("I should be repeated", "Indeed you should") 427 | ->expectsConfirmation("Should I repeat him, though?", 'yes') 428 | ->expectsQuestion("I should be repeated", "And repeated you are."); 429 | } 430 | 431 | /** @test */ 432 | public function wizard_can_only_repeat_taken_or_skipped_tests() 433 | { 434 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 435 | 436 | $mock->shouldReceive('getSteps')->once()->andReturn([ 437 | 'repeat-after-me' => new TextStep("I really want to repeat a step"), 438 | 'second-step' => new TextStep("I'm just chilling here"), 439 | 'repeat_me' => new TextStep("Y U NO REPEAT ME"), 440 | ]); 441 | 442 | 443 | $this->artisan('console-wizard-test:base') 444 | ->expectsQuestion("I really want to repeat a step", "But you cannot") 445 | ->expectsQuestion("I'm just chilling here", "Good for you") 446 | ->expectsQuestion("Y U NO REPEAT ME", "Because you're too late"); 447 | } 448 | 449 | /** @test */ 450 | public function wizard_can_skip_steps() 451 | { 452 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 453 | $mock->shouldReceive('getSteps')->once()->andReturn([ 454 | 'unskippable' => new TextStep("I shouldn't be skipped"), 455 | 'skip-me' => new TextStep("I am to be skipped"), 456 | 'i-will-run' => new TextStep("Running"), 457 | ]); 458 | 459 | $this->artisan('console-wizard-test:base') 460 | ->expectsQuestion("I shouldn't be skipped", "That's right") 461 | ->expectsQuestion('Running', 'good for you'); 462 | } 463 | 464 | /** @test */ 465 | public function wizard_cannot_skip_a_step_that_is_already_running() 466 | { 467 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 468 | $mock->shouldReceive('getSteps')->once()->andReturn([ 469 | 'i-will-run' => new TextStep('Running'), 470 | 'unskippable' => new TextStep("I'm running too"), 471 | ]); 472 | 473 | $this->artisan('console-wizard-test:base') 474 | ->expectsQuestion('Running', 'Good for you') 475 | ->expectsQuestion("I'm running too", 'Yes you are'); 476 | } 477 | 478 | /** @test */ 479 | public function wizard_can_repeat_a_step_a_fixed_number_of_times() 480 | { 481 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 482 | 483 | $mock->shouldReceive('getSteps')->once()->andReturn([ 484 | 'repeated' => tap(new RepeatStep(new TextStep("I will run three times")))->times(3), 485 | ]); 486 | 487 | $this->artisan('console-wizard-test:base') 488 | ->expectsQuestion('I will run three times', "Yes you will") 489 | ->expectsQuestion('I will run three times', "That's right") 490 | ->expectsQuestion('I will run three times', "Okay, we get it"); 491 | } 492 | 493 | /** @test */ 494 | public function wizard_can_repeat_a_step_until_a_specified_answer_is_provided() 495 | { 496 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 497 | 498 | $mock->shouldReceive('getSteps')->once()->andReturn([ 499 | tap(new RepeatStep(new TextStep("Gimme 5 or I shall never stop")))->untilAnswerIs(5), 500 | ]); 501 | 502 | $this->artisan('console-wizard-test:base') 503 | ->expectsQuestion("Gimme 5 or I shall never stop", 3) 504 | ->expectsQuestion("Gimme 5 or I shall never stop", 2) 505 | ->expectsQuestion("Gimme 5 or I shall never stop", 7) 506 | ->expectsQuestion("Gimme 5 or I shall never stop", "You can't have 5") 507 | ->expectsQuestion("Gimme 5 or I shall never stop", 5); 508 | } 509 | 510 | public static function callbackRepetitionTests() 511 | { 512 | return [ 513 | [ 514 | function($answer) { 515 | return $answer === 'stop'; 516 | }, ['go on', 'keep running', 'continue', 'stop'], 517 | ], 518 | [ 519 | function($answer) { 520 | if ($answer === null) { 521 | return false; 522 | } 523 | 524 | return $answer > 20; 525 | }, [1, 7, 4, 12, 19, 55], 526 | ], 527 | [ 528 | function($answer) { 529 | if ($answer === null) { 530 | return false; 531 | } 532 | 533 | return !is_string($answer); 534 | }, ['go on', 'keep it up', 'this is the last time', false], 535 | ] 536 | ]; 537 | } 538 | 539 | /** 540 | * @test 541 | * @dataProvider callbackRepetitionTests 542 | */ 543 | public function wizard_can_repeat_a_step_until_a_specific_condition_is_met(callable $callback, array $promptAnswers) 544 | { 545 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 546 | 547 | $mock->shouldReceive('getSteps')->once()->andReturn([ 548 | 'repeated' => tap(new RepeatStep(new TextStep("Repeat me")))->until($callback), 549 | ]); 550 | 551 | $consoleExpectation = $this->artisan('console-wizard-test:base'); 552 | 553 | foreach ($promptAnswers as $answer) { 554 | $consoleExpectation->expectsQuestion("Repeat me", $answer); 555 | } 556 | } 557 | 558 | /** @test */ 559 | public function wizard_can_repeat_a_steps_as_long_as_the_user_requests_so() 560 | { 561 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 562 | 563 | $mock->shouldReceive('getSteps')->andReturn([ 564 | 'repeated' => tap(new RepeatStep(new TextStep('Repeat me')))->withRepeatPrompt('Repeat me again?'), 565 | ]); 566 | 567 | 568 | $this->artisan('console-wizard-test:base') 569 | ->expectsQuestion('Repeat me', 'I will') 570 | ->expectsConfirmation('Repeat me again?', 'yes') 571 | ->expectsQuestion('Repeat me', 'Okay') 572 | ->expectsConfirmation('Repeat me again?', 'yes') 573 | ->expectsQuestion('Repeat me', 'Once more') 574 | ->expectsConfirmation('Repeat me again?', 'yes') 575 | ->expectsQuestion('Repeat me', 'No more') 576 | ->expectsConfirmation('Repeat me again?', 'no'); 577 | 578 | $this->assertEquals([ 579 | 'I will', 'Okay', 'Once more', 'No more' 580 | ], $mock->getAnswers()->get('repeated')); 581 | } 582 | 583 | /** @test */ 584 | public function wizard_can_prompt_the_user_before_the_first_repetition() 585 | { 586 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 587 | 588 | $mock->shouldReceive('getSteps')->andReturn([ 589 | 'repeated' => tap(new RepeatStep(new TextStep('Repeat me')))->withRepeatPrompt('Repeat me again?', true), 590 | ]); 591 | 592 | 593 | $this->artisan('console-wizard-test:base') 594 | ->expectsConfirmation('Repeat me again?', 'yes') 595 | ->expectsQuestion('Repeat me', 'I will') 596 | ->expectsConfirmation('Repeat me again?', 'yes') 597 | ->expectsQuestion('Repeat me', 'Okay') 598 | ->expectsConfirmation('Repeat me again?', 'yes') 599 | ->expectsQuestion('Repeat me', 'Once more') 600 | ->expectsConfirmation('Repeat me again?', 'yes') 601 | ->expectsQuestion('Repeat me', 'No more') 602 | ->expectsConfirmation('Repeat me again?', 'no'); 603 | 604 | $this->assertEquals([ 605 | 'I will', 'Okay', 'Once more', 'No more' 606 | ], $mock->getAnswers()->get('repeated')); 607 | } 608 | 609 | /** @test */ 610 | public function repeated_step_answers_will_be_returned_as_an_array() 611 | { 612 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 613 | 614 | $mock->shouldReceive('getSteps')->once()->andReturn([ 615 | 'text-step' => new TextStep("My answer will be a string"), 616 | 'repeated' => tap(new RepeatStep(new TextStep("My answer will be an array with 3 elements")))->times(3), 617 | ]); 618 | 619 | $this->artisan('console-wizard-test:base') 620 | ->expectsQuestion("My answer will be a string", "Yes it will") 621 | ->expectsQuestion("My answer will be an array with 3 elements", "True that") 622 | ->expectsQuestion("My answer will be an array with 3 elements", "Here's the second element") 623 | ->expectsQuestion("My answer will be an array with 3 elements", "And I'm the third"); 624 | 625 | $answers = tap(new \ReflectionProperty(get_class($mock), 'answers'))->setAccessible(true)->getValue($mock); 626 | $this->assertEquals([ 627 | 'text-step' => 'Yes it will', 628 | 'repeated' => [ 629 | 'True that', 630 | "Here's the second element", 631 | "And I'm the third", 632 | ] 633 | ], $answers->toArray()); 634 | } 635 | 636 | /** @test */ 637 | public function repeated_step_can_exclude_the_last_answer() 638 | { 639 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 640 | 641 | $mock->shouldReceive('getSteps')->once()->andReturn([ 642 | 'repeated' => tap(new RepeatStep(new TextStep("Gimme 5 or I shall never stop")))->untilAnswerIs(5)->withoutLastAnswer(), 643 | ]); 644 | 645 | $this->artisan('console-wizard-test:base') 646 | ->expectsQuestion("Gimme 5 or I shall never stop", 3) 647 | ->expectsQuestion("Gimme 5 or I shall never stop", 2) 648 | ->expectsQuestion("Gimme 5 or I shall never stop", 7) 649 | ->expectsQuestion("Gimme 5 or I shall never stop", "You can't have 5") 650 | ->expectsQuestion("Gimme 5 or I shall never stop", 5); 651 | 652 | $answers = $mock->getAnswers(); 653 | $this->assertEquals([ 654 | 3, 2, 7, "You can't have 5", 655 | ], $answers->get('repeated')); 656 | } 657 | 658 | /** @test */ 659 | public function wizard_will_throw_an_exception_if_a_repeated_question_is_not_properly_initialized() 660 | { 661 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['getSteps']); 662 | 663 | $mock->shouldReceive('getSteps')->once()->andReturn([ 664 | 'repeated' => new RepeatStep(new TextStep("I'll never be ran")), 665 | ]); 666 | 667 | $this->expectException(InvalidStepException::class); 668 | 669 | $this->artisan('console-wizard-test:base'); 670 | } 671 | 672 | /** @test */ 673 | public function wizard_will_store_all_the_answers() 674 | { 675 | $wizard = $this->loadWizard(BaseTestWizard::class); 676 | 677 | $this->runBaseTestWizard(); 678 | 679 | $answers = $this->answers->getValue($wizard); 680 | $this->assertEquals([ 681 | 'name' => 'Misa', 682 | 'age' => 25, 683 | 'preferred-language' => 0, 684 | ], $answers->toArray()); 685 | } 686 | 687 | /** @test */ 688 | public function wizard_will_move_taken_steps_to_the_taken_collection() 689 | { 690 | $wizard = $this->loadWizard(BaseTestWizard::class); 691 | 692 | $this->runBaseTestWizard(); 693 | 694 | $asked = $this->taken->getValue($wizard); 695 | $questions = $this->steps->getValue($wizard); 696 | 697 | $this->assertInstanceOf(TextStep::class, $asked->get('name')); 698 | $this->assertInstanceOf(TextStep::class, $asked->get('age')); 699 | $this->assertInstanceOf(ChoiceStep::class, $asked->get('preferred-language')); 700 | 701 | $this->assertInstanceOf(Collection::class, $questions); 702 | $this->assertEmpty($questions); 703 | } 704 | 705 | /** @test */ 706 | public function wizard_will_invoke_completed_upon_completion() 707 | { 708 | $mock = $this->partiallyMockWizard(BaseTestWizard::class, ['completed']); 709 | $mock->shouldNotReceive('completed')->once(); 710 | 711 | $this->runBaseTestWizard(); 712 | } 713 | 714 | /** @test */ 715 | public function wizard_can_be_aborted() 716 | { 717 | $this->artisan('wizard:abort') 718 | ->expectsConfirmation("Should I abort?", 'yes'); 719 | } 720 | } 721 | -------------------------------------------------------------------------------- /tests/phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 11 | 12 | 13 | ./Unit 14 | 15 | 16 | 17 | 18 | ./../src 19 | 20 | 21 | 22 | 23 | 24 | 25 | --------------------------------------------------------------------------------