Skip to content

Commit 1204c54

Browse files
committed
Merge branch 'feature/testability' into develop
2 parents da03d32 + 7364c84 commit 1204c54

9 files changed

Lines changed: 1464 additions & 7 deletions

File tree

readme.md

Lines changed: 140 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,10 @@ public function execute(): int
474474

475475
## Testing Your Commands
476476

477+
The CLI component provides comprehensive testing support, including the ability to test commands that require user input.
478+
479+
### Testing Commands Without User Input
480+
477481
```php
478482
use PHPUnit\Framework\TestCase;
479483
use Neuron\Cli\Console\Input;
@@ -484,23 +488,155 @@ class MakeControllerCommandTest extends TestCase
484488
public function testExecute(): void
485489
{
486490
$command = new MakeControllerCommand();
487-
491+
488492
// Mock input
489493
$input = new Input(['UserController', '--resource']);
490494
$output = new Output(false); // No colors for testing
491-
495+
492496
$command->setInput($input);
493497
$command->setOutput($output);
494498
$command->configure();
495499
$input->parse($command);
496-
500+
497501
$exitCode = $command->execute();
498-
502+
499503
$this->assertEquals(0, $exitCode);
500504
}
501505
}
502506
```
503507

508+
### Testing Commands With User Input
509+
510+
Commands that use `prompt()`, `confirm()`, `secret()`, or `choice()` can be tested using the `TestInputReader`:
511+
512+
```php
513+
use PHPUnit\Framework\TestCase;
514+
use Neuron\Cli\Console\Input;
515+
use Neuron\Cli\Console\Output;
516+
use Neuron\Cli\IO\TestInputReader;
517+
518+
class SetupCommandTest extends TestCase
519+
{
520+
public function testInteractiveSetup(): void
521+
{
522+
$command = new SetupCommand();
523+
524+
// Create test input reader with pre-programmed responses
525+
$inputReader = new TestInputReader();
526+
$inputReader->addResponse('my-project'); // Project name
527+
$inputReader->addResponse('John Doe'); // Author name
528+
$inputReader->addResponse('john@example.com'); // Email
529+
$inputReader->addResponse('yes'); // Confirmation
530+
531+
// Configure command
532+
$input = new Input([]);
533+
$output = new Output(false);
534+
535+
$command->setInput($input);
536+
$command->setOutput($output);
537+
$command->setInputReader($inputReader);
538+
539+
// Execute command
540+
$exitCode = $command->execute();
541+
542+
// Assertions
543+
$this->assertEquals(0, $exitCode);
544+
545+
// Verify the prompts that were shown
546+
$prompts = $inputReader->getPromptHistory();
547+
$this->assertCount(4, $prompts);
548+
$this->assertStringContainsString('Project name', $prompts[0]);
549+
$this->assertStringContainsString('Author name', $prompts[1]);
550+
}
551+
552+
public function testUserCancelsSetup(): void
553+
{
554+
$command = new SetupCommand();
555+
556+
// User will cancel the setup
557+
$inputReader = new TestInputReader();
558+
$inputReader->addResponses([
559+
'test-project',
560+
'Test User',
561+
'test@example.com',
562+
'no' // Cancel confirmation
563+
]);
564+
565+
$input = new Input([]);
566+
$output = new Output(false);
567+
568+
$command->setInput($input);
569+
$command->setOutput($output);
570+
$command->setInputReader($inputReader);
571+
572+
$exitCode = $command->execute();
573+
574+
// Should return non-zero exit code when cancelled
575+
$this->assertNotEquals(0, $exitCode);
576+
}
577+
}
578+
```
579+
580+
### Using Input Reader in Commands
581+
582+
To make your commands testable, use the convenience methods provided by the `Command` base class:
583+
584+
```php
585+
class SetupCommand extends Command
586+
{
587+
public function execute(): int
588+
{
589+
// Use built-in convenience methods instead of reading STDIN directly
590+
$name = $this->prompt('Enter project name: ');
591+
592+
if ($this->confirm('Enable caching?', true)) {
593+
// User confirmed
594+
}
595+
596+
$password = $this->secret('Enter password: ');
597+
598+
$env = $this->choice(
599+
'Select environment:',
600+
['development', 'staging', 'production'],
601+
'development'
602+
);
603+
604+
return 0;
605+
}
606+
}
607+
```
608+
609+
These convenience methods automatically use the injected `IInputReader`, making your commands fully testable without requiring actual user input.
610+
611+
### TestInputReader Features
612+
613+
The `TestInputReader` class provides:
614+
615+
- **Response Queue**: Pre-program multiple responses with `addResponse()` or `addResponses()`
616+
- **Prompt History**: Track all prompts shown with `getPromptHistory()`
617+
- **Automatic Validation**: Throws exceptions if responses run out, helping catch test bugs
618+
- **Fluent Interface**: Chain `addResponse()` calls for cleaner test setup
619+
- **Full Interface Support**: Implements all `IInputReader` methods (prompt, confirm, secret, choice)
620+
621+
```php
622+
$reader = new TestInputReader();
623+
$reader
624+
->addResponse('value1')
625+
->addResponse('value2')
626+
->addResponse('yes');
627+
628+
// Check if there are responses remaining
629+
if ($reader->hasMoreResponses()) {
630+
$count = $reader->getRemainingResponseCount();
631+
}
632+
633+
// Get history of prompts
634+
$prompts = $reader->getPromptHistory();
635+
636+
// Reset for reuse
637+
$reader->reset();
638+
```
639+
504640
## Contributing
505641

