diff --git a/TODO.md b/TODO.md index 7d12b0c..3edb645 100644 --- a/TODO.md +++ b/TODO.md @@ -54,7 +54,7 @@ Status icons: ๐Ÿ”ด Not started ยท ๐ŸŸก In progress ยท ๐ŸŸข Done ยท โšช Deferred | Item | Priority | Status | Notes | |---|---|---|---| -| **Abstract `AbstractPrompt`** โ€” decouple `Terminal::readKey()` | P1 | ๐Ÿ”ด | Inject a `KeyReader` interface so components can be tested without a real terminal | +| **Abstract `AbstractPrompt`** โ€” decouple `Terminal::readKey()` | P1 | ๐ŸŸข | Done โ€” `KeyReaderInterface`, `TerminalKeyReader`, `ArrayKeyReader`; `AbstractPrompt::withKeyReader()` injection; full test coverage in `KeyReaderTest.php` | | **`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 | diff --git a/src/AbstractPrompt.php b/src/AbstractPrompt.php index f3ea041..289b0e3 100644 --- a/src/AbstractPrompt.php +++ b/src/AbstractPrompt.php @@ -7,18 +7,62 @@ use AlfacodeTeam\PhpIoCli\Depends\Colors; use AlfacodeTeam\PhpIoCli\Depends\Key; use AlfacodeTeam\PhpIoCli\Depends\RenderContext; -use AlfacodeTeam\PhpIoCli\Depends\Terminal; - +use AlfacodeTeam\PhpIoCli\Depends\TerminalKeyReader; + +/** + * Reactive prompt engine. + * + * The key-reading strategy is now fully injectable via {@see KeyReaderInterface}. + * The production default ({@see TerminalKeyReader}) wraps Terminal::readKey() + * exactly as before, so all existing call-sites are unaffected. + * + * To use a custom reader (e.g. for headless testing): + * + * $result = (new MyComponent('Question')) + * ->withKeyReader(new ArrayKeyReader(['DOWN', 'ENTER'])) + * ->run(); + */ abstract class AbstractPrompt implements IPromptComponent, ILifecycle { protected bool $running = false; protected RenderContext $context; + private KeyReaderInterface $keyReader; + public function __construct( protected Hooks $hooks = new Hooks(), ) { $this->context = new RenderContext(); + $this->keyReader = new TerminalKeyReader(); + } + + /* ========================================================= + KEY READER INJECTION + ========================================================= */ + + /** + * Replace the key source before calling run(). + * + * Returns $this so the call can be chained fluently: + * + * $value = (new Confirm('Continue?')) + * ->withKeyReader(new ArrayKeyReader(['ENTER'])) + * ->run(); + */ + final public function withKeyReader(KeyReaderInterface $reader): static + { + $this->keyReader = $reader; + + return $this; + } + + /** + * Return the active KeyReaderInterface (useful for assertions in tests). + */ + final public function getKeyReader(): KeyReaderInterface + { + return $this->keyReader; } /* ========================================================= @@ -27,7 +71,7 @@ public function __construct( public function run(): mixed { - Terminal::enableRaw(); + $this->keyReader->setUp(); $this->running = true; try { @@ -45,7 +89,13 @@ public function run(): mixed $this->dispatch('render'); } - $rawKey = Terminal::readKey(); + $rawKey = $this->keyReader->readKey(); + + // Empty string -> exhausted ArrayKeyReader or no-op source; stop loop. + if ($rawKey === '') { + break; + } + $key = Key::normalize($rawKey); if ($key === 'CTRL_C') { @@ -70,7 +120,7 @@ public function run(): mixed } finally { $this->destroy(); $this->dispatch('destroy'); - Terminal::disableRaw(); + $this->keyReader->tearDown(); } } @@ -84,16 +134,14 @@ abstract public function destroy(): void; /* ========================================================= RENDER LIFECYCLE HOOKS - Concrete subclasses may override these to delegate to an + Concrete subclasses may override these to delegate to an IRenderer without breaking the base run() contract. ========================================================= */ - /** * Called immediately before render() in the engine loop. * Override to invoke IRenderer::beforeRender() when using a renderer object. */ protected function beforeRenderHook(): void {} - /** * Called immediately after render() in the engine loop. * Override to invoke IRenderer::afterRender() when using a renderer object. diff --git a/src/Components/RadioGroup.php b/src/Components/RadioGroup.php new file mode 100644 index 0000000..0bb3d49 --- /dev/null +++ b/src/Components/RadioGroup.php @@ -0,0 +1,273 @@ +default('M') + * ->run(); // returns string + * + * $priority = (new RadioGroup('Priority', ['low', 'medium', 'high'])) + * ->columns(3) // render options side-by-side + * ->run(); + */ +final class RadioGroup extends Component +{ + private int $lastLines = 0; + + private string $defaultValue = ''; + + private int $columns = 1; + + public function __construct( + private string $question, + private array $choices, + ) { + parent::__construct(); + } + + /* ========================================================= + FLUENT CONFIGURATION + ========================================================= */ + + /** + * Pre-select a choice by value. + */ + public function default(string $value): self + { + $this->defaultValue = $value; + + return $this; + } + + /** + * Render options in multiple columns (side-by-side). + * Useful when choices are short words and screen width allows it. + */ + public function columns(int $count): self + { + $this->columns = max(1, $count); + + return $this; + } + + /* ========================================================= + LIFECYCLE + ========================================================= */ + + protected function setup(): void + { + // Resolve the default index + $defaultIndex = 0; + if ($this->defaultValue !== '') { + $found = array_search($this->defaultValue, $this->choices, strict: true); + if ($found !== false) { + $defaultIndex = (int) $found; + } + } + + $this->state->batch([ + 'index' => $defaultIndex, + 'done' => false, + ]); + + $total = count($this->choices); + + // โ†‘ / โ† โ€” move up/left + $this->input->bind(['UP', 'LEFT'], static function ($s) use ($total): void { + $s->index = ((int) $s->index - 1 + $total) % $total; + }); + + // โ†“ / โ†’ โ€” move down/right + $this->input->bind(['DOWN', 'RIGHT'], static function ($s) use ($total): void { + $s->index = ((int) $s->index + 1) % $total; + }); + + // Digit shortcuts: 1-9 jump directly to that position + foreach (range(1, min(9, $total)) as $digit) { + $idx = $digit - 1; + $this->input->bind((string) $digit, static function ($s) use ($idx): void { + $s->index = $idx; + }); + } + + // ENTER โ€” confirm + $this->input->bind('ENTER', function ($s): void { + $s->done = true; + $this->stop(); + }); + } + + /* ========================================================= + RENDER + ========================================================= */ + + public function render(): void + { + if ($this->lastLines > 0) { + Terminal::moveCursorUp($this->lastLines); + } + + Terminal::hideCursor(); + + $index = (int) $this->state->index; + $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) { + $lines = array_merge($lines, $this->renderOptions($index)); + $lines[] = Colors::muted(' โ†‘โ†“ move โ€ข 1-9 jump โ€ข ENTER confirm'); + } else { + $selected = $this->choices[$index] ?? ''; + $lines[] = Colors::wrap(' โ€บ ', Colors::GRAY) . Colors::wrap($selected, Colors::GREEN); + } + + foreach ($lines as $line) { + Terminal::clearLine(); + echo $line . PHP_EOL; + } + + $this->lastLines = count($lines); + } + + /* ========================================================= + RESOLVE + ========================================================= */ + + public function resolve(): mixed + { + return $this->choices[(int) $this->state->index] ?? null; + } + + /* ========================================================= + CLEANUP + ========================================================= */ + + public function destroy(): void + { + Terminal::showCursor(); + parent::destroy(); + } + + /* ========================================================= + PRIVATE RENDERING HELPERS + ========================================================= */ + + /** + * Returns rendered option lines, respecting the column layout. + * + * @return string[] + */ + private function renderOptions(int $activeIndex): array + { + if ($this->columns === 1) { + return $this->renderSingleColumn($activeIndex); + } + + return $this->renderMultiColumn($activeIndex); + } + + /** + * Classic vertical list โ€” one option per line. + * + * @return string[] + */ + private function renderSingleColumn(int $activeIndex): array + { + $lines = []; + $lines[] = ''; + + foreach ($this->choices as $i => $choice) { + $lines[] = $this->renderOption((int) $i, (string) $choice, $i === $activeIndex); + } + + $lines[] = ''; + + return $lines; + } + + /** + * Multi-column layout โ€” groups choices into rows of $this->columns. + * + * @return string[] + */ + private function renderMultiColumn(int $activeIndex): array + { + // Compute max visual width of any option label (for uniform column padding) + $maxLen = 0; + foreach ($this->choices as $choice) { + $maxLen = max($maxLen, mb_strlen((string) $choice)); + } + // Each cell: " โ—‰ label" or " โ—‹ label" โ€” radio (2) + space (1) + label + 2 pad + $cellWidth = $maxLen + 5; + + $lines = []; + $lines[] = ''; + + $chunks = array_chunk($this->choices, $this->columns, preserve_keys: true); + + foreach ($chunks as $row) { + $parts = []; + foreach ($row as $i => $choice) { + $parts[] = $this->renderOption((int) $i, (string) $choice, $i === $activeIndex, $cellWidth); + } + $lines[] = implode(' ', $parts); + } + + $lines[] = ''; + + return $lines; + } + + /** + * Renders a single radio option with its indicator, label, and digit shortcut. + */ + private function renderOption(int $index, string $choice, bool $active, int $padWidth = 0): string + { + $digit = ($index < 9) ? Colors::muted((string) ($index + 1)) : ' '; + + if ($active) { + $radio = Colors::wrap('โ—‰', Colors::CYAN); + $label = Colors::wrap($choice, [Colors::YELLOW, Colors::BOLD]); + $prefix = Colors::wrap('โ€บ ', Colors::CYAN); + } else { + $radio = Colors::muted('โ—‹'); + $label = Colors::wrap($choice, Colors::GRAY); + $prefix = ' '; + } + + $cell = "{$prefix}{$radio} {$label}"; + + // Pad to uniform column width if rendering multi-column + if ($padWidth > 0) { + $visualLen = mb_strlen(Colors::strip($cell)); + $cell .= str_repeat(' ', max(0, $padWidth - $visualLen)); + } + + return $digit . ' ' . $cell; + } +} diff --git a/src/Depends/ArrayKeyReader.php b/src/Depends/ArrayKeyReader.php new file mode 100644 index 0000000..3dbcf63 --- /dev/null +++ b/src/Depends/ArrayKeyReader.php @@ -0,0 +1,76 @@ +withKeyReader($reader) + * ->run(); + * $this->assertSame('dev', $result); + * + * Keys are passed through Key::normalize() inside AbstractPrompt, so you + * can supply either normalised names ('UP', 'ENTER') or raw escape bytes + * ("\e[A", "\n") โ€” both work. + * + * setUp() and tearDown() are intentional no-ops: there is no real terminal + * to configure when running under PHPUnit. + */ +final class ArrayKeyReader implements KeyReaderInterface +{ + /** @var list */ + private array $queue; + + private int $position = 0; + + /** + * @param list $keys Ordered sequence of keys to replay. + */ + public function __construct(array $keys) + { + $this->queue = array_values($keys); + } + + public function readKey(): string + { + if ($this->position >= count($this->queue)) { + return ''; + } + + return $this->queue[$this->position++]; + } + + /** + * Returns true when all queued keys have been consumed. + */ + public function exhausted(): bool + { + return $this->position >= count($this->queue); + } + + /** + * Rewinds the reader so the same sequence can be replayed. + */ + public function reset(): void + { + $this->position = 0; + } + + /** No-op โ€” no terminal to configure in test context. */ + public function setUp(): void {} + + /** No-op โ€” no terminal to restore in test context. */ + public function tearDown(): void {} +} diff --git a/src/Depends/TerminalKeyReader.php b/src/Depends/TerminalKeyReader.php new file mode 100644 index 0000000..3585ca3 --- /dev/null +++ b/src/Depends/TerminalKeyReader.php @@ -0,0 +1,32 @@ +withKeyReader(new MyCustomKeyReader())->run(); + * + * The reader MUST block until a key is available, just as a real TTY does, + * so the event loop in AbstractPrompt::run() can yield correctly. + * Returning an empty string is treated as a no-op key (no bindings fire). + */ +interface KeyReaderInterface +{ + /** + * Block until a keypress is available and return the raw byte sequence. + * + * The returned string will be passed through Key::normalize() before + * being dispatched to Input bindings, so implementations do not need + * to normalise escape sequences themselves โ€” just return exactly what + * the source produced. + * + * @return string Raw key bytes, e.g. "\e[A" for the up arrow. + */ + public function readKey(): string; + + /** + * Called by AbstractPrompt before the event loop starts. + * + * Use this to enable raw mode, open a stream, or perform any other + * one-time setup required by the source. The default TTY implementation + * calls Terminal::enableRaw() here. + */ + public function setUp(): void; + + /** + * Called by AbstractPrompt in the finally block after the loop ends. + * + * Use this to restore terminal state, close streams, or clean up. + * The default TTY implementation calls Terminal::disableRaw() here. + * Must be idempotent โ€” it may be called more than once. + */ + public function tearDown(): void; +} diff --git a/tests/Unit/KeyReaderTest.php b/tests/Unit/KeyReaderTest.php new file mode 100644 index 0000000..22e7abc --- /dev/null +++ b/tests/Unit/KeyReaderTest.php @@ -0,0 +1,250 @@ +assertInstanceOf(KeyReaderInterface::class, new TerminalKeyReader()); + } + + public function test_terminal_key_reader_setup_and_teardown_do_not_throw(): void + { + // We cannot call setUp() in a test (would block on stty), + // but tearDown() must always be safe to call even without setUp(). + $reader = new TerminalKeyReader(); + $reader->tearDown(); // idempotent โ€” must not throw + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // ArrayKeyReader โ€” basic replay + // --------------------------------------------------------------- + + public function test_array_reader_implements_interface(): void + { + $this->assertInstanceOf(KeyReaderInterface::class, new ArrayKeyReader([])); + } + + public function test_array_reader_returns_keys_in_order(): void + { + $reader = new ArrayKeyReader(['UP', 'DOWN', 'ENTER']); + + $this->assertSame('UP', $reader->readKey()); + $this->assertSame('DOWN', $reader->readKey()); + $this->assertSame('ENTER', $reader->readKey()); + } + + public function test_array_reader_returns_empty_string_when_exhausted(): void + { + $reader = new ArrayKeyReader(['a']); + $reader->readKey(); // consume the only key + + $this->assertSame('', $reader->readKey()); + $this->assertSame('', $reader->readKey()); // subsequent calls also empty + } + + public function test_array_reader_exhausted_returns_false_initially(): void + { + $reader = new ArrayKeyReader(['x', 'y']); + + $this->assertFalse($reader->exhausted()); + } + + public function test_array_reader_exhausted_returns_true_after_all_keys_consumed(): void + { + $reader = new ArrayKeyReader(['x']); + $reader->readKey(); + + $this->assertTrue($reader->exhausted()); + } + + public function test_array_reader_empty_queue_is_immediately_exhausted(): void + { + $reader = new ArrayKeyReader([]); + + $this->assertTrue($reader->exhausted()); + $this->assertSame('', $reader->readKey()); + } + + // --------------------------------------------------------------- + // ArrayKeyReader โ€” reset + // --------------------------------------------------------------- + + public function test_array_reader_reset_replays_sequence_from_start(): void + { + $reader = new ArrayKeyReader(['A', 'B']); + + $this->assertSame('A', $reader->readKey()); + $this->assertSame('B', $reader->readKey()); + + $reader->reset(); + + $this->assertSame('A', $reader->readKey()); + $this->assertSame('B', $reader->readKey()); + } + + public function test_array_reader_reset_clears_exhausted_state(): void + { + $reader = new ArrayKeyReader(['Z']); + $reader->readKey(); + $this->assertTrue($reader->exhausted()); + + $reader->reset(); + $this->assertFalse($reader->exhausted()); + } + + // --------------------------------------------------------------- + // ArrayKeyReader โ€” setUp / tearDown are no-ops + // --------------------------------------------------------------- + + public function test_array_reader_setup_does_not_throw(): void + { + $reader = new ArrayKeyReader(['ENTER']); + $reader->setUp(); + $this->assertTrue(true); + } + + public function test_array_reader_teardown_does_not_throw(): void + { + $reader = new ArrayKeyReader(['ENTER']); + $reader->tearDown(); + $reader->tearDown(); // idempotent + $this->assertTrue(true); + } + + // --------------------------------------------------------------- + // ArrayKeyReader โ€” raw escape bytes also work + // --------------------------------------------------------------- + + public function test_array_reader_accepts_raw_escape_sequences(): void + { + $reader = new ArrayKeyReader(["\e[A", "\e[B", "\n"]); + + $this->assertSame("\e[A", $reader->readKey()); + $this->assertSame("\e[B", $reader->readKey()); + $this->assertSame("\n", $reader->readKey()); + } + + // --------------------------------------------------------------- + // AbstractPrompt integration โ€” withKeyReader() / getKeyReader() + // --------------------------------------------------------------- + + public function test_with_key_reader_injects_custom_reader(): void + { + $component = $this->makeMinimalComponent(); + $reader = new ArrayKeyReader([]); + + $component->withKeyReader($reader); + + $this->assertSame($reader, $component->getKeyReader()); + } + + public function test_with_key_reader_returns_same_instance_for_chaining(): void + { + $component = $this->makeMinimalComponent(); + $reader = new ArrayKeyReader([]); + + $returned = $component->withKeyReader($reader); + + $this->assertSame($component, $returned); + } + + public function test_default_key_reader_is_terminal_key_reader(): void + { + $component = $this->makeMinimalComponent(); + + $this->assertInstanceOf(TerminalKeyReader::class, $component->getKeyReader()); + } + + // --------------------------------------------------------------- + // Full run() integration โ€” ArrayKeyReader drives the loop + // --------------------------------------------------------------- + + public function test_run_resolves_confirm_via_array_reader(): void + { + // Simulate: user presses RIGHT (toggle to No), then ENTER + $reader = new ArrayKeyReader(['RIGHT', 'ENTER']); + + ob_start(); + $result = (new \AlfacodeTeam\PhpIoCli\Components\Confirm('Continue?', default: true)) + ->withKeyReader($reader) + ->run(); + ob_end_clean(); + + $this->assertFalse($result); // toggled from true โ†’ false + } + + public function test_run_resolves_select_via_array_reader(): void + { + $choices = ['alpha', 'beta', 'gamma']; + // Navigate down twice to land on 'gamma', then ENTER + $reader = new ArrayKeyReader(['DOWN', 'DOWN', 'ENTER']); + + ob_start(); + $result = (new \AlfacodeTeam\PhpIoCli\Components\Select('Pick', $choices)) + ->withKeyReader($reader) + ->run(); + ob_end_clean(); + + $this->assertSame('gamma', $result); + } + + public function test_run_resolves_radio_group_via_array_reader(): void + { + $choices = ['xs', 'sm', 'md', 'lg']; + // Press digit '3' to jump to 'md', then ENTER + $reader = new ArrayKeyReader(['3', 'ENTER']); + + ob_start(); + $result = (new \AlfacodeTeam\PhpIoCli\Components\RadioGroup('Size', $choices)) + ->withKeyReader($reader) + ->run(); + ob_end_clean(); + + $this->assertSame('md', $result); + } + + public function test_exhausted_reader_stops_loop_and_returns_default(): void + { + // No keys at all โ€” loop exits immediately, resolve() returns default + $reader = new ArrayKeyReader([]); + + ob_start(); + $result = (new \AlfacodeTeam\PhpIoCli\Components\Confirm('Go?', default: true)) + ->withKeyReader($reader) + ->run(); + ob_end_clean(); + + // No ENTER was pressed, so state stays at default (true) + $this->assertTrue($result); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + /** + * Returns a concrete minimal AbstractPrompt subclass for testing the + * injection API without depending on any specific component. + */ + private function makeMinimalComponent(): \AlfacodeTeam\PhpIoCli\Components\Confirm + { + return new \AlfacodeTeam\PhpIoCli\Components\Confirm('Test?'); + } +} diff --git a/tests/Unit/RadioGroupTest.php b/tests/Unit/RadioGroupTest.php new file mode 100644 index 0000000..18d9ef5 --- /dev/null +++ b/tests/Unit/RadioGroupTest.php @@ -0,0 +1,384 @@ +assertSame($rg, $rg->default('medium')); + $this->assertSame($rg, $rg->columns(2)); + } + + // --------------------------------------------------------------- + // Default selection + // --------------------------------------------------------------- + + public function test_default_selects_first_item_when_not_set(): void + { + $rg = new RadioGroup('Pick', self::CHOICES); + $this->mount($rg); + + $this->assertSame('small', $this->resolve($rg)); + } + + public function test_default_method_pre_selects_correct_item(): void + { + $rg = (new RadioGroup('Pick', self::CHOICES))->default('large'); + $this->mount($rg); + + $this->assertSame('large', $this->resolve($rg)); + } + + public function test_default_with_unknown_value_falls_back_to_first(): void + { + $rg = (new RadioGroup('Pick', self::CHOICES))->default('nonexistent'); + $this->mount($rg); + + $this->assertSame('small', $this->resolve($rg)); + } + + // --------------------------------------------------------------- + // Keyboard navigation โ€” DOWN wraps around + // --------------------------------------------------------------- + + public function test_down_arrow_advances_index(): void + { + $rg = new RadioGroup('Pick', self::CHOICES); + $this->mount($rg); + $this->pressKey($rg, 'DOWN'); + + $this->assertSame('medium', $this->resolve($rg)); + } + + public function test_down_arrow_wraps_to_first_at_end(): void + { + $rg = (new RadioGroup('Pick', self::CHOICES))->default('x-large'); + $this->mount($rg); + $this->pressKey($rg, 'DOWN'); + + $this->assertSame('small', $this->resolve($rg)); + } + + // --------------------------------------------------------------- + // Keyboard navigation โ€” UP wraps around + // --------------------------------------------------------------- + + public function test_up_arrow_moves_to_previous(): void + { + $rg = (new RadioGroup('Pick', self::CHOICES))->default('large'); + $this->mount($rg); + $this->pressKey($rg, 'UP'); + + $this->assertSame('medium', $this->resolve($rg)); + } + + public function test_up_arrow_wraps_to_last_from_first(): void + { + $rg = new RadioGroup('Pick', self::CHOICES); + $this->mount($rg); + $this->pressKey($rg, 'UP'); + + $this->assertSame('x-large', $this->resolve($rg)); + } + + // --------------------------------------------------------------- + // LEFT / RIGHT mirror UP / DOWN + // --------------------------------------------------------------- + + public function test_right_arrow_advances_same_as_down(): void + { + $rg = new RadioGroup('Pick', self::CHOICES); + $this->mount($rg); + $this->pressKey($rg, 'RIGHT'); + + $this->assertSame('medium', $this->resolve($rg)); + } + + public function test_left_arrow_moves_back_same_as_up(): void + { + $rg = (new RadioGroup('Pick', self::CHOICES))->default('large'); + $this->mount($rg); + $this->pressKey($rg, 'LEFT'); + + $this->assertSame('medium', $this->resolve($rg)); + } + + // --------------------------------------------------------------- + // Digit shortcuts โ€” 1-based jump + // --------------------------------------------------------------- + + #[DataProvider('digitJumpProvider')] + public function test_digit_key_jumps_to_correct_item(string $digit, string $expected): void + { + $rg = new RadioGroup('Pick', self::CHOICES); + $this->mount($rg); + $this->pressKey($rg, $digit); + + $this->assertSame($expected, $this->resolve($rg)); + } + + public static function digitJumpProvider(): array + { + return [ + '1 โ†’ small' => ['1', 'small'], + '2 โ†’ medium' => ['2', 'medium'], + '3 โ†’ large' => ['3', 'large'], + '4 โ†’ x-large' => ['4', 'x-large'], + ]; + } + + // --------------------------------------------------------------- + // Multiple keypresses accumulate correctly + // --------------------------------------------------------------- + + public function test_multiple_keys_navigate_correctly(): void + { + $rg = new RadioGroup('Pick', self::CHOICES); + $this->mount($rg); + $this->pressKey($rg, 'DOWN'); // medium + $this->pressKey($rg, 'DOWN'); // large + $this->pressKey($rg, 'UP'); // medium + + $this->assertSame('medium', $this->resolve($rg)); + } + + // --------------------------------------------------------------- + // render() โ€” question text + // --------------------------------------------------------------- + + public function test_render_contains_question(): void + { + $rg = new RadioGroup('Deployment target', self::CHOICES); + $this->mount($rg); + + $output = $this->capture(fn() => $rg->render()); + + $this->assertStringContainsString('Deployment target', $output); + } + + // --------------------------------------------------------------- + // render() โ€” all choices visible + // --------------------------------------------------------------- + + public function test_render_shows_all_choices(): void + { + $rg = new RadioGroup('Size', self::CHOICES); + $this->mount($rg); + + $output = $this->capture(fn() => $rg->render()); + + foreach (self::CHOICES as $choice) { + $this->assertStringContainsString($choice, $output); + } + } + + // --------------------------------------------------------------- + // render() โ€” active indicator + // --------------------------------------------------------------- + + public function test_render_shows_filled_radio_for_active_item(): void + { + $rg = new RadioGroup('Size', self::CHOICES); + $this->mount($rg); + + $output = $this->capture(fn() => $rg->render()); + + // Active item uses filled โ—‰, inactive use โ—‹ + $this->assertStringContainsString('โ—‰', $output); + $this->assertStringContainsString('โ—‹', $output); + } + + // --------------------------------------------------------------- + // render() โ€” digit shortcuts shown + // --------------------------------------------------------------- + + public function test_render_shows_digit_shortcuts(): void + { + $rg = new RadioGroup('Pick', self::CHOICES); + $this->mount($rg); + + $output = $this->capture(fn() => $rg->render()); + + $this->assertStringContainsString('1', $output); + $this->assertStringContainsString('4', $output); + } + + // --------------------------------------------------------------- + // render() โ€” help text + // --------------------------------------------------------------- + + public function test_render_shows_keyboard_hint(): void + { + $rg = new RadioGroup('Pick', self::CHOICES); + $this->mount($rg); + + $output = $this->capture(fn() => $rg->render()); + + $this->assertStringContainsString('ENTER', $output); + } + + // --------------------------------------------------------------- + // render() โ€” collapsed state after done + // --------------------------------------------------------------- + + public function test_render_collapses_after_done(): void + { + $rg = (new RadioGroup('Pick', self::CHOICES))->default('large'); + $this->mount($rg); + + // Simulate ENTER by setting state directly + $this->setState($rg, 'done', true); + + $output = $this->capture(fn() => $rg->render()); + + // Should show only the selected value, not all options + $this->assertStringContainsString('large', $output); + $this->assertStringNotContainsString('ENTER', $output); + $this->assertStringNotContainsString('โ—‰', $output); + } + + // --------------------------------------------------------------- + // render() โ€” multi-column layout + // --------------------------------------------------------------- + + public function test_render_multi_column_shows_all_choices(): void + { + $rg = (new RadioGroup('Size', self::CHOICES))->columns(2); + $this->mount($rg); + + $output = $this->capture(fn() => $rg->render()); + + foreach (self::CHOICES as $choice) { + $this->assertStringContainsString($choice, $output); + } + } + + // --------------------------------------------------------------- + // render() โ€” no ANSI when colors disabled + // --------------------------------------------------------------- + + public function test_render_no_ansi_when_colors_disabled(): void + { + Colors::disable(); + $rg = new RadioGroup('Pick', self::CHOICES); + $this->mount($rg); + + $output = $this->capture(fn() => $rg->render()); + + $this->assertStringContainsString('Pick', $output); + $this->assertStringContainsString('small', $output); + $this->assertStringContainsString('medium', $output); + $this->assertStringContainsString('large', $output); + $this->assertStringContainsString('x-large', $output); + + + } + + // --------------------------------------------------------------- + // Single-item list edge case + // --------------------------------------------------------------- + + public function test_single_choice_list_resolves_correctly(): void + { + $rg = new RadioGroup('Confirm', ['yes']); + $this->mount($rg); + + $this->assertSame('yes', $this->resolve($rg)); + } + + public function test_single_choice_up_down_stays_on_item(): void + { + $rg = new RadioGroup('Confirm', ['yes']); + $this->mount($rg); + $this->pressKey($rg, 'DOWN'); + $this->pressKey($rg, 'UP'); + + $this->assertSame('yes', $this->resolve($rg)); + } + + // --------------------------------------------------------------- + // Helpers + // --------------------------------------------------------------- + + private function mount(RadioGroup $rg): void + { + $ref = new \ReflectionObject($rg); + $method = $ref->getMethod('setup'); + $method->setAccessible(true); + $method->invoke($rg); + } + + private function resolve(RadioGroup $rg): mixed + { + $ref = new \ReflectionObject($rg); + $method = $ref->getMethod('resolve'); + $method->setAccessible(true); + + return $method->invoke($rg); + } + + private function pressKey(RadioGroup $rg, string $key): void + { + $ref = new \ReflectionObject($rg); + $state = $ref->getProperty('state'); + $state->setAccessible(true); + $input = $ref->getProperty('input'); + $input->setAccessible(true); + + $input->getValue($rg)->handle($key, $state->getValue($rg)); + } + + private function setState(RadioGroup $rg, string $key, mixed $value): void + { + $ref = new \ReflectionObject($rg); + $prop = $ref->getProperty('state'); + $prop->setAccessible(true); + $prop->getValue($rg)->$key = $value; + } + + private function capture(callable $fn): string + { + ob_start(); + $fn(); + + return Colors::strip((string) ob_get_clean()); + } +}