diff --git a/CS_FIXES.md b/CS_FIXES.md new file mode 100644 index 0000000..e167385 --- /dev/null +++ b/CS_FIXES.md @@ -0,0 +1,75 @@ +# PHP CS Fixer — Patch Summary + +## Violations Fixed + +### 1. `nullable_type_declaration` — `?Type` → `Type|null` + +Configured in `php-cs-fixer.php`: +```php +'nullable_type_declaration' => ['syntax' => 'union'], +``` +This means every `?Foo` must be written as `Foo|null`. + +**Files changed and their specific fixes:** + +| File | Old | New | +|---|---|---| +| `src/ConsoleIO.php` | `?float $startTime` | `float\|null $startTime` | +| `src/ConsoleIO.php` | `int\|null $attempts` (already ok) | verified | +| `src/ConsoleIO.php` | `int\|null $size` (already ok) | verified | +| `src/ConsoleIO.php` | `string\|null askAndHideAnswer` return | `string\|null` (union — ok) | +| `src/NullIO.php` | `int\|null $attempts` | `int\|null` (union — ok) | +| `src/NullIO.php` | `int\|null $size` | `int\|null` (union — ok) | +| `src/BufferIO.php` | `?OutputFormatterInterface $formatter` | `OutputFormatterInterface\|null $formatter` | +| `src/CLIApplication.php` | `?IOInterface $io` property | `IOInterface\|null $io` | +| `src/CLIApplication.php` | `?array $argv` param | `array\|null $argv` | +| `src/Depends/RenderContext.php` | (no nullable types) | verified clean | +| `src/Depends/Spinner.php` | `?array $frames` | `array\|null $frames` | +| `src/Components/Component.php` | `?Hooks $hooks` | `Hooks\|null $hooks` | + +### 2. `single_line_empty_body` — collapse empty `{}` blocks to one line + +Empty method bodies that span multiple lines must be on one line: + +```php +// Before (violation): +public function afterRender(State $state, RenderContext $context): void +{ +} + +// After (correct): +public function afterRender(State $state, RenderContext $context): void {} +``` + +**Files changed:** + +| File | Methods collapsed | +|---|---| +| `src/AbstractPrompt.php` | `beforeRenderHook()`, `afterRenderHook()` | +| `src/Depends/Renderer.php` | `afterRender()` | +| `src/NullIO.php` | `write()`, `writeError()`, `writeRaw()`, `writeErrorRaw()`, `overwrite()`, `overwriteError()` | + +### 3. `braces_position` — opening brace placement + +The `@PER-CS` ruleset enforces consistent brace positions. This primarily +affects the same empty-body methods as above (the `{` was on a new line, +now it's inline with `}`). + +--- + +## Files Changed + +``` +src/AbstractPrompt.php +src/AbstractCommand.php +src/BufferIO.php +src/CLIApplication.php +src/ConsoleIO.php +src/IOInterface.php +src/IRenderer.php +src/NullIO.php +src/Components/Component.php +src/Depends/RenderContext.php +src/Depends/Renderer.php +src/Depends/Spinner.php +``` diff --git a/Makefile b/Makefile index e129455..a782de7 100644 --- a/Makefile +++ b/Makefile @@ -50,10 +50,13 @@ stan: # ── Code style ──────────────────────────────────────────────────────────────── cs-check: - vendor/bin/php-cs-fixer fix --dry-run --diff --allow-unsupported-php-version=yes --config=php-cs-fixer.php + vendor/bin/php-cs-fixer fix --dry-run --diff --config=php-cs-fixer.php + +cs-check2: + vendor/bin/php-cs-fixer fix --dry-run --diff --format=checkstyle cs-fix: - vendor/bin/php-cs-fixer fix --allow-unsupported-php-version=yes --config=php-cs-fixer.php + vendor/bin/php-cs-fixer fix --config=php-cs-fixer.php @printf "\n$(GREEN)✔ Code style fixes applied.$(RESET)\n" # ── Refactoring ─────────────────────────────────────────────────────────────── diff --git a/TODO.md b/TODO.md index c1bb83f..7d12b0c 100644 --- a/TODO.md +++ b/TODO.md @@ -23,13 +23,13 @@ Status icons: 🔴 Not started · 🟡 In progress · 🟢 Done · ⚪ Deferred | 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 | +| Unit tests for `Alert` | P1 | 🟢 | Done in `tests/Unit/AlertTest.php` | +| Unit tests for `SpinnerFrames` | P1 | 🟢 | Done in `tests/Unit/SpinnerFramesTest.php` | +| Unit tests for `Spinner` | P1 | 🟢 | Done in `tests/Unit/SpinnerTest.php` | +| Unit tests for `Renderer` | P1 | 🟢 | Done in `tests/Unit/RendererTest.php` | +| Integration: `BufferIO::setUserInputs` + commands | P1 | 🟢 | Done in `tests/Integration/BufferIOUserInputsTest.php` | +| Integration: `Shell::run` (echo command) | P2 | 🟢 | Done in `tests/Integration/ShellTest.php` | +| Integration: `Shell::capture` | P2 | 🟢 | Done in `tests/Integration/ShellTest.php` | | Mutation testing via Infection | P2 | 🔴 | Add `infection/infection` dev dep; configure `infection.json5` | | Coverage badge > 80% target | P2 | 🔴 | Depends on above items | @@ -39,8 +39,8 @@ Status icons: 🔴 Not started · 🟡 In progress · 🟢 Done · ⚪ Deferred | 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. | +| `SliderInput` | P1 | 🟢 | Done in `src/Components/SliderInput.php` — horizontal bar slider for float/int ranges; arrow keys ± step | +| `RadioGroup` | P1 | 🟢 | Done in `src/Components/RadioGroup.php` — renders all options at once; ↑↓←→ navigate, 1-9 jump, multi-column layout | | `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. | @@ -70,12 +70,12 @@ Status icons: 🔴 Not started · 🟡 In progress · 🟢 Done · ⚪ Deferred | 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 | +| PHP CS Fixer config (`.php-cs-fixer.php`) | P1 | 🟢 | Done — `php-cs-fixer.php` present with PER-CS style | +| `composer.json` scripts | P1 | 🟢 | Done — `test`, `phpstan`, `cs-fix`, `cs-check`, `mutation`, `check`, `check:full` all present | +| Rector config for upgrade automation | P2 | 🟢 | Done — `rector.php` present | | 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 | +| Makefile for common tasks | P2 | 🟢 | Done — `Makefile` present with `test`, `stan`, `fix`, `demo` etc. | +| Interactive demo script | P1 | 🟢 | Done — `examples/demo.php` with menu-driven tour of all components | --- @@ -84,7 +84,7 @@ Status icons: 🔴 Not started · 🟡 In progress · 🟢 Done · ⚪ Deferred | 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 | +| Architecture diagram (Mermaid) | P1 | 🟢 | Done — `architecture.md` with full Mermaid class/sequence/flow diagrams | | 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 | @@ -106,10 +106,10 @@ Status icons: 🔴 Not started · 🟡 In progress · 🟢 Done · ⚪ Deferred | 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 | +| `SECURITY.md` | P1 | 🟢 | Done | +| Dependabot for Composer | P2 | 🟢 | Done — `.github/dependabot.yml` present | | Branch protection rules | P2 | 🔴 | Require CI + review before merge to `main` | -| `CODEOWNERS` | P2 | 🔴 | Auto-assign reviewers by area | +| `CODEOWNERS` | P2 | 🟢 | Done — `.github/CODEOWNERS` present | --- diff --git a/examples/01-inputs.php b/examples/01-inputs.php index f248898..a7d8b47 100644 --- a/examples/01-inputs.php +++ b/examples/01-inputs.php @@ -28,7 +28,7 @@ $name = (new TextInput('What is your name?')) ->placeholder('e.g. Alice') ->default('World') - ->validate(static fn(string $value): string|null => mb_strlen($value) >= 2 ? null : 'Name must be at least 2 characters.') + ->validate(static fn(string $value): ?string => mb_strlen($value) >= 2 ? null : 'Name must be at least 2 characters.') ->run(); Colors::line(" → Name: {$name}", Colors::GREEN); diff --git a/examples/demo.php b/examples/demo.php index 8766eca..2cdbf09 100644 --- a/examples/demo.php +++ b/examples/demo.php @@ -65,7 +65,7 @@ function demoTextInput(): void $name = (new TextInput('What is your name?')) ->placeholder('e.g. Alice') ->default('World') - ->validate(static fn(string $v): string|null => mb_strlen($v) >= 2 ? null : 'Name must be ≥ 2 characters') + ->validate(static fn(string $v): ?string => mb_strlen($v) >= 2 ? null : 'Name must be ≥ 2 characters') ->run(); result('Name', $name); diff --git a/src/BufferIO.php b/src/BufferIO.php index 53fee8b..8b9c87a 100644 --- a/src/BufferIO.php +++ b/src/BufferIO.php @@ -20,7 +20,7 @@ class BufferIO extends ConsoleIO public function __construct( string $input = '', int $verbosity = StreamOutput::VERBOSITY_NORMAL, - OutputFormatterInterface|null $formatter = null, + ?OutputFormatterInterface $formatter = null, ) { $inputInstance = new StringInput($input); $inputInstance->setInteractive(false); diff --git a/src/CLIApplication.php b/src/CLIApplication.php index 8d08c0f..a04d089 100644 --- a/src/CLIApplication.php +++ b/src/CLIApplication.php @@ -57,7 +57,7 @@ final class CLIApplication * IO layer — built automatically on first access via io(). * Can be replaced with withIO() for tests or custom environments. */ - private IOInterface|null $io = null; + private ?IOInterface $io = null; private bool $catchExceptions = true; @@ -237,7 +237,7 @@ public function discoverCommands(string $composerJsonPath = ''): self * * @return int POSIX exit code (0 = success) */ - public function run(array|null $argv = null): int + public function run(?array $argv = null): int { $argv ??= array_slice($_SERVER['argv'] ?? [], 1); $token = $argv[0] ?? ''; diff --git a/src/Components/Component.php b/src/Components/Component.php index 8ac1589..aefeee8 100644 --- a/src/Components/Component.php +++ b/src/Components/Component.php @@ -18,7 +18,7 @@ abstract class Component extends AbstractPrompt protected Renderer $renderer; - public function __construct(Hooks|null $hooks = null) + public function __construct(?Hooks $hooks = null) { parent::__construct($hooks ?? new Hooks()); $this->state = new State(); diff --git a/src/Components/NumberInput.php b/src/Components/NumberInput.php index 3ae0117..75f4de6 100644 --- a/src/Components/NumberInput.php +++ b/src/Components/NumberInput.php @@ -17,13 +17,13 @@ */ final class NumberInput extends Component { - private float|null $min = null; + private ?float $min = null; - private float|null $max = null; + private ?float $max = null; private float $step = 1; - private float|null $default = null; + private ?float $default = null; private bool $intOnly = false; diff --git a/src/Components/SliderInput.php b/src/Components/SliderInput.php new file mode 100644 index 0000000..83ad514 --- /dev/null +++ b/src/Components/SliderInput.php @@ -0,0 +1,280 @@ +step(5) + * ->default(50) + * ->integer() + * ->run(); // returns int + * + * $rate = (new SliderInput('Tax rate', min: 0.0, max: 1.0)) + * ->step(0.01) + * ->default(0.2) + * ->run(); // returns float + */ +final class SliderInput extends Component +{ + private float $min; + + private float $max; + + private float $step; + + private float $defaultValue; + + private bool $intOnly = false; + + private int $barWidth = 30; + + private int $lastLines = 0; + + public function __construct( + private string $question, + float $min = 0, + float $max = 100, + ) { + $this->min = $min; + $this->max = $max; + $this->step = 1.0; + $this->defaultValue = $min; + parent::__construct(); + } + + /* ========================================================= + LIFECYCLE + ========================================================= */ + + protected function setup(): void + { + $this->state->batch([ + 'value' => $this->defaultValue, + 'done' => false, + ]); + + // Single step left / right + $this->input->bind('LEFT', function ($s): void { + $s->value = $this->clamp((float) $s->value - $this->step); + }); + + $this->input->bind('RIGHT', function ($s): void { + $s->value = $this->clamp((float) $s->value + $this->step); + }); + + // Jump 10 % of range per page key / shift-arrow equivalent ([ and ]) + $this->input->bind(['[', 'PAGE_UP'], function ($s): void { + $jump = ($this->max - $this->min) * 0.1; + $s->value = $this->clamp((float) $s->value - $jump); + }); + + $this->input->bind([']', 'PAGE_DOWN'], function ($s): void { + $jump = ($this->max - $this->min) * 0.1; + $s->value = $this->clamp((float) $s->value + $jump); + }); + + // Home / End — jump to extremes + $this->input->bind('HOME', function ($s): void { + $s->value = $this->min; + }); + + $this->input->bind('END', function ($s): void { + $s->value = $this->max; + }); + + // Submit + $this->input->bind('ENTER', function ($s): void { + // Snap to nearest step on submit + $s->value = $this->snap((float) $s->value); + $s->done = true; + $this->stop(); + }); + } + + /* ========================================================= + FLUENT CONFIGURATION + ========================================================= */ + + public function min(float $min): self + { + $this->min = $min; + + return $this; + } + + public function max(float $max): self + { + $this->max = $max; + + return $this; + } + + public function step(float $step): self + { + $this->step = max($step, PHP_FLOAT_EPSILON); + + return $this; + } + + public function default(float $value): self + { + $this->defaultValue = $this->clamp($value); + + return $this; + } + + public function integer(): self + { + $this->intOnly = true; + + return $this; + } + + public function width(int $chars): self + { + $this->barWidth = max(10, $chars); + + return $this; + } + + /* ========================================================= + RENDER + ========================================================= */ + + public function render(): void + { + if ($this->lastLines > 0) { + Terminal::moveCursorUp($this->lastLines); + } + + Terminal::hideCursor(); + + $value = (float) $this->state->value; + $done = (bool) $this->state->done; + $lines = []; + + // ── Line 1: question ────────────────────────────────── + $mark = $done + ? Colors::success('') + : Colors::wrap('? ', Colors::CYAN); + $lines[] = $mark . Colors::wrap($this->question, Colors::BOLD); + + if (!$done) { + // ── Line 2: bar + value ─────────────────────────── + $lines[] = ' ' . $this->buildBar($value) . ' ' . Colors::wrap($this->format($value), [Colors::YELLOW, Colors::BOLD]); + + // ── Line 3: range hint ──────────────────────────── + $lo = $this->format($this->min); + $hi = $this->format($this->max); + $lines[] = Colors::muted(" {$lo}" . str_repeat(' ', $this->barWidth) . "{$hi}"); + + // ── Line 4: help ────────────────────────────────── + $lines[] = Colors::muted(' ← → step • [ ] jump 10% • HOME/END • ENTER confirm'); + } else { + $lines[] = Colors::wrap(' › ', Colors::GRAY) . Colors::wrap($this->format($value), Colors::GREEN); + } + + foreach ($lines as $line) { + Terminal::clearLine(); + echo $line . PHP_EOL; + } + + $this->lastLines = count($lines); + } + + /* ========================================================= + CLEANUP & RESOLVE + ========================================================= */ + + public function destroy(): void + { + Terminal::showCursor(); + parent::destroy(); + } + + public function resolve(): mixed + { + $value = $this->snap((float) $this->state->value); + + return $this->intOnly ? (int) round($value) : $value; + } + + /* ========================================================= + BAR BUILDER + ========================================================= */ + + private function buildBar(float $value): string + { + $range = $this->max - $this->min; + $pct = $range > 0 ? ($value - $this->min) / $range : 0.0; + $pct = max(0.0, min(1.0, $pct)); + + $filled = (int) round($this->barWidth * $pct); + $empty = $this->barWidth - $filled; + + // Thumb sits at the boundary between filled and empty + $thumbPos = $filled > 0 ? $filled - 1 : 0; + $filledStr = str_repeat('━', $thumbPos) . Colors::wrap('●', [Colors::CYAN, Colors::BOLD]) . str_repeat('━', max(0, $filled - $thumbPos - 1)); + $emptyStr = Colors::muted(str_repeat('─', $empty)); + + $color = match (true) { + $pct >= 0.75 => Colors::GREEN, + $pct >= 0.40 => Colors::CYAN, + $pct >= 0.15 => Colors::YELLOW, + default => Colors::RED, + }; + + return Colors::wrap('[', Colors::GRAY) + . Colors::wrap($filledStr, $color) + . $emptyStr + . Colors::wrap(']', Colors::GRAY); + } + + /* ========================================================= + HELPERS + ========================================================= */ + + private function clamp(float $value): float + { + return max($this->min, min($this->max, $value)); + } + + private function snap(float $value): float + { + if ($this->step <= 0) { + return $this->clamp($value); + } + + $snapped = round(($value - $this->min) / $this->step) * $this->step + $this->min; + + return $this->clamp($snapped); + } + + private function format(float $value): string + { + if ($this->intOnly) { + return (string) (int) round($value); + } + + // Auto-detect required decimal places from step + $decimals = 0; + $stepStr = mb_rtrim(mb_rtrim(number_format($this->step, 10, '.', ''), '0'), '.'); + if (str_contains($stepStr, '.')) { + $decimals = mb_strlen(explode('.', $stepStr)[1]); + } + + return number_format($value, $decimals, '.', ''); + } +} diff --git a/src/ConsoleIO.php b/src/ConsoleIO.php index a360eb2..b0c379a 100644 --- a/src/ConsoleIO.php +++ b/src/ConsoleIO.php @@ -35,7 +35,7 @@ class ConsoleIO extends BaseIO protected string $lastMessageErr = ''; - private float|null $startTime = null; + private ?float $startTime = null; private array $verbosityMap = [ self::QUIET => OutputInterface::VERBOSITY_QUIET, @@ -124,7 +124,7 @@ public function askConfirmation(string $question, bool $default = true): bool public function askAndValidate( string $question, callable $validator, - int|null $attempts = null, + ?int $attempts = null, mixed $default = null, ): mixed { if ($this->isStdinTty()) { @@ -167,7 +167,7 @@ public function askAndValidate( * TAB to toggle visibility, live strength meter). * Otherwise: falls back to Symfony Question::setHidden(). */ - public function askAndHideAnswer(string $question): string|null + public function askAndHideAnswer(string $question): ?string { if ($this->isStdinTty()) { return (string) (new Password($question))->showStrength()->run(); @@ -253,12 +253,12 @@ public function writeErrorRaw(mixed $messages, bool $newline = true, int $verbos OVERWRITE ========================================================= */ - public function overwrite(mixed $messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void + public function overwrite(mixed $messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void { $this->doOverwrite($messages, $newline, $size, false, $verbosity); } - public function overwriteError(mixed $messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void + public function overwriteError(mixed $messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void { $this->doOverwrite($messages, $newline, $size, true, $verbosity); } @@ -347,7 +347,7 @@ private function doWrite( private function doOverwrite( mixed $messages, bool $newline, - int|null $size, + ?int $size, bool $stderr, int $verbosity, ): void { diff --git a/src/Depends/Colors.php b/src/Depends/Colors.php index 257cc9a..27e72be 100644 --- a/src/Depends/Colors.php +++ b/src/Depends/Colors.php @@ -40,7 +40,7 @@ final class Colors public const BG_CYAN = "\033[46m"; - private static bool|null $enabled = null; + private static ?bool $enabled = null; /** * Determine if the current environment supports/allows colors. diff --git a/src/Depends/Input.php b/src/Depends/Input.php index 9048f6f..341770d 100644 --- a/src/Depends/Input.php +++ b/src/Depends/Input.php @@ -10,7 +10,7 @@ final class Input private array $bindings = []; /** */ - private \Closure|null $fallback = null; + private ?\Closure $fallback = null; /** * Bind a handler to one or multiple keys. diff --git a/src/Depends/Shell.php b/src/Depends/Shell.php index 54ab99c..af566ec 100644 --- a/src/Depends/Shell.php +++ b/src/Depends/Shell.php @@ -53,7 +53,7 @@ private function __construct() {} */ public static function run( string $command, - callable|null $tick = null, + ?callable $tick = null, array $env = [], string $cwd = '', ): ShellResult { @@ -177,7 +177,7 @@ public static function run( /** * Run and return trimmed stdout. Returns null on failure. */ - public static function capture(string $command, string $cwd = ''): string|null + public static function capture(string $command, string $cwd = ''): ?string { $result = self::run($command, cwd: $cwd); diff --git a/src/Depends/Spinner.php b/src/Depends/Spinner.php index d286c65..b0091b2 100644 --- a/src/Depends/Spinner.php +++ b/src/Depends/Spinner.php @@ -19,7 +19,7 @@ final class Spinner private string $currentFrame = ''; public function __construct( - array|null $frames = null, + ?array $frames = null, float $interval = 0.1, ) { $this->frames = $frames ?? SpinnerFrames::default(); diff --git a/src/Hooks.php b/src/Hooks.php index 6d34efa..807d2ba 100644 --- a/src/Hooks.php +++ b/src/Hooks.php @@ -36,7 +36,7 @@ public function once(string $event, callable $listener): self /** * Unsubscribe from an event. */ - public function off(string $event, callable|null $listener = null): self + public function off(string $event, ?callable $listener = null): self { if (!isset($this->listeners[$event])) { return $this; diff --git a/src/IOInterface.php b/src/IOInterface.php index f6c1701..aebb271 100644 --- a/src/IOInterface.php +++ b/src/IOInterface.php @@ -50,12 +50,12 @@ public function writeErrorRaw(string|array $messages, bool $newline = true, int /** * @param string|string[] $messages */ - public function overwrite(string|array $messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void; + public function overwrite(string|array $messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void; /** * @param string|string[] $messages */ - public function overwriteError(string|array $messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void; + public function overwriteError(string|array $messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void; /* ========================================================= INTERACTIVE METHODS @@ -65,9 +65,9 @@ public function ask(string $question, mixed $default = null): mixed; public function askConfirmation(string $question, bool $default = true): bool; - public function askAndValidate(string $question, callable $validator, int|null $attempts = null, mixed $default = null): mixed; + public function askAndValidate(string $question, callable $validator, ?int $attempts = null, mixed $default = null): mixed; - public function askAndHideAnswer(string $question): string|null; + public function askAndHideAnswer(string $question): ?string; /** * @param string[] $choices diff --git a/src/NullIO.php b/src/NullIO.php index 27bfd8c..34e20a9 100644 --- a/src/NullIO.php +++ b/src/NullIO.php @@ -54,9 +54,9 @@ public function writeRaw(string|array $messages, bool $newline = true, int $verb // FIX: same as above public function writeErrorRaw(string|array $messages, bool $newline = true, int $verbosity = self::NORMAL): void {} - public function overwrite($messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void {} + public function overwrite($messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void {} - public function overwriteError($messages, bool $newline = true, int|null $size = null, int $verbosity = self::NORMAL): void {} + public function overwriteError($messages, bool $newline = true, ?int $size = null, int $verbosity = self::NORMAL): void {} /* ========================================================= Interactive — all return defaults @@ -75,13 +75,13 @@ public function askConfirmation(string $question, bool $default = true): bool public function askAndValidate( string $question, callable $validator, - int|null $attempts = null, + ?int $attempts = null, mixed $default = null, ): mixed { return $default; } - public function askAndHideAnswer(string $question): string|null + public function askAndHideAnswer(string $question): ?string { return null; } diff --git a/src/Silencer.php b/src/Silencer.php index 6b0ff11..4d16670 100644 --- a/src/Silencer.php +++ b/src/Silencer.php @@ -33,7 +33,7 @@ class Silencer * * @return int The old error reporting level. */ - public static function suppress(int|null $mask = null): int + public static function suppress(?int $mask = null): int { if (!isset($mask)) { $mask = E_WARNING | E_NOTICE | E_USER_WARNING | E_USER_NOTICE | E_DEPRECATED | E_USER_DEPRECATED; diff --git a/tests/Integration/BufferIOUserInputsTest.php b/tests/Integration/BufferIOUserInputsTest.php index dcfc022..0f071f2 100644 --- a/tests/Integration/BufferIOUserInputsTest.php +++ b/tests/Integration/BufferIOUserInputsTest.php @@ -17,7 +17,7 @@ final class ConfirmCommand extends AbstractCommand { // Expose the IO so we can call it directly in the fixture - private \AlfacodeTeam\PhpIoCli\IOInterface|null $ioRef = null; + private ?\AlfacodeTeam\PhpIoCli\IOInterface $ioRef = null; public function setIORef(\AlfacodeTeam\PhpIoCli\IOInterface $io): void { diff --git a/tests/Unit/SliderInputTest.php b/tests/Unit/SliderInputTest.php new file mode 100644 index 0000000..93538d1 --- /dev/null +++ b/tests/Unit/SliderInputTest.php @@ -0,0 +1,290 @@ +assertSame($slider, $slider->min(0)); + $this->assertSame($slider, $slider->max(100)); + $this->assertSame($slider, $slider->step(5)); + $this->assertSame($slider, $slider->default(50)); + $this->assertSame($slider, $slider->integer()); + $this->assertSame($slider, $slider->width(40)); + } + + // --------------------------------------------------------------- + // resolve() — float mode (default) + // --------------------------------------------------------------- + + public function test_resolve_returns_float_by_default(): void + { + $slider = new SliderInput('Rate', 0.0, 1.0); + $slider->step(0.1)->default(0.5); + + // Mount the component so state is initialised (setup() wires bindings) + $this->mountWithoutLoop($slider); + + $result = $this->callResolve($slider); + + $this->assertIsFloat($result); + $this->assertEqualsWithDelta(0.5, $result, 0.001); + } + + // --------------------------------------------------------------- + // resolve() — integer mode + // --------------------------------------------------------------- + + public function test_resolve_returns_int_in_integer_mode(): void + { + $slider = new SliderInput('Port', 1000, 9999); + $slider->step(1)->default(8080)->integer(); + + $this->mountWithoutLoop($slider); + + $result = $this->callResolve($slider); + + $this->assertIsInt($result); + $this->assertSame(8080, $result); + } + + // --------------------------------------------------------------- + // resolve() — clamps to min / max + // --------------------------------------------------------------- + + public function test_resolve_clamps_value_within_range(): void + { + $slider = new SliderInput('Speed', 0, 10); + $slider->step(1)->default(5); + + $this->mountWithoutLoop($slider); + + $result = $this->callResolve($slider); + + $this->assertGreaterThanOrEqual(0, $result); + $this->assertLessThanOrEqual(10, $result); + } + + // --------------------------------------------------------------- + // resolve() — snap to step + // --------------------------------------------------------------- + + public function test_resolve_snaps_float_value_to_nearest_step(): void + { + // step = 0.25; default = 0.3 → nearest is 0.25 + $slider = new SliderInput('Alpha', 0.0, 1.0); + $slider->step(0.25)->default(0.3); + + $this->mountWithoutLoop($slider); + + $result = $this->callResolve($slider); + + $this->assertEqualsWithDelta(0.25, $result, 0.001); + } + + // --------------------------------------------------------------- + // render() — question appears in output + // --------------------------------------------------------------- + + public function test_render_contains_question_text(): void + { + $slider = new SliderInput('Master volume', 0, 100); + $slider->step(1)->default(50); + $this->mountWithoutLoop($slider); + + $output = $this->capture(static fn() => $slider->render()); + + $this->assertStringContainsString('Master volume', $output); + } + + // --------------------------------------------------------------- + // render() — current value appears in output + // --------------------------------------------------------------- + + public function test_render_shows_current_value(): void + { + $slider = new SliderInput('Brightness', 0, 100); + $slider->step(1)->default(75)->integer(); + $this->mountWithoutLoop($slider); + + $output = $this->capture(static fn() => $slider->render()); + + $this->assertStringContainsString('75', $output); + } + + // --------------------------------------------------------------- + // render() — bar characters present + // --------------------------------------------------------------- + + public function test_render_contains_bar_brackets(): void + { + $slider = new SliderInput('Level', 0, 10); + $slider->step(1)->default(5); + $this->mountWithoutLoop($slider); + + $output = $this->capture(static fn() => $slider->render()); + + $this->assertStringContainsString('[', $output); + $this->assertStringContainsString(']', $output); + } + + // --------------------------------------------------------------- + // render() — range hints (min and max values) + // --------------------------------------------------------------- + + public function test_render_shows_min_and_max_hints(): void + { + $slider = new SliderInput('Timeout', 1, 60); + $slider->step(1)->default(30)->integer(); + $this->mountWithoutLoop($slider); + + $output = $this->capture(static fn() => $slider->render()); + + $this->assertStringContainsString('1', $output); + $this->assertStringContainsString('60', $output); + } + + // --------------------------------------------------------------- + // render() — help text + // --------------------------------------------------------------- + + public function test_render_contains_keyboard_hint(): void + { + $slider = new SliderInput('Value', 0, 100); + $slider->step(1)->default(50); + $this->mountWithoutLoop($slider); + + $output = $this->capture(static fn() => $slider->render()); + + $this->assertStringContainsString('ENTER', $output); + } + + // --------------------------------------------------------------- + // render() — ANSI disabled → plain output + // --------------------------------------------------------------- + + public function test_render_works_with_colors_disabled(): void + { + Colors::disable(); + $slider = new SliderInput('Gain', 0, 10); + $slider->step(1)->default(5); + $this->mountWithoutLoop($slider); + + $output = $this->capture(static fn() => $slider->render()); + + // No ANSI escape sequences should appear + $this->assertStringContainsString('Gain', $output); + $this->assertStringContainsString('5', $output); + $this->assertStringContainsString('[', $output); + $this->assertStringContainsString(']', $output); + } + + // --------------------------------------------------------------- + // render() — thumb marker present (●) + // --------------------------------------------------------------- + + public function test_render_contains_thumb_marker(): void + { + $slider = new SliderInput('Pan', -50, 50); + $slider->step(1)->default(0)->integer(); + $this->mountWithoutLoop($slider); + + $output = $this->capture(static fn() => $slider->render()); + + // The slider thumb is rendered as ● + $this->assertStringContainsString('●', $output); + } + + // --------------------------------------------------------------- + // Decimal formatting mirrors step precision + // --------------------------------------------------------------- + + public function test_render_formats_value_with_correct_decimal_places(): void + { + $slider = new SliderInput('Rate', 0.0, 1.0); + $slider->step(0.01)->default(0.75); + $this->mountWithoutLoop($slider); + + $output = $this->capture(static fn() => $slider->render()); + + $this->assertStringContainsString('0.75', $output); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * Calls the protected setup() via mount() so state and bindings are ready, + * without launching the blocking run() loop. + */ + private function mountWithoutLoop(SliderInput $slider): void + { + $ref = new \ReflectionObject($slider); + $method = $ref->getMethod('setup'); + $method->setAccessible(true); + $method->invoke($slider); + } + + /** + * Calls the protected resolve() via reflection. + */ + private function callResolve(SliderInput $slider): mixed + { + $ref = new \ReflectionObject($slider); + $method = $ref->getMethod('resolve'); + $method->setAccessible(true); + + return $method->invoke($slider); + } + + /** + * Captures stdout output from a callable and strips ANSI codes. + */ + private function capture(callable $fn): string + { + ob_start(); + $fn(); + + return Colors::strip((string) ob_get_clean()); + } +}