fix(console): comprehensive TUI rendering fixes
- Fix Enter key detection: handle multiple Enter key formats (\n, \r, \r\n) - Reduce flickering: lower render frequency from 60 FPS to 30 FPS - Fix menu bar visibility: re-render menu bar after content to prevent overwriting - Fix content positioning: explicit line positioning for categories and commands - Fix line shifting: clear lines before writing, control newlines manually - Limit visible items: prevent overflow with maxVisibleCategories/Commands - Improve CPU usage: increase sleep interval when no events processed This fixes: - Enter key not working for selection - Strong flickering of the application - Menu bar not visible or being overwritten - Top half of selection list not displayed - Lines being shifted/misaligned
This commit is contained in:
163
tests/Framework/Console/Helpers/MockKeyboard.php
Normal file
163
tests/Framework/Console/Helpers/MockKeyboard.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console\Helpers;
|
||||
|
||||
/**
|
||||
* Mock keyboard input for testing interactive menus and TUI components
|
||||
*/
|
||||
final class MockKeyboard
|
||||
{
|
||||
private array $keySequence = [];
|
||||
|
||||
private int $currentIndex = 0;
|
||||
|
||||
/**
|
||||
* ANSI escape sequences for common keys
|
||||
*/
|
||||
public const KEY_UP = "\033[A";
|
||||
public const KEY_DOWN = "\033[B";
|
||||
public const KEY_RIGHT = "\033[C";
|
||||
public const KEY_LEFT = "\033[D";
|
||||
public const KEY_ENTER = "\n";
|
||||
public const KEY_ESCAPE = "\033";
|
||||
public const KEY_TAB = "\t";
|
||||
public const KEY_BACKSPACE = "\x08";
|
||||
public const KEY_DELETE = "\033[3~";
|
||||
public const KEY_HOME = "\033[H";
|
||||
public const KEY_END = "\033[F";
|
||||
public const KEY_PAGE_UP = "\033[5~";
|
||||
public const KEY_PAGE_DOWN = "\033[6~";
|
||||
|
||||
public function __construct(array $keySequence = [])
|
||||
{
|
||||
$this->keySequence = $keySequence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a key press to the sequence
|
||||
*/
|
||||
public function pressKey(string $key): self
|
||||
{
|
||||
$this->keySequence[] = $key;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple key presses
|
||||
*/
|
||||
public function pressKeys(array $keys): self
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
$this->pressKey($key);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add arrow up key
|
||||
*/
|
||||
public function arrowUp(): self
|
||||
{
|
||||
return $this->pressKey(self::KEY_UP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add arrow down key
|
||||
*/
|
||||
public function arrowDown(): self
|
||||
{
|
||||
return $this->pressKey(self::KEY_DOWN);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add arrow left key
|
||||
*/
|
||||
public function arrowLeft(): self
|
||||
{
|
||||
return $this->pressKey(self::KEY_LEFT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add arrow right key
|
||||
*/
|
||||
public function arrowRight(): self
|
||||
{
|
||||
return $this->pressKey(self::KEY_RIGHT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add enter key
|
||||
*/
|
||||
public function enter(): self
|
||||
{
|
||||
return $this->pressKey(self::KEY_ENTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add escape key
|
||||
*/
|
||||
public function escape(): self
|
||||
{
|
||||
return $this->pressKey(self::KEY_ESCAPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add tab key
|
||||
*/
|
||||
public function tab(): self
|
||||
{
|
||||
return $this->pressKey(self::KEY_TAB);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next key in sequence
|
||||
*/
|
||||
public function getNextKey(): ?string
|
||||
{
|
||||
if ($this->currentIndex >= count($this->keySequence)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = $this->keySequence[$this->currentIndex];
|
||||
$this->currentIndex++;
|
||||
|
||||
return $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to beginning
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->currentIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are more keys
|
||||
*/
|
||||
public function hasMoreKeys(): bool
|
||||
{
|
||||
return $this->currentIndex < count($this->keySequence);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all remaining keys
|
||||
*/
|
||||
public function getRemainingKeys(): array
|
||||
{
|
||||
return array_slice($this->keySequence, $this->currentIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full sequence
|
||||
*/
|
||||
public function getSequence(): array
|
||||
{
|
||||
return $this->keySequence;
|
||||
}
|
||||
}
|
||||
|
||||
128
tests/Framework/Console/Helpers/MockStdin.php
Normal file
128
tests/Framework/Console/Helpers/MockStdin.php
Normal file
@@ -0,0 +1,128 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console\Helpers;
|
||||
|
||||
/**
|
||||
* Mock STDIN for testing interactive console components
|
||||
*/
|
||||
final class MockStdin
|
||||
{
|
||||
private array $inputs = [];
|
||||
|
||||
private int $currentIndex = 0;
|
||||
|
||||
private ?resource $originalStdin = null;
|
||||
|
||||
private bool $isActive = false;
|
||||
|
||||
public function __construct(array $inputs = [])
|
||||
{
|
||||
$this->inputs = $inputs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add input to the queue
|
||||
*/
|
||||
public function addInput(string $input): self
|
||||
{
|
||||
$this->inputs[] = $input;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add multiple inputs
|
||||
*/
|
||||
public function addInputs(array $inputs): self
|
||||
{
|
||||
foreach ($inputs as $input) {
|
||||
$this->addInput($input);
|
||||
}
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the mock (replace STDIN)
|
||||
*/
|
||||
public function activate(): void
|
||||
{
|
||||
if ($this->isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a temporary file for input
|
||||
$tempFile = tmpfile();
|
||||
if ($tempFile === false) {
|
||||
throw new \RuntimeException('Could not create temporary file for STDIN mock');
|
||||
}
|
||||
|
||||
// Write all inputs to the temp file
|
||||
$content = implode("\n", $this->inputs) . "\n";
|
||||
fwrite($tempFile, $content);
|
||||
rewind($tempFile);
|
||||
|
||||
// Store original STDIN if we can
|
||||
if (defined('STDIN') && is_resource(STDIN)) {
|
||||
$this->originalStdin = STDIN;
|
||||
}
|
||||
|
||||
// Replace STDIN constant (not possible in PHP, so we use a workaround)
|
||||
// We'll need to use a different approach - create a stream wrapper
|
||||
$this->isActive = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate the mock (restore original STDIN)
|
||||
*/
|
||||
public function deactivate(): void
|
||||
{
|
||||
if (!$this->isActive) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->isActive = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next input (simulates fgets)
|
||||
*/
|
||||
public function getNextInput(): ?string
|
||||
{
|
||||
if ($this->currentIndex >= count($this->inputs)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$input = $this->inputs[$this->currentIndex];
|
||||
$this->currentIndex++;
|
||||
|
||||
return $input . "\n";
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to beginning
|
||||
*/
|
||||
public function reset(): void
|
||||
{
|
||||
$this->currentIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there are more inputs
|
||||
*/
|
||||
public function hasMoreInputs(): bool
|
||||
{
|
||||
return $this->currentIndex < count($this->inputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all remaining inputs
|
||||
*/
|
||||
public function getRemainingInputs(): array
|
||||
{
|
||||
return array_slice($this->inputs, $this->currentIndex);
|
||||
}
|
||||
}
|
||||
|
||||
153
tests/Framework/Console/Helpers/MockTerminal.php
Normal file
153
tests/Framework/Console/Helpers/MockTerminal.php
Normal file
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console\Helpers;
|
||||
|
||||
/**
|
||||
* Mock terminal capabilities for testing
|
||||
*/
|
||||
final class MockTerminal
|
||||
{
|
||||
private bool $isTerminal = true;
|
||||
|
||||
private bool $supportsAnsi = true;
|
||||
|
||||
private bool $supportsColors = true;
|
||||
|
||||
private bool $supportsTrueColor = false;
|
||||
|
||||
private int $width = 80;
|
||||
|
||||
private int $height = 24;
|
||||
|
||||
private bool $supportsMouse = false;
|
||||
|
||||
private string $termType = 'xterm-256color';
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a mock terminal with specific capabilities
|
||||
*/
|
||||
public static function create(
|
||||
bool $isTerminal = true,
|
||||
bool $supportsAnsi = true,
|
||||
int $width = 80,
|
||||
int $height = 24
|
||||
): self {
|
||||
$mock = new self();
|
||||
$mock->isTerminal = $isTerminal;
|
||||
$mock->supportsAnsi = $supportsAnsi;
|
||||
$mock->width = $width;
|
||||
$mock->height = $height;
|
||||
|
||||
return $mock;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a non-terminal mock (e.g., for CI environments)
|
||||
*/
|
||||
public static function nonTerminal(): self
|
||||
{
|
||||
return self::create(isTerminal: false, supportsAnsi: false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a minimal terminal mock
|
||||
*/
|
||||
public static function minimal(): self
|
||||
{
|
||||
return self::create(isTerminal: true, supportsAnsi: true, width: 40, height: 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a full-featured terminal mock
|
||||
*/
|
||||
public static function fullFeatured(): self
|
||||
{
|
||||
$mock = self::create(isTerminal: true, supportsAnsi: true, width: 120, height: 40);
|
||||
$mock->supportsTrueColor = true;
|
||||
$mock->supportsMouse = true;
|
||||
|
||||
return $mock;
|
||||
}
|
||||
|
||||
public function isTerminal(): bool
|
||||
{
|
||||
return $this->isTerminal;
|
||||
}
|
||||
|
||||
public function supportsAnsi(): bool
|
||||
{
|
||||
return $this->supportsAnsi;
|
||||
}
|
||||
|
||||
public function supportsColors(): bool
|
||||
{
|
||||
return $this->supportsColors;
|
||||
}
|
||||
|
||||
public function supportsTrueColor(): bool
|
||||
{
|
||||
return $this->supportsTrueColor;
|
||||
}
|
||||
|
||||
public function getWidth(): int
|
||||
{
|
||||
return $this->width;
|
||||
}
|
||||
|
||||
public function getHeight(): int
|
||||
{
|
||||
return $this->height;
|
||||
}
|
||||
|
||||
public function supportsMouse(): bool
|
||||
{
|
||||
return $this->supportsMouse;
|
||||
}
|
||||
|
||||
public function getTermType(): string
|
||||
{
|
||||
return $this->termType;
|
||||
}
|
||||
|
||||
public function setWidth(int $width): self
|
||||
{
|
||||
$this->width = $width;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setHeight(int $height): self
|
||||
{
|
||||
$this->height = $height;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setTermType(string $termType): self
|
||||
{
|
||||
$this->termType = $termType;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSupportsMouse(bool $supports): self
|
||||
{
|
||||
$this->supportsMouse = $supports;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function setSupportsTrueColor(bool $supports): self
|
||||
{
|
||||
$this->supportsTrueColor = $supports;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
149
tests/Framework/Console/Helpers/TestConsoleOutput.php
Normal file
149
tests/Framework/Console/Helpers/TestConsoleOutput.php
Normal file
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console\Helpers;
|
||||
|
||||
use App\Framework\Console\ConsoleColor;
|
||||
use App\Framework\Console\ConsoleOutputInterface;
|
||||
use App\Framework\Console\ConsoleStyle;
|
||||
|
||||
/**
|
||||
* Enhanced test console output that captures all output for assertions
|
||||
*/
|
||||
final class TestConsoleOutput implements ConsoleOutputInterface
|
||||
{
|
||||
public array $capturedLines = [];
|
||||
|
||||
public array $capturedWrites = [];
|
||||
|
||||
public array $capturedErrors = [];
|
||||
|
||||
public array $capturedSuccesses = [];
|
||||
|
||||
public array $capturedWarnings = [];
|
||||
|
||||
public array $capturedInfos = [];
|
||||
|
||||
public int $newLineCount = 0;
|
||||
|
||||
public array $windowTitles = [];
|
||||
|
||||
public function write(string $message, ConsoleStyle|ConsoleColor|null $style = null): void
|
||||
{
|
||||
$this->capturedWrites[] = ['message' => $message, 'style' => $style];
|
||||
}
|
||||
|
||||
public function writeLine(string $message = '', ?ConsoleColor $color = null): void
|
||||
{
|
||||
$this->capturedLines[] = ['message' => $message, 'color' => $color];
|
||||
}
|
||||
|
||||
public function writeError(string $message): void
|
||||
{
|
||||
$this->capturedErrors[] = $message;
|
||||
$this->capturedLines[] = ['message' => $message, 'type' => 'error'];
|
||||
}
|
||||
|
||||
public function writeSuccess(string $message): void
|
||||
{
|
||||
$this->capturedSuccesses[] = $message;
|
||||
$this->capturedLines[] = ['message' => $message, 'type' => 'success'];
|
||||
}
|
||||
|
||||
public function writeWarning(string $message): void
|
||||
{
|
||||
$this->capturedWarnings[] = $message;
|
||||
$this->capturedLines[] = ['message' => $message, 'type' => 'warning'];
|
||||
}
|
||||
|
||||
public function writeInfo(string $message): void
|
||||
{
|
||||
$this->capturedInfos[] = $message;
|
||||
$this->capturedLines[] = ['message' => $message, 'type' => 'info'];
|
||||
}
|
||||
|
||||
public function newLine(int $count = 1): void
|
||||
{
|
||||
$this->newLineCount += $count;
|
||||
for ($i = 0; $i < $count; $i++) {
|
||||
$this->capturedLines[] = ['message' => '', 'type' => 'newline'];
|
||||
}
|
||||
}
|
||||
|
||||
public function askQuestion(string $question, ?string $default = null): string
|
||||
{
|
||||
$this->capturedLines[] = ['message' => $question, 'type' => 'question', 'default' => $default];
|
||||
return $default ?? '';
|
||||
}
|
||||
|
||||
public function confirm(string $question, bool $default = false): bool
|
||||
{
|
||||
$this->capturedLines[] = ['message' => $question, 'type' => 'confirm', 'default' => $default];
|
||||
return $default;
|
||||
}
|
||||
|
||||
public function writeWindowTitle(string $title, int $mode = 0): void
|
||||
{
|
||||
$this->windowTitles[] = ['title' => $title, 'mode' => $mode];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all captured output as plain text
|
||||
*/
|
||||
public function getOutput(): string
|
||||
{
|
||||
$output = [];
|
||||
foreach ($this->capturedLines as $line) {
|
||||
$output[] = $line['message'] ?? '';
|
||||
}
|
||||
|
||||
return implode("\n", $output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all captured writes
|
||||
*/
|
||||
public function getWrites(): array
|
||||
{
|
||||
return $this->capturedWrites;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all captured output
|
||||
*/
|
||||
public function clear(): void
|
||||
{
|
||||
$this->capturedLines = [];
|
||||
$this->capturedWrites = [];
|
||||
$this->capturedErrors = [];
|
||||
$this->capturedSuccesses = [];
|
||||
$this->capturedWarnings = [];
|
||||
$this->capturedInfos = [];
|
||||
$this->newLineCount = 0;
|
||||
$this->windowTitles = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if output contains a specific string
|
||||
*/
|
||||
public function contains(string $search): bool
|
||||
{
|
||||
$output = $this->getOutput();
|
||||
return str_contains($output, $search);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last captured line
|
||||
*/
|
||||
public function getLastLine(): ?string
|
||||
{
|
||||
if (empty($this->capturedLines)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$last = end($this->capturedLines);
|
||||
return $last['message'] ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user