506642
When adding new features to the CLI component:

src/Cli/Commands/Command.php

Lines changed: 128 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use Neuron\Cli\Console\Input;
66
use Neuron\Cli\Console\Output;
7+
use Neuron\Cli\IO\IInputReader;
8+
use Neuron\Cli\IO\StdinInputReader;
79

810
/**
911
* Abstract base class for all CLI commands.
@@ -14,6 +16,7 @@ abstract class Command
1416
{
1517
protected Input $input;
1618
protected Output $output;
19+
protected ?IInputReader $inputReader = null;
1720
protected array $arguments = [];
1821
protected array $options = [];
1922

@@ -287,7 +290,7 @@ public function hasOption( string $name ): bool
287290

288291
/**
289292
* Validate that required arguments are present
290-
*
293+
*
291294
* @throws \InvalidArgumentException
292295
* @return void
293296
*/
@@ -301,4 +304,128 @@ public function validate(): void
301304
}
302305
}
303306
}
307+
308+
/**
309+
* Get the input reader instance.
310+
*
311+
* Creates a default StdinInputReader if not already set.
312+
* This enables testable CLI commands by abstracting user input.
313+
*
314+
* If output hasn't been set, a default Output instance will be created automatically.
315+
*
316+
* @return IInputReader
317+
*/
318+
protected function getInputReader(): IInputReader
319+
{
320+
if( !$this->inputReader ) {
321+
// Ensure output is initialized before creating StdinInputReader
322+
if( !isset( $this->output ) ) {
323+
$this->output = new Output();
324+
}
325+
326+
$this->inputReader = new StdinInputReader( $this->output );
327+
}
328+
329+
return $this->inputReader;
330+
}
331+
332+
/**
333+
* Set the input reader (for dependency injection, especially in tests).
334+
*
335+
* This allows tests to inject a TestInputReader with pre-programmed
336+
* responses instead of requiring actual user input.
337+
*
338+
* @param IInputReader $inputReader
339+
* @return self
340+
*/
341+
public function setInputReader( IInputReader $inputReader ): self
342+
{
343+
$this->inputReader = $inputReader;
344+
return $this;
345+
}
346+
347+
/**
348+
* Prompt user for input.
349+
*
350+
* Convenience method that delegates to the input reader.
351+
*
352+
* Example:
353+
* ```php
354+
* $name = $this->prompt( "Enter your name: " );
355+
* ```
356+
*
357+
* @param string $message The prompt message to display
358+
* @return string The user's response (trimmed)
359+
*/
360+
protected function prompt( string $message ): string
361+
{
362+
return $this->getInputReader()->prompt( $message );
363+
}
364+
365+
/**
366+
* Ask user for yes/no confirmation.
367+
*
368+
* Convenience method that delegates to the input reader.
369+
* Accepts: y, yes, true, 1 (case-insensitive) as positive responses.
370+
*
371+
* Example:
372+
* ```php
373+
* if( $this->confirm( "Delete all files?" ) ) {
374+
* // User confirmed
375+
* }
376+
* ```
377+
*
378+
* @param string $message The confirmation message
379+
* @param bool $default Default value if user just presses enter
380+
* @return bool True if user confirms, false otherwise
381+
*/
382+
protected function confirm( string $message, bool $default = false ): bool
383+
{
384+
return $this->getInputReader()->confirm( $message, $default );
385+
}
386+
387+
/**
388+
* Prompt for sensitive input without echoing to console.
389+
*
390+
* Convenience method that delegates to the input reader.
391+
* Note: Secret input hiding only works on Unix-like systems.
392+
*
393+
* Example:
394+
* ```php
395+
* $password = $this->secret( "Enter password: " );
396+
* ```
397+
*
398+
* @param string $message The prompt message
399+
* @return string The user's input (trimmed)
400+
*/
401+
protected function secret( string $message ): string
402+
{
403+
return $this->getInputReader()->secret( $message );
404+
}
405+
406+
/**
407+
* Prompt user to select from a list of options.
408+
*
409+
* Convenience method that delegates to the input reader.
410+
* Users can select by entering either the option index (numeric)
411+
* or the exact option text.
412+
*
413+
* Example:
414+
* ```php
415+
* $env = $this->choice(
416+
* "Select environment:",
417+
* ['development', 'staging', 'production'],
418+
* 'development'
419+
* );
420+
* ```
421+
*
422+
* @param string $message The prompt message
423+
* @param array<string> $options Available options
424+
* @param string|null $default Default option (will be marked with *)
425+
* @return string The selected option
426+
*/
427+
protected function choice( string $message, array $options, ?string $default = null ): string
428+
{
429+
return $this->getInputReader()->choice( $message, $options, $default );
430+
}
304431
}

