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:
@@ -0,0 +1,201 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Application\Admin\Registry\AdminNavigationRegistry;
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Application\Admin\Service\AdminNavigationService;
|
||||
use App\Application\Admin\ValueObjects\AdminLayoutData;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
|
||||
describe('AdminLayoutProcessor', function () {
|
||||
beforeEach(function () {
|
||||
$this->navigationService = Mockery::mock(AdminNavigationService::class);
|
||||
$this->request = Mockery::mock(HttpRequest::class);
|
||||
});
|
||||
|
||||
it('processes admin layout data correctly', function () {
|
||||
$this->request->path = '/admin/dashboard';
|
||||
$this->request->shouldReceive('getPath')
|
||||
->andReturn('/admin/dashboard');
|
||||
|
||||
$this->navigationService->shouldReceive('getNavigationMenu')
|
||||
->once()
|
||||
->andReturn([
|
||||
'System' => [
|
||||
'icon' => 'server',
|
||||
'items' => [
|
||||
'Dashboard' => '/admin',
|
||||
'Health' => '/admin/system/health',
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->navigationService->shouldReceive('getBreadcrumbs')
|
||||
->once()
|
||||
->with('/admin/dashboard')
|
||||
->andReturn([
|
||||
['name' => 'Admin', 'url' => '/admin'],
|
||||
['name' => 'Dashboard', 'url' => '/admin/dashboard'],
|
||||
]);
|
||||
|
||||
$processor = new AdminLayoutProcessor(
|
||||
$this->navigationService,
|
||||
$this->request
|
||||
);
|
||||
|
||||
$layoutData = AdminLayoutData::fromArray([
|
||||
'title' => 'Dashboard',
|
||||
'currentPath' => '/admin/dashboard',
|
||||
]);
|
||||
|
||||
$result = $processor->processAdminLayout($layoutData);
|
||||
|
||||
expect($result)->toBeInstanceOf(AdminLayoutData::class);
|
||||
expect($result->navigationMenu)->not->toBeNull();
|
||||
expect($result->breadcrumbs)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('handles navigation service exceptions gracefully', function () {
|
||||
$this->request->path = '/admin/dashboard';
|
||||
$this->request->shouldReceive('getPath')
|
||||
->andReturn('/admin/dashboard');
|
||||
|
||||
$this->navigationService->shouldReceive('getNavigationMenu')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Navigation failed'));
|
||||
|
||||
$processor = new AdminLayoutProcessor(
|
||||
$this->navigationService,
|
||||
$this->request
|
||||
);
|
||||
|
||||
$layoutData = AdminLayoutData::fromArray([
|
||||
'title' => 'Dashboard',
|
||||
'currentPath' => '/admin/dashboard',
|
||||
]);
|
||||
|
||||
// Should not throw, but use fallback menu
|
||||
$result = $processor->processAdminLayout($layoutData);
|
||||
|
||||
expect($result)->toBeInstanceOf(AdminLayoutData::class);
|
||||
expect($result->navigationMenu)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('handles breadcrumb service exceptions gracefully', function () {
|
||||
$this->request->path = '/admin/dashboard';
|
||||
$this->request->shouldReceive('getPath')
|
||||
->andReturn('/admin/dashboard');
|
||||
|
||||
$this->navigationService->shouldReceive('getNavigationMenu')
|
||||
->once()
|
||||
->andReturn([
|
||||
'System' => [
|
||||
'icon' => 'server',
|
||||
'items' => ['Dashboard' => '/admin'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->navigationService->shouldReceive('getBreadcrumbs')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Breadcrumbs failed'));
|
||||
|
||||
$processor = new AdminLayoutProcessor(
|
||||
$this->navigationService,
|
||||
$this->request
|
||||
);
|
||||
|
||||
$layoutData = AdminLayoutData::fromArray([
|
||||
'title' => 'Dashboard',
|
||||
'currentPath' => '/admin/dashboard',
|
||||
]);
|
||||
|
||||
// Should not throw, but use fallback breadcrumbs
|
||||
$result = $processor->processAdminLayout($layoutData);
|
||||
|
||||
expect($result)->toBeInstanceOf(AdminLayoutData::class);
|
||||
expect($result->breadcrumbs)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('processes layout from array correctly', function () {
|
||||
$this->request->path = '/admin/users';
|
||||
$this->request->shouldReceive('getPath')
|
||||
->andReturn('/admin/users');
|
||||
|
||||
$this->navigationService->shouldReceive('getNavigationMenu')
|
||||
->once()
|
||||
->andReturn([
|
||||
'Content' => [
|
||||
'icon' => 'photo',
|
||||
'items' => ['Users' => '/admin/users'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->navigationService->shouldReceive('getBreadcrumbs')
|
||||
->once()
|
||||
->andReturn([
|
||||
['name' => 'Admin', 'url' => '/admin'],
|
||||
['name' => 'Users', 'url' => '/admin/users'],
|
||||
]);
|
||||
|
||||
$processor = new AdminLayoutProcessor(
|
||||
$this->navigationService,
|
||||
$this->request
|
||||
);
|
||||
|
||||
$data = [
|
||||
'title' => 'Users',
|
||||
'users' => [['id' => 1, 'name' => 'John']],
|
||||
];
|
||||
|
||||
$result = $processor->processLayoutFromArray($data);
|
||||
|
||||
expect($result)->toBeArray();
|
||||
expect($result)->toHaveKey('title');
|
||||
expect($result)->toHaveKey('users');
|
||||
expect($result)->toHaveKey('navigation_menu');
|
||||
expect($result)->toHaveKey('breadcrumbs');
|
||||
expect($result)->toHaveKey('current_path');
|
||||
});
|
||||
|
||||
it('merges original data with layout data', function () {
|
||||
$this->request->path = '/admin/analytics';
|
||||
$this->request->shouldReceive('getPath')
|
||||
->andReturn('/admin/analytics');
|
||||
|
||||
$this->navigationService->shouldReceive('getNavigationMenu')
|
||||
->once()
|
||||
->andReturn([
|
||||
'Analytics' => [
|
||||
'icon' => 'chart-bar',
|
||||
'items' => ['Dashboard' => '/admin/analytics'],
|
||||
],
|
||||
]);
|
||||
|
||||
$this->navigationService->shouldReceive('getBreadcrumbs')
|
||||
->once()
|
||||
->andReturn([
|
||||
['name' => 'Admin', 'url' => '/admin'],
|
||||
['name' => 'Analytics', 'url' => '/admin/analytics'],
|
||||
]);
|
||||
|
||||
$processor = new AdminLayoutProcessor(
|
||||
$this->navigationService,
|
||||
$this->request
|
||||
);
|
||||
|
||||
$originalData = [
|
||||
'title' => 'Analytics',
|
||||
'stats' => ['page_views' => 1000],
|
||||
'custom_field' => 'custom_value',
|
||||
];
|
||||
|
||||
$result = $processor->processLayoutFromArray($originalData);
|
||||
|
||||
expect($result['title'])->toBe('Analytics');
|
||||
expect($result['stats'])->toBe($originalData['stats']);
|
||||
expect($result['custom_field'])->toBe('custom_value');
|
||||
expect($result)->toHaveKey('navigation_menu');
|
||||
});
|
||||
});
|
||||
|
||||
333
tests/Unit/Framework/Router/RouteResponderTest.php
Normal file
333
tests/Unit/Framework/Router/RouteResponderTest.php
Normal file
@@ -0,0 +1,333 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Application\Admin\Service\AdminLayoutProcessor;
|
||||
use App\Framework\Core\PathProvider;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\Http\HttpRequest;
|
||||
use App\Framework\Http\Method;
|
||||
use App\Framework\Http\RequestId;
|
||||
use App\Framework\Meta\MetaData;
|
||||
use App\Framework\Router\Result\ViewResult;
|
||||
use App\Framework\Router\RouteResponder;
|
||||
use App\Framework\View\RenderContext;
|
||||
use App\Framework\View\TemplateRenderer;
|
||||
|
||||
describe('RouteResponder - Admin Layout Integration', function () {
|
||||
beforeEach(function () {
|
||||
// PathProvider and DefaultContainer are final, so we need real instances
|
||||
$this->pathProvider = new PathProvider(__DIR__ . '/../../../../');
|
||||
$this->container = new DefaultContainer();
|
||||
$this->templateRenderer = Mockery::mock(TemplateRenderer::class);
|
||||
// RequestId needs a secret for tests
|
||||
$this->requestId = new RequestId('test-secret-for-unit-tests');
|
||||
});
|
||||
|
||||
it('detects admin routes correctly', function () {
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/admin/dashboard',
|
||||
id: $this->requestId
|
||||
);
|
||||
|
||||
$responder = new RouteResponder(
|
||||
$this->pathProvider,
|
||||
$this->container,
|
||||
$this->templateRenderer,
|
||||
$request
|
||||
);
|
||||
|
||||
// Use reflection to test private method
|
||||
$reflection = new ReflectionClass($responder);
|
||||
$method = $reflection->getMethod('isAdminRoute');
|
||||
$method->setAccessible(true);
|
||||
|
||||
expect($method->invoke($responder, '/admin/dashboard'))->toBeTrue();
|
||||
expect($method->invoke($responder, '/admin'))->toBeTrue();
|
||||
expect($method->invoke($responder, '/admin/users'))->toBeTrue();
|
||||
expect($method->invoke($responder, '/'))->toBeFalse();
|
||||
expect($method->invoke($responder, '/api/users'))->toBeFalse();
|
||||
expect($method->invoke($responder, '/admin-api/users'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('enriches admin routes with layout data', function () {
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/admin/dashboard',
|
||||
id: $this->requestId
|
||||
);
|
||||
|
||||
$layoutProcessor = Mockery::mock(AdminLayoutProcessor::class);
|
||||
$layoutProcessor->shouldReceive('processLayoutFromArray')
|
||||
->once()
|
||||
->with(['title' => 'Dashboard'])
|
||||
->andReturn([
|
||||
'title' => 'Dashboard',
|
||||
'navigation_menu' => ['System' => ['items' => []]],
|
||||
'breadcrumbs' => [],
|
||||
'current_path' => '/admin/dashboard',
|
||||
]);
|
||||
|
||||
// Register layout processor in real container
|
||||
$this->container->instance(AdminLayoutProcessor::class, $layoutProcessor);
|
||||
|
||||
$this->templateRenderer->shouldReceive('render')
|
||||
->once()
|
||||
->andReturn('<html>rendered</html>');
|
||||
|
||||
$responder = new RouteResponder(
|
||||
$this->pathProvider,
|
||||
$this->container,
|
||||
$this->templateRenderer,
|
||||
$request
|
||||
);
|
||||
|
||||
$viewResult = new ViewResult(
|
||||
template: 'dashboard',
|
||||
metaData: new MetaData('Dashboard'),
|
||||
data: ['title' => 'Dashboard']
|
||||
);
|
||||
|
||||
$context = $responder->getContext($viewResult);
|
||||
|
||||
expect($context)->toBeInstanceOf(RenderContext::class);
|
||||
expect($context->data)->toHaveKey('navigation_menu');
|
||||
expect($context->data)->toHaveKey('breadcrumbs');
|
||||
expect($context->data)->toHaveKey('current_path');
|
||||
});
|
||||
|
||||
it('does not enrich non-admin routes', function () {
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/home',
|
||||
id: $this->requestId
|
||||
);
|
||||
|
||||
$this->templateRenderer->shouldReceive('render')
|
||||
->once()
|
||||
->andReturn('<html>rendered</html>');
|
||||
|
||||
$responder = new RouteResponder(
|
||||
$this->pathProvider,
|
||||
$this->container,
|
||||
$this->templateRenderer,
|
||||
$request
|
||||
);
|
||||
|
||||
$viewResult = new ViewResult(
|
||||
template: 'home',
|
||||
metaData: new MetaData('Home'),
|
||||
data: ['title' => 'Home']
|
||||
);
|
||||
|
||||
$context = $responder->getContext($viewResult);
|
||||
|
||||
expect($context)->toBeInstanceOf(RenderContext::class);
|
||||
expect($context->data)->not->toHaveKey('navigation_menu');
|
||||
expect($context->data)->not->toHaveKey('breadcrumbs');
|
||||
expect($context->data['title'])->toBe('Home');
|
||||
});
|
||||
|
||||
it('handles missing AdminLayoutProcessor gracefully', function () {
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/admin/dashboard',
|
||||
id: $this->requestId
|
||||
);
|
||||
|
||||
// Container doesn't have AdminLayoutProcessor registered
|
||||
// (it's a fresh container without it)
|
||||
|
||||
$this->templateRenderer->shouldReceive('render')
|
||||
->once()
|
||||
->andReturn('<html>rendered</html>');
|
||||
|
||||
$responder = new RouteResponder(
|
||||
$this->pathProvider,
|
||||
$this->container,
|
||||
$this->templateRenderer,
|
||||
$request
|
||||
);
|
||||
|
||||
$viewResult = new ViewResult(
|
||||
template: 'dashboard',
|
||||
metaData: new MetaData('Dashboard'),
|
||||
data: ['title' => 'Dashboard']
|
||||
);
|
||||
|
||||
// Should not throw, but return original data
|
||||
$context = $responder->getContext($viewResult);
|
||||
|
||||
expect($context)->toBeInstanceOf(RenderContext::class);
|
||||
expect($context->data['title'])->toBe('Dashboard');
|
||||
});
|
||||
|
||||
it('handles AdminLayoutProcessor exceptions gracefully', function () {
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/admin/dashboard',
|
||||
id: $this->requestId
|
||||
);
|
||||
|
||||
$layoutProcessor = Mockery::mock(AdminLayoutProcessor::class);
|
||||
$layoutProcessor->shouldReceive('processLayoutFromArray')
|
||||
->once()
|
||||
->andThrow(new RuntimeException('Layout processing failed'));
|
||||
|
||||
// Register layout processor that throws exception
|
||||
$this->container->instance(AdminLayoutProcessor::class, $layoutProcessor);
|
||||
|
||||
$this->templateRenderer->shouldReceive('render')
|
||||
->once()
|
||||
->andReturn('<html>rendered</html>');
|
||||
|
||||
$responder = new RouteResponder(
|
||||
$this->pathProvider,
|
||||
$this->container,
|
||||
$this->templateRenderer,
|
||||
$request
|
||||
);
|
||||
|
||||
$viewResult = new ViewResult(
|
||||
template: 'dashboard',
|
||||
metaData: new MetaData('Dashboard'),
|
||||
data: ['title' => 'Dashboard']
|
||||
);
|
||||
|
||||
// Should not throw, but return original data
|
||||
$context = $responder->getContext($viewResult);
|
||||
|
||||
expect($context)->toBeInstanceOf(RenderContext::class);
|
||||
expect($context->data['title'])->toBe('Dashboard');
|
||||
});
|
||||
|
||||
it('preserves original data when enriching', function () {
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/admin/users',
|
||||
id: $this->requestId
|
||||
);
|
||||
|
||||
$originalData = [
|
||||
'title' => 'Users',
|
||||
'users' => [['id' => 1, 'name' => 'John']],
|
||||
'pagination' => ['page' => 1],
|
||||
];
|
||||
|
||||
$layoutProcessor = Mockery::mock(AdminLayoutProcessor::class);
|
||||
$layoutProcessor->shouldReceive('processLayoutFromArray')
|
||||
->once()
|
||||
->with($originalData)
|
||||
->andReturn(array_merge($originalData, [
|
||||
'navigation_menu' => ['Content' => ['items' => []]],
|
||||
'breadcrumbs' => [],
|
||||
'current_path' => '/admin/users',
|
||||
]));
|
||||
|
||||
// Register layout processor in real container
|
||||
$this->container->instance(AdminLayoutProcessor::class, $layoutProcessor);
|
||||
|
||||
$this->templateRenderer->shouldReceive('render')
|
||||
->once()
|
||||
->andReturn('<html>rendered</html>');
|
||||
|
||||
$responder = new RouteResponder(
|
||||
$this->pathProvider,
|
||||
$this->container,
|
||||
$this->templateRenderer,
|
||||
$request
|
||||
);
|
||||
|
||||
$viewResult = new ViewResult(
|
||||
template: 'users',
|
||||
metaData: new MetaData('Users'),
|
||||
data: $originalData
|
||||
);
|
||||
|
||||
$context = $responder->getContext($viewResult);
|
||||
|
||||
expect($context->data)->toHaveKey('users');
|
||||
expect($context->data)->toHaveKey('pagination');
|
||||
expect($context->data)->toHaveKey('navigation_menu');
|
||||
expect($context->data['users'])->toBe($originalData['users']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('RouteResponder - Admin Route Detection Edge Cases', function () {
|
||||
beforeEach(function () {
|
||||
// PathProvider and DefaultContainer are final, so we need real instances
|
||||
$this->pathProvider = new PathProvider(__DIR__ . '/../../../../');
|
||||
$this->container = new DefaultContainer();
|
||||
$this->templateRenderer = Mockery::mock(TemplateRenderer::class);
|
||||
// RequestId needs a secret for tests
|
||||
$this->requestId = new RequestId('test-secret-for-unit-tests');
|
||||
});
|
||||
|
||||
it('handles exact /admin path', function () {
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/admin',
|
||||
id: $this->requestId
|
||||
);
|
||||
|
||||
$layoutProcessor = Mockery::mock(AdminLayoutProcessor::class);
|
||||
$layoutProcessor->shouldReceive('processLayoutFromArray')
|
||||
->once()
|
||||
->andReturn(['title' => 'Admin', 'navigation_menu' => [], 'breadcrumbs' => []]);
|
||||
|
||||
// Register layout processor in real container
|
||||
$this->container->instance(AdminLayoutProcessor::class, $layoutProcessor);
|
||||
|
||||
$this->templateRenderer->shouldReceive('render')
|
||||
->once()
|
||||
->andReturn('<html>rendered</html>');
|
||||
|
||||
$responder = new RouteResponder(
|
||||
$this->pathProvider,
|
||||
$this->container,
|
||||
$this->templateRenderer,
|
||||
$request
|
||||
);
|
||||
|
||||
$viewResult = new ViewResult(
|
||||
template: 'admin',
|
||||
metaData: new MetaData('Admin'),
|
||||
data: ['title' => 'Admin']
|
||||
);
|
||||
|
||||
$context = $responder->getContext($viewResult);
|
||||
|
||||
expect($context->data)->toHaveKey('navigation_menu');
|
||||
});
|
||||
|
||||
it('does not treat /admin-api as admin route', function () {
|
||||
$request = new HttpRequest(
|
||||
method: Method::GET,
|
||||
path: '/admin-api/users',
|
||||
id: $this->requestId
|
||||
);
|
||||
|
||||
$this->templateRenderer->shouldReceive('render')
|
||||
->once()
|
||||
->andReturn('<html>rendered</html>');
|
||||
|
||||
$responder = new RouteResponder(
|
||||
$this->pathProvider,
|
||||
$this->container,
|
||||
$this->templateRenderer,
|
||||
$request
|
||||
);
|
||||
|
||||
$viewResult = new ViewResult(
|
||||
template: 'api',
|
||||
metaData: new MetaData('API'),
|
||||
data: ['title' => 'API']
|
||||
);
|
||||
|
||||
$context = $responder->getContext($viewResult);
|
||||
|
||||
expect($context->data)->not->toHaveKey('navigation_menu');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user