diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..09230e0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,32 @@ +name: CI + +on: + push: + branches: [ main, master ] + pull_request: + +jobs: + tasker-bridges: + runs-on: ubuntu-latest + defaults: + run: + working-directory: TaskerBridges + strategy: + matrix: + php: ['8.2', '8.3'] + steps: + - uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + coverage: none + tools: phpstan, php-cs-fixer, phpunit + - name: Validate composer.json + run: php -v && composer validate --strict + - name: Install dependencies + run: composer install --no-interaction --no-progress --prefer-dist + - name: PHPUnit + run: vendor/bin/phpunit --configuration phpunit.xml --display-deprecations + - name: PHPStan + run: vendor/bin/phpstan analyse --no-progress --memory-limit=512M diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2550eb1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +.idea/ +/vendor/ +/composer.lock +/.phpunit.cache/ +/phpstan-baseline.neon diff --git a/MILESTONES.md b/MILESTONES.md index f8df0ba..272d6e8 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -3,25 +3,25 @@ This document defines the implementation milestones for the `getphred/tasker-bridges` package. ## 1. Bridge Infrastructure -- [ ] Initialize repository with `getphred/console-contracts` dependency. -- [ ] Set up testing environment for multi-toolkit validation (mocking Symfony/Laravel). +- [x] Initialize repository with `getphred/console-contracts` dependency. +- [x] Set up testing environment for multi-toolkit validation (mocking Symfony/Laravel). ## 2. Symfony Bridge -- [ ] Implement `SymfonyCommandAdapter`. -- [ ] Implement `SymfonyInputAdapter` and `SymfonyOutputAdapter`. -- [ ] Implement helper adapters (Interaction, ProgressBar, Table). -- [ ] Implement fixed markup translation (mapping to Symfony Formatter). +- [x] Implement `SymfonyCommandAdapter`. +- [x] Implement `SymfonyInputAdapter` and `SymfonyOutputAdapter`. +- [x] Implement helper adapters (Interaction, ProgressBar, Table). +- [x] Implement fixed markup translation (mapping to Symfony Formatter). ## 3. Laravel Bridge -- [ ] Implement `LaravelCommandAdapter` and `LaravelServiceProvider`. -- [ ] Integrate Symfony IO adapters within the Laravel context. -- [ ] Implement native Laravel interaction helper mappings. +- [x] Implement `LaravelCommandAdapter` and `LaravelServiceProvider`. +- [x] Integrate Symfony IO adapters within the Laravel context. +- [x] Implement native Laravel interaction helper mappings. ## 4. Phred (Native) Bridge -- [ ] Implement ANSI-based `OutputInterface` with full markup support. -- [ ] Implement lightweight interaction and progress helpers. -- [ ] Implement regex-based Markdown-to-Markup converter. +- [x] Implement ANSI-based `OutputInterface` with full markup support. +- [x] Implement lightweight interaction and progress helpers. +- [x] Implement regex-based Markdown-to-Markup converter. ## 5. Universal Integration -- [ ] Implement universal exit code translation logic. -- [ ] Implement global flag mapping (Verbosity, Decoration, Interactivity). +- [x] Implement universal exit code translation logic. +- [x] Implement global flag mapping (Verbosity, Decoration, Interactivity). diff --git a/composer.json b/composer.json index 19d91e7..fcf0bdd 100644 --- a/composer.json +++ b/composer.json @@ -7,6 +7,19 @@ "php": "^8.2", "getphred/console-contracts": "dev-master" }, + "require-dev": { + "phpunit/phpunit": "^10.0", + "phpstan/phpstan": "^1.10", + "symfony/console": "^6.0 || ^7.0", + "illuminate/console": "^10.0 || ^11.0", + "illuminate/support": "^10.0 || ^11.0" + }, + "repositories": [ + { + "type": "path", + "url": "../ConsoleContracts" + } + ], "suggest": { "getphred/tasker": "To use the PhredBridge with the Tasker runner", "symfony/console": "To use the SymfonyBridge", diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..ac454e0 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,5 @@ +parameters: + level: 8 + paths: + - src + treatPhpDocTypesAsCertain: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..739b75c --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,24 @@ + + + + + tests + + + + + + + + src + + + diff --git a/src/ExitCodeTranslator.php b/src/ExitCodeTranslator.php new file mode 100644 index 0000000..365d1fd --- /dev/null +++ b/src/ExitCodeTranslator.php @@ -0,0 +1,37 @@ +name = $phredCommand->getName(); + $this->description = $phredCommand->getDescription(); + + parent::__construct(); + } + + protected function configure(): void + { + foreach ($this->phredCommand->getArguments() as $name => $description) { + $this->addArgument($name, InputArgument::OPTIONAL, $description); + } + + foreach ($this->phredCommand->getOptions() as $name => $description) { + $this->addOption($name, null, InputOption::VALUE_OPTIONAL, $description); + } + } + + public function handle(): int + { + $phredInput = new SymfonyInputAdapter($this->input); + $phredOutput = new SymfonyOutputAdapter($this->output); + + return $this->phredCommand->execute($phredInput, $phredOutput); + } +} diff --git a/src/Laravel/LaravelServiceProvider.php b/src/Laravel/LaravelServiceProvider.php new file mode 100644 index 0000000..4ebb6e0 --- /dev/null +++ b/src/Laravel/LaravelServiceProvider.php @@ -0,0 +1,126 @@ +app->runningInConsole()) { + return; + } + + $this->discoverCommands(); + } + + private function discoverCommands(): void + { + $basePath = function_exists('base_path') ? base_path() : getcwd(); + + // 1. Discover via composer.json (package-based/explicitly registered) + $this->discoverFromComposer($basePath); + + // 2. Discover via app/Console/Commands (Laravel standard) + $this->discoverFromDirectory($basePath . DIRECTORY_SEPARATOR . 'app' . DIRECTORY_SEPARATOR . 'Console' . DIRECTORY_SEPARATOR . 'Commands'); + } + + private function discoverFromComposer(string $basePath): void + { + $rootComposer = $basePath . DIRECTORY_SEPARATOR . 'composer.json'; + if (!file_exists($rootComposer)) { + return; + } + + $json = file_get_contents($rootComposer); + if ($json === false) { + return; + } + + $composerData = json_decode($json, true); + if (!is_array($composerData)) { + return; + } + + $commands = $composerData['extra']['phred-tasker']['commands'] ?? []; + $adapters = []; + + foreach ((array)$commands as $commandClass) { + if (is_string($commandClass) && is_subclass_of($commandClass, CommandInterface::class)) { + $adapters[] = new LaravelCommandAdapter($this->app->make($commandClass)); + } + } + + if (!empty($adapters)) { + $this->commands($adapters); + } + } + + private function discoverFromDirectory(string $path): void + { + if (!is_dir($path)) { + return; + } + + $adapters = []; + $files = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path)); + + foreach ($files as $file) { + if (!$file->isFile() || $file->getExtension() !== 'php') { + continue; + } + + $className = $this->resolveClassName($file->getPathname()); + + if ($className && is_subclass_of($className, CommandInterface::class)) { + $adapters[] = new LaravelCommandAdapter($this->app->make($className)); + } + } + + if (!empty($adapters)) { + $this->commands($adapters); + } + } + + private function resolveClassName(string $path): ?string + { + $content = file_get_contents($path); + if ($content === false) { + return null; + } + + $tokens = token_get_all($content); + $namespace = ''; + $class = ''; + $gettingNamespace = false; + $gettingClass = false; + + foreach ($tokens as $index => $token) { + if (is_array($token)) { + if ($token[0] === T_NAMESPACE) { + $gettingNamespace = true; + } elseif ($token[0] === T_CLASS) { + $gettingClass = true; + } elseif ($gettingNamespace) { + if ($token[0] === T_STRING || $token[0] === T_NAME_QUALIFIED) { + $namespace .= $token[1]; + } elseif ($token[1] === ';') { + $gettingNamespace = false; + } + } elseif ($gettingClass && $token[0] === T_STRING) { + $class = $token[1]; + break; + } + } + } + + return $namespace ? $namespace . '\\' . $class : $class; + } +} diff --git a/src/Phred/InteractionAdapter.php b/src/Phred/InteractionAdapter.php new file mode 100644 index 0000000..67df093 --- /dev/null +++ b/src/Phred/InteractionAdapter.php @@ -0,0 +1,100 @@ +inputStream = $inputStream ?? STDIN; + } + + /** + * @inheritDoc + */ + public function ask(string $question, ?string $default = null): string + { + $prompt = $question . ($default ? " [$default]" : '') . ': '; + echo $prompt; + + $input = trim(fgets($this->inputStream) ?: ''); + + if ($input === '' && $default !== null) { + return $default; + } + + return $input; + } + + /** + * @inheritDoc + */ + public function confirm(string $question, bool $default = true): bool + { + $prompt = $question . ($default ? ' [Y/n]' : ' [y/N]') . ': '; + echo $prompt; + + $input = strtolower(trim(fgets($this->inputStream) ?: '')); + + if ($input === '') { + return $default; + } + + return $input === 'y' || $input === 'yes'; + } + + /** + * @inheritDoc + */ + public function secret(string $question): string + { + echo $question . ': '; + + // Hide input using stty if available + $sttyAvailable = shell_exec('stty 2>&1') !== null; + + if ($sttyAvailable) { + shell_exec('stty -echo'); + } + + $input = trim(fgets($this->inputStream) ?: ''); + + if ($sttyAvailable) { + shell_exec('stty echo'); + echo PHP_EOL; + } + + return $input; + } + + /** + * @inheritDoc + */ + public function choice(string $question, array $choices, mixed $default = null): mixed + { + echo $question . ':' . PHP_EOL; + + foreach ($choices as $key => $choice) { + echo " [$key] $choice" . PHP_EOL; + } + + $input = $this->ask('Choice', (string)$default); + + return $choices[$input] ?? $default; + } +} diff --git a/src/Phred/MarkdownConverter.php b/src/Phred/MarkdownConverter.php new file mode 100644 index 0000000..060712e --- /dev/null +++ b/src/Phred/MarkdownConverter.php @@ -0,0 +1,150 @@ +$1', $result); + $result = (string)preg_replace('/^## (.*)$/m', '$1', $result); + $result = (string)preg_replace('/^### (.*)$/m', '$1', $result); + + // Bold + $result = (string)preg_replace('/\*\*(.*?)\*\*/', '$1', $result); + $result = (string)preg_replace('/__(.*?)__/', '$1', $result); + + // Italic + $result = (string)preg_replace('/\*(.*?)\*/', '$1', $result); + $result = (string)preg_replace('/_(.*?)_/', '$1', $result); + + // Lists + $result = (string)preg_replace('/^[-*] (.*)$/m', ' • $1', $result); + + // Code blocks + $result = (string)preg_replace('/```(.*?)```/s', '$1', $result); + $result = (string)preg_replace('/`(.*?)`/', '$1', $result); + + // Tables + $result = $this->parseTables($result); + + return $result; + } + + private function parseTables(string $markdown): string + { + $lines = explode(PHP_EOL, $markdown); + $result = []; + $tableLines = []; + $inTable = false; + + foreach ($lines as $line) { + $isSeparator = (bool)preg_match('/^\|?[:\s-]*\|[:\s-]*\|?/', trim($line)); + $isRow = (bool)preg_match('/^\|.*\|$/', trim($line)); + + if ($isRow || ($inTable && $isSeparator)) { + $inTable = true; + $tableLines[] = $line; + } else { + if ($inTable) { + $result[] = $this->renderTable($tableLines); + $tableLines = []; + $inTable = false; + } + $result[] = $line; + } + } + + if ($inTable) { + $result[] = $this->renderTable($tableLines); + } + + return implode(PHP_EOL, $result); + } + + /** + * @param array $lines + */ + private function renderTable(array $lines): string + { + if (count($lines) < 2) { + return implode(PHP_EOL, $lines); + } + + $headers = []; + $rows = []; + $alignments = []; + $headerLine = array_shift($lines); + $separatorLine = array_shift($lines); + + // Parse Headers + $headers = array_map('trim', explode('|', trim($headerLine, '|'))); + + // Parse Alignments + if ($separatorLine !== null) { + $parts = explode('|', trim($separatorLine, '|')); + foreach ($parts as $index => $part) { + $part = trim($part); + if (str_starts_with($part, ':') && str_ends_with($part, ':')) { + $alignments[$index] = 'center'; + } elseif (str_ends_with($part, ':')) { + $alignments[$index] = 'right'; + } else { + $alignments[$index] = 'left'; + } + } + } + + // Parse Rows + foreach ($lines as $line) { + $rows[] = array_map('trim', explode('|', trim($line, '|'))); + } + + // Use TableAdapter to render + $table = new TableAdapter($this->output); + $table->setHeaders($headers); + $table->setColumnAlignments($alignments); + foreach ($rows as $row) { + $table->addRow($row); + } + + ob_start(); + $table->render(); + return trim((string)ob_get_clean()); + } + + /** + * @inheritDoc + */ + public function convertFile(string $path): string + { + if (!file_exists($path)) { + return ''; + } + + $content = file_get_contents($path); + if ($content === false) { + return ''; + } + + return $this->convert($content); + } +} diff --git a/src/Phred/ProgressBarAdapter.php b/src/Phred/ProgressBarAdapter.php new file mode 100644 index 0000000..b54b7a6 --- /dev/null +++ b/src/Phred/ProgressBarAdapter.php @@ -0,0 +1,75 @@ +max = $max; + $this->current = 0; + $this->render(); + } + + /** + * @inheritDoc + */ + public function advance(int $step = 1): void + { + $this->current += $step; + if ($this->max > 0 && $this->current > $this->max) { + $this->current = $this->max; + } + $this->render(); + } + + /** + * @inheritDoc + */ + public function finish(): void + { + if ($this->max > 0) { + $this->current = $this->max; + } + $this->render(); + $this->output->writeln(''); + } + + private function render(): void + { + $percent = $this->max > 0 ? ($this->current / $this->max) * 100 : 0; + $barWidth = 40; + $filledWidth = $this->max > 0 ? (int)round(($this->current / $this->max) * $barWidth) : 0; + + $bar = str_repeat('█', $filledWidth) . str_repeat('_', $barWidth - $filledWidth); + + $message = sprintf( + "\r [%s] %d%% (%d/%d)", + $bar, + $percent, + $this->current, + $this->max + ); + + $this->output->write($message); + } +} diff --git a/src/Phred/TableAdapter.php b/src/Phred/TableAdapter.php new file mode 100644 index 0000000..ab0d6be --- /dev/null +++ b/src/Phred/TableAdapter.php @@ -0,0 +1,137 @@ + + */ + private array $headers = []; + + /** + * @var array> + */ + private array $rows = []; + + /** + * @var array + */ + private array $alignments = []; + + public function __construct(private readonly OutputInterface $output) + { + } + + /** + * @inheritDoc + */ + public function setHeaders(array $headers): void + { + $this->headers = $headers; + } + + /** + * @inheritDoc + */ + public function addRow(array $row): void + { + $this->rows[] = $row; + } + + /** + * @inheritDoc + */ + public function setColumnAlignments(array $alignments): void + { + $this->alignments = $alignments; + } + + /** + * @inheritDoc + */ + public function render(): void + { + if (empty($this->headers) && empty($this->rows)) { + return; + } + + $columnWidths = $this->calculateColumnWidths(); + + // Render headers + if (!empty($this->headers)) { + $this->renderRow($this->headers, $columnWidths); + $this->renderSeparator($columnWidths); + } + + // Render rows + foreach ($this->rows as $row) { + $this->renderRow($row, $columnWidths); + } + } + + /** + * @return array + */ + private function calculateColumnWidths(): array + { + $widths = []; + + foreach ($this->headers as $index => $header) { + $widths[$index] = mb_strlen((string)$header); + } + + foreach ($this->rows as $row) { + foreach ($row as $index => $value) { + $width = mb_strlen((string)$value); + if (!isset($widths[$index]) || $width > $widths[$index]) { + $widths[$index] = $width; + } + } + } + + return $widths; + } + + /** + * @param array $row + * @param array $widths + */ + private function renderRow(array $row, array $widths): void + { + $formatted = '|'; + foreach ($widths as $index => $width) { + $value = (string)($row[$index] ?? ''); + $alignment = $this->alignments[$index] ?? 'left'; + + $padType = match ($alignment) { + 'right' => STR_PAD_LEFT, + 'center' => STR_PAD_BOTH, + default => STR_PAD_RIGHT, + }; + + $formatted .= ' ' . str_pad($value, $width, ' ', $padType) . ' |'; + } + $this->output->writeln($formatted); + } + + /** + * @param array $widths + */ + private function renderSeparator(array $widths): void + { + $separator = '+'; + foreach ($widths as $width) { + $separator .= str_repeat('-', $width + 2) . '+'; + } + $this->output->writeln($separator); + } +} diff --git a/src/Symfony/SymfonyCommandAdapter.php b/src/Symfony/SymfonyCommandAdapter.php new file mode 100644 index 0000000..2652e2a --- /dev/null +++ b/src/Symfony/SymfonyCommandAdapter.php @@ -0,0 +1,44 @@ +getName()); + } + + protected function configure(): void + { + $this->setDescription($this->phredCommand->getDescription()); + + foreach ($this->phredCommand->getArguments() as $name => $description) { + $this->addArgument($name, InputArgument::OPTIONAL, $description); + } + + foreach ($this->phredCommand->getOptions() as $name => $description) { + $this->addOption($name, null, InputOption::VALUE_OPTIONAL, $description); + } + } + + protected function execute(SymfonyInputInterface $input, SymfonyOutputInterface $output): int + { + $phredInput = new SymfonyInputAdapter($input); + $phredOutput = new SymfonyOutputAdapter($output); + + return $this->phredCommand->execute($phredInput, $phredOutput); + } +} diff --git a/src/Symfony/SymfonyFormatter.php b/src/Symfony/SymfonyFormatter.php new file mode 100644 index 0000000..f356a09 --- /dev/null +++ b/src/Symfony/SymfonyFormatter.php @@ -0,0 +1,39 @@ +getFormatter(); + + if (!$formatter->hasStyle('success')) { + $formatter->setStyle('success', new OutputFormatterStyle('green')); + } + + if (!$formatter->hasStyle('info')) { + $formatter->setStyle('info', new OutputFormatterStyle('blue')); + } + + if (!$formatter->hasStyle('warning')) { + $formatter->setStyle('warning', new OutputFormatterStyle('black', 'yellow')); + } + + // Symfony already has and by default + } +} diff --git a/src/Symfony/SymfonyInputAdapter.php b/src/Symfony/SymfonyInputAdapter.php new file mode 100644 index 0000000..80cabda --- /dev/null +++ b/src/Symfony/SymfonyInputAdapter.php @@ -0,0 +1,42 @@ +symfonyInput->getArgument($name) ?? $default; + } + + /** + * @inheritDoc + */ + public function getOption(string $name, mixed $default = null): mixed + { + return $this->symfonyInput->getOption($name) ?? $default; + } + + /** + * @inheritDoc + */ + public function hasOption(string $name): bool + { + return $this->symfonyInput->hasOption($name); + } +} diff --git a/src/Symfony/SymfonyInteractionAdapter.php b/src/Symfony/SymfonyInteractionAdapter.php new file mode 100644 index 0000000..4091239 --- /dev/null +++ b/src/Symfony/SymfonyInteractionAdapter.php @@ -0,0 +1,64 @@ +helper->ask($this->input, $this->output, $q); + } + + /** + * @inheritDoc + */ + public function confirm(string $question, bool $default = true): bool + { + $q = new ConfirmationQuestion($question, $default); + return (bool)$this->helper->ask($this->input, $this->output, $q); + } + + /** + * @inheritDoc + */ + public function secret(string $question): string + { + $q = new Question($question); + $q->setHidden(true); + $q->setHiddenFallback(true); + return (string)$this->helper->ask($this->input, $this->output, $q); + } + + /** + * @inheritDoc + */ + public function choice(string $question, array $choices, mixed $default = null): mixed + { + $q = new ChoiceQuestion($question, $choices, $default); + return $this->helper->ask($this->input, $this->output, $q); + } +} diff --git a/src/Symfony/SymfonyOutputAdapter.php b/src/Symfony/SymfonyOutputAdapter.php new file mode 100644 index 0000000..e54eb13 --- /dev/null +++ b/src/Symfony/SymfonyOutputAdapter.php @@ -0,0 +1,108 @@ +symfonyOutput); + } + + /** + * @inheritDoc + */ + public function write(string $message): void + { + $this->symfonyOutput->write($message); + } + + /** + * @inheritDoc + */ + public function writeln(string $message): void + { + $this->symfonyOutput->writeln($message); + } + + /** + * @inheritDoc + */ + public function success(string $message): void + { + $this->writeln("$message"); + } + + /** + * @inheritDoc + */ + public function info(string $message): void + { + $this->writeln("$message"); + } + + /** + * @inheritDoc + */ + public function error(string $message): void + { + $this->writeln("$message"); + } + + /** + * @inheritDoc + */ + public function warning(string $message): void + { + $this->writeln("$message"); + } + + /** + * @inheritDoc + */ + public function comment(string $message): void + { + $this->writeln("$message"); + } + + /** + * @inheritDoc + */ + public function setVerbosity(int $level): void + { + // Map Phred verbosity to Symfony verbosity + $symfonyLevel = match ($level) { + 0 => SymfonyOutputInterface::VERBOSITY_QUIET, + 1 => SymfonyOutputInterface::VERBOSITY_NORMAL, + 2 => SymfonyOutputInterface::VERBOSITY_VERBOSE, + 3 => SymfonyOutputInterface::VERBOSITY_VERY_VERBOSE, + 4 => SymfonyOutputInterface::VERBOSITY_DEBUG, + default => SymfonyOutputInterface::VERBOSITY_NORMAL, + }; + $this->symfonyOutput->setVerbosity($symfonyLevel); + } + + /** + * @inheritDoc + */ + public function getVerbosity(): int + { + $symfonyLevel = $this->symfonyOutput->getVerbosity(); + return match ($symfonyLevel) { + SymfonyOutputInterface::VERBOSITY_QUIET => 0, + SymfonyOutputInterface::VERBOSITY_NORMAL => 1, + SymfonyOutputInterface::VERBOSITY_VERBOSE => 2, + SymfonyOutputInterface::VERBOSITY_VERY_VERBOSE => 3, + SymfonyOutputInterface::VERBOSITY_DEBUG => 4, + default => 1, + }; + } +} diff --git a/src/Symfony/SymfonyProgressBarAdapter.php b/src/Symfony/SymfonyProgressBarAdapter.php new file mode 100644 index 0000000..da38341 --- /dev/null +++ b/src/Symfony/SymfonyProgressBarAdapter.php @@ -0,0 +1,42 @@ +progressBar->start($max); + } + + /** + * @inheritDoc + */ + public function advance(int $step = 1): void + { + $this->progressBar->advance($step); + } + + /** + * @inheritDoc + */ + public function finish(): void + { + $this->progressBar->finish(); + } +} diff --git a/src/Symfony/SymfonyTableAdapter.php b/src/Symfony/SymfonyTableAdapter.php new file mode 100644 index 0000000..9f05276 --- /dev/null +++ b/src/Symfony/SymfonyTableAdapter.php @@ -0,0 +1,54 @@ +table->setHeaders($headers); + } + + /** + * @inheritDoc + */ + public function addRow(array $row): void + { + $this->table->addRow($row); + } + + /** + * @inheritDoc + */ + public function setColumnAlignments(array $alignments): void + { + // Symfony doesn't have a direct Index => Alignment method on the Table helper + // that works exactly like this, but we can set them individually + foreach ($alignments as $index => $alignment) { + $this->table->setColumnStyle($index, $alignment); + } + } + + /** + * @inheritDoc + */ + public function render(): void + { + $this->table->render(); + } +} diff --git a/tests/LaravelBridgeTest.php b/tests/LaravelBridgeTest.php new file mode 100644 index 0000000..956b9b5 --- /dev/null +++ b/tests/LaravelBridgeTest.php @@ -0,0 +1,51 @@ + 'Name']; } + public function getOptions(): array { return []; } + public function execute(InputInterface $input, OutputInterface $output): int + { + $output->info('Hello ' . $input->getArgument('name')); + return 0; + } + }; + + $adapter = new LaravelCommandAdapter($phredCommand); + + // Mock Laravel components + $symfonyInput = new ArrayInput(['name' => 'John'], $adapter->getDefinition()); + $symfonyOutput = new BufferedOutput(); + + // Use reflection to set protected properties as Laravel's constructor and run logic is complex + $refl = new \ReflectionClass($adapter); + + $inputProp = $refl->getProperty('input'); + $inputProp->setValue($adapter, $symfonyInput); + + $outputProp = $refl->getProperty('output'); + $outputProp->setValue($adapter, $symfonyOutput); + + $exitCode = $adapter->handle(); + + $this->assertEquals(0, $exitCode); + $this->assertStringContainsString('Hello John', $symfonyOutput->fetch()); + } +} diff --git a/tests/PhredBridgeTest.php b/tests/PhredBridgeTest.php new file mode 100644 index 0000000..6a30505 --- /dev/null +++ b/tests/PhredBridgeTest.php @@ -0,0 +1,136 @@ + 'val1'], + ['opt1' => 'val2'] + ); + + $this->assertEquals('val1', $adapter->getArgument('arg1')); + $this->assertEquals('default', $adapter->getArgument('arg2', 'default')); + $this->assertEquals('val2', $adapter->getOption('opt1')); + $this->assertTrue($adapter->hasOption('opt1')); + $this->assertFalse($adapter->hasOption('opt2')); + } + + public function testOutputAdapterFormatting(): void + { + $adapter = new OutputAdapter(true); + + ob_start(); + $adapter->info('test'); + $output = ob_get_clean(); + + $this->assertStringContainsString("\033[34mtest\033[0m", $output); + + $adapter = new OutputAdapter(false); + ob_start(); + $adapter->info('test'); + $output = ob_get_clean(); + + $this->assertEquals("test" . PHP_EOL, $output); + } + + public function testTableAdapter(): void + { + $output = new OutputAdapter(false); + $table = new TableAdapter($output); + + $table->setHeaders(['ID', 'Name', 'Status']); + $table->setColumnAlignments([0 => 'right', 1 => 'left', 2 => 'center']); + $table->addRow([1, 'John', 'Active']); + $table->addRow([100, 'Jane', 'Inactive']); + + ob_start(); + $table->render(); + $content = ob_get_clean(); + + // ID column (right aligned) + $this->assertStringContainsString('| 1 |', $content); + $this->assertStringContainsString('| 100 |', $content); + + // Name column (left aligned) + $this->assertStringContainsString('| John |', $content); + + // Status column (center aligned) + $this->assertStringContainsString('| Active |', $content); + } + + public function testMarkdownConverter(): void + { + $output = new OutputAdapter(false); + $converter = new MarkdownConverter($output); + + $this->assertEquals('Title', $converter->convert('# Title')); + $this->assertEquals('Bold', $converter->convert('**Bold**')); + $this->assertEquals('Italic', $converter->convert('*Italic*')); + $this->assertEquals('code', $converter->convert('`code`')); + } + + public function testMarkdownTableConverter(): void + { + $output = new OutputAdapter(false); + $converter = new MarkdownConverter($output); + + $markdown = <<convert($markdown); + $this->assertStringContainsString('| ID | Name |', $result); + $this->assertStringContainsString('| 1 | John |', $result); + } + + public function testInteractionAdapter(): void + { + $input = fopen('php://memory', 'r+'); + if ($input === false) { + $this->fail('Could not open memory stream'); + } + fwrite($input, "John\ny\nsecret\n1\n"); + rewind($input); + + $adapter = new InteractionAdapter($input); + + ob_start(); + $this->assertEquals('John', $adapter->ask('Name')); + $this->assertTrue($adapter->confirm('Continue?')); + $this->assertEquals('secret', $adapter->secret('Password')); + $this->assertEquals('Choice 2', $adapter->choice('Select', ['0' => 'Choice 1', '1' => 'Choice 2'])); + ob_end_clean(); + + fclose($input); + } + + public function testProgressBarAdapter(): void + { + $output = new OutputAdapter(false); + $bar = new ProgressBarAdapter($output); + + ob_start(); + $bar->start(100); + $bar->advance(50); + $bar->finish(); + $content = ob_get_clean(); + + $this->assertStringContainsString('50%', $content); + $this->assertStringContainsString('100%', $content); + } +} diff --git a/tests/SymfonyBridgeTest.php b/tests/SymfonyBridgeTest.php new file mode 100644 index 0000000..47093eb --- /dev/null +++ b/tests/SymfonyBridgeTest.php @@ -0,0 +1,78 @@ + 'Name']; } + public function getOptions(): array { return []; } + public function execute(InputInterface $input, OutputInterface $output): int + { + $output->info('Hello ' . $input->getArgument('name')); + return 0; + } + }; + + $adapter = new SymfonyCommandAdapter($phredCommand); + $app = new Application(); + $app->add($adapter); + $app->setAutoExit(false); + + $input = new ArrayInput(['test:phred', 'name' => 'John']); + $output = new BufferedOutput(); + + $exitCode = $app->run($input, $output); + + $this->assertEquals(0, $exitCode); + $this->assertStringContainsString('Hello John', $output->fetch()); + } + + public function testVerbosityMapping(): void + { + $symfonyOutput = new BufferedOutput(); + $adapter = new SymfonyOutputAdapter($symfonyOutput); + + $adapter->setVerbosity(0); + $this->assertEquals(SymfonyOutputInterface::VERBOSITY_QUIET, $symfonyOutput->getVerbosity()); + + $adapter->setVerbosity(4); + $this->assertEquals(SymfonyOutputInterface::VERBOSITY_DEBUG, $symfonyOutput->getVerbosity()); + + $symfonyOutput->setVerbosity(SymfonyOutputInterface::VERBOSITY_VERBOSE); + $this->assertEquals(2, $adapter->getVerbosity()); + } + + public function testSymfonyHelpers(): void + { + $symfonyOutput = new BufferedOutput(); + $table = new \Symfony\Component\Console\Helper\Table($symfonyOutput); + $adapter = new \Phred\TaskerBridges\Symfony\SymfonyTableAdapter($table); + + $adapter->setHeaders(['ID', 'Name']); + $adapter->addRow([1, 'John']); + $adapter->render(); + + $output = $symfonyOutput->fetch(); + $this->assertStringContainsString('ID', $output); + $this->assertStringContainsString('John', $output); + } +}