src/Cli/IO/IInputReader.php

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<?php
2+
3+
namespace Neuron\Cli\IO;
4+
5+
/**
6+
* Interface for reading user input in CLI commands.
7+
*
8+
* Provides an abstraction over STDIN to enable testable CLI commands.
9+
* Implementations can read from actual user input (StdinInputReader)
10+
* or from pre-programmed responses (TestInputReader) for testing.
11+
*
12+
* @package Neuron\Cli\IO
13+
*/
14+
interface IInputReader
15+
{
16+
/**
17+
* Prompt user for input and return their response.
18+
*
19+
* @param string $message The prompt message to display
20+
* @return string The user's response (trimmed)
21+
*/
22+
public function prompt( string $message ): string;
23+
24+
/**
25+
* Ask user for yes/no confirmation.
26+
*
27+
* Accepts: y, yes, true, 1 (case-insensitive) as positive responses.
28+
* All other inputs are treated as negative responses.
29+
*
30+
* @param string $message The confirmation message
31+
* @param bool $default Default value if user just presses enter
32+
* @return bool True if user confirms, false otherwise
33+
*/
34+
public function confirm( string $message, bool $default = false ): bool;
35+
36+
/**
37+
* Prompt for sensitive input without echoing to console.
38+
*
39+
* Note: Secret input is only supported on Unix-like systems.
40+
* On Windows, input will be visible.
41+
*
42+
* @param string $message The prompt message
43+
* @return string The user's input (trimmed)
44+
*/
45+
public function secret( string $message ): string;
46+
47+
/**
48+
* Prompt user to select from a list of options.
49+
*
50+
* Users can select by entering either the option index (numeric)
51+
* or the exact option text.
52+
*
53+
* @param string $message The prompt message
54+
* @param array<string> $options Available options
55+
* @param string|null $default Default option (will be marked with *)
56+
* @return string The selected option
57+
*/
58+
public function choice( string $message, array $options, ?string $default = null ): string;
59+
}

0 commit comments

Comments
 (0)