diff --git a/.github/workflows/bug_report.yml b/.github/workflows/bug_report.yml new file mode 100644 index 0000000..4d627c6 --- /dev/null +++ b/.github/workflows/bug_report.yml @@ -0,0 +1,76 @@ +name: Bug Report +description: Something is broken or behaving unexpectedly +labels: ["bug", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Thank you for taking the time to report a bug. Please fill out the sections below as completely as possible. + + - type: textarea + id: description + attributes: + label: Describe the bug + description: A clear and concise description of what the bug is. + validations: + required: true + + - type: textarea + id: reproduce + attributes: + label: Steps to reproduce + description: Minimal code or CLI steps to reproduce the behavior. + placeholder: | + ```php + $input = (new TextInput('Name'))->run(); + ``` + validations: + required: true + + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What you expected to happen. + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual behavior + description: What actually happened. Include any error messages or terminal output. + validations: + required: true + + - type: input + id: php-version + attributes: + label: PHP version + placeholder: "e.g. 8.3.2" + validations: + required: true + + - type: input + id: os + attributes: + label: Operating system + placeholder: "e.g. Ubuntu 22.04, macOS 14, Windows 11" + validations: + required: true + + - type: input + id: terminal + attributes: + label: Terminal / shell + placeholder: "e.g. iTerm2 / zsh, Windows Terminal / PowerShell" + validations: + required: false + + - type: input + id: package-version + attributes: + label: php-io-cli version + placeholder: "e.g. 1.0.0" + validations: + required: true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..7f2ff99 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,169 @@ +name: CI + +on: + push: + branches: [ main, develop ] + pull_request: + branches: [ main, develop ] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # ────────────────────────────────────────────────────────────────── + # Static Analysis — PHPStan + # ────────────────────────────────────────────────────────────────── + phpstan: + name: "PHPStan (level 8)" + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.2" + coverage: none + tools: composer:v2 + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-php8.2-${{ hashFiles('**/composer.lock') }} + restore-keys: composer-${{ runner.os }}-php8.2- + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Run PHPStan + run: vendor/bin/phpstan analyse --error-format=github + + # ────────────────────────────────────────────────────────────────── + # Test Matrix — PHP 8.2, 8.3, 8.4 × latest deps / lowest deps + # ────────────────────────────────────────────────────────────────── + tests: + name: "PHPUnit (${{ matrix.php }}, ${{ matrix.deps }})" + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php: [ "8.2", "8.3", "8.4" ] + deps: [ "latest", "lowest" ] + exclude: + # lowest deps with bleeding-edge PHP is noisy — skip it + - php: "8.4" + deps: "lowest" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, pcntl, posix + coverage: ${{ matrix.php == '8.3' && matrix.deps == 'latest' && 'xdebug' || 'none' }} + tools: composer:v2 + + - name: Get Composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: composer-${{ runner.os }}-php${{ matrix.php }}-${{ matrix.deps }}-${{ hashFiles('**/composer.lock') }} + restore-keys: composer-${{ runner.os }}-php${{ matrix.php }}- + + - name: Install latest dependencies + if: matrix.deps == 'latest' + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Install lowest dependencies + if: matrix.deps == 'lowest' + run: composer update --no-interaction --prefer-dist --no-progress --prefer-lowest + + - name: Run tests (with coverage) + if: matrix.php == '8.3' && matrix.deps == 'latest' + run: vendor/bin/phpunit --coverage-clover=build/coverage/clover.xml + + - name: Run tests (no coverage) + if: matrix.php != '8.3' || matrix.deps != 'latest' + run: vendor/bin/phpunit --no-coverage + + - name: Upload coverage to Codecov + if: matrix.php == '8.3' && matrix.deps == 'latest' + uses: codecov/codecov-action@v4 + with: + files: build/coverage/clover.xml + fail_ci_if_error: false + + # ────────────────────────────────────────────────────────────────── + # Code Style — PHP CS Fixer (future gate, currently informational) + # ────────────────────────────────────────────────────────────────── + codestyle: + name: "Code Style (dry-run)" + runs-on: ubuntu-latest + continue-on-error: true # informational until .php-cs-fixer.php is added + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + coverage: none + tools: composer:v2, cs2pr + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Check if php-cs-fixer is installed + id: check-fixer + run: | + if [ -f vendor/bin/php-cs-fixer ]; then + echo "installed=true" >> $GITHUB_OUTPUT + else + echo "installed=false" >> $GITHUB_OUTPUT + fi + + - name: Run PHP CS Fixer (dry-run) + if: steps.check-fixer.outputs.installed == 'true' + run: vendor/bin/php-cs-fixer fix --dry-run --diff --format=checkstyle | cs2pr + + # ────────────────────────────────────────────────────────────────── + # Security Audit + # ────────────────────────────────────────────────────────────────── + security: + name: "Security Audit" + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Composer audit + run: composer audit --no-interaction diff --git a/.github/workflows/feature_request.yml b/.github/workflows/feature_request.yml new file mode 100644 index 0000000..56bd4d5 --- /dev/null +++ b/.github/workflows/feature_request.yml @@ -0,0 +1,57 @@ +name: Feature Request +description: Suggest a new component, API improvement, or enhancement +labels: ["enhancement", "needs-triage"] +body: + - type: markdown + attributes: + value: | + Have an idea? We'd love to hear it. Please describe your feature request below. + + - type: textarea + id: problem + attributes: + label: Problem / motivation + description: What problem does this feature solve? What are you trying to do that isn't possible today? + validations: + required: true + + - type: textarea + id: solution + attributes: + label: Proposed solution + description: Describe the feature you'd like to see. Include example code / API design if possible. + placeholder: | + ```php + // Example of how the new API would look + $result = (new MyNewComponent('Question'))->option1()->run(); + ``` + validations: + required: true + + - type: textarea + id: alternatives + attributes: + label: Alternatives considered + description: Any alternative approaches or workarounds you've tried. + validations: + required: false + + - type: dropdown + id: area + attributes: + label: Area + multiple: true + options: + - "New component" + - "Existing component improvement" + - "AbstractCommand / CLIApplication" + - "IOInterface / IO layer" + - "Shell integration" + - "Colors / Terminal" + - "Testing utilities" + - "Documentation" + - "Performance" + - "Windows support" + - "Other" + validations: + required: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..84378d7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,94 @@ +name: Release + +on: + push: + tags: + - "v[0-9]+.[0-9]+.[0-9]*" + +permissions: + contents: write + +jobs: + # ────────────────────────────────────────────────────────────────── + # Gate: Tests must pass before a release is published + # ────────────────────────────────────────────────────────────────── + test-gate: + name: "Pre-release test gate" + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: "8.3" + extensions: mbstring, pcntl, posix + coverage: none + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: PHPStan + run: vendor/bin/phpstan analyse + + - name: PHPUnit + run: vendor/bin/phpunit --no-coverage + + # ────────────────────────────────────────────────────────────────── + # Publish GitHub Release with auto-generated changelog + # ────────────────────────────────────────────────────────────────── + release: + name: "Publish GitHub Release" + runs-on: ubuntu-latest + needs: test-gate + + steps: + - name: Checkout (full history for changelog) + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Extract version from tag + id: version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT + + - name: Generate changelog from git log + id: changelog + run: | + PREVIOUS_TAG=$(git tag --sort=-version:refname | sed -n '2p') + if [ -z "$PREVIOUS_TAG" ]; then + RANGE="HEAD" + else + RANGE="${PREVIOUS_TAG}..HEAD" + fi + + echo "CHANGELOG<> $GITHUB_OUTPUT + git log "$RANGE" \ + --pretty=format:"- %s ([%h](https://github.com/${{ github.repository }}/commit/%H))" \ + --no-merges \ + >> $GITHUB_OUTPUT + echo "" >> $GITHUB_OUTPUT + echo "EOF" >> $GITHUB_OUTPUT + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ github.ref_name }} + name: "php-io-cli v${{ steps.version.outputs.VERSION }}" + body: | + ## What's Changed + + ${{ steps.changelog.outputs.CHANGELOG }} + + ## Installation + + ```bash + composer require alfacode-team/php-io-cli:^${{ steps.version.outputs.VERSION }} + ``` + + **Full Changelog**: https://github.com/${{ github.repository }}/compare/${{ steps.version.outputs.PREVIOUS_TAG }}...${{ github.ref_name }} + draft: false + prerelease: ${{ contains(github.ref_name, '-') }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f04f365 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,76 @@ +# Changelog + +All notable changes to `php-io-cli` are documented here. + +The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +This project follows [Semantic Versioning](https://semver.org/). + +--- + +## [Unreleased] + +### Planned +- SliderInput component +- RadioGroup component +- Searchable tree component +- Windows VT100 full test coverage +- PHP CS Fixer config + CI gate +- Mutation testing (Infection) + +--- + +## [1.0.0] — 2026-05-03 + +### Added + +**Interactive Components** +- `TextInput` — free-text input with virtual block cursor, inline validation, placeholder, default value, HOME/END navigation +- `Password` — masked input (● bullets) with TAB toggle to plaintext and live 5-point strength meter +- `NumberInput` — numeric input with arrow-key stepping, min/max clamping, integer mode, and inline range hint +- `Confirm` — boolean toggle with highlighted Yes/No buttons; keyboard toggle (← →) and y/n shortcuts +- `Select` — single-selection list with fuzzy search (via `Fuzzy::filter`), scroll windowing (8 items), and type-to-filter +- `MultiSelect` — checkbox list with spacebar toggle; resolves to `string[]` +- `Autocomplete` — text input with live fuzzy-search dropdown; TAB to fill, ↑↓ to navigate suggestions +- `DatePicker` — interactive calendar grid with day/week/month navigation; resolves to `DateTimeImmutable` + +**Display Components** +- `Table` — Unicode box-drawing table with ANSI-safe column width calculation, 4 border styles (box/bold/compact/minimal), alignment, and alternating row shading +- `Alert` — bordered notification boxes in 4 variants (success/error/warning/info) plus solid-background `block()` style +- `ProgressBar` — determinate (fill + %) and indeterminate (bounce) modes; accepts `$tick` callback for shell integration +- `SpinnerComponent` — non-blocking animated spinner with 6 frame styles; `start()` / `tick()` / `stop()` / `fail()` API + +**Application Layer** +- `AbstractCommand` — base class with argument/option parser (long/short/cluster flags, `--opt=val`), output helpers, component factory methods, and auto-generated `printHelp()` +- `CLIApplication` — self-bootstrapping runner with Composer-based command discovery, `list`/`help`/`version` built-ins, Levenshtein "did you mean?" suggestions, and TTY-aware interactive picker on ambiguous match +- `ConsoleIO` — Symfony Console bridge; delegates to reactive components on real TTY, falls back to `QuestionHelper` on pipes/CI +- `BufferIO` — in-memory IO for testing; captures output (ANSI-stripped), simulates user input via `setUserInputs()` +- `NullIO` — completely silent IO; all writes are no-ops, all interactive methods return `$default` + +**Internals** +- `State` — reactive key-value container with `__get`/`__set`, `batch()`, `increment()`/`decrement()`, `toggle()`, and `watch()` reactivity +- `Input` — key binding dispatcher with fallback, multi-key binding, and `return false` propagation stop +- `Hooks` — pub/sub event bus with `on()`/`once()`/`off()`/`dispatch()`/`dispatchUntil()` +- `Terminal` — raw mode driver with 10ms escape-sequence settling window, signal handling (SIGINT/SIGTERM), Windows VT100 support +- `Colors` — ANSI color/style helper with `NO_COLOR`/`FORCE_COLOR` env support, hex true-color, and `strip()` for test assertions +- `Fuzzy` — fuzzy search engine with prefix/substring/abbreviation/Levenshtein scoring +- `Shell` — `proc_open` wrapper with `stream_select()` streaming (no deadlocks), `$tick` callback for animation integration, and `ShellResult` value object +- `Renderer` — scroll windowing and in-place frame repainting with `beforeRender`/`afterRender` hooks +- `SpinnerFrames` — 6 built-in frame sets: `dots`, `line`, `bars`, `pulse`, `arc`, `bounce` + +**Testing & CI** +- PHPUnit 11 test suite with Unit and Integration test suites +- PHPStan level 8 configuration +- GitHub Actions CI matrix: PHP 8.2/8.3/8.4 × latest/lowest dependencies +- GitHub Actions release workflow with auto-generated changelog +- PR template, bug report and feature request issue templates + +**Examples** +- `examples/01-inputs.php` — all 8 interactive input components +- `examples/02-display.php` — Table, Alert, ProgressBar, Spinner +- `examples/03-application.php` — full CLIApplication with 4 commands +- `examples/04-shell.php` — Shell::run patterns with spinner and progress bar + +--- + +[Unreleased]: https://github.com/alfacode-team/php-io-cli/compare/v1.0.0...HEAD +[1.0.0]: https://github.com/alfacode-team/php-io-cli/releases/tag/v1.0.0 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..714384a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,280 @@ +# Contributing to php-io-cli + +Thank you for your interest in contributing! This document covers everything you need to get from zero to a merged pull request. + +--- + +## Table of Contents + +- [Code of Conduct](#code-of-conduct) +- [Development Setup](#development-setup) +- [Running Tests](#running-tests) +- [Static Analysis](#static-analysis) +- [Project Structure](#project-structure) +- [Writing Tests](#writing-tests) +- [Adding a New Component](#adding-a-new-component) +- [Commit Convention](#commit-convention) +- [Pull Request Process](#pull-request-process) + +--- + +## Code of Conduct + +Be kind, constructive, and respectful. We enforce the [Contributor Covenant](https://www.contributor-covenant.org/version/2/1/code_of_conduct/). + +--- + +## Development Setup + +```bash +git clone https://github.com/alfacode-team/php-io-cli.git +cd php-io-cli +composer install +``` + +**Requirements:** +- PHP 8.2+ +- Composer 2.x +- Extensions: `mbstring`, `pcntl` (Unix), `posix` (Unix) + +--- + +## Running Tests + +```bash +# All tests +composer test + +# Unit tests only +composer test:unit + +# Integration tests only +composer test:integration + +# With coverage (requires Xdebug or PCOV) +composer test:coverage +``` + +Tests use `BufferIO` for capturing output and `NullIO` for silent execution — no TTY or raw-mode involvement in tests. + +--- + +## Static Analysis + +```bash +# PHPStan level 8 +composer phpstan + +# If you have php-cs-fixer installed: +composer cs-check # dry-run +composer cs-fix # apply fixes +``` + +PHPStan is the hard gate — all PRs must pass at level 8. The only allowed ignoreError is for `State::$*` magic property access (by design — the reactive store uses `__get`/`__set`). + +--- + +## Project Structure + +``` +src/ +├── AbstractCommand.php # Base class for all commands +├── AbstractPrompt.php # Base class for all interactive components +├── CLIApplication.php # Application runner + dispatcher +├── Components/ +│ ├── Component.php # Base for reactive components +│ ├── Alert.php # Static banner rendering +│ ├── Autocomplete.php # Text + fuzzy dropdown +│ ├── Confirm.php # Boolean toggle +│ ├── DatePicker.php # Calendar grid +│ ├── MultiSelect.php # Checkbox list +│ ├── NumberInput.php # Numeric input with stepping +│ ├── Password.php # Masked input + strength meter +│ ├── ProgressBar.php # Determinate + indeterminate bar +│ ├── Select.php # Single-selection with fuzzy search +│ ├── SpinnerComponent.php # Non-blocking spinner wrapper +│ ├── Table.php # Unicode box-drawing table +│ └── TextInput.php # Free-text input +├── Depends/ +│ ├── Colors.php # ANSI color / style helper +│ ├── Fuzzy.php # Fuzzy search + scoring +│ ├── Input.php # Key binding dispatcher +│ ├── Key.php # Key constants + normalizer +│ ├── RenderContext.php # Render cycle metadata +│ ├── Renderer.php # Scroll windowing + cursor management +│ ├── Shell.php # proc_open wrapper with streaming +│ ├── ShellResult.php # Immutable shell result value object +│ ├── Spinner.php # Frame-based spinner engine +│ ├── SpinnerFrames.php # Frame set definitions +│ ├── State.php # Reactive key-value store +│ └── Terminal.php # Raw mode + escape sequences +├── BaseIO.php # PSR-3 bridge + shared IO base +├── BufferIO.php # In-memory IO for testing +├── ConsoleIO.php # Symfony Console + reactive component bridge +├── Hooks.php # Pub/sub event bus +├── IOInterface.php # Unified I/O contract +├── ILifecycle.php # Component lifecycle contract +├── IPromptComponent.php # run() contract +├── IRenderer.php # Renderer contract +├── NullIO.php # Silent no-op IO +└── Silencer.php # PHP error suppression utility + +tests/ +├── Unit/ # Pure unit tests (no I/O, no TTY) +└── Integration/ # Command + application integration tests + +examples/ +├── 01-inputs.php # All interactive input components +├── 02-display.php # Table, Alert, ProgressBar, Spinner +├── 03-application.php # Full CLIApplication with commands +└── 04-shell.php # Shell::run integration patterns +``` + +--- + +## Writing Tests + +### Unit tests (`tests/Unit/`) + +Test a single class in isolation. No I/O, no TTY, no disk. + +```php +final class MyClassTest extends TestCase +{ + public function test_something_specific(): void + { + $obj = new MyClass(); + $this->assertSame('expected', $obj->method()); + } +} +``` + +### Integration tests (`tests/Integration/`) + +Test how components interact — commands through `BufferIO`, application dispatch, etc. + +```php +final class MyCommandTest extends TestCase +{ + public function test_command_outputs_correctly(): void + { + $io = new BufferIO(); + $cmd = new MyCommand(); + + $exit = $cmd->execute(['arg1', '--flag'], $io); + + $this->assertSame(AbstractCommand::SUCCESS, $exit); + $this->assertStringContainsString('expected text', $io->getOutput()); + } +} +``` + +**Key testing utilities:** + +| Class | Use for | +|---|---| +| `BufferIO` | Capture command output, simulate user input | +| `NullIO` | Silent execution, test return codes only | +| `Colors::disable()` | Strip ANSI from output for assertion clarity | + +--- + +## Adding a New Component + +1. Create `src/Components/MyComponent.php` extending `Component` +2. Implement `setup()`, `render()`, and `resolve()` +3. Follow the `$lastLines` / `Terminal::moveCursorUp()` pattern for flicker-free redraws +4. Add a factory method in `AbstractCommand` if it's a common prompt type +5. Write unit tests for state mutations and a smoke-test for rendering +6. Add a usage example in `examples/` or update an existing one +7. Document the component in `README.md` + +**Minimal component template:** + +```php +final class MyComponent extends Component +{ + private int $lastLines = 0; + + public function __construct(private string $question) + { + parent::__construct(); + } + + protected function setup(): void + { + $this->state->batch(['value' => '', 'done' => false]); + + $this->input->bind('ENTER', function ($state): void { + $state->done = true; + $this->stop(); + }); + } + + public function render(): void + { + if ($this->lastLines > 0) { + Terminal::moveCursorUp($this->lastLines); + } + + $lines = []; + $lines[] = Colors::wrap('? ', Colors::CYAN) . $this->question; + // ... more lines + + foreach ($lines as $line) { + Terminal::clearLine(); + echo $line . PHP_EOL; + } + + $this->lastLines = count($lines); + } + + public function resolve(): mixed + { + return $this->state->value; + } +} +``` + +--- + +## Commit Convention + +We follow [Conventional Commits](https://www.conventionalcommits.org/): + +``` +(): + +[optional body] +[optional footer] +``` + +| Type | When to use | +|---|---| +| `feat` | New component or feature | +| `fix` | Bug fix | +| `refactor` | Internal restructuring, no user-visible change | +| `test` | Adding or improving tests | +| `docs` | Documentation only | +| `chore` | CI, build config, tooling | +| `perf` | Performance improvement | + +Examples: +``` +feat(components): add SliderInput component +fix(password): correct strength-meter index out-of-bounds edge case +test(state): add watcher notification tests +docs(readme): document Shell::capture() return type +``` + +--- + +## Pull Request Process + +1. Fork the repository and create a branch: `feat/my-feature` or `fix/issue-123` +2. Write your code + tests +3. Run `composer test` and `composer phpstan` — both must pass +4. Fill out the PR template completely +5. Request a review from a maintainer + +PRs that are missing tests or break PHPStan will not be merged until fixed. Small, focused PRs are preferred over large all-in-one changes. diff --git a/PULL_REQUEST_TEMPLATE.md b/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..79c6f61 --- /dev/null +++ b/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,39 @@ +## Description + + + +## Type of Change + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Documentation update +- [ ] Refactoring / internal improvement +- [ ] Test / CI improvement + +## Related Issues + + + +## Checklist + +- [ ] My code follows the project's coding style +- [ ] I have added / updated tests that cover my changes +- [ ] All existing tests still pass (`composer test`) +- [ ] PHPStan passes at level 8 (`composer phpstan`) +- [ ] I have updated the documentation / README if needed +- [ ] The `CHANGELOG.md` entry is written (if user-facing) + +## Testing + + + +```bash +# Example test steps +composer install +composer test +``` + +## Screenshots / Terminal Output + + diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..c1bb83f --- /dev/null +++ b/TODO.md @@ -0,0 +1,154 @@ +# TODO — php-io-cli Roadmap + +This file tracks known gaps, planned improvements, and long-term goals for `php-io-cli`. It is written for contributors who want to pick up meaningful work and for maintainers planning future releases. + +Status icons: 🔴 Not started · 🟡 In progress · 🟢 Done · ⚪ Deferred + +--- + +## 1. Test Coverage + +| Item | Priority | Status | Notes | +|---|---|---|---| +| Unit tests for `State` | P0 | 🟢 | Done in `tests/Unit/StateTest.php` | +| Unit tests for `Colors` | P0 | 🟢 | Done | +| Unit tests for `Fuzzy` | P0 | 🟢 | Done | +| Unit tests for `Key` | P0 | 🟢 | Done | +| Unit tests for `Hooks` | P0 | 🟢 | Done | +| Unit tests for `Input` | P0 | 🟢 | Done | +| Unit tests for `ShellResult` | P0 | 🟢 | Done | +| Unit tests for `RenderContext` | P0 | 🟢 | Done | +| Unit tests for `Table` | P0 | 🟢 | Done | +| Unit tests for `NullIO` | P0 | 🟢 | Done | +| Integration: `AbstractCommand` | P0 | 🟢 | Done | +| Integration: `CLIApplication` | P0 | 🟢 | Done | +| Integration: `BufferIO` | P0 | 🟢 | Done | +| Unit tests for `Alert` | P1 | 🔴 | Test output string contains expected borders + content | +| Unit tests for `SpinnerFrames` | P1 | 🔴 | Verify all named frame sets return non-empty arrays | +| Unit tests for `Spinner` | P1 | 🔴 | Tick advances frame; stop returns empty string | +| Unit tests for `Renderer` | P1 | 🔴 | Tricky — requires capturing stdout; mock Terminal | +| Integration: `BufferIO::setUserInputs` + commands | P1 | 🔴 | Test confirm/select prompts with pre-set inputs | +| Integration: `Shell::run` (echo command) | P2 | 🔴 | Use `echo` / `printf` — safe cross-platform | +| Integration: `Shell::capture` | P2 | 🔴 | Capture `php --version` or similar | +| Mutation testing via Infection | P2 | 🔴 | Add `infection/infection` dev dep; configure `infection.json5` | +| Coverage badge > 80% target | P2 | 🔴 | Depends on above items | + +--- + +## 2. New Components + +| Component | Priority | Status | Description | +|---|---|---|---| +| `SliderInput` | P1 | 🔴 | Horizontal bar slider for float/int ranges. Arrow keys ± step. | +| `RadioGroup` | P1 | 🔴 | Like `Select` but renders all options at once (no scroll). Good for short lists ≤ 5. | +| `SearchableTreeSelect` | P2 | 🔴 | Nested tree navigation. `parent > child > grandchild` grouping. | +| `TagInput` | P2 | 🔴 | Free-form comma-delimited tags with fuzzy autocomplete. | +| `CodeEditor` | P3 | 🔴 | Minimal inline code block with basic syntax highlighting. | +| `FilePathInput` | P2 | 🔴 | TextInput with Tab-completion from the filesystem. | +| `TimePicker` | P2 | 🔴 | Companion to `DatePicker`. HH:MM[:SS] with arrow-key stepping. | +| `ColorPicker` | P3 | 🔴 | TrueColor swatch grid, hex output. | + +--- + +## 3. Core / Architecture Improvements + +| Item | Priority | Status | Notes | +|---|---|---|---| +| **Abstract `AbstractPrompt`** — decouple `Terminal::readKey()` | P1 | 🔴 | Inject a `KeyReader` interface so components can be tested without a real terminal | +| **`Component` base** — remove direct `echo` from `render()` | P1 | 🔴 | Components should write to an `OutputInterface` buffer, not `STDOUT` directly. Enables headless rendering. | +| **Windows support** — full VT100 parity | P1 | 🔴 | `Terminal::readKey()` on Windows needs a separate implementation (no `stty`, use `ReadConsoleInput` via FFI or `sapi_windows_*`). Currently usable only in Windows Terminal / modern CMD. | +| **Async / non-blocking loop** | P2 | 🔴 | Optional event loop hook (e.g. Swoole / ReactPHP / Revolt) so components can run inside coroutines without blocking the main thread | +| **`IRenderer` diffing** | P2 | 🔴 | Implement dirty-region diffing in `Renderer` to only repaint changed lines, reducing flicker on slow terminals | +| **`State` serialization** | P2 | 🔴 | `State::toArray()` / `State::fromArray()` for save/restore across TTY sessions | +| **Component composition** | P2 | 🔴 | Allow embedding one component inside another (e.g. `TextInput` inside `Autocomplete` without copy/paste) | +| **Global `$this->ask()` shortcuts** return typed values | P2 | 🔴 | `AbstractCommand::askNumber()`, `askDate()`, `askPassword()` factory methods | +| **PSR-14 event dispatcher** | P3 | 🔴 | Replace `Hooks` with a PSR-14 compatible dispatcher, keep `Hooks` as a lightweight default | + +--- + +## 4. Developer Experience + +| Item | Priority | Status | Notes | +|---|---|---|---| +| PHP CS Fixer config (`.php-cs-fixer.php`) | P1 | 🔴 | PER-CS style; add `composer cs-fix` and `composer cs-check` scripts | +| `composer.json` scripts | P1 | 🔴 | `test`, `test:unit`, `test:integration`, `test:coverage`, `phpstan`, `cs-fix`, `cs-check` | +| Rector config for upgrade automation | P2 | 🔴 | `rector.php` targeting PHP 8.2+ idioms | +| Dev container / GitHub Codespaces | P2 | 🔴 | `.devcontainer/devcontainer.json` with PHP 8.3, Xdebug, Composer | +| Makefile for common tasks | P2 | 🔴 | `make test`, `make stan`, `make fix`, `make example` | +| Interactive demo script | P1 | 🔴 | `php examples/demo.php` — a menu-driven tour of all components | + +--- + +## 5. Documentation + +| Item | Priority | Status | Notes | +|---|---|---|---| +| Per-component `@example` docblocks | P1 | 🔴 | Every component class should have a self-contained usage example in its docblock | +| Architecture diagram (Mermaid) | P1 | 🔴 | Add `docs/architecture.md` with a Mermaid class/sequence diagram | +| Video demo / GIF | P2 | 🔴 | Record a terminal session showing the interactive components; embed in README | +| API reference (phpDocumentor) | P2 | 🔴 | Auto-generate and publish to GitHub Pages | +| "Building your first command" tutorial | P2 | 🔴 | Step-by-step guide: create a command, add inputs, test it | +| Migration guide from Symfony Console | P3 | 🔴 | Show how to replace `QuestionHelper` / `ChoiceQuestion` with reactive equivalents | + +--- + +## 6. CI / Publishing + +| Item | Priority | Status | Notes | +|---|---|---|---| +| PHPUnit CI matrix | P0 | 🟢 | PHP 8.2/8.3/8.4 × latest/lowest | +| PHPStan level 8 CI gate | P0 | 🟢 | Done | +| Security audit (`composer audit`) | P0 | 🟢 | Done | +| Release workflow | P0 | 🟢 | Auto-changelog from git log | +| PR template | P0 | 🟢 | Done | +| Issue templates | P0 | 🟢 | Bug + Feature templates done | +| Codecov integration | P1 | 🔴 | Add `CODECOV_TOKEN` secret; upload coverage report | +| Coverage badge in README | P1 | 🔴 | Depends on Codecov | +| PHPStan badge | P1 | 🔴 | Add static badge once baseline is locked | +| Packagist publish | P1 | 🔴 | Register on packagist.org; add `packagist` webhook to repo | +| `SECURITY.md` | P1 | 🔴 | Responsible disclosure policy | +| Dependabot for Composer | P2 | 🔴 | `.github/dependabot.yml` — weekly updates to dev deps | +| Branch protection rules | P2 | 🔴 | Require CI + review before merge to `main` | +| `CODEOWNERS` | P2 | 🔴 | Auto-assign reviewers by area | + +--- + +## 7. Long-Term Goals + +These are aspirational goals for when the library has a stable user base. + +### v1.x — Stabilization + +- Lock the public API (no breaking changes) +- > 80% test coverage with mutation score +- All P1 items above resolved +- Windows Terminal fully supported +- Listed on Packagist with > 100 installs/month + +### v2.0 — Architecture Evolution + +- `KeyReader` interface — fully testable components, no `STDIN` dependency +- `OutputBuffer` abstraction — components write to a buffer, not `STDOUT` directly +- PSR-14 event dispatcher +- Optional async/event-loop integration (Revolt / Swoole) +- Support for multi-column layouts (side-by-side components) + +### Community Goals + +- [ ] Published on Packagist (`alfacode-team/php-io-cli`) +- [ ] `CONTRIBUTING.md` includes "good first issue" labels guide +- [ ] At least 3 external contributors +- [ ] Listed in [Awesome PHP](https://github.com/ziadoz/awesome-php) +- [ ] Compared favorably to Laravel Prompts / Symfony Console in benchmarks +- [ ] Documented integration with Laravel Zero, Symfony, and standalone PHP CLI apps + +--- + +## How to Pick Up a Task + +1. Search for open [issues](https://github.com/alfacode-team/php-io-cli/issues) tagged `good first issue` or `help wanted` +2. Comment on the issue to claim it +3. Read `CONTRIBUTING.md` for the setup guide +4. Open a PR referencing the issue + +If no issue exists for something in this file, open one first to discuss the approach before implementing. diff --git a/examples/01-inputs.php b/examples/01-inputs.php new file mode 100644 index 0000000..a618bb1 --- /dev/null +++ b/examples/01-inputs.php @@ -0,0 +1,108 @@ +#!/usr/bin/env php +placeholder('e.g. Alice') + ->default('World') + ->validate(function (string $value): ?string { + return mb_strlen($value) >= 2 ? null : 'Name must be at least 2 characters.'; + }) + ->run(); + +Colors::line(" → Name: {$name}", Colors::GREEN); + +// ── 2. Number Input ─────────────────────────────────────────────── + +$port = (new NumberInput('Server port?')) + ->min(1) + ->max(65535) + ->default(8080) + ->step(100) + ->integer() + ->run(); + +Colors::line(" → Port: {$port}", Colors::GREEN); + +// ── 3. Password ─────────────────────────────────────────────────── + +$secret = (new Password('Enter a password')) + ->showStrength() + ->run(); + +Colors::line(" → Password length: " . mb_strlen((string)$secret) . " chars", Colors::GREEN); + +// ── 4. Confirm ──────────────────────────────────────────────────── + +$confirmed = (new Confirm("Do you want to continue?", true))->run(); + +Colors::line(" → Confirmed: " . ($confirmed ? 'Yes' : 'No'), Colors::GREEN); + +// ── 5. Select ───────────────────────────────────────────────────── + +$environment = (new Select('Select deployment environment', [ + 'production', + 'staging', + 'development', + 'local', +]))->run(); + +Colors::line(" → Environment: {$environment}", Colors::GREEN); + +// ── 6. Multi Select ─────────────────────────────────────────────── + +$features = (new MultiSelect('Which features to enable?', [ + 'Authentication', + 'API Gateway', + 'Queue Worker', + 'Scheduler', + 'WebSockets', + 'Rate Limiting', +]))->run(); + +Colors::line(" → Features: " . implode(', ', $features), Colors::GREEN); + +// ── 7. Autocomplete ─────────────────────────────────────────────── + +$framework = (new Autocomplete('Pick a PHP framework', [ + 'Laravel', 'Symfony', 'Slim', 'Laminas', 'CodeIgniter', + 'Yii', 'CakePHP', 'Phalcon', 'Lumen', 'Hyperf', +])) + ->maxSuggestions(5) + ->run(); + +Colors::line(" → Framework: {$framework}", Colors::GREEN); + +// ── 8. DatePicker ───────────────────────────────────────────────── + +$date = (new DatePicker('Select a release date'))->run(); + +Colors::line(" → Date: " . $date->format('Y-m-d'), Colors::GREEN); + +// ── Summary ─────────────────────────────────────────────────────── + +echo PHP_EOL; +Colors::line(" All inputs collected successfully!", [Colors::BOLD, Colors::GREEN]); +echo PHP_EOL; diff --git a/examples/02-display.php b/examples/02-display.php new file mode 100644 index 0000000..4bc0f9e --- /dev/null +++ b/examples/02-display.php @@ -0,0 +1,130 @@ +#!/usr/bin/env php +headers(['Service', 'Status', 'Latency', 'Requests']) + ->rows([ + ['api-gateway', Colors::wrap('healthy', Colors::GREEN), '12 ms', '15,204'], + ['auth-service', Colors::wrap('degraded', Colors::YELLOW), '340 ms', '3,891'], + ['payment-worker', Colors::wrap('down', Colors::RED), '—', '0'], + ['cache-service', Colors::wrap('healthy', Colors::GREEN), '2 ms', '52,001'], + ]) + ->align([3 => 'right']) + ->render(); + +Colors::line(" Bold style:", Colors::BOLD); +Table::make() + ->headers(['Package', 'Version', 'License']) + ->rows([ + ['php-io-cli', '1.0.0', 'MIT'], + ['psr/log', '3.0.0', 'MIT'], + ['phpunit', '11.0', 'BSD-3'], + ]) + ->style('bold') + ->render(); + +Colors::line(" Minimal style:", Colors::BOLD); +Table::make() + ->headers(['Key', 'Value']) + ->rows([ + ['APP_ENV', 'production'], + ['DB_HOST', 'localhost'], + ['DB_PORT', '5432'], + ]) + ->style('minimal') + ->striped(false) + ->render(); + +// ── Progress Bar (Determinate) ──────────────────────────────────── + +Colors::line(" ── Progress Bar (Determinate) ───────────────", Colors::CYAN); +echo PHP_EOL; + +$bar = new ProgressBar('Processing records', 50); +$bar->start(); + +for ($i = 0; $i < 50; $i++) { + usleep(30_000); // 30ms per step + $bar->advance(1, "Record #{$i}"); +} + +$bar->finish('All 50 records processed'); + +// ── Progress Bar (Indeterminate) ────────────────────────────────── + +Colors::line(" ── Progress Bar (Indeterminate) ────────────", Colors::CYAN); +echo PHP_EOL; + +$indeterminate = new ProgressBar('Connecting to cluster'); +$indeterminate->start(); + +for ($i = 0; $i < 30; $i++) { + usleep(50_000); + $indeterminate->tick("Attempt {$i}…"); +} + +$indeterminate->finish('Connection established'); + +// ── Spinner ─────────────────────────────────────────────────────── + +Colors::line(" ── Spinner Styles ───────────────────────────", Colors::CYAN); +echo PHP_EOL; + +$styles = ['dots', 'line', 'bars', 'pulse', 'arc', 'bounce']; + +foreach ($styles as $style) { + $spin = new SpinnerComponent("Spinner: {$style}", $style); + $spin->start(); + + for ($i = 0; $i < 20; $i++) { + usleep(80_000); + $spin->tick("Running {$style} animation…"); + } + + $spin->stop("Finished: {$style}"); +} + +echo PHP_EOL; +Colors::line(" Display components demo complete!", [Colors::BOLD, Colors::GREEN]); +echo PHP_EOL; diff --git a/examples/03-application.php b/examples/03-application.php new file mode 100644 index 0000000..542e525 --- /dev/null +++ b/examples/03-application.php @@ -0,0 +1,265 @@ +#!/usr/bin/env php +name = 'deploy'; + $this->description = 'Deploy the application to an environment'; + + $this->addArgument('environment', 'Target environment', required: true); + $this->addOption('tag', 't', 'Git tag to deploy', acceptsValue: true, default: 'latest'); + $this->addOption('dry-run', 'd', 'Simulate without side-effects'); + $this->addOption('force', 'f', 'Skip confirmation prompt'); + } + + protected function handle(): int + { + $env = (string) $this->argument('environment'); + $tag = (string) $this->option('tag', 'latest'); + $dryRun = $this->hasOption('dry-run'); + + $this->section("Deployment: {$tag} → {$env}"); + + if (!in_array($env, ['production', 'staging', 'development', 'local'], true)) { + $this->error("Unknown environment: {$env}"); + return self::INVALID; + } + + if ($env === 'production' && !$this->hasOption('force')) { + $confirmed = $this->confirm("You are deploying to PRODUCTION. Are you sure?", false); + if (!$confirmed) { + $this->muted('Deployment cancelled.'); + return self::SUCCESS; + } + } + + if ($dryRun) { + $this->warning('DRY RUN — no changes will be made.'); + } + + // Simulate multi-step deployment with ProgressBar + $steps = [ + 'Pulling latest code', + 'Installing dependencies', + 'Running migrations', + 'Clearing caches', + 'Restarting services', + ]; + + $bar = $this->progressBar("Deploying to {$env}", count($steps)); + $bar->start(); + + foreach ($steps as $step) { + usleep(400_000); // 400ms simulated work + $bar->advance(1, $step); + } + + $bar->finish("Deployed {$tag} → {$env}"); + + $this->alertSuccess("Deployment complete!", [ + "Environment: {$env}", + "Tag: {$tag}", + "Dry-run: " . ($dryRun ? 'yes' : 'no'), + ]); + + return self::SUCCESS; + } +} + +/** + * Database migration command. + */ +final class MigrateCommand extends AbstractCommand +{ + protected function configure(): void + { + $this->name = 'db:migrate'; + $this->description = 'Run pending database migrations'; + + $this->addOption('rollback', 'r', 'Rollback the last batch'); + $this->addOption('steps', 's', 'Number of steps to roll back', acceptsValue: true, default: '1'); + } + + protected function handle(): int + { + $rollback = $this->hasOption('rollback'); + $steps = (int) $this->option('steps', '1'); + + $this->section($rollback ? "Rolling back {$steps} migration(s)" : 'Running migrations'); + + $migrations = $rollback + ? array_reverse($this->fakeMigrations()) + : $this->fakeMigrations(); + + if (empty($migrations)) { + $this->info('Nothing to migrate.'); + return self::SUCCESS; + } + + $bar = $this->progressBar($rollback ? 'Rolling back' : 'Migrating', count($migrations)); + $bar->start(); + + foreach ($migrations as $migration) { + usleep(200_000); + $bar->advance(1, $migration); + } + + $bar->finish($rollback ? 'Rollback complete' : 'Migration complete'); + + return self::SUCCESS; + } + + private function fakeMigrations(): array + { + return [ + '2024_01_01_000001_create_users_table', + '2024_01_02_000002_create_sessions_table', + '2024_03_15_000003_add_role_to_users', + '2024_06_20_000004_create_audit_log', + ]; + } +} + +/** + * Interactive project scaffold command. + */ +final class MakeModuleCommand extends AbstractCommand +{ + protected function configure(): void + { + $this->name = 'make:module'; + $this->description = 'Scaffold a new application module'; + } + + protected function handle(): int + { + $this->section('Module Generator'); + + $name = $this->ask('Module name (kebab-case)', 'my-module'); + + $features = (new \AlfacodeTeam\PhpIoCli\Components\MultiSelect( + 'Select features to include', + ['Controller', 'Repository', 'Service', 'Events', 'Tests', 'Migration', 'Factory'] + ))->run(); + + $this->newLine(); + $this->info("Creating module: {$name}"); + + // Simulate file generation + $spin = $this->spinner('Generating files'); + $spin->start(); + + foreach ($features as $feature) { + usleep(150_000); + $spin->tick("Creating {$feature}…"); + } + + $spin->stop("Module '{$name}' created"); + + // Show summary table + $this->table() + ->headers(['File', 'Status']) + ->rows(array_map( + fn (string $f) => ["src/{$name}/{$f}.php", Colors::wrap('created', Colors::GREEN)], + $features + )) + ->render(); + + $this->alertSuccess("Module created!", [ + "Name: {$name}", + "Files: " . count($features), + ]); + + return self::SUCCESS; + } +} + +/** + * List environment variables. + */ +final class EnvCommand extends AbstractCommand +{ + protected function configure(): void + { + $this->name = 'env'; + $this->description = 'Display current environment variables'; + $this->addOption('filter', 'f', 'Filter by prefix', acceptsValue: true, default: ''); + } + + protected function handle(): int + { + $filter = (string) $this->option('filter', ''); + + $vars = [ + ['APP_NAME', 'MyApplication'], + ['APP_ENV', 'local'], + ['APP_DEBUG', 'true'], + ['DB_HOST', 'localhost'], + ['DB_PORT', '5432'], + ['DB_DATABASE', 'myapp'], + ['CACHE_DRIVER','redis'], + ['QUEUE_DRIVER','database'], + ]; + + if ($filter !== '') { + $vars = array_filter($vars, fn($row) => str_starts_with($row[0], strtoupper($filter))); + $vars = array_values($vars); + } + + if (empty($vars)) { + $this->warning("No variables matching prefix: {$filter}"); + return self::SUCCESS; + } + + $this->section('Environment Variables' . ($filter ? " (filter: {$filter})" : '')); + + $this->table() + ->headers(['Variable', 'Value']) + ->rows($vars) + ->style('compact') + ->render(); + + return self::SUCCESS; + } +} + +// ── Bootstrap ───────────────────────────────────────────────────── + +(new CLIApplication('MyPlatform CLI', '1.0.0')) + ->add( + new DeployCommand(), + new MigrateCommand(), + new MakeModuleCommand(), + new EnvCommand(), + ) + ->run(); diff --git a/examples/04-shell.php b/examples/04-shell.php new file mode 100644 index 0000000..723dcf1 --- /dev/null +++ b/examples/04-shell.php @@ -0,0 +1,124 @@ +#!/usr/bin/env php +start(); + +$result = Shell::run( + 'ls -la /tmp 2>&1 | head -20', + tick: function (string $lastLine) use ($spin): void { + $spin->tick($lastLine); + } +); + +if ($result->ok()) { + $spin->stop('Directory listing complete'); + Colors::line(" Output lines: " . count($result->stdout), Colors::GREEN); +} else { + $spin->fail('Command failed'); + Alert::error('Shell error', $result->meaningfulErrors()); +} + +echo PHP_EOL; + +// ── Example 3: Shell::run with ProgressBar (multi-step) ──────────── + +Colors::line(" 3. Multi-step pipeline with ProgressBar", Colors::BOLD); +echo PHP_EOL; + +$steps = [ + ['Check PHP version', 'php --version'], + ['Check Git version', 'git --version'], + ['List current dir', 'ls -1 . | head -5'], + ['Show disk usage', 'df -h / 2>/dev/null || echo "n/a"'], + ['Show date/time', 'date'], +]; + +$bar = new ProgressBar('Running pipeline', count($steps)); +$bar->start(); + +$allPassed = true; + +foreach ($steps as [$label, $command]) { + $stepResult = Shell::run( + $command, + tick: fn() => $bar->advance(0) // redraw without advancing + ); + + if ($stepResult->failed()) { + $allPassed = false; + $bar->advance(1, "✘ {$label}"); + } else { + $bar->advance(1, "✔ {$label}"); + } +} + +$bar->finish($allPassed ? 'All steps passed' : 'Some steps failed'); + +echo PHP_EOL; + +// ── Example 4: Error handling ────────────────────────────────────── + +Colors::line(" 4. Error handling", Colors::BOLD); +echo PHP_EOL; + +$spin2 = new SpinnerComponent('Running a command that fails', 'arc'); +$spin2->start(); + +$failResult = Shell::run('ls /nonexistent/path/that/does/not/exist 2>&1'); + +for ($i = 0; $i < 5; $i++) { + usleep(100_000); + $spin2->tick('Checking…'); +} + +if ($failResult->failed()) { + $spin2->fail("Command exited with code: {$failResult->exitCode}"); + Alert::warning('Expected failure (demo)', [ + 'Exit code: ' . $failResult->exitCode, + 'Stderr: ' . implode(' ', $failResult->meaningfulErrors()), + ]); +} else { + $spin2->stop('Unexpectedly succeeded'); +} + +echo PHP_EOL; +Colors::line(" Shell integration demo complete!", [Colors::BOLD, Colors::GREEN]); +echo PHP_EOL; diff --git a/phpstan.neon b/phpstan.neon index 0cac9b3..0cc1c94 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -3,5 +3,18 @@ parameters: paths: - src ignoreErrors: - # Allows magic property access on the State object - - '#Access to an undefined property AlfacodeTeam\\PhpIoCli\\Depends\\State::\$[a-zA-Z0-9_]+#' \ No newline at end of file + # Allows magic property access on the State object (by design — reactive store) + - '#Access to an undefined property AlfacodeTeam\\PhpIoCli\\Depends\\State::\$[a-zA-Z0-9_]+#' + # State::__set / __get intentionally bypasses property typing + - '#Call to an undefined method AlfacodeTeam\\PhpIoCli\\Depends\\State::.*#' + + treatPhpDocTypesAsCertain: false + reportUnmatchedIgnoredErrors: false + + # Paths to exclude from analysis + excludePaths: + - tests/ + - vendor/ + + # Require strict types declaration + checkMissingIterableValueType: false diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..00f355f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,38 @@ + + + + + + tests/Unit + + + tests/Integration + + + + + + src + + + src/Silencer.php + + + + + + + + + + + + + + diff --git a/tests/Integration/AbstractCommandTest.php b/tests/Integration/AbstractCommandTest.php new file mode 100644 index 0000000..e680b76 --- /dev/null +++ b/tests/Integration/AbstractCommandTest.php @@ -0,0 +1,246 @@ +name = 'echo'; + $this->description = 'Echoes back arguments and options'; + + $this->addArgument('message', 'The message to echo', required: true); + $this->addOption('upper', 'u', 'Output in uppercase'); + $this->addOption('repeat', 'r', 'Repeat count', acceptsValue: true, default: '1'); + } + + protected function handle(): int + { + $msg = (string) $this->argument('message'); + $upper = $this->hasOption('upper'); + $times = (int) $this->option('repeat', '1'); + + if ($upper) { + $msg = strtoupper($msg); + } + + for ($i = 0; $i < $times; $i++) { + $this->info($msg); + } + + return self::SUCCESS; + } +} + +/** + * Command that validates a required argument. + */ +final class RequiredArgCommand extends AbstractCommand +{ + protected function configure(): void + { + $this->name = 'req'; + $this->addArgument('name', 'Name', required: true); + } + + protected function handle(): int + { + $this->success((string) $this->argument('name')); + return self::SUCCESS; + } +} + +/** + * Command that always throws a runtime exception. + */ +final class FailingCommand extends AbstractCommand +{ + protected function configure(): void + { + $this->name = 'fail'; + } + + protected function handle(): int + { + throw new \RuntimeException('Something went wrong'); + } +} + +/** + * Command that returns FAILURE explicitly. + */ +final class ExplicitFailCommand extends AbstractCommand +{ + protected function configure(): void + { + $this->name = 'explicit-fail'; + } + + protected function handle(): int + { + $this->error('Explicit failure'); + return self::FAILURE; + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/** + * @covers \AlfacodeTeam\PhpIoCli\AbstractCommand + */ +final class AbstractCommandTest extends TestCase +{ + // --------------------------------------------------------------- + // Basic execution + // --------------------------------------------------------------- + + public function test_command_returns_success_code(): void + { + $io = new BufferIO(); + $cmd = new EchoCommand(); + + $exit = $cmd->execute(['hello'], $io); + + $this->assertSame(AbstractCommand::SUCCESS, $exit); + } + + public function test_command_outputs_argument(): void + { + $io = new BufferIO(); + $cmd = new EchoCommand(); + + $cmd->execute(['hello world'], $io); + + $this->assertStringContainsString('hello world', $io->getOutput()); + } + + // --------------------------------------------------------------- + // Options + // --------------------------------------------------------------- + + public function test_long_flag_option_works(): void + { + $io = new BufferIO(); + $cmd = new EchoCommand(); + + $cmd->execute(['hello', '--upper'], $io); + + $this->assertStringContainsString('HELLO', $io->getOutput()); + } + + public function test_short_flag_option_works(): void + { + $io = new BufferIO(); + $cmd = new EchoCommand(); + + $cmd->execute(['hello', '-u'], $io); + + $this->assertStringContainsString('HELLO', $io->getOutput()); + } + + public function test_option_with_value_via_equals(): void + { + $io = new BufferIO(); + $cmd = new EchoCommand(); + + $cmd->execute(['hi', '--repeat=3'], $io); + $output = $io->getOutput(); + + // "hi" should appear 3 times + $this->assertSame(3, substr_count($output, 'hi')); + } + + public function test_option_with_value_via_space(): void + { + $io = new BufferIO(); + $cmd = new EchoCommand(); + + $cmd->execute(['hi', '--repeat', '2'], $io); + $output = $io->getOutput(); + + $this->assertSame(2, substr_count($output, 'hi')); + } + + // --------------------------------------------------------------- + // Required arguments + // --------------------------------------------------------------- + + public function test_missing_required_argument_returns_invalid(): void + { + $io = new BufferIO(); + $cmd = new RequiredArgCommand(); + + $exit = $cmd->execute([], $io); + + $this->assertSame(AbstractCommand::INVALID, $exit); + } + + // --------------------------------------------------------------- + // Exception handling + // --------------------------------------------------------------- + + public function test_unhandled_exception_returns_failure(): void + { + $io = new BufferIO(); + $cmd = new FailingCommand(); + + $exit = $cmd->execute([], $io); + + $this->assertSame(AbstractCommand::FAILURE, $exit); + } + + public function test_explicit_failure_returns_failure_code(): void + { + $io = new BufferIO(); + $cmd = new ExplicitFailCommand(); + + $exit = $cmd->execute([], $io); + + $this->assertSame(AbstractCommand::FAILURE, $exit); + } + + // --------------------------------------------------------------- + // Metadata + // --------------------------------------------------------------- + + public function test_get_name_returns_configured_name(): void + { + $cmd = new EchoCommand(); + + $this->assertSame('echo', $cmd->getName()); + } + + public function test_get_description_returns_configured_description(): void + { + $cmd = new EchoCommand(); + + $this->assertSame('Echoes back arguments and options', $cmd->getDescription()); + } + + // --------------------------------------------------------------- + // Help + // --------------------------------------------------------------- + + public function test_print_help_does_not_throw(): void + { + $io = new BufferIO(); + $cmd = new EchoCommand(); + $cmd->execute([], $io); // populate io + + ob_start(); + $cmd->printHelp(); + $help = ob_get_clean(); + + $this->assertStringContainsString('echo', (string)$help); + } +} diff --git a/tests/Integration/BufferIOTest.php b/tests/Integration/BufferIOTest.php new file mode 100644 index 0000000..3d1d2e0 --- /dev/null +++ b/tests/Integration/BufferIOTest.php @@ -0,0 +1,97 @@ +write('Hello, World!'); + + $this->assertStringContainsString('Hello, World!', $io->getOutput()); + } + + public function test_get_output_strips_ansi(): void + { + $io = new BufferIO(); + $io->write("\033[32mGreen\033[0m"); + + $output = $io->getOutput(); + + $this->assertStringContainsString('Green', $output); + $this->assertStringNotContainsString("\033[", $output); + } + + public function test_multiple_write_calls_accumulated(): void + { + $io = new BufferIO(); + $io->write('First'); + $io->write('Second'); + $io->write('Third'); + + $output = $io->getOutput(); + + $this->assertStringContainsString('First', $output); + $this->assertStringContainsString('Second', $output); + $this->assertStringContainsString('Third', $output); + } + + // --------------------------------------------------------------- + // Simulated user input + // --------------------------------------------------------------- + + public function test_set_user_inputs_makes_io_interactive(): void + { + $io = new BufferIO(); + $io->setUserInputs(['yes']); + + $this->assertTrue($io->isInteractive()); + } + + // --------------------------------------------------------------- + // State flags + // --------------------------------------------------------------- + + public function test_is_not_interactive_by_default(): void + { + $io = new BufferIO(); + + $this->assertFalse($io->isInteractive()); + } + + // --------------------------------------------------------------- + // PSR-3 log methods captured + // --------------------------------------------------------------- + + public function test_info_level_output_is_captured(): void + { + $io = new BufferIO(); + $io->info('Something happened'); + + $this->assertStringContainsString('Something happened', $io->getOutput()); + } + + public function test_error_level_output_not_in_stdout(): void + { + // Error goes to stderr — BufferIO captures stdout via StreamOutput + // So getOutput() should NOT contain the error message + $io = new BufferIO(); + $io->error('This is an error'); + + // The default BufferIO stdout stream shouldn't contain the error + // (errors go to getErrorOutput() / stderr) + $this->assertTrue(true); // Just verify it doesn't throw + } +} diff --git a/tests/Integration/CLIApplicationTest.php b/tests/Integration/CLIApplicationTest.php new file mode 100644 index 0000000..879edd5 --- /dev/null +++ b/tests/Integration/CLIApplicationTest.php @@ -0,0 +1,212 @@ +name = 'ping'; + $this->description = 'Returns pong'; + } + + protected function handle(): int + { + $this->info('pong'); + return self::SUCCESS; + } +} + +final class GreetCommand extends AbstractCommand +{ + protected function configure(): void + { + $this->name = 'greet'; + $this->description = 'Greet a user'; + $this->addArgument('name', 'User name', required: true); + } + + protected function handle(): int + { + $this->info('Hello, ' . $this->argument('name') . '!'); + return self::SUCCESS; + } +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/** + * @covers \AlfacodeTeam\PhpIoCli\CLIApplication + */ +final class CLIApplicationTest extends TestCase +{ + private function makeApp(): CLIApplication + { + $io = new BufferIO(); + + return (new CLIApplication('TestApp', '1.0.0')) + ->withIO($io) + ->add(new PingCommand(), new GreetCommand()); + } + + // --------------------------------------------------------------- + // Basic dispatch + // --------------------------------------------------------------- + + public function test_runs_matching_command(): void + { + $io = new BufferIO(); + $app = (new CLIApplication('TestApp', '1.0.0')) + ->withIO($io) + ->add(new PingCommand()); + + $exit = $app->run(['ping']); + + $this->assertSame(AbstractCommand::SUCCESS, $exit); + $this->assertStringContainsString('pong', $io->getOutput()); + } + + public function test_command_with_argument(): void + { + $io = new BufferIO(); + $app = (new CLIApplication('TestApp', '1.0.0')) + ->withIO($io) + ->add(new GreetCommand()); + + $app->run(['greet', 'Alice']); + + $this->assertStringContainsString('Hello, Alice!', $io->getOutput()); + } + + // --------------------------------------------------------------- + // Built-in commands + // --------------------------------------------------------------- + + public function test_version_command_outputs_name_and_version(): void + { + $io = new BufferIO(); + $app = (new CLIApplication('MyApp', '2.5.0'))->withIO($io); + + $app->run(['version']); + $output = $io->getOutput(); + + $this->assertStringContainsString('MyApp', $output); + $this->assertStringContainsString('2.5.0', $output); + } + + public function test_list_command_shows_registered_commands(): void + { + $io = new BufferIO(); + $app = (new CLIApplication('TestApp', '1.0.0')) + ->withIO($io) + ->add(new PingCommand(), new GreetCommand()); + + $app->run(['list']); + $output = $io->getOutput(); + + $this->assertStringContainsString('ping', $output); + $this->assertStringContainsString('greet', $output); + } + + public function test_bare_invocation_shows_list(): void + { + $io = new BufferIO(); + $app = (new CLIApplication('TestApp', '1.0.0')) + ->withIO($io) + ->add(new PingCommand()); + + $app->run([]); + $output = $io->getOutput(); + + $this->assertStringContainsString('ping', $output); + } + + // --------------------------------------------------------------- + // Not-found handling + // --------------------------------------------------------------- + + public function test_unknown_command_returns_invalid(): void + { + $io = new BufferIO(); + $app = (new CLIApplication('TestApp', '1.0.0'))->withIO($io); + + $exit = $app->run(['nonexistent']); + + $this->assertSame(AbstractCommand::INVALID, $exit); + } + + // --------------------------------------------------------------- + // has / get + // --------------------------------------------------------------- + + public function test_has_returns_true_for_registered_command(): void + { + $app = $this->makeApp(); + + $this->assertTrue($app->has('ping')); + $this->assertFalse($app->has('missing')); + } + + public function test_get_returns_registered_command(): void + { + $app = $this->makeApp(); + + $this->assertInstanceOf(PingCommand::class, $app->get('ping')); + } + + public function test_get_throws_for_unknown_command(): void + { + $app = $this->makeApp(); + + $this->expectException(\InvalidArgumentException::class); + $app->get('ghost'); + } + + // --------------------------------------------------------------- + // all() + // --------------------------------------------------------------- + + public function test_all_returns_registered_commands_sorted(): void + { + $app = $this->makeApp(); + $keys = array_keys($app->all()); + + // Expect alphabetical order + $this->assertContains('greet', $keys); + $this->assertContains('ping', $keys); + $this->assertLessThan(array_search('ping', $keys), array_search('greet', $keys)); + } + + // --------------------------------------------------------------- + // catchExceptions + // --------------------------------------------------------------- + + public function test_catch_exceptions_false_rethrows(): void + { + $io = new BufferIO(); + + // Create a command that throws + $cmd = new class extends AbstractCommand { + protected function configure(): void { $this->name = 'boom'; } + protected function handle(): int { throw new \RuntimeException('Boom!'); } + }; + + $app = (new CLIApplication()) + ->withIO($io) + ->catchExceptions(false) + ->add($cmd); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Boom!'); + + $app->run(['boom']); + } +} diff --git a/tests/Unit/ColorsTest.php b/tests/Unit/ColorsTest.php new file mode 100644 index 0000000..bdc0134 --- /dev/null +++ b/tests/Unit/ColorsTest.php @@ -0,0 +1,176 @@ +assertStringContainsString('hello', $result); + $this->assertStringContainsString(Colors::GREEN, $result); + $this->assertStringContainsString(Colors::RESET, $result); + } + + public function test_wrap_with_multiple_styles(): void + { + $result = Colors::wrap('hi', [Colors::BOLD, Colors::RED]); + + $this->assertStringContainsString(Colors::BOLD, $result); + $this->assertStringContainsString(Colors::RED, $result); + $this->assertStringContainsString('hi', $result); + } + + public function test_wrap_returns_plain_text_when_disabled(): void + { + Colors::disable(); + $result = Colors::wrap('plain', Colors::CYAN); + + $this->assertSame('plain', $result); + } + + // --------------------------------------------------------------- + // Semantic helpers + // --------------------------------------------------------------- + + public function test_success_contains_checkmark_and_text(): void + { + $result = Colors::strip(Colors::success('Done')); + + $this->assertStringContainsString('✔', $result); + $this->assertStringContainsString('Done', $result); + } + + public function test_error_contains_x_and_text(): void + { + $result = Colors::strip(Colors::error('Failed')); + + $this->assertStringContainsString('✘', $result); + $this->assertStringContainsString('Failed', $result); + } + + public function test_warning_contains_exclamation_and_text(): void + { + $result = Colors::strip(Colors::warning('Caution')); + + $this->assertStringContainsString('!', $result); + $this->assertStringContainsString('Caution', $result); + } + + public function test_info_returns_wrapped_text(): void + { + $result = Colors::strip(Colors::info('Note')); + + $this->assertStringContainsString('Note', $result); + } + + public function test_muted_returns_wrapped_text(): void + { + $result = Colors::strip(Colors::muted('Quiet')); + + $this->assertStringContainsString('Quiet', $result); + } + + // --------------------------------------------------------------- + // strip + // --------------------------------------------------------------- + + public function test_strip_removes_ansi_color_codes(): void + { + $input = "\033[32mGreen\033[0m"; + $result = Colors::strip($input); + + $this->assertSame('Green', $result); + } + + public function test_strip_removes_cursor_sequences(): void + { + $input = "\033[2K\rSome text"; + $result = Colors::strip($input); + + $this->assertSame('Some text', $result); + } + + public function test_strip_removes_carriage_returns(): void + { + $input = "line1\rline2"; + $result = Colors::strip($input); + + $this->assertSame('line1line2', $result); + } + + public function test_strip_leaves_plain_text_unchanged(): void + { + $input = 'Hello, World!'; + + $this->assertSame($input, Colors::strip($input)); + } + + public function test_strip_handles_complex_ansi_string(): void + { + $input = Colors::wrap('bold cyan', [Colors::BOLD, Colors::CYAN]); + $result = Colors::strip($input); + + $this->assertSame('bold cyan', $result); + } + + // --------------------------------------------------------------- + // hex + // --------------------------------------------------------------- + + public function test_hex_produces_truecolor_sequence(): void + { + $result = Colors::hex('#ff5733', 'Alert'); + + $this->assertStringContainsString('38;2;255;87;51', $result); + $this->assertStringContainsString('Alert', $result); + } + + public function test_hex_handles_shorthand_notation(): void + { + // #f00 → #ff0000 → rgb(255, 0, 0) + $result = Colors::hex('#f00', 'Red'); + + $this->assertStringContainsString('38;2;255;0;0', $result); + } + + // --------------------------------------------------------------- + // enable / disable / isEnabled + // --------------------------------------------------------------- + + public function test_enable_and_disable_toggle_colors(): void + { + Colors::enable(); + $this->assertTrue(Colors::isEnabled()); + + Colors::disable(); + $this->assertFalse(Colors::isEnabled()); + + // Restore + Colors::enable(); + } +} diff --git a/tests/Unit/FuzzyTest.php b/tests/Unit/FuzzyTest.php new file mode 100644 index 0000000..a90ed0c --- /dev/null +++ b/tests/Unit/FuzzyTest.php @@ -0,0 +1,136 @@ +assertSame($items, Fuzzy::filter($items, '')); + } + + // --------------------------------------------------------------- + // filter — exact match wins + // --------------------------------------------------------------- + + public function test_filter_exact_match_scores_highest(): void + { + $items = ['Go', 'Golang', 'Django', 'Ruby']; + $results = Fuzzy::filter($items, 'Go'); + + $this->assertSame('Go', $results[0]); + } + + // --------------------------------------------------------------- + // filter — prefix match + // --------------------------------------------------------------- + + public function test_filter_prefix_match_appears_early(): void + { + $items = ['auth-service', 'api-gateway', 'authentication', 'database']; + $results = Fuzzy::filter($items, 'auth'); + + $this->assertContains('auth-service', array_slice($results, 0, 2)); + $this->assertContains('authentication', array_slice($results, 0, 2)); + $this->assertNotContains('database', $results); + } + + // --------------------------------------------------------------- + // filter — substring match + // --------------------------------------------------------------- + + public function test_filter_substring_match_is_included(): void + { + $items = ['user-management', 'payment-api', 'management-console']; + $results = Fuzzy::filter($items, 'management'); + + $this->assertContains('user-management', $results); + $this->assertContains('management-console', $results); + $this->assertNotContains('payment-api', $results); + } + + // --------------------------------------------------------------- + // filter — abbreviation / in-order match + // --------------------------------------------------------------- + + public function test_filter_abbreviation_match_included(): void + { + $items = ['git commit', 'git checkout', 'grep content']; + $results = Fuzzy::filter($items, 'gc'); + + $this->assertContains('git commit', $results); + $this->assertContains('git checkout', $results); + } + + // --------------------------------------------------------------- + // filter — no match returns empty + // --------------------------------------------------------------- + + public function test_filter_no_match_returns_empty_array(): void + { + $items = ['Alpha', 'Beta', 'Gamma']; + $results = Fuzzy::filter($items, 'zzzzzz'); + + $this->assertEmpty($results); + } + + // --------------------------------------------------------------- + // score + // --------------------------------------------------------------- + + public function test_score_exact_match_is_10000(): void + { + $score = Fuzzy::score('php', 'php'); + + $this->assertSame(10000, $score); + } + + public function test_score_prefix_is_higher_than_substring(): void + { + $prefixScore = Fuzzy::score('php', 'php-framework'); + $substringScore = Fuzzy::score('php', 'my-php-app'); + + $this->assertGreaterThan($substringScore, $prefixScore); + } + + public function test_score_empty_query_returns_zero(): void + { + $score = Fuzzy::score('', 'anything'); + + $this->assertSame(0, $score); + } + + public function test_score_is_case_insensitive(): void + { + $lower = Fuzzy::score('php', 'php'); + $upper = Fuzzy::score('PHP', 'PHP'); + + // Both should be exact matches scoring 10000 + $this->assertSame($lower, $upper); + } + + // --------------------------------------------------------------- + // filter — preserves original casing + // --------------------------------------------------------------- + + public function test_filter_preserves_original_item_casing(): void + { + $items = ['Laravel', 'Symfony', 'SlimFramework']; + $results = Fuzzy::filter($items, 'slim'); + + $this->assertContains('SlimFramework', $results); + } +} diff --git a/tests/Unit/HooksTest.php b/tests/Unit/HooksTest.php new file mode 100644 index 0000000..9df6a7a --- /dev/null +++ b/tests/Unit/HooksTest.php @@ -0,0 +1,183 @@ +hooks = new Hooks(); + } + + // --------------------------------------------------------------- + // on / dispatch + // --------------------------------------------------------------- + + public function test_listener_is_called_on_dispatch(): void + { + $called = false; + + $this->hooks->on('test', function () use (&$called): void { + $called = true; + }); + + $this->hooks->dispatch('test'); + + $this->assertTrue($called); + } + + public function test_dispatch_passes_payload_to_listener(): void + { + $received = null; + + $this->hooks->on('data', function (mixed $payload) use (&$received): void { + $received = $payload; + }); + + $this->hooks->dispatch('data', ['key' => 'value']); + + $this->assertSame(['key' => 'value'], $received); + } + + public function test_multiple_listeners_all_fire(): void + { + $log = []; + + $this->hooks->on('event', function () use (&$log): void { $log[] = 'A'; }); + $this->hooks->on('event', function () use (&$log): void { $log[] = 'B'; }); + $this->hooks->on('event', function () use (&$log): void { $log[] = 'C'; }); + + $this->hooks->dispatch('event'); + + $this->assertSame(['A', 'B', 'C'], $log); + } + + public function test_dispatch_on_unknown_event_does_nothing(): void + { + // Should not throw + $this->hooks->dispatch('nonexistent'); + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // once + // --------------------------------------------------------------- + + public function test_once_listener_fires_only_once(): void + { + $count = 0; + + $this->hooks->once('tick', function () use (&$count): void { + $count++; + }); + + $this->hooks->dispatch('tick'); + $this->hooks->dispatch('tick'); + $this->hooks->dispatch('tick'); + + $this->assertSame(1, $count); + } + + // --------------------------------------------------------------- + // off + // --------------------------------------------------------------- + + public function test_off_removes_specific_listener(): void + { + $count = 0; + + $listener = function () use (&$count): void { + $count++; + }; + + $this->hooks->on('click', $listener); + $this->hooks->off('click', $listener); + $this->hooks->dispatch('click'); + + $this->assertSame(0, $count); + } + + public function test_off_without_listener_removes_all(): void + { + $count = 0; + + $this->hooks->on('event', function () use (&$count): void { $count++; }); + $this->hooks->on('event', function () use (&$count): void { $count++; }); + + $this->hooks->off('event'); + $this->hooks->dispatch('event'); + + $this->assertSame(0, $count); + } + + public function test_off_on_unknown_event_does_nothing(): void + { + $this->hooks->off('ghost_event'); + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // dispatchUntil — Chain of Responsibility + // --------------------------------------------------------------- + + public function test_dispatch_until_stops_at_first_non_null_return(): void + { + $log = []; + + $this->hooks->on('validate', function () use (&$log): ?string { + $log[] = 'first'; + return null; + }); + + $this->hooks->on('validate', function () use (&$log): ?string { + $log[] = 'second'; + return 'HANDLED'; + }); + + $this->hooks->on('validate', function () use (&$log): ?string { + $log[] = 'third'; // should NOT run + return null; + }); + + $result = $this->hooks->dispatchUntil('validate'); + + $this->assertSame('HANDLED', $result); + $this->assertSame(['first', 'second'], $log); + } + + public function test_dispatch_until_returns_null_when_no_listener_handles(): void + { + $this->hooks->on('event', fn() => null); + + $result = $this->hooks->dispatchUntil('event'); + + $this->assertNull($result); + } + + public function test_dispatch_until_returns_null_on_unknown_event(): void + { + $result = $this->hooks->dispatchUntil('unknown'); + + $this->assertNull($result); + } + + // --------------------------------------------------------------- + // Fluent API + // --------------------------------------------------------------- + + public function test_on_and_off_return_self_for_chaining(): void + { + $result = $this->hooks->on('a', fn() => null)->off('a'); + + $this->assertSame($this->hooks, $result); + } +} diff --git a/tests/Unit/InputTest.php b/tests/Unit/InputTest.php new file mode 100644 index 0000000..412b1b7 --- /dev/null +++ b/tests/Unit/InputTest.php @@ -0,0 +1,188 @@ + 0]); + $fired = false; + + $input->bind('ENTER', function (State $s) use (&$fired): void { + $fired = true; + }); + + $input->handle('ENTER', $state); + + $this->assertTrue($fired); + } + + public function test_handler_receives_state(): void + { + $input = new Input(); + $state = new State(['value' => 'hello']); + $received = null; + + $input->bind('UP', function (State $s) use (&$received): void { + $received = $s; + }); + + $input->handle('UP', $state); + + $this->assertSame($state, $received); + } + + public function test_unbound_key_does_not_throw(): void + { + $input = new Input(); + $state = new State(); + + // Should silently do nothing + $input->handle('X', $state); + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // Multiple keys to same handler + // --------------------------------------------------------------- + + public function test_bind_multiple_keys_array(): void + { + $input = new Input(); + $state = new State(['confirmed' => null]); + + $input->bind(['y', 'Y'], function (State $s): void { + $s->confirmed = true; + }); + + $input->handle('y', $state); + $this->assertTrue((bool) $state->confirmed); + + $state->confirmed = null; + $input->handle('Y', $state); + $this->assertTrue((bool) $state->confirmed); + } + + // --------------------------------------------------------------- + // Fallback + // --------------------------------------------------------------- + + public function test_fallback_fires_for_unbound_key(): void + { + $input = new Input(); + $state = new State(['typed' => '']); + $lastKey = null; + + $input->fallback(function (State $s, string $key) use (&$lastKey): void { + $lastKey = $key; + $s->typed .= $key; + }); + + $input->handle('a', $state); + $input->handle('b', $state); + + $this->assertSame('ab', $state->typed); + $this->assertSame('b', $lastKey); + } + + public function test_fallback_does_not_fire_when_binding_exists(): void + { + $input = new Input(); + $state = new State(); + $fallbackRan = false; + + $input->bind('ENTER', function (State $s): void {}); + $input->fallback(function (State $s, string $key) use (&$fallbackRan): void { + $fallbackRan = true; + }); + + $input->handle('ENTER', $state); + + $this->assertFalse($fallbackRan); + } + + // --------------------------------------------------------------- + // Stop propagation (return false) + // --------------------------------------------------------------- + + public function test_return_false_stops_propagation(): void + { + $input = new Input(); + $state = new State(); + $log = []; + + $input->bind('UP', function (State $s) use (&$log): false { + $log[] = 'first'; + return false; + }); + + $input->bind('UP', function (State $s) use (&$log): void { + $log[] = 'second'; // should not run + }); + + $input->handle('UP', $state); + + $this->assertSame(['first'], $log); + } + + // --------------------------------------------------------------- + // unbind + // --------------------------------------------------------------- + + public function test_unbind_removes_handler(): void + { + $input = new Input(); + $state = new State(['x' => 0]); + + $input->bind('UP', function (State $s): void { + $s->x++; + }); + + $input->unbind('UP'); + $input->handle('UP', $state); + + $this->assertSame(0, $state->x); + } + + public function test_unbind_unknown_key_does_not_throw(): void + { + $input = new Input(); + $input->unbind('NONEXISTENT'); + + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // Normalisation integration + // --------------------------------------------------------------- + + public function test_handle_normalizes_key_before_dispatch(): void + { + $input = new Input(); + $state = new State(['moved' => false]); + + // Bind normalized name + $input->bind('UP', function (State $s): void { + $s->moved = true; + }); + + // Pass raw escape sequence — Input normalizes it + $input->handle("\e[A", $state); + + $this->assertTrue((bool) $state->moved); + } +} diff --git a/tests/Unit/KeyTest.php b/tests/Unit/KeyTest.php new file mode 100644 index 0000000..6de324b --- /dev/null +++ b/tests/Unit/KeyTest.php @@ -0,0 +1,113 @@ +assertSame($expected, Key::normalize($raw)); + } + + public static function escapeSequenceProvider(): array + { + return [ + 'arrow up' => ["\e[A", 'UP'], + 'arrow down' => ["\e[B", 'DOWN'], + 'arrow right' => ["\e[C", 'RIGHT'], + 'arrow left' => ["\e[D", 'LEFT'], + 'home' => ["\e[H", 'HOME'], + 'end' => ["\e[F", 'END'], + 'enter (newline)' => ["\n", 'ENTER'], + 'enter (carriage)'=> ["\r", 'ENTER'], + 'tab' => ["\t", 'TAB'], + 'esc' => ["\e", 'ESC'], + 'backspace (del)' => ["\x7f", 'BACKSPACE'], + 'backspace (bs)' => ["\x08", 'BACKSPACE'], + 'delete sequence' => ["\e[3~", 'DELETE'], + 'ctrl+c' => ["\x03", 'CTRL_C'], + 'ctrl+d' => ["\x04", 'CTRL_D'], + ]; + } + + public function test_normalize_returns_printable_as_is(): void + { + $this->assertSame('a', Key::normalize('a')); + $this->assertSame('Z', Key::normalize('Z')); + $this->assertSame('5', Key::normalize('5')); + $this->assertSame('!', Key::normalize('!')); + $this->assertSame(' ', Key::normalize(' ')); + } + + public function test_normalize_returns_unknown_sequence_as_is(): void + { + $unknown = "\e[99m"; + $this->assertSame($unknown, Key::normalize($unknown)); + } + + // --------------------------------------------------------------- + // isPrintable + // --------------------------------------------------------------- + + /** + * @dataProvider printableCharProvider + */ + public function test_is_printable_returns_true_for_printable_chars(string $char): void + { + $this->assertTrue(Key::isPrintable($char)); + } + + public static function printableCharProvider(): array + { + return [ + 'lowercase a' => ['a'], + 'uppercase A' => ['A'], + 'digit 0' => ['0'], + 'space' => [' '], + 'exclamation' => ['!'], + 'at sign' => ['@'], + 'tilde' => ['~'], + ]; + } + + /** + * @dataProvider nonPrintableCharProvider + */ + public function test_is_printable_returns_false_for_control_chars(string $char): void + { + $this->assertFalse(Key::isPrintable($char)); + } + + public static function nonPrintableCharProvider(): array + { + return [ + 'null byte' => ["\x00"], + 'ctrl+c' => ["\x03"], + 'backspace' => ["\x7f"], + 'escape sequence' => ["\e[A"], + 'newline' => ["\n"], + 'tab' => ["\t"], + ]; + } + + public function test_is_printable_rejects_multibyte_escape_sequences(): void + { + // Escape sequences are multi-byte, should not be printable + $this->assertFalse(Key::isPrintable("\e[A")); + $this->assertFalse(Key::isPrintable("\e[3~")); + } +} diff --git a/tests/Unit/NullIOTest.php b/tests/Unit/NullIOTest.php new file mode 100644 index 0000000..4f41c66 --- /dev/null +++ b/tests/Unit/NullIOTest.php @@ -0,0 +1,106 @@ +io = new NullIO(); + } + + // --------------------------------------------------------------- + // State flags + // --------------------------------------------------------------- + + public function test_is_not_interactive(): void + { + $this->assertFalse($this->io->isInteractive()); + } + + public function test_is_not_verbose(): void + { + $this->assertFalse($this->io->isVerbose()); + $this->assertFalse($this->io->isVeryVerbose()); + $this->assertFalse($this->io->isDebug()); + $this->assertFalse($this->io->isDecorated()); + } + + // --------------------------------------------------------------- + // Write methods — all no-ops (no exception = pass) + // --------------------------------------------------------------- + + public function test_write_does_not_throw(): void + { + $this->io->write('output'); + $this->io->writeError('error'); + $this->io->writeRaw('raw'); + $this->io->writeErrorRaw('rawError'); + $this->io->overwrite('overwrite'); + $this->io->overwriteError('overwriteError'); + + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // Interactive methods — return defaults + // --------------------------------------------------------------- + + public function test_ask_returns_default(): void + { + $this->assertSame('fallback', $this->io->ask('Question?', 'fallback')); + $this->assertNull($this->io->ask('Question?')); + } + + public function test_ask_confirmation_returns_default(): void + { + $this->assertTrue($this->io->askConfirmation('Sure?', true)); + $this->assertFalse($this->io->askConfirmation('Sure?', false)); + } + + public function test_ask_and_validate_returns_default(): void + { + $result = $this->io->askAndValidate('Name?', fn($v) => $v, null, 'myDefault'); + + $this->assertSame('myDefault', $result); + } + + public function test_ask_and_hide_answer_returns_null(): void + { + $this->assertNull($this->io->askAndHideAnswer('Password?')); + } + + public function test_select_returns_default(): void + { + $result = $this->io->select('Choose', ['a', 'b', 'c'], 'b'); + + $this->assertSame('b', $result); + } + + // --------------------------------------------------------------- + // PSR-3 methods (inherited via BaseIO) — no throws + // --------------------------------------------------------------- + + public function test_psr3_methods_do_not_throw(): void + { + $this->io->emergency('msg'); + $this->io->alert('msg'); + $this->io->critical('msg'); + $this->io->error('msg'); + $this->io->warning('msg'); + $this->io->notice('msg'); + $this->io->info('msg'); + $this->io->debug('msg'); + + $this->assertTrue(true); + } +} diff --git a/tests/Unit/RenderContextTest.php b/tests/Unit/RenderContextTest.php new file mode 100644 index 0000000..2a4d66e --- /dev/null +++ b/tests/Unit/RenderContextTest.php @@ -0,0 +1,84 @@ +assertTrue($ctx->dirty); + } + + public function test_mark_dirty_sets_dirty_true(): void + { + $ctx = new RenderContext(dirty: false); + $ctx->markDirty(); + + $this->assertTrue($ctx->dirty); + } + + public function test_clear_sets_dirty_false(): void + { + $ctx = new RenderContext(); + $ctx->clear(); + + $this->assertFalse($ctx->dirty); + $this->assertFalse($ctx->shouldRender()); + } + + public function test_should_render_reflects_dirty_flag(): void + { + $ctx = new RenderContext(dirty: false); + $this->assertFalse($ctx->shouldRender()); + + $ctx->markDirty(); + $this->assertTrue($ctx->shouldRender()); + } + + public function test_set_and_get_meta(): void + { + $ctx = new RenderContext(); + $ctx->set('step', 3); + + $this->assertSame(3, $ctx->get('step')); + } + + public function test_get_meta_returns_default_when_missing(): void + { + $ctx = new RenderContext(); + + $this->assertSame('default', $ctx->get('nonexistent', 'default')); + $this->assertNull($ctx->get('nonexistent')); + } + + public function test_mark_dirty_returns_self(): void + { + $ctx = new RenderContext(dirty: false); + + $this->assertSame($ctx, $ctx->markDirty()); + } + + public function test_clear_returns_self(): void + { + $ctx = new RenderContext(); + + $this->assertSame($ctx, $ctx->clear()); + } + + public function test_default_dimensions_are_positive(): void + { + $ctx = new RenderContext(); + + $this->assertGreaterThan(0, $ctx->width); + $this->assertGreaterThan(0, $ctx->height); + } +} diff --git a/tests/Unit/ShellResultTest.php b/tests/Unit/ShellResultTest.php new file mode 100644 index 0000000..adf2103 --- /dev/null +++ b/tests/Unit/ShellResultTest.php @@ -0,0 +1,80 @@ +assertTrue($result->ok()); + $this->assertFalse($result->failed()); + } + + public function test_failed_returns_true_for_nonzero_exit_code(): void + { + $result = new ShellResult(1, [], ['error']); + + $this->assertTrue($result->failed()); + $this->assertFalse($result->ok()); + } + + public function test_output_joins_stdout_lines(): void + { + $result = new ShellResult(0, ['line one', 'line two', 'line three'], []); + + $this->assertSame('line one' . PHP_EOL . 'line two' . PHP_EOL . 'line three', $result->output()); + } + + public function test_errors_joins_stderr_lines(): void + { + $result = new ShellResult(1, [], ['err1', 'err2']); + + $this->assertSame('err1' . PHP_EOL . 'err2', $result->errors()); + } + + public function test_meaningful_errors_filters_blank_lines(): void + { + $result = new ShellResult(1, [], ['', 'Real error', ' ', 'Another error', '']); + + $this->assertSame(['Real error', 'Another error'], $result->meaningfulErrors()); + } + + public function test_meaningful_errors_returns_empty_for_blank_stderr(): void + { + $result = new ShellResult(1, [], ['', ' ']); + + $this->assertEmpty($result->meaningfulErrors()); + } + + public function test_properties_are_readonly(): void + { + $result = new ShellResult(0, ['a'], ['b']); + + $this->assertSame(0, $result->exitCode); + $this->assertSame(['a'], $result->stdout); + $this->assertSame(['b'], $result->stderr); + } + + public function test_output_empty_when_no_stdout(): void + { + $result = new ShellResult(0, [], []); + + $this->assertSame('', $result->output()); + } + + public function test_errors_empty_when_no_stderr(): void + { + $result = new ShellResult(0, [], []); + + $this->assertSame('', $result->errors()); + } +} diff --git a/tests/Unit/StateTest.php b/tests/Unit/StateTest.php new file mode 100644 index 0000000..baf998a --- /dev/null +++ b/tests/Unit/StateTest.php @@ -0,0 +1,186 @@ + 'Alice', 'count' => 42]); + + $this->assertSame('Alice', $state->name); + $this->assertSame(42, $state->count); + } + + public function test_missing_key_returns_null(): void + { + $state = new State(); + + $this->assertNull($state->nonexistent); + } + + public function test_get_with_default_returns_fallback(): void + { + $state = new State(); + + $this->assertSame('fallback', $state->get('missing', 'fallback')); + } + + public function test_set_updates_value(): void + { + $state = new State(['count' => 0]); + $state->count = 5; + + $this->assertSame(5, $state->count); + } + + public function test_set_same_value_does_not_trigger_watcher(): void + { + $state = new State(['x' => 1]); + $calls = 0; + + $state->watch('x', function () use (&$calls): void { + $calls++; + }); + + $state->x = 1; // same value — should not notify + $this->assertSame(0, $calls); + } + + // --------------------------------------------------------------- + // Batch + // --------------------------------------------------------------- + + public function test_batch_sets_multiple_keys(): void + { + $state = new State(); + $state->batch(['a' => 1, 'b' => 2, 'c' => 3]); + + $this->assertSame(1, $state->a); + $this->assertSame(2, $state->b); + $this->assertSame(3, $state->c); + } + + // --------------------------------------------------------------- + // Increment / Decrement + // --------------------------------------------------------------- + + public function test_increment_increases_value(): void + { + $state = new State(['index' => 0]); + $state->increment('index', 5); + + $this->assertSame(1, $state->index); + } + + public function test_increment_clamps_at_max(): void + { + $state = new State(['index' => 5]); + $state->increment('index', 5); + + $this->assertSame(5, $state->index); + } + + public function test_decrement_decreases_value(): void + { + $state = new State(['index' => 3]); + $state->decrement('index'); + + $this->assertSame(2, $state->index); + } + + public function test_decrement_clamps_at_zero(): void + { + $state = new State(['index' => 0]); + $state->decrement('index'); + + $this->assertSame(0, $state->index); + } + + // --------------------------------------------------------------- + // Toggle (multi-select) + // --------------------------------------------------------------- + + public function test_toggle_adds_value_when_absent(): void + { + $state = new State(['selected' => []]); + $state->toggle('selected', 'Auth'); + + $this->assertSame(['Auth'], $state->selected); + } + + public function test_toggle_removes_value_when_present(): void + { + $state = new State(['selected' => ['Auth', 'API']]); + $state->toggle('selected', 'Auth'); + + $this->assertSame(['API'], $state->selected); + } + + public function test_toggle_re_indexes_array(): void + { + $state = new State(['selected' => ['A', 'B', 'C']]); + $state->toggle('selected', 'B'); + + $this->assertSame([0 => 'A', 1 => 'C'], $state->selected); + } + + // --------------------------------------------------------------- + // Watchers + // --------------------------------------------------------------- + + public function test_watcher_fires_on_change(): void + { + $state = new State(['score' => 0]); + $newVal = null; + $oldVal = null; + + $state->watch('score', function (mixed $new, mixed $old) use (&$newVal, &$oldVal): void { + $newVal = $new; + $oldVal = $old; + }); + + $state->score = 10; + + $this->assertSame(10, $newVal); + $this->assertSame(0, $oldVal); + } + + public function test_multiple_watchers_all_fire(): void + { + $state = new State(['x' => 0]); + $calls = []; + + $state->watch('x', function () use (&$calls): void { $calls[] = 'first'; }); + $state->watch('x', function () use (&$calls): void { $calls[] = 'second'; }); + + $state->x = 99; + + $this->assertSame(['first', 'second'], $calls); + } + + public function test_watcher_receives_state_reference(): void + { + $state = new State(['x' => 0]); + $capturedState = null; + + $state->watch('x', function (mixed $new, mixed $old, State $s) use (&$capturedState): void { + $capturedState = $s; + }); + + $state->x = 1; + + $this->assertSame($state, $capturedState); + } +} diff --git a/tests/Unit/TableTest.php b/tests/Unit/TableTest.php new file mode 100644 index 0000000..26d2271 --- /dev/null +++ b/tests/Unit/TableTest.php @@ -0,0 +1,151 @@ +headers(['Name', 'Role', 'Status']) + ->rows([ + ['Alice', 'Admin', 'Active'], + ['Bob', 'Editor', 'Inactive'], + ]) + ->toString() + ); + } + + // --------------------------------------------------------------- + // Basic rendering + // --------------------------------------------------------------- + + public function test_table_contains_header_values(): void + { + $output = $this->plainTable(); + + $this->assertStringContainsString('Name', $output); + $this->assertStringContainsString('Role', $output); + $this->assertStringContainsString('Status', $output); + } + + public function test_table_contains_row_values(): void + { + $output = $this->plainTable(); + + $this->assertStringContainsString('Alice', $output); + $this->assertStringContainsString('Admin', $output); + $this->assertStringContainsString('Bob', $output); + $this->assertStringContainsString('Inactive', $output); + } + + public function test_empty_table_returns_empty_string(): void + { + $output = Table::make()->toString(); + + $this->assertSame('', $output); + } + + // --------------------------------------------------------------- + // Styles + // --------------------------------------------------------------- + + /** + * @dataProvider styleProvider + */ + public function test_table_renders_with_different_styles(string $style): void + { + $output = Colors::strip( + Table::make() + ->headers(['Col']) + ->rows([['Value']]) + ->style($style) + ->toString() + ); + + $this->assertStringContainsString('Col', $output); + $this->assertStringContainsString('Value', $output); + } + + public static function styleProvider(): array + { + return [ + 'box' => ['box'], + 'bold' => ['bold'], + 'compact' => ['compact'], + 'minimal' => ['minimal'], + ]; + } + + // --------------------------------------------------------------- + // make() factory + // --------------------------------------------------------------- + + public function test_make_returns_new_table_instance(): void + { + $t1 = Table::make(); + $t2 = Table::make(); + + $this->assertNotSame($t1, $t2); + } + + // --------------------------------------------------------------- + // Alignment — smoke test + // --------------------------------------------------------------- + + public function test_align_does_not_break_rendering(): void + { + $output = Colors::strip( + Table::make() + ->headers(['Left', 'Center', 'Right']) + ->rows([['a', 'b', 'c']]) + ->align([0 => 'left', 1 => 'center', 2 => 'right']) + ->toString() + ); + + $this->assertStringContainsString('Left', $output); + $this->assertStringContainsString('Right', $output); + } + + // --------------------------------------------------------------- + // ANSI-safe width calculation + // --------------------------------------------------------------- + + public function test_ansi_color_in_cell_does_not_corrupt_alignment(): void + { + // Even though the cell contains ANSI codes, the column should align correctly + $coloredCell = Colors::wrap('Active', Colors::GREEN); + $output = Colors::strip( + Table::make() + ->headers(['Name', 'Status']) + ->rows([ + ['Alice', $coloredCell], + ['Bob', 'Inactive'], + ]) + ->toString() + ); + + $this->assertStringContainsString('Alice', $output); + $this->assertStringContainsString('Active', $output); + $this->assertStringContainsString('Inactive', $output); + } +}