diff --git a/.cursor/rules/03-commands.mdc b/.cursor/rules/03-commands.mdc index b28e138a..91b1c3e7 100644 --- a/.cursor/rules/03-commands.mdc +++ b/.cursor/rules/03-commands.mdc @@ -21,8 +21,14 @@ alwaysApply: true ```php // ✅ CORRECT - BaseCommand custom methods $this->writeln(['Multiple', 'lines']); // Multi-line output -$this->text('Single line message'); // Simple text output -$this->hr(); // Beautiful section separator +$this->text('Single line message'); // Simple text output +$this->hr(); // Beautiful section separator +$this->h1('Section Heading'); // Heading with icon + +// Status messages +$this->success('Server added successfully'); +$this->error('Failed to connect', 'Check your SSH key permissions'); +$this->warning('Skipping connection check'); ``` **❌ FORBIDDEN - Direct Symfony IO Usage:** @@ -53,7 +59,58 @@ protected function success(string $message): void { **Integration Points:** - Base implementation: [BaseCommand.php](mdc:app/Contracts/BaseCommand.php) -- Current methods: `writeln()`, `text()`, `hr()` +- Output trait: [ConsoleOutputTrait.php](mdc:app/Traits/ConsoleOutputTrait.php) +- Input trait: [ConsoleInputTrait.php](mdc:app/Traits/ConsoleInputTrait.php) +- Output methods: `writeln()`, `text()`, `info()`, `note()`, `hr()`, `h1()` +- Status methods: `success()`, `error()`, `warning()` +- Input methods: `getOptionOrPrompt()` +- Helper methods: `showCommandHint()` + +**When to Add New Methods:** + +Console functionality is organized using traits for reusability: + +- **ConsoleOutputTrait:** Add output/formatting methods that format and display text + - Examples: status messages, headings, separators, formatting helpers + - All methods should work with `$this->io` (SymfonyStyle) + +- **ConsoleInputTrait:** Add input gathering methods that collect user data + - Examples: prompt helpers, option validators, input transformers + - All methods should work with `$this->input` (InputInterface) + +- **BaseCommand:** Add only shared initialization, configuration, or orchestration logic + - Examples: service initialization, common options, execution flow + - NOT for individual I/O operations + +This separation ensures console I/O methods remain reusable across different command contexts. + +### 📣 Status Message Helpers + +**Use status helpers for consistent success/error/warning messages:** + +```php +// ✅ Success messages (green checkmark) +$this->success('Server added successfully'); +$this->success('Connection successful'); + +// ✅ Error messages (red X) with optional tip +$this->error('Failed to connect to server'); +$this->error( + 'Failed to add server: ' . $e->getMessage(), + 'Use server:list to view existing servers.' +); + +// ✅ Warning messages (yellow warning symbol) +$this->warning('Skipping SSH connection check'); +$this->warning('Server add cancelled'); +``` + +**Benefits:** + +- Consistent formatting across all commands +- Automatic spacing (blank line after message) +- Clear visual indicators (✓, ✗, ⚠) +- Optional tips for error messages ### 🎯 User Input with Laravel Prompts @@ -69,16 +126,14 @@ use function Laravel\Prompts\suggest; use function Laravel\Prompts\search; use function Laravel\Prompts\spin; -// ✅ CORRECT - Modern prompts with interaction check -if ($this->io->isInteractive()) { - $name = text('What is your name?', required: true); - $password = password('Enter password:', required: true); - $confirmed = confirm('Deploy to production?', default: false); - $environment = select('Select environment:', ['dev', 'staging', 'prod']); - $features = multiselect('Enable features:', ['cache', 'queue', 'logs']); -} +// ✅ CORRECT - Modern prompts +$name = text('What is your name?', required: true); +$password = password('Enter password:', required: true); +$confirmed = confirm('Deploy to production?', default: false); +$environment = select('Select environment:', ['dev', 'staging', 'prod']); +$features = multiselect('Enable features:', ['cache', 'queue', 'logs']); -// Spinners work in all modes +// Spinners for long operations $result = spin(fn() => $this->service->heavyOperation(), 'Processing...'); ``` @@ -128,3 +183,70 @@ class MyCommand extends BaseCommand { - Clean separation between business logic and presentation **Rule:** Commands orchestrate Services and format output. Services never touch console I/O. + +### 🔀 Interactive + Options Pattern + +**Support both interactive prompts AND command-line options for maximum flexibility:** + +```php +protected function configure(): void { + parent::configure(); + + $this->addOption('name', null, InputOption::VALUE_REQUIRED, 'Server name'); + $this->addOption('host', null, InputOption::VALUE_REQUIRED, 'Host/IP address'); + $this->addOption('yes', 'y', InputOption::VALUE_NONE, 'Skip confirmation'); +} + +protected function execute(InputInterface $input, OutputInterface $output): int { + // Track which options were provided vs prompted + $provided = []; + + // Use BaseCommand helper method (uses $this->input internally) + $name = $this->getOptionOrPrompt( + 'name', + 'Server name:', + placeholder: 'production-web-01', + wasProvided: $provided['name'] + ); + + $host = $this->getOptionOrPrompt( + 'host', + 'Host/IP address:', + placeholder: '192.168.1.100', + wasProvided: $provided['host'] + ); + + // ... process command ... + + // Show command hint with highlighted missing options + $this->showCommandHint('my:command', [ + 'name' => $name, + 'host' => $host, + 'yes' => true, + ], $provided); + + return Command::SUCCESS; +} +``` + +**`getOptionOrPrompt()` Helper:** + +The `getOptionOrPrompt()` method is available in BaseCommand for all commands: + +- Checks if option was provided via CLI +- If yes, returns the value and sets `wasProvided` to `true` +- If not, prompts user interactively and sets `wasProvided` to `false` +- Supports all Laravel Prompts parameters (label, default, required, placeholder) + +**Command Hint Output:** + +- Gray = option was provided +- Bright yellow = option was prompted (user should include next time) +- Shows complete non-interactive command for copy/paste + +**Benefits:** + +- Script-friendly with full option support +- User-friendly with interactive fallbacks +- Educational - teaches users the non-interactive syntax +- DRY - single command serves both use cases diff --git a/app/Console/HelloCommand.php b/app/Console/HelloCommand.php index 16ec8b25..b99fc230 100644 --- a/app/Console/HelloCommand.php +++ b/app/Console/HelloCommand.php @@ -22,7 +22,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $user = $this->env->get(['USER', 'USERNAME'], false) ?? 'there'; - $this->io->success('Hello ' . $user . '!'); + $this->success('Hello ' . $user . '!'); return Command::SUCCESS; } diff --git a/app/Contracts/BaseCommand.php b/app/Contracts/BaseCommand.php index d2108927..06a08477 100644 --- a/app/Contracts/BaseCommand.php +++ b/app/Contracts/BaseCommand.php @@ -7,14 +7,27 @@ use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; +use Bigpixelrocket\DeployerPHP\Traits\ConsoleInputTrait; +use Bigpixelrocket\DeployerPHP\Traits\ConsoleOutputTrait; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; use Symfony\Component\Console\Style\SymfonyStyle; -use Symfony\Component\Console\Input\InputOption; +/** + * Base command with shared functionality for all commands. + * + * Uses ConsoleInputTrait for input gathering and ConsoleOutputTrait + * for formatted output. All console commands should extend this class. + */ abstract class BaseCommand extends Command { + use ConsoleInputTrait; + use ConsoleOutputTrait; + + protected InputInterface $input; + protected OutputInterface $output; protected SymfonyStyle $io; public function __construct( @@ -26,7 +39,7 @@ public function __construct( } // - // Common config + // Configuration // ------------------------------------------------------------------------------- /** @@ -52,12 +65,14 @@ protected function configure(): void } /** - * Initialize IO and services early. + * Initialize IO and services. */ protected function initialize(InputInterface $input, OutputInterface $output): void { parent::initialize($input, $output); + $this->input = $input; + $this->output = $output; $this->io = new SymfonyStyle($input, $output); // @@ -77,11 +92,18 @@ protected function initialize(InputInterface $input, OutputInterface $output): v $this->inventory->loadInventoryFile(); } + // + // Execution + // ------------------------------------------------------------------------------- + /** - * Display env and inventory statuses. + * Common execution logic. */ protected function execute(InputInterface $input, OutputInterface $output): int { + // + // Display env and inventory statuses + $envStatus = $this->env->getEnvFileStatus(); $color = str_starts_with($envStatus, 'No .env') ? 'yellow' : 'gray'; $this->writeln([ @@ -99,45 +121,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int return Command::SUCCESS; } - - // - // Output helpers - // ------------------------------------------------------------------------------- - - /** - * Write-out multiple lines. - * - * @param array $lines - */ - protected function writeln(string|array $lines): void - { - $writeLines = is_array($lines) ? $lines : [$lines]; - foreach ($writeLines as $line) { - $this->io->writeln(' ' . $line); - } - } - - /** - * Write-out styled text lines. - * - * @param array $lines - */ - protected function text(string|array $lines): void - { - $writeLines = is_array($lines) ? $lines : [$lines]; - foreach ($writeLines as $line) { - $this->io->text(' ' . $line); - } - } - - /** - * Write-out a separator line. - */ - protected function hr(): void - { - $this->writeln([ - '╭──────────────────────────────────────────', - '', - ]); - } } diff --git a/app/Traits/ConsoleInputTrait.php b/app/Traits/ConsoleInputTrait.php new file mode 100644 index 00000000..7d66dc08 --- /dev/null +++ b/app/Traits/ConsoleInputTrait.php @@ -0,0 +1,56 @@ +input->getOption($optionName); + + if (is_string($value) && $value !== '') { + $wasProvided = true; + + return $value; + } + + $wasProvided = false; + + return text( + label: $label, + placeholder: $placeholder, + default: $default, + required: $required + ); + } +} diff --git a/app/Traits/ConsoleOutputTrait.php b/app/Traits/ConsoleOutputTrait.php new file mode 100644 index 00000000..974f8dbe --- /dev/null +++ b/app/Traits/ConsoleOutputTrait.php @@ -0,0 +1,155 @@ + $lines + */ + protected function writeln(string|array $lines): void + { + $writeLines = is_array($lines) ? $lines : [$lines]; + foreach ($writeLines as $line) { + $this->io->writeln(' '.$line); + } + } + + // + // Message helpers + // ------------------------------------------------------------------------------- + + /** + * Display a plain text message. + */ + protected function text(string $message): void + { + $this->writeln($message); + } + + /** + * Display an info message with cyan info symbol. + */ + protected function info(string $message): void + { + $this->writeln("ℹ {$message}"); + } + + /** + * Display a note message with cyan info symbol (alias for info). + */ + protected function note(string $message): void + { + $this->info($message); + } + + /** + * Display a success message with green checkmark. + */ + protected function success(string $message): void + { + $this->writeln("✓ {$message}"); + } + + /** + * Display a warning message with yellow warning symbol. + */ + protected function warning(string $message): void + { + $this->writeln("⚠ {$message}"); + } + + /** + * Display an error message with red X and optional tip. + */ + protected function error(string $message, ?string $tip = null): void + { + $output = ["✗ {$message}"]; + + if ($tip !== null) { + $output[] = "Tip: {$tip}"; + } + + $this->writeln($output); + } + + // + // Heading and separator + // ------------------------------------------------------------------------------- + + /** + * Write-out a heading. + */ + protected function h1(string $text): void + { + $this->writeln(''.$text.''); + } + + /** + * Write-out a separator line. + */ + protected function hr(): void + { + $this->writeln([ + '╭──────────────────────────────────────────', + '', + ]); + } + + // + // Command Hints + // ------------------------------------------------------------------------------- + + /** + * Display a command replay hint showing how to run non-interactively. + * + * @param array $options Array of option name => value pairs + * @param array $provided Array of option name => was provided (true) or prompted (false) + */ + protected function showCommandHint(string $commandName, array $options, array $provided): void + { + $this->writeln('💡 Next time, run non-interactively:'); + $this->writeln(''); + + // Build command parts + $parts = [$commandName]; + + foreach ($options as $optionName => $value) { + $wasProvided = $provided[$optionName] ?? false; + $color = $wasProvided ? 'gray' : 'bright-yellow'; + + if ($value === null || $value === '') { + continue; + } + + // Format the option + $optionFlag = '--'.$optionName; + if (is_bool($value)) { + if ($value) { + $parts[] = "{$optionFlag}"; + } + } else { + $stringValue = is_scalar($value) ? (string) $value : ''; + $escapedValue = escapeshellarg($stringValue); + $parts[] = "{$optionFlag}={$escapedValue}"; + } + } + + $command = implode(' ', $parts); + $this->writeln(" {$command}"); + $this->writeln(''); + } +} diff --git a/pint.json b/pint.json index ea5e72cf..bde1c173 100644 --- a/pint.json +++ b/pint.json @@ -1,3 +1,9 @@ { - "preset": "psr12" + "preset": "psr12", + "rules": { + "ordered_imports": { + "sort_algorithm": "alpha" + }, + "no_unused_imports": true + } } diff --git a/tests/Fixtures/TestConsoleCommand.php b/tests/Fixtures/TestConsoleCommand.php new file mode 100644 index 00000000..65c3d099 --- /dev/null +++ b/tests/Fixtures/TestConsoleCommand.php @@ -0,0 +1,83 @@ +methodToTest = $method; + $this->testArgs = $args; + } + + protected function configure(): void + { + parent::configure(); + $this->setName('test-console')->setDescription('Test console trait methods'); + $this->addOption('name', null, InputOption::VALUE_REQUIRED, 'Test name option'); + $this->addOption('host', null, InputOption::VALUE_REQUIRED, 'Test host option'); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + if ($this->methodToTest !== '') { + match ($this->methodToTest) { + 'text' => $this->text(...$this->testArgs), + 'info' => $this->info(...$this->testArgs), + 'note' => $this->note(...$this->testArgs), + 'error' => $this->error(...$this->testArgs), + 'success' => $this->success(...$this->testArgs), + 'warning' => $this->warning(...$this->testArgs), + 'h1' => $this->h1(...$this->testArgs), + 'hr' => $this->hr(), + 'writeln' => $this->writeln(...$this->testArgs), + 'showCommandHint' => $this->showCommandHint(...$this->testArgs), + 'getOptionOrPrompt' => $this->testGetOptionOrPrompt(), + default => null, + }; + } + + return Command::SUCCESS; + } + + /** + * Test helper for getOptionOrPrompt method. + */ + private function testGetOptionOrPrompt(): void + { + $wasProvided = false; + $result = $this->getOptionOrPrompt('name', 'Name:', wasProvided: $wasProvided); + $this->io->text("Result: {$result}, Provided: ".($wasProvided ? 'true' : 'false')); + } +} diff --git a/tests/Integration/SymfonyAppTest.php b/tests/Integration/SymfonyAppTest.php index ec3484f6..4ea25e93 100644 --- a/tests/Integration/SymfonyAppTest.php +++ b/tests/Integration/SymfonyAppTest.php @@ -4,10 +4,10 @@ use Bigpixelrocket\DeployerPHP\Container; use Bigpixelrocket\DeployerPHP\SymfonyApp; +use Symfony\Component\Console\Command\Command; +use Symfony\Component\Console\Exception\CommandNotFoundException; use Symfony\Component\Console\Input\ArrayInput; use Symfony\Component\Console\Output\BufferedOutput; -use Symfony\Component\Console\Exception\CommandNotFoundException; -use Symfony\Component\Console\Command\Command; describe('SymfonyApp', function () { diff --git a/tests/Unit/ContainerTest.php b/tests/Unit/ContainerTest.php index 1437c6fc..21685cb2 100644 --- a/tests/Unit/ContainerTest.php +++ b/tests/Unit/ContainerTest.php @@ -5,20 +5,20 @@ namespace Bigpixelrocket\DeployerPHP\Tests\Unit; use Bigpixelrocket\DeployerPHP\Container; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\SimpleService; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\AbstractClass; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\CircularA; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\NoConstructorService; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithMultipleDeps; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\PrivateConstructor; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithDefaults; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithIntersectionType; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithMultipleDeps; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithOptionalClassDep; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\CircularA; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithScalarParam; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestInterface; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\AbstractClass; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\PrivateConstructor; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithUnionType; -use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithIntersectionType; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithUnionAndCircular; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithUnionType; use Bigpixelrocket\DeployerPHP\Tests\Fixtures\ServiceWithUnresolvableDependency; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\SimpleService; +use Bigpixelrocket\DeployerPHP\Tests\Fixtures\TestInterface; // // Load test fixtures diff --git a/tests/Unit/Contracts/BaseCommandTest.php b/tests/Unit/Contracts/BaseCommandTest.php index 3e4168a2..2d4abdfe 100644 --- a/tests/Unit/Contracts/BaseCommandTest.php +++ b/tests/Unit/Contracts/BaseCommandTest.php @@ -4,8 +4,8 @@ namespace Bigpixelrocket\DeployerPHP\Tests\Unit\Contracts; -use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Bigpixelrocket\DeployerPHP\Container; +use Bigpixelrocket\DeployerPHP\Contracts\BaseCommand; use Bigpixelrocket\DeployerPHP\Services\EnvService; use Bigpixelrocket\DeployerPHP\Services\InventoryService; use Symfony\Component\Console\Command\Command; @@ -39,7 +39,7 @@ protected function configure(): void protected function execute(InputInterface $input, OutputInterface $output): int { $result = parent::execute($input, $output); - $this->io->text('Test command executed successfully'); + $this->text('Test command executed successfully'); return $result; } } @@ -68,7 +68,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int ->toContain('Custom path to inventory.yml file'); }); - it('executes with proper status output', function (bool $hasEnvFile, string $expectedEnvMessage) { + it('executes with proper env and inventory status output', function (bool $hasEnvFile, string $expectedEnvMessage) { // ARRANGE $container = new Container(); $env = mockEnvService($hasEnvFile); @@ -91,56 +91,4 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'env file exists' => [true, 'Reading variables from'], 'no env file' => [false, 'No .env file found'], ]); - - it('displays correct env status messages for different scenarios', function (bool $hasEnvFile, string $envPattern) { - // ARRANGE - $container = new Container(); - $env = mockEnvService($hasEnvFile); - $inventory = mockInventoryService(true); - $command = new TestableBaseCommand($container, $env, $inventory); - $tester = new CommandTester($command); - - // ACT - $exitCode = $tester->execute([]); - $output = $tester->getDisplay(); - - // ASSERT - expect($exitCode)->toBe(Command::SUCCESS) - ->and($output)->toMatch($envPattern) - ->and($output)->toContain('Reading inventory from'); - })->with([ - 'env file exists' => [true, '/Reading variables from/'], - 'no env file' => [false, '/No \\.env file found/'], - ]); - - it('hr displays separator line', function () { - // ARRANGE - $container = new Container(); - $env = mockEnvService(true); - $inventory = mockInventoryService(true); - - $command = new class ($container, $env, $inventory) extends BaseCommand { - protected function configure(): void - { - parent::configure(); - $this->setName('test-hr'); - } - - protected function execute(InputInterface $input, OutputInterface $output): int - { - $this->hr(); - return Command::SUCCESS; - } - }; - - $tester = new CommandTester($command); - - // ACT - $tester->execute([]); - $output = $tester->getDisplay(); - - // ASSERT - expect($output)->toContain('╭───────') - ->and(strlen($output))->toBeGreaterThan(40); - }); }); diff --git a/tests/Unit/Traits/ConsoleInputTraitTest.php b/tests/Unit/Traits/ConsoleInputTraitTest.php new file mode 100644 index 00000000..41d2311d --- /dev/null +++ b/tests/Unit/Traits/ConsoleInputTraitTest.php @@ -0,0 +1,35 @@ +command = new TestConsoleCommand($container, mockEnvService(true), mockInventoryService(true)); + $this->tester = new CommandTester($this->command); + }); + + // + // getOptionOrPrompt + + it('returns option value and sets wasProvided to true when option provided', function () { + // ARRANGE + $this->command->setTestMethod('getOptionOrPrompt'); + + // ACT + $this->tester->execute(['--name' => 'production']); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Result: production') + ->and($output)->toContain('Provided: true'); + }); +}); diff --git a/tests/Unit/Traits/ConsoleOutputTraitTest.php b/tests/Unit/Traits/ConsoleOutputTraitTest.php new file mode 100644 index 00000000..e6f09a0c --- /dev/null +++ b/tests/Unit/Traits/ConsoleOutputTraitTest.php @@ -0,0 +1,231 @@ +command = new TestConsoleCommand($container, mockEnvService(true), mockInventoryService(true)); + $this->tester = new CommandTester($this->command); + }); + + // + // Basic Output + + it('displays plain text message', function () { + // ARRANGE + $this->command->setTestMethod('text', ['Plain text message']); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Plain text message'); + }); + + // + // Status Messages + + it('displays info message with cyan info symbol', function () { + // ARRANGE + $this->command->setTestMethod('info', ['Information message']); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('ℹ') + ->and($output)->toContain('Information message'); + }); + + it('displays note message with cyan info symbol', function () { + // ARRANGE + $this->command->setTestMethod('note', ['Note message']); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('ℹ') + ->and($output)->toContain('Note message'); + }); + + it('displays error message with red X symbol', function () { + // ARRANGE + $this->command->setTestMethod('error', ['Connection failed']); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('✗') + ->and($output)->toContain('Connection failed'); + }); + + it('displays error message with optional tip', function () { + // ARRANGE + $this->command->setTestMethod('error', ['Connection failed', 'Check your SSH key']); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('✗') + ->and($output)->toContain('Connection failed') + ->and($output)->toContain('Tip:') + ->and($output)->toContain('Check your SSH key'); + }); + + it('displays success message with green checkmark', function () { + // ARRANGE + $this->command->setTestMethod('success', ['Server added successfully']); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('✓') + ->and($output)->toContain('Server added successfully'); + }); + + it('displays warning message with yellow warning symbol', function () { + // ARRANGE + $this->command->setTestMethod('warning', ['Skipping connection check']); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('⚠') + ->and($output)->toContain('Skipping connection check'); + }); + + // + // Output Formatting + + it('displays heading with icon', function () { + // ARRANGE + $this->command->setTestMethod('h1', ['Server Configuration']); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('▸') + ->and($output)->toContain('Server Configuration'); + }); + + it('displays separator line with box-drawing characters', function () { + // ARRANGE + $this->command->setTestMethod('hr'); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('╭───────') + ->and(strlen($output))->toBeGreaterThan(40); + }); + + it('writes single line', function () { + // ARRANGE + $this->command->setTestMethod('writeln', ['Output line']); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Output line'); + }); + + it('writes multiple lines', function () { + // ARRANGE + $this->command->setTestMethod('writeln', [['First line', 'Second line']]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('First line') + ->and($output)->toContain('Second line'); + }); + + // + // Command Hints + + it('displays command hint for non-interactive execution', function () { + // ARRANGE + $this->command->setTestMethod('showCommandHint', [ + 'server:add', + ['name' => 'prod-server', 'host' => '192.168.1.100', 'yes' => true], + ['name' => false, 'host' => false, 'yes' => true], + ]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('Next time, run non-interactively:') + ->and($output)->toContain('server:add') + ->and($output)->toContain('--name') + ->and($output)->toContain('--host') + ->and($output)->toContain('--yes'); + }); + + it('highlights prompted options differently in command hint', function () { + // ARRANGE + $this->command->setTestMethod('showCommandHint', [ + 'server:add', + ['name' => 'prod-server', 'host' => '192.168.1.100'], + ['name' => true, 'host' => false], + ]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('--name') + ->and($output)->toContain('--host') + ->and($output)->toContain('prod-server') + ->and($output)->toContain('192.168.1.100'); + }); + + it('skips null and empty values in command hint', function () { + // ARRANGE + $this->command->setTestMethod('showCommandHint', [ + 'server:add', + ['name' => 'prod-server', 'host' => null, 'port' => ''], + ['name' => true, 'host' => false, 'port' => false], + ]); + + // ACT + $this->tester->execute([]); + $output = $this->tester->getDisplay(); + + // ASSERT + expect($output)->toContain('--name') + ->and($output)->not->toContain('--host') + ->and($output)->not->toContain('--port'); + }); +});