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:
2025-11-10 11:06:07 +01:00
parent 6bc78f5540
commit 8f3c15ddbb
106 changed files with 9082 additions and 4483 deletions

View File

@@ -0,0 +1,224 @@
<?php
declare(strict_types=1);
namespace Tests\Framework\Console\Progress;
use App\Framework\Console\Progress\ProgressTracker;
use Tests\Framework\Console\Helpers\TestConsoleOutput;
describe('ProgressTracker', function () {
beforeEach(function () {
$this->output = new TestConsoleOutput();
});
it('creates progress tracker', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test Progress');
expect($tracker)->toBeInstanceOf(ProgressTracker::class);
});
it('creates progress tracker with default title', function () {
$tracker = new ProgressTracker($this->output, 100);
expect($tracker)->toBeInstanceOf(ProgressTracker::class);
});
it('ensures total is at least 1', function () {
$tracker = new ProgressTracker($this->output, 0);
// Should not throw, total should be adjusted to 1
expect($tracker)->toBeInstanceOf(ProgressTracker::class);
});
it('renders initial progress', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
expect($this->output->capturedWrites)->not->toBeEmpty();
});
it('advances progress', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->advance(10);
expect($tracker->getProgress())->toBeGreaterThan(0);
});
it('advances by custom step', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->advance(25);
expect($tracker->getProgress())->toBe(0.25);
});
it('advances with task description', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->advance(10, 'Processing item 1');
expect($tracker->getProgress())->toBeGreaterThan(0);
});
it('sets progress directly', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(50);
expect($tracker->getProgress())->toBe(0.5);
});
it('sets progress with task', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(50, 'Halfway done');
expect($tracker->getProgress())->toBe(0.5);
});
it('does not exceed total', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(150);
expect($tracker->getProgress())->toBe(1.0);
});
it('does not go below zero', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(-10);
expect($tracker->getProgress())->toBe(0.0);
});
it('sets task separately', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setTask('Current task');
expect($tracker)->toBeInstanceOf(ProgressTracker::class);
});
it('finishes progress', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->finish();
expect($tracker->isFinished())->toBeTrue();
expect($tracker->getProgress())->toBe(1.0);
});
it('finishes with message', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->finish('Completed!');
expect($tracker->isFinished())->toBeTrue();
expect($this->output->capturedLines)->not->toBeEmpty();
});
it('does not advance when finished', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->finish();
$tracker->advance(10);
expect($tracker->isFinished())->toBeTrue();
});
it('does not set progress when finished', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->finish();
$tracker->setProgress(50);
expect($tracker->isFinished())->toBeTrue();
});
it('gets progress as float', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(50);
expect($tracker->getProgress())->toBe(0.5);
expect($tracker->getProgress())->toBeFloat();
});
it('gets elapsed time', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
usleep(100000); // 100ms
$elapsed = $tracker->getElapsedTime();
expect($elapsed)->toBeGreaterThan(0);
expect($elapsed)->toBeFloat();
});
it('gets estimated time remaining', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(50);
usleep(100000); // 100ms
$eta = $tracker->getEstimatedTimeRemaining();
expect($eta)->not->toBeNull();
expect($eta)->toBeFloat();
expect($eta)->toBeGreaterThan(0);
});
it('returns null for estimated time when at start', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
expect($tracker->getEstimatedTimeRemaining())->toBeNull();
});
it('sets total dynamically', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setTotal(200);
expect($tracker->getProgress())->toBeLessThan(0.1); // Progress should be recalculated
});
it('ensures total is at least 1 when setting', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setTotal(0);
// Should not throw, total should be adjusted to 1
expect($tracker)->toBeInstanceOf(ProgressTracker::class);
});
it('automatically finishes when progress reaches total', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->setProgress(100);
expect($tracker->isFinished())->toBeTrue();
});
it('automatically finishes when advance reaches total', function () {
$tracker = new ProgressTracker($this->output, 100, 'Test');
$tracker->advance(100);
expect($tracker->isFinished())->toBeTrue();
});
it('handles very large total', function () {
$tracker = new ProgressTracker($this->output, 1000000, 'Test');
$tracker->advance(100000);
expect($tracker->getProgress())->toBe(0.1);
});
it('handles edge case with total of 1', function () {
$tracker = new ProgressTracker($this->output, 1, 'Test');
$tracker->advance(1);
expect($tracker->isFinished())->toBeTrue();
expect($tracker->getProgress())->toBe(1.0);
});
});
describe('ProgressType', function () {
it('has all expected enum values', function () {
expect(\App\Framework\Console\Progress\ProgressType::AUTO->value)->toBe('auto');
expect(\App\Framework\Console\Progress\ProgressType::TRACKER->value)->toBe('tracker');
expect(\App\Framework\Console\Progress\ProgressType::SPINNER->value)->toBe('spinner');
expect(\App\Framework\Console\Progress\ProgressType::BAR->value)->toBe('bar');
expect(\App\Framework\Console\Progress\ProgressType::NONE->value)->toBe('none');
});
it('provides descriptions for all types', function () {
expect(\App\Framework\Console\Progress\ProgressType::AUTO->getDescription())->toBe('Automatically select based on operation type');
expect(\App\Framework\Console\Progress\ProgressType::TRACKER->getDescription())->toBe('Detailed progress tracker with time estimates');
expect(\App\Framework\Console\Progress\ProgressType::SPINNER->getDescription())->toBe('Spinner for indeterminate operations');
expect(\App\Framework\Console\Progress\ProgressType::BAR->getDescription())->toBe('Simple progress bar');
expect(\App\Framework\Console\Progress\ProgressType::NONE->getDescription())->toBe('No progress indication');
});
});