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:
196
tests/Framework/Console/ArgumentDefinitionTest.php
Normal file
196
tests/Framework/Console/ArgumentDefinitionTest.php
Normal file
@@ -0,0 +1,196 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\ArgumentDefinition;
|
||||
use App\Framework\Console\ArgumentType;
|
||||
|
||||
describe('ArgumentDefinition', function () {
|
||||
it('creates required string argument', function () {
|
||||
$def = ArgumentDefinition::required('name', 'User name');
|
||||
|
||||
expect($def->name)->toBe('name');
|
||||
expect($def->type)->toBe(ArgumentType::STRING);
|
||||
expect($def->required)->toBeTrue();
|
||||
expect($def->default)->toBeNull();
|
||||
expect($def->description)->toBe('User name');
|
||||
});
|
||||
|
||||
it('creates optional string argument with default', function () {
|
||||
$def = ArgumentDefinition::optional('name', 'Guest', 'User name');
|
||||
|
||||
expect($def->name)->toBe('name');
|
||||
expect($def->type)->toBe(ArgumentType::STRING);
|
||||
expect($def->required)->toBeFalse();
|
||||
expect($def->default)->toBe('Guest');
|
||||
expect($def->description)->toBe('User name');
|
||||
});
|
||||
|
||||
it('creates boolean flag', function () {
|
||||
$def = ArgumentDefinition::flag('verbose', 'v', 'Enable verbose output');
|
||||
|
||||
expect($def->name)->toBe('verbose');
|
||||
expect($def->type)->toBe(ArgumentType::BOOLEAN);
|
||||
expect($def->shortName)->toBe('v');
|
||||
expect($def->description)->toBe('Enable verbose output');
|
||||
});
|
||||
|
||||
it('creates email argument', function () {
|
||||
$def = ArgumentDefinition::email('email', required: true, description: 'User email');
|
||||
|
||||
expect($def->name)->toBe('email');
|
||||
expect($def->type)->toBe(ArgumentType::EMAIL);
|
||||
expect($def->required)->toBeTrue();
|
||||
expect($def->description)->toBe('User email');
|
||||
});
|
||||
|
||||
it('creates integer argument', function () {
|
||||
$def = ArgumentDefinition::integer('count', required: false, default: 10, description: 'Item count');
|
||||
|
||||
expect($def->name)->toBe('count');
|
||||
expect($def->type)->toBe(ArgumentType::INTEGER);
|
||||
expect($def->required)->toBeFalse();
|
||||
expect($def->default)->toBe(10);
|
||||
expect($def->description)->toBe('Item count');
|
||||
});
|
||||
|
||||
it('creates choice argument', function () {
|
||||
$def = ArgumentDefinition::choice('mode', ['dev', 'prod', 'test'], required: false, default: 'dev');
|
||||
|
||||
expect($def->name)->toBe('mode');
|
||||
expect($def->type)->toBe(ArgumentType::STRING);
|
||||
expect($def->allowedValues)->toBe(['dev', 'prod', 'test']);
|
||||
expect($def->default)->toBe('dev');
|
||||
});
|
||||
|
||||
it('throws exception for empty name', function () {
|
||||
expect(fn () => new ArgumentDefinition('', ArgumentType::STRING))
|
||||
->toThrow(\InvalidArgumentException::class, 'Argument name cannot be empty');
|
||||
});
|
||||
|
||||
it('throws exception for invalid short name length', function () {
|
||||
expect(fn () => new ArgumentDefinition('name', ArgumentType::STRING, shortName: 'ab'))
|
||||
->toThrow(\InvalidArgumentException::class, 'Short name must be exactly one character');
|
||||
});
|
||||
|
||||
it('throws exception when required argument has default', function () {
|
||||
expect(fn () => new ArgumentDefinition('name', ArgumentType::STRING, required: true, default: 'Guest'))
|
||||
->toThrow(\InvalidArgumentException::class, 'Required arguments cannot have default values');
|
||||
});
|
||||
|
||||
it('throws exception when boolean argument has allowed values', function () {
|
||||
expect(fn () => new ArgumentDefinition('flag', ArgumentType::BOOLEAN, allowedValues: ['true', 'false']))
|
||||
->toThrow(\InvalidArgumentException::class, 'Boolean arguments cannot have allowed values');
|
||||
});
|
||||
|
||||
it('gets display name with short name', function () {
|
||||
$def = ArgumentDefinition::flag('verbose', 'v');
|
||||
|
||||
expect($def->getDisplayName())->toBe('v, verbose');
|
||||
});
|
||||
|
||||
it('gets display name without short name', function () {
|
||||
$def = ArgumentDefinition::required('name');
|
||||
|
||||
expect($def->getDisplayName())->toBe('name');
|
||||
});
|
||||
|
||||
it('gets usage text for boolean flag', function () {
|
||||
$def = ArgumentDefinition::flag('verbose', 'v');
|
||||
|
||||
expect($def->getUsageText())->toBe('[--verbose]');
|
||||
});
|
||||
|
||||
it('gets usage text for required boolean flag', function () {
|
||||
$def = new ArgumentDefinition('verbose', ArgumentType::BOOLEAN, required: true);
|
||||
|
||||
expect($def->getUsageText())->toBe('--verbose');
|
||||
});
|
||||
|
||||
it('gets usage text for optional boolean flag', function () {
|
||||
$def = ArgumentDefinition::flag('verbose', 'v');
|
||||
|
||||
expect($def->getUsageText())->toBe('[--verbose]');
|
||||
});
|
||||
|
||||
it('gets usage text for required string argument', function () {
|
||||
$def = ArgumentDefinition::required('name');
|
||||
|
||||
expect($def->getUsageText())->toContain('--name');
|
||||
expect($def->getUsageText())->toContain('<');
|
||||
});
|
||||
|
||||
it('gets usage text for optional string argument', function () {
|
||||
$def = ArgumentDefinition::optional('name', 'Guest');
|
||||
|
||||
expect($def->getUsageText())->toContain('--name');
|
||||
expect($def->getUsageText())->toContain('[');
|
||||
});
|
||||
|
||||
it('gets usage text with allowed values', function () {
|
||||
$def = ArgumentDefinition::choice('mode', ['dev', 'prod']);
|
||||
|
||||
expect($def->getUsageText())->toContain('dev|prod');
|
||||
});
|
||||
|
||||
it('validates required value', function () {
|
||||
$def = ArgumentDefinition::required('name');
|
||||
|
||||
expect(fn () => $def->validateValue(null))
|
||||
->toThrow(\InvalidArgumentException::class, "Required argument 'name' is missing");
|
||||
});
|
||||
|
||||
it('validates allowed values', function () {
|
||||
$def = ArgumentDefinition::choice('mode', ['dev', 'prod', 'test']);
|
||||
|
||||
// Should not throw for valid value
|
||||
$def->validateValue('dev');
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('throws exception for invalid allowed value', function () {
|
||||
$def = ArgumentDefinition::choice('mode', ['dev', 'prod', 'test']);
|
||||
|
||||
expect(fn () => $def->validateValue('invalid'))
|
||||
->toThrow(\InvalidArgumentException::class, "Invalid value 'invalid'");
|
||||
});
|
||||
|
||||
it('allows empty value for optional argument', function () {
|
||||
$def = ArgumentDefinition::optional('name', 'Guest');
|
||||
|
||||
// Should not throw
|
||||
$def->validateValue(null);
|
||||
$def->validateValue('');
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('validates value with default', function () {
|
||||
$def = ArgumentDefinition::optional('name', 'Guest');
|
||||
|
||||
// Should not throw
|
||||
$def->validateValue('John');
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates argument with all properties', function () {
|
||||
$def = new ArgumentDefinition(
|
||||
name: 'test',
|
||||
type: ArgumentType::INTEGER,
|
||||
required: false,
|
||||
default: 42,
|
||||
description: 'Test description',
|
||||
shortName: 't',
|
||||
allowedValues: []
|
||||
);
|
||||
|
||||
expect($def->name)->toBe('test');
|
||||
expect($def->type)->toBe(ArgumentType::INTEGER);
|
||||
expect($def->required)->toBeFalse();
|
||||
expect($def->default)->toBe(42);
|
||||
expect($def->description)->toBe('Test description');
|
||||
expect($def->shortName)->toBe('t');
|
||||
});
|
||||
});
|
||||
|
||||
283
tests/Framework/Console/ArgumentParserTest.php
Normal file
283
tests/Framework/Console/ArgumentParserTest.php
Normal file
@@ -0,0 +1,283 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\ArgumentDefinition;
|
||||
use App\Framework\Console\ArgumentParser;
|
||||
use App\Framework\Console\ArgumentParserBuilder;
|
||||
use App\Framework\Console\ArgumentType;
|
||||
|
||||
describe('ArgumentParser', function () {
|
||||
it('parses simple positional arguments', function () {
|
||||
$parser = new ArgumentParser();
|
||||
$parsed = $parser->parse(['arg1', 'arg2']);
|
||||
|
||||
expect($parsed->getAllArguments())->toBeArray();
|
||||
});
|
||||
|
||||
it('parses long options with equals sign', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->optionalString('name')
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['--name=John']);
|
||||
|
||||
expect($parsed->get('name'))->toBe('John');
|
||||
});
|
||||
|
||||
it('parses long options with space', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->optionalString('name')
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['--name', 'John']);
|
||||
|
||||
expect($parsed->get('name'))->toBe('John');
|
||||
});
|
||||
|
||||
it('parses boolean flags', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->flag('verbose', 'v')
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['--verbose']);
|
||||
|
||||
expect($parsed->getBool('verbose'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('parses short options', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->addArgument(new ArgumentDefinition('name', ArgumentType::STRING, shortName: 'n'))
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['-n', 'John']);
|
||||
|
||||
expect($parsed->get('name'))->toBe('John');
|
||||
});
|
||||
|
||||
it('parses combined short flags', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->flag('verbose', 'v')
|
||||
->flag('force', 'f')
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['-vf']);
|
||||
|
||||
expect($parsed->getBool('verbose'))->toBeTrue();
|
||||
expect($parsed->getBool('force'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('converts kebab-case to camelCase', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->addArgument(new ArgumentDefinition('dryRun', ArgumentType::BOOLEAN))
|
||||
->optionalString('outputFile')
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['--dry-run', '--output-file=test.txt']);
|
||||
|
||||
expect($parsed->getBool('dryRun'))->toBeTrue();
|
||||
expect($parsed->get('outputFile'))->toBe('test.txt');
|
||||
});
|
||||
|
||||
it('validates required arguments', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->requiredString('name')
|
||||
->build();
|
||||
|
||||
expect(fn () => $parser->parse([]))
|
||||
->toThrow(\InvalidArgumentException::class, "Required argument 'name' is missing");
|
||||
});
|
||||
|
||||
it('applies default values for optional arguments', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->optionalString('name', 'Guest')
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse([]);
|
||||
|
||||
expect($parsed->get('name'))->toBe('Guest');
|
||||
});
|
||||
|
||||
it('parses integer values', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->integer('count')
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['--count=42']);
|
||||
|
||||
expect($parsed->getInt('count'))->toBe(42);
|
||||
});
|
||||
|
||||
it('validates integer values', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->integer('count')
|
||||
->build();
|
||||
|
||||
expect(fn () => $parser->parse(['--count=not-a-number']))
|
||||
->toThrow(\InvalidArgumentException::class, 'not a valid integer');
|
||||
});
|
||||
|
||||
it('parses float values', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->addArgument(new ArgumentDefinition('price', ArgumentType::FLOAT))
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['--price=12.34']);
|
||||
|
||||
expect($parsed->getFloat('price'))->toBe(12.34);
|
||||
});
|
||||
|
||||
it('parses boolean values', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->addArgument(new ArgumentDefinition('active', ArgumentType::BOOLEAN))
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['--active=true']);
|
||||
|
||||
expect($parsed->getBool('active'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('parses array values from comma-separated string', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->addArgument(new ArgumentDefinition('items', ArgumentType::ARRAY))
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['--items=item1,item2,item3']);
|
||||
|
||||
expect($parsed->getArray('items'))->toBe(['item1', 'item2', 'item3']);
|
||||
});
|
||||
|
||||
it('validates email addresses', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->email('email')
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['--email=user@example.com']);
|
||||
|
||||
expect($parsed->get('email'))->toBe('user@example.com');
|
||||
});
|
||||
|
||||
it('throws exception for invalid email', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->email('email')
|
||||
->build();
|
||||
|
||||
expect(fn () => $parser->parse(['--email=invalid-email']))
|
||||
->toThrow(\InvalidArgumentException::class, 'not a valid email address');
|
||||
});
|
||||
|
||||
it('validates URL addresses', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->addArgument(new ArgumentDefinition('url', ArgumentType::URL))
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['--url=https://example.com']);
|
||||
|
||||
expect($parsed->get('url'))->toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('throws exception for invalid URL', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->addArgument(new ArgumentDefinition('url', ArgumentType::URL))
|
||||
->build();
|
||||
|
||||
expect(fn () => $parser->parse(['--url=not-a-url']))
|
||||
->toThrow(\InvalidArgumentException::class, 'not a valid URL');
|
||||
});
|
||||
|
||||
it('validates allowed values', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->choice('mode', ['dev', 'prod', 'test'])
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['--mode=dev']);
|
||||
|
||||
expect($parsed->get('mode'))->toBe('dev');
|
||||
});
|
||||
|
||||
it('throws exception for invalid choice value', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->choice('mode', ['dev', 'prod', 'test'])
|
||||
->build();
|
||||
|
||||
expect(fn () => $parser->parse(['--mode=invalid']))
|
||||
->toThrow(\InvalidArgumentException::class, "Invalid value 'invalid'");
|
||||
});
|
||||
|
||||
it('handles positional arguments', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->addArgument(new ArgumentDefinition('name', ArgumentType::STRING))
|
||||
->addArgument(new ArgumentDefinition('age', ArgumentType::INTEGER))
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['John', '25']);
|
||||
|
||||
expect($parsed->get('name'))->toBe('John');
|
||||
expect($parsed->getInt('age'))->toBe(25);
|
||||
});
|
||||
|
||||
it('throws exception when required option value is missing', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->requiredString('name')
|
||||
->build();
|
||||
|
||||
expect(fn () => $parser->parse(['--name']))
|
||||
->toThrow(\InvalidArgumentException::class, "Option '--name' requires a value");
|
||||
});
|
||||
|
||||
it('handles optional boolean flags without value', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->flag('verbose', 'v')
|
||||
->build();
|
||||
|
||||
$parsed = $parser->parse(['--verbose']);
|
||||
|
||||
expect($parsed->getBool('verbose'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('prevents combining short options that require values', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->addArgument(new ArgumentDefinition('name', ArgumentType::STRING, shortName: 'n'))
|
||||
->flag('verbose', 'v')
|
||||
->build();
|
||||
|
||||
expect(fn () => $parser->parse(['-nv']))
|
||||
->toThrow(\InvalidArgumentException::class, "requires a value and cannot be combined");
|
||||
});
|
||||
|
||||
it('returns all definitions', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->optionalString('name')
|
||||
->flag('verbose', 'v')
|
||||
->build();
|
||||
|
||||
$definitions = $parser->getDefinitions();
|
||||
|
||||
expect($definitions)->toHaveKey('name');
|
||||
expect($definitions)->toHaveKey('verbose');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ArgumentParserBuilder', function () {
|
||||
it('creates parser with fluent interface', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->requiredString('name')
|
||||
->build();
|
||||
|
||||
expect($parser)->toBeInstanceOf(ArgumentParser::class);
|
||||
});
|
||||
|
||||
it('supports all argument definition factory methods', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->requiredString('arg1')
|
||||
->optionalString('opt1')
|
||||
->flag('flag1', 'f')
|
||||
->choice('choice1', ['a', 'b', 'c'])
|
||||
->build();
|
||||
|
||||
expect($parser)->toBeInstanceOf(ArgumentParser::class);
|
||||
});
|
||||
});
|
||||
|
||||
57
tests/Framework/Console/ArgumentTypeTest.php
Normal file
57
tests/Framework/Console/ArgumentTypeTest.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\ArgumentType;
|
||||
|
||||
describe('ArgumentType', function () {
|
||||
it('has all expected enum values', function () {
|
||||
expect(ArgumentType::STRING->value)->toBe('string');
|
||||
expect(ArgumentType::INTEGER->value)->toBe('int');
|
||||
expect(ArgumentType::FLOAT->value)->toBe('float');
|
||||
expect(ArgumentType::BOOLEAN->value)->toBe('bool');
|
||||
expect(ArgumentType::ARRAY->value)->toBe('array');
|
||||
expect(ArgumentType::EMAIL->value)->toBe('email');
|
||||
expect(ArgumentType::URL->value)->toBe('url');
|
||||
});
|
||||
|
||||
it('provides descriptions for all types', function () {
|
||||
expect(ArgumentType::STRING->getDescription())->toBe('Text string');
|
||||
expect(ArgumentType::INTEGER->getDescription())->toBe('Integer number');
|
||||
expect(ArgumentType::FLOAT->getDescription())->toBe('Decimal number');
|
||||
expect(ArgumentType::BOOLEAN->getDescription())->toBe('True/false flag');
|
||||
expect(ArgumentType::ARRAY->getDescription())->toBe('Comma-separated values');
|
||||
expect(ArgumentType::EMAIL->getDescription())->toBe('Valid email address');
|
||||
expect(ArgumentType::URL->getDescription())->toBe('Valid URL');
|
||||
});
|
||||
|
||||
it('checks if type requires value', function () {
|
||||
expect(ArgumentType::STRING->requiresValue())->toBeTrue();
|
||||
expect(ArgumentType::INTEGER->requiresValue())->toBeTrue();
|
||||
expect(ArgumentType::FLOAT->requiresValue())->toBeTrue();
|
||||
expect(ArgumentType::BOOLEAN->requiresValue())->toBeFalse();
|
||||
expect(ArgumentType::ARRAY->requiresValue())->toBeTrue();
|
||||
expect(ArgumentType::EMAIL->requiresValue())->toBeTrue();
|
||||
expect(ArgumentType::URL->requiresValue())->toBeTrue();
|
||||
});
|
||||
|
||||
it('provides example values for all types', function () {
|
||||
expect(ArgumentType::STRING->getExample())->toBe('text');
|
||||
expect(ArgumentType::INTEGER->getExample())->toBe('123');
|
||||
expect(ArgumentType::FLOAT->getExample())->toBe('12.34');
|
||||
expect(ArgumentType::BOOLEAN->getExample())->toBe('true|false');
|
||||
expect(ArgumentType::ARRAY->getExample())->toBe('item1,item2,item3');
|
||||
expect(ArgumentType::EMAIL->getExample())->toBe('user@example.com');
|
||||
expect(ArgumentType::URL->getExample())->toBe('https://example.com');
|
||||
});
|
||||
|
||||
it('can be used as string value', function () {
|
||||
$type = ArgumentType::STRING;
|
||||
|
||||
expect($type->value)->toBeString();
|
||||
expect($type->value)->toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
152
tests/Framework/Console/CommandCategorizerTest.php
Normal file
152
tests/Framework/Console/CommandCategorizerTest.php
Normal file
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\CommandCategorizer;
|
||||
use App\Framework\Console\CommandList;
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
|
||||
describe('CommandCategorizer', function () {
|
||||
it('categorizes commands by namespace prefix', function () {
|
||||
$commands = [
|
||||
new ConsoleCommand('demo:hello', 'Hello command'),
|
||||
new ConsoleCommand('demo:colors', 'Colors command'),
|
||||
new ConsoleCommand('db:migrate', 'Migrate command'),
|
||||
new ConsoleCommand('db:rollback', 'Rollback command'),
|
||||
];
|
||||
|
||||
$commandList = new CommandList(...$commands);
|
||||
$categorizer = new CommandCategorizer();
|
||||
$categories = $categorizer->categorize($commandList);
|
||||
|
||||
expect($categories)->toHaveKey('demo');
|
||||
expect($categories)->toHaveKey('db');
|
||||
expect($categories['demo'])->toHaveCount(2);
|
||||
expect($categories['db'])->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('sorts categories alphabetically', function () {
|
||||
$commands = [
|
||||
new ConsoleCommand('zebra:command', 'Zebra command'),
|
||||
new ConsoleCommand('alpha:command', 'Alpha command'),
|
||||
new ConsoleCommand('beta:command', 'Beta command'),
|
||||
];
|
||||
|
||||
$commandList = new CommandList(...$commands);
|
||||
$categorizer = new CommandCategorizer();
|
||||
$categories = $categorizer->categorize($commandList);
|
||||
|
||||
$keys = array_keys($categories);
|
||||
expect($keys)->toBe(['alpha', 'beta', 'zebra']);
|
||||
});
|
||||
|
||||
it('handles commands without namespace', function () {
|
||||
$commands = [
|
||||
new ConsoleCommand('standalone', 'Standalone command'),
|
||||
new ConsoleCommand('demo:hello', 'Hello command'),
|
||||
];
|
||||
|
||||
$commandList = new CommandList(...$commands);
|
||||
$categorizer = new CommandCategorizer();
|
||||
$categories = $categorizer->categorize($commandList);
|
||||
|
||||
expect($categories)->toHaveKey('standalone');
|
||||
expect($categories)->toHaveKey('demo');
|
||||
expect($categories['standalone'])->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('handles empty command list', function () {
|
||||
$commandList = CommandList::empty();
|
||||
$categorizer = new CommandCategorizer();
|
||||
$categories = $categorizer->categorize($commandList);
|
||||
|
||||
expect($categories)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('groups multiple commands in same category', function () {
|
||||
$commands = [
|
||||
new ConsoleCommand('demo:hello', 'Hello'),
|
||||
new ConsoleCommand('demo:colors', 'Colors'),
|
||||
new ConsoleCommand('demo:interactive', 'Interactive'),
|
||||
new ConsoleCommand('demo:menu', 'Menu'),
|
||||
];
|
||||
|
||||
$commandList = new CommandList(...$commands);
|
||||
$categorizer = new CommandCategorizer();
|
||||
$categories = $categorizer->categorize($commandList);
|
||||
|
||||
expect($categories['demo'])->toHaveCount(4);
|
||||
});
|
||||
|
||||
it('handles commands with multiple colons', function () {
|
||||
$commands = [
|
||||
new ConsoleCommand('db:migrate:up', 'Migrate up'),
|
||||
new ConsoleCommand('db:migrate:down', 'Migrate down'),
|
||||
];
|
||||
|
||||
$commandList = new CommandList(...$commands);
|
||||
$categorizer = new CommandCategorizer();
|
||||
$categories = $categorizer->categorize($commandList);
|
||||
|
||||
// Should use first part before first colon
|
||||
expect($categories)->toHaveKey('db');
|
||||
expect($categories['db'])->toHaveCount(2);
|
||||
});
|
||||
|
||||
it('gets category description for known category', function () {
|
||||
$categorizer = new CommandCategorizer();
|
||||
|
||||
expect($categorizer->getCategoryDescription('db'))->toBe('Database operations (migrations, health checks)');
|
||||
expect($categorizer->getCategoryDescription('demo'))->toBe('Demo and example commands');
|
||||
expect($categorizer->getCategoryDescription('cache'))->toBe('Cache management operations');
|
||||
});
|
||||
|
||||
it('returns default description for unknown category', function () {
|
||||
$categorizer = new CommandCategorizer();
|
||||
|
||||
expect($categorizer->getCategoryDescription('unknown'))->toBe('Various commands');
|
||||
});
|
||||
|
||||
it('gets all category info', function () {
|
||||
$categorizer = new CommandCategorizer();
|
||||
$info = $categorizer->getCategoryInfo();
|
||||
|
||||
expect($info)->toBeArray();
|
||||
expect($info)->toHaveKey('db');
|
||||
expect($info)->toHaveKey('demo');
|
||||
expect($info)->toHaveKey('cache');
|
||||
});
|
||||
|
||||
it('handles single command in category', function () {
|
||||
$commands = [
|
||||
new ConsoleCommand('unique:command', 'Unique command'),
|
||||
];
|
||||
|
||||
$commandList = new CommandList(...$commands);
|
||||
$categorizer = new CommandCategorizer();
|
||||
$categories = $categorizer->categorize($commandList);
|
||||
|
||||
expect($categories)->toHaveKey('unique');
|
||||
expect($categories['unique'])->toHaveCount(1);
|
||||
});
|
||||
|
||||
it('preserves command order within category', function () {
|
||||
$commands = [
|
||||
new ConsoleCommand('demo:first', 'First'),
|
||||
new ConsoleCommand('demo:second', 'Second'),
|
||||
new ConsoleCommand('demo:third', 'Third'),
|
||||
];
|
||||
|
||||
$commandList = new CommandList(...$commands);
|
||||
$categorizer = new CommandCategorizer();
|
||||
$categories = $categorizer->categorize($commandList);
|
||||
|
||||
$demoCommands = $categories['demo'];
|
||||
expect($demoCommands[0]->name)->toBe('demo:first');
|
||||
expect($demoCommands[1]->name)->toBe('demo:second');
|
||||
expect($demoCommands[2]->name)->toBe('demo:third');
|
||||
});
|
||||
});
|
||||
|
||||
191
tests/Framework/Console/CommandListTest.php
Normal file
191
tests/Framework/Console/CommandListTest.php
Normal file
@@ -0,0 +1,191 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\CommandList;
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Console\Exceptions\CommandNotFoundException;
|
||||
use App\Framework\Console\Exceptions\DuplicateCommandException;
|
||||
|
||||
describe('CommandList', function () {
|
||||
it('creates empty command list', function () {
|
||||
$list = CommandList::empty();
|
||||
|
||||
expect($list->count())->toBe(0);
|
||||
expect($list->isEmpty())->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates command list with commands', function () {
|
||||
$command1 = new ConsoleCommand('test:command1', 'Description 1');
|
||||
$command2 = new ConsoleCommand('test:command2', 'Description 2');
|
||||
|
||||
$list = new CommandList($command1, $command2);
|
||||
|
||||
expect($list->count())->toBe(2);
|
||||
expect($list->has('test:command1'))->toBeTrue();
|
||||
expect($list->has('test:command2'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('throws exception for duplicate command names', function () {
|
||||
$command1 = new ConsoleCommand('test:command', 'Description 1');
|
||||
$command2 = new ConsoleCommand('test:command', 'Description 2');
|
||||
|
||||
expect(fn () => new CommandList($command1, $command2))
|
||||
->toThrow(DuplicateCommandException::class);
|
||||
});
|
||||
|
||||
it('adds command to list', function () {
|
||||
$command1 = new ConsoleCommand('test:command1', 'Description 1');
|
||||
$command2 = new ConsoleCommand('test:command2', 'Description 2');
|
||||
|
||||
$list = CommandList::empty();
|
||||
$list = $list->add($command1);
|
||||
$list = $list->add($command2);
|
||||
|
||||
expect($list->count())->toBe(2);
|
||||
expect($list->has('test:command1'))->toBeTrue();
|
||||
expect($list->has('test:command2'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('throws exception when adding duplicate command', function () {
|
||||
$command1 = new ConsoleCommand('test:command', 'Description 1');
|
||||
$command2 = new ConsoleCommand('test:command', 'Description 2');
|
||||
|
||||
$list = new CommandList($command1);
|
||||
|
||||
expect(fn () => $list->add($command2))
|
||||
->toThrow(DuplicateCommandException::class);
|
||||
});
|
||||
|
||||
it('gets command by name', function () {
|
||||
$command = new ConsoleCommand('test:command', 'Description');
|
||||
$list = new CommandList($command);
|
||||
|
||||
$retrieved = $list->get('test:command');
|
||||
|
||||
expect($retrieved)->toBe($command);
|
||||
expect($retrieved->name)->toBe('test:command');
|
||||
expect($retrieved->description)->toBe('Description');
|
||||
});
|
||||
|
||||
it('throws exception when getting non-existent command', function () {
|
||||
$list = CommandList::empty();
|
||||
|
||||
expect(fn () => $list->get('nonexistent:command'))
|
||||
->toThrow(CommandNotFoundException::class);
|
||||
});
|
||||
|
||||
it('returns all command names', function () {
|
||||
$command1 = new ConsoleCommand('test:command1', 'Description 1');
|
||||
$command2 = new ConsoleCommand('test:command2', 'Description 2');
|
||||
$command3 = new ConsoleCommand('test:command3', 'Description 3');
|
||||
|
||||
$list = new CommandList($command1, $command2, $command3);
|
||||
$names = $list->getNames();
|
||||
|
||||
expect($names)->toHaveCount(3);
|
||||
expect($names)->toContain('test:command1');
|
||||
expect($names)->toContain('test:command2');
|
||||
expect($names)->toContain('test:command3');
|
||||
});
|
||||
|
||||
it('finds similar commands using Levenshtein distance', function () {
|
||||
$command1 = new ConsoleCommand('test:hello', 'Description');
|
||||
$command2 = new ConsoleCommand('test:help', 'Description');
|
||||
$command3 = new ConsoleCommand('test:world', 'Description');
|
||||
$command4 = new ConsoleCommand('test:hell', 'Description');
|
||||
|
||||
$list = new CommandList($command1, $command2, $command3, $command4);
|
||||
|
||||
// 'test:helo' is similar to 'test:hello' (distance 1)
|
||||
$similar = $list->findSimilar('test:helo', 2);
|
||||
|
||||
expect($similar)->toContain('test:hello');
|
||||
expect($similar)->toContain('test:help');
|
||||
expect($similar)->toContain('test:hell');
|
||||
});
|
||||
|
||||
it('returns empty array when no similar commands found', function () {
|
||||
$command = new ConsoleCommand('test:command', 'Description');
|
||||
$list = new CommandList($command);
|
||||
|
||||
$similar = $list->findSimilar('completely:different', 3);
|
||||
|
||||
expect($similar)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('respects max distance parameter', function () {
|
||||
$command1 = new ConsoleCommand('test:hello', 'Description');
|
||||
$command2 = new ConsoleCommand('test:world', 'Description');
|
||||
|
||||
$list = new CommandList($command1, $command2);
|
||||
|
||||
// 'test:helo' has distance 1 from 'test:hello', but distance 4 from 'test:world'
|
||||
$similar = $list->findSimilar('test:helo', 1);
|
||||
|
||||
expect($similar)->toContain('test:hello');
|
||||
expect($similar)->not->toContain('test:world');
|
||||
});
|
||||
|
||||
it('does not include exact match in similar results', function () {
|
||||
$command = new ConsoleCommand('test:command', 'Description');
|
||||
$list = new CommandList($command);
|
||||
|
||||
$similar = $list->findSimilar('test:command', 3);
|
||||
|
||||
// Exact match should not be included (distance 0)
|
||||
expect($similar)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('implements IteratorAggregate', function () {
|
||||
$command1 = new ConsoleCommand('test:command1', 'Description 1');
|
||||
$command2 = new ConsoleCommand('test:command2', 'Description 2');
|
||||
|
||||
$list = new CommandList($command1, $command2);
|
||||
|
||||
$commands = [];
|
||||
foreach ($list as $name => $command) {
|
||||
$commands[$name] = $command;
|
||||
}
|
||||
|
||||
expect($commands)->toHaveCount(2);
|
||||
expect($commands['test:command1'])->toBe($command1);
|
||||
expect($commands['test:command2'])->toBe($command2);
|
||||
});
|
||||
|
||||
it('implements Countable', function () {
|
||||
$command1 = new ConsoleCommand('test:command1', 'Description 1');
|
||||
$command2 = new ConsoleCommand('test:command2', 'Description 2');
|
||||
|
||||
$list = new CommandList($command1, $command2);
|
||||
|
||||
expect(count($list))->toBe(2);
|
||||
});
|
||||
|
||||
it('converts to array', function () {
|
||||
$command1 = new ConsoleCommand('test:command1', 'Description 1');
|
||||
$command2 = new ConsoleCommand('test:command2', 'Description 2');
|
||||
|
||||
$list = new CommandList($command1, $command2);
|
||||
$array = $list->toArray();
|
||||
|
||||
expect($array)->toHaveCount(2);
|
||||
expect($array)->toHaveKey('test:command1');
|
||||
expect($array)->toHaveKey('test:command2');
|
||||
});
|
||||
|
||||
it('returns all commands as array', function () {
|
||||
$command1 = new ConsoleCommand('test:command1', 'Description 1');
|
||||
$command2 = new ConsoleCommand('test:command2', 'Description 2');
|
||||
|
||||
$list = new CommandList($command1, $command2);
|
||||
$allCommands = $list->getAllCommands();
|
||||
|
||||
expect($allCommands)->toHaveCount(2);
|
||||
expect($allCommands)->toContain($command1);
|
||||
expect($allCommands)->toContain($command2);
|
||||
});
|
||||
});
|
||||
|
||||
237
tests/Framework/Console/CommandParameterResolverTest.php
Normal file
237
tests/Framework/Console/CommandParameterResolverTest.php
Normal file
@@ -0,0 +1,237 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\CommandParameterResolver;
|
||||
use App\Framework\Console\ConsoleInput;
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
use App\Framework\Console\MethodSignatureAnalyzer;
|
||||
use ReflectionMethod;
|
||||
|
||||
class TestCommandClass
|
||||
{
|
||||
public function simpleMethod(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function methodWithString(string $name): void
|
||||
{
|
||||
}
|
||||
|
||||
public function methodWithInt(int $count): void
|
||||
{
|
||||
}
|
||||
|
||||
public function methodWithBool(bool $active): void
|
||||
{
|
||||
}
|
||||
|
||||
public function methodWithArray(array $items): void
|
||||
{
|
||||
}
|
||||
|
||||
public function methodWithDefault(string $name = 'Guest'): void
|
||||
{
|
||||
}
|
||||
|
||||
public function methodWithNullable(?string $name): void
|
||||
{
|
||||
}
|
||||
|
||||
public function methodWithConsoleInput(ConsoleInput $input): void
|
||||
{
|
||||
}
|
||||
|
||||
public function methodWithConsoleOutput(ConsoleOutput $output): void
|
||||
{
|
||||
}
|
||||
|
||||
public function methodWithBoth(ConsoleInput $input, ConsoleOutput $output, string $name): void
|
||||
{
|
||||
}
|
||||
|
||||
public function methodWithOptionalFrameworkParams(string $name, ?ConsoleInput $input = null): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
describe('CommandParameterResolver', function () {
|
||||
beforeEach(function () {
|
||||
$this->analyzer = new MethodSignatureAnalyzer();
|
||||
$this->resolver = new CommandParameterResolver($this->analyzer);
|
||||
});
|
||||
|
||||
it('resolves simple string parameter', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithString');
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--name=John']);
|
||||
|
||||
expect($resolved)->toHaveCount(1);
|
||||
expect($resolved[0])->toBe('John');
|
||||
});
|
||||
|
||||
it('resolves integer parameter', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithInt');
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--count=42']);
|
||||
|
||||
expect($resolved)->toHaveCount(1);
|
||||
expect($resolved[0])->toBe(42);
|
||||
expect($resolved[0])->toBeInt();
|
||||
});
|
||||
|
||||
it('resolves boolean parameter', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithBool');
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--active=true']);
|
||||
|
||||
expect($resolved)->toHaveCount(1);
|
||||
expect($resolved[0])->toBeTrue();
|
||||
});
|
||||
|
||||
it('resolves array parameter', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithArray');
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--items=item1,item2,item3']);
|
||||
|
||||
expect($resolved)->toHaveCount(1);
|
||||
expect($resolved[0])->toBe(['item1', 'item2', 'item3']);
|
||||
});
|
||||
|
||||
it('uses default values for optional parameters', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithDefault');
|
||||
$resolved = $this->resolver->resolveParameters($method, []);
|
||||
|
||||
expect($resolved)->toHaveCount(1);
|
||||
expect($resolved[0])->toBe('Guest');
|
||||
});
|
||||
|
||||
it('resolves nullable parameters as null when missing', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithNullable');
|
||||
$resolved = $this->resolver->resolveParameters($method, []);
|
||||
|
||||
expect($resolved)->toHaveCount(1);
|
||||
expect($resolved[0])->toBeNull();
|
||||
});
|
||||
|
||||
it('resolves ConsoleInput parameter', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithConsoleInput');
|
||||
$input = new ConsoleInput([]);
|
||||
$resolved = $this->resolver->resolveParameters($method, [], $input);
|
||||
|
||||
expect($resolved)->toHaveCount(1);
|
||||
expect($resolved[0])->toBe($input);
|
||||
});
|
||||
|
||||
it('resolves ConsoleOutput parameter', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithConsoleOutput');
|
||||
$output = new ConsoleOutput();
|
||||
$resolved = $this->resolver->resolveParameters($method, [], null, $output);
|
||||
|
||||
expect($resolved)->toHaveCount(1);
|
||||
expect($resolved[0])->toBe($output);
|
||||
});
|
||||
|
||||
it('resolves both ConsoleInput and ConsoleOutput', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithBoth');
|
||||
$input = new ConsoleInput([]);
|
||||
$output = new ConsoleOutput();
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--name=John'], $input, $output);
|
||||
|
||||
expect($resolved)->toHaveCount(3);
|
||||
expect($resolved[0])->toBe($input);
|
||||
expect($resolved[1])->toBe($output);
|
||||
expect($resolved[2])->toBe('John');
|
||||
});
|
||||
|
||||
it('handles optional framework parameters', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithOptionalFrameworkParams');
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--name=John']);
|
||||
|
||||
expect($resolved)->toHaveCount(2);
|
||||
expect($resolved[0])->toBe('John');
|
||||
expect($resolved[1])->toBeNull();
|
||||
});
|
||||
|
||||
it('throws exception for missing required parameter', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithString');
|
||||
|
||||
expect(fn () => $this->resolver->resolveParameters($method, []))
|
||||
->toThrow(\InvalidArgumentException::class, "Required parameter 'name' is missing");
|
||||
});
|
||||
|
||||
it('throws exception when ConsoleInput is required but not provided', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithConsoleInput');
|
||||
|
||||
expect(fn () => $this->resolver->resolveParameters($method, []))
|
||||
->toThrow(\InvalidArgumentException::class, "ConsoleInput is required but not provided");
|
||||
});
|
||||
|
||||
it('validates method signature compatibility', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'simpleMethod');
|
||||
|
||||
// Should not throw for valid method
|
||||
$this->resolver->validateMethodSignature($method);
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('creates parser for method', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithString');
|
||||
$parser = $this->resolver->createParserForMethod($method);
|
||||
|
||||
expect($parser)->toBeInstanceOf(\App\Framework\Console\ArgumentParser::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CommandParameterResolver Type Conversion', function () {
|
||||
beforeEach(function () {
|
||||
$this->analyzer = new MethodSignatureAnalyzer();
|
||||
$this->resolver = new CommandParameterResolver($this->analyzer);
|
||||
});
|
||||
|
||||
it('converts string values correctly', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithString');
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--name=123']);
|
||||
|
||||
expect($resolved[0])->toBe('123');
|
||||
expect($resolved[0])->toBeString();
|
||||
});
|
||||
|
||||
it('converts integer values correctly', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithInt');
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--count=42']);
|
||||
|
||||
expect($resolved[0])->toBe(42);
|
||||
expect($resolved[0])->toBeInt();
|
||||
});
|
||||
|
||||
it('converts boolean values correctly', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithBool');
|
||||
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--active=true']);
|
||||
expect($resolved[0])->toBeTrue();
|
||||
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--active=false']);
|
||||
expect($resolved[0])->toBeFalse();
|
||||
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--active=1']);
|
||||
expect($resolved[0])->toBeTrue();
|
||||
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--active=0']);
|
||||
expect($resolved[0])->toBeFalse();
|
||||
});
|
||||
|
||||
it('converts array values correctly', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithArray');
|
||||
$resolved = $this->resolver->resolveParameters($method, ['--items=item1,item2,item3']);
|
||||
|
||||
expect($resolved[0])->toBeArray();
|
||||
expect($resolved[0])->toBe(['item1', 'item2', 'item3']);
|
||||
});
|
||||
|
||||
it('throws exception for invalid type conversion', function () {
|
||||
$method = new ReflectionMethod(TestCommandClass::class, 'methodWithInt');
|
||||
|
||||
expect(fn () => $this->resolver->resolveParameters($method, ['--count=not-a-number']))
|
||||
->toThrow(\InvalidArgumentException::class);
|
||||
});
|
||||
});
|
||||
|
||||
103
tests/Framework/Console/CommandResultProcessorTest.php
Normal file
103
tests/Framework/Console/CommandResultProcessorTest.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\CommandResultProcessor;
|
||||
use App\Framework\Console\ExitCode;
|
||||
use App\Framework\Console\Result\TextResult;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use Tests\Framework\Console\Helpers\TestConsoleOutput;
|
||||
|
||||
describe('CommandResultProcessor', function () {
|
||||
beforeEach(function () {
|
||||
$this->container = new DefaultContainer();
|
||||
$this->output = new TestConsoleOutput();
|
||||
$this->processor = new CommandResultProcessor($this->container, $this->output);
|
||||
});
|
||||
|
||||
it('processes ConsoleResult', function () {
|
||||
$result = TextResult::success('Test output');
|
||||
$exitCode = $this->processor->process($result);
|
||||
|
||||
expect($exitCode)->toBe(ExitCode::SUCCESS);
|
||||
expect($this->output->getOutput())->toContain('Test output');
|
||||
});
|
||||
|
||||
it('renders ConsoleResult to output', function () {
|
||||
$result = TextResult::success('Rendered message');
|
||||
$this->processor->process($result);
|
||||
|
||||
expect($this->output->getOutput())->toContain('Rendered message');
|
||||
});
|
||||
|
||||
it('processes ExitCode directly', function () {
|
||||
$exitCode = $this->processor->process(ExitCode::SUCCESS);
|
||||
|
||||
expect($exitCode)->toBe(ExitCode::SUCCESS);
|
||||
});
|
||||
|
||||
it('processes different ExitCode values', function () {
|
||||
expect($this->processor->process(ExitCode::GENERAL_ERROR))->toBe(ExitCode::GENERAL_ERROR);
|
||||
expect($this->processor->process(ExitCode::COMMAND_NOT_FOUND))->toBe(ExitCode::COMMAND_NOT_FOUND);
|
||||
expect($this->processor->process(ExitCode::INVALID_INPUT))->toBe(ExitCode::INVALID_INPUT);
|
||||
});
|
||||
|
||||
it('processes legacy int return values', function () {
|
||||
$exitCode = $this->processor->process(0);
|
||||
|
||||
expect($exitCode)->toBe(ExitCode::SUCCESS);
|
||||
expect($exitCode->value)->toBe(0);
|
||||
});
|
||||
|
||||
it('converts int to ExitCode', function () {
|
||||
expect($this->processor->process(1))->toBe(ExitCode::GENERAL_ERROR);
|
||||
expect($this->processor->process(64))->toBe(ExitCode::COMMAND_NOT_FOUND);
|
||||
});
|
||||
|
||||
it('handles invalid return types', function () {
|
||||
$exitCode = $this->processor->process('invalid');
|
||||
|
||||
expect($exitCode)->toBe(ExitCode::GENERAL_ERROR);
|
||||
});
|
||||
|
||||
it('handles null return type', function () {
|
||||
$exitCode = $this->processor->process(null);
|
||||
|
||||
expect($exitCode)->toBe(ExitCode::GENERAL_ERROR);
|
||||
});
|
||||
|
||||
it('handles array return type', function () {
|
||||
$exitCode = $this->processor->process([]);
|
||||
|
||||
expect($exitCode)->toBe(ExitCode::GENERAL_ERROR);
|
||||
});
|
||||
|
||||
it('handles object return type', function () {
|
||||
$exitCode = $this->processor->process(new \stdClass());
|
||||
|
||||
expect($exitCode)->toBe(ExitCode::GENERAL_ERROR);
|
||||
});
|
||||
|
||||
it('processes ConsoleResult with different exit codes', function () {
|
||||
$successResult = TextResult::success('Success');
|
||||
expect($this->processor->process($successResult))->toBe(ExitCode::SUCCESS);
|
||||
|
||||
$errorResult = TextResult::error('Error');
|
||||
expect($this->processor->process($errorResult))->toBe(ExitCode::FAILURE);
|
||||
});
|
||||
|
||||
it('renders multiple ConsoleResults correctly', function () {
|
||||
$result1 = TextResult::success('First message');
|
||||
$result2 = TextResult::success('Second message');
|
||||
|
||||
$this->processor->process($result1);
|
||||
$this->output->clear();
|
||||
$this->processor->process($result2);
|
||||
|
||||
expect($this->output->getOutput())->toContain('Second message');
|
||||
expect($this->output->getOutput())->not->toContain('First message');
|
||||
});
|
||||
});
|
||||
|
||||
291
tests/Framework/Console/Components/ConsoleDialogTest.php
Normal file
291
tests/Framework/Console/Components/ConsoleDialogTest.php
Normal file
@@ -0,0 +1,291 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console\Components;
|
||||
|
||||
use App\Framework\Console\CommandHistory;
|
||||
use App\Framework\Console\CommandList;
|
||||
use App\Framework\Console\CommandGroupRegistry;
|
||||
use App\Framework\Console\Components\ConsoleDialog;
|
||||
use App\Framework\Console\Components\DialogCommandExecutor;
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\Discovery\Results\AttributeRegistry;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Discovery\Results\InterfaceRegistry;
|
||||
use App\Framework\Discovery\Results\TemplateRegistry;
|
||||
use Tests\Framework\Console\Helpers\TestConsoleOutput;
|
||||
|
||||
describe('ConsoleDialog', function () {
|
||||
beforeEach(function () {
|
||||
$this->container = new DefaultContainer();
|
||||
$this->output = new TestConsoleOutput();
|
||||
$this->discoveryRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry([]),
|
||||
templates: new TemplateRegistry([])
|
||||
);
|
||||
$this->commandHistory = new CommandHistory();
|
||||
$this->groupRegistry = new CommandGroupRegistry($this->discoveryRegistry);
|
||||
$this->commandList = CommandList::empty();
|
||||
$this->commandExecutor = new DialogCommandExecutor(
|
||||
$this->output,
|
||||
new \App\Framework\Console\CommandRegistry($this->container, $this->discoveryRegistry),
|
||||
$this->commandHistory,
|
||||
'test-console'
|
||||
);
|
||||
});
|
||||
|
||||
it('can be instantiated', function () {
|
||||
$dialog = new ConsoleDialog(
|
||||
$this->output,
|
||||
$this->discoveryRegistry,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->commandExecutor,
|
||||
$this->commandList,
|
||||
$this->container
|
||||
);
|
||||
|
||||
expect($dialog)->toBeInstanceOf(ConsoleDialog::class);
|
||||
});
|
||||
|
||||
it('can be instantiated with custom prompt', function () {
|
||||
$dialog = new ConsoleDialog(
|
||||
$this->output,
|
||||
$this->discoveryRegistry,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->commandExecutor,
|
||||
$this->commandList,
|
||||
$this->container,
|
||||
'custom> '
|
||||
);
|
||||
|
||||
expect($dialog)->toBeInstanceOf(ConsoleDialog::class);
|
||||
});
|
||||
|
||||
it('has run method', function () {
|
||||
$dialog = new ConsoleDialog(
|
||||
$this->output,
|
||||
$this->discoveryRegistry,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->commandExecutor,
|
||||
$this->commandList,
|
||||
$this->container
|
||||
);
|
||||
|
||||
expect(method_exists($dialog, 'run'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has completeCommand method for readline', function () {
|
||||
$dialog = new ConsoleDialog(
|
||||
$this->output,
|
||||
$this->discoveryRegistry,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->commandExecutor,
|
||||
$this->commandList,
|
||||
$this->container
|
||||
);
|
||||
|
||||
expect(method_exists($dialog, 'completeCommand'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('detects readline availability', function () {
|
||||
$dialog = new ConsoleDialog(
|
||||
$this->output,
|
||||
$this->discoveryRegistry,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->commandExecutor,
|
||||
$this->commandList,
|
||||
$this->container
|
||||
);
|
||||
|
||||
// Should detect readline if available
|
||||
expect($dialog)->toBeInstanceOf(ConsoleDialog::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConsoleDialog Input Parsing', function () {
|
||||
beforeEach(function () {
|
||||
$this->container = new DefaultContainer();
|
||||
$this->output = new TestConsoleOutput();
|
||||
$this->discoveryRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry([]),
|
||||
templates: new TemplateRegistry([])
|
||||
);
|
||||
$this->commandHistory = new CommandHistory();
|
||||
$this->groupRegistry = new CommandGroupRegistry($this->discoveryRegistry);
|
||||
$this->commandList = CommandList::empty();
|
||||
$this->commandExecutor = new DialogCommandExecutor(
|
||||
$this->output,
|
||||
new \App\Framework\Console\CommandRegistry($this->container, $this->discoveryRegistry),
|
||||
$this->commandHistory,
|
||||
'test-console'
|
||||
);
|
||||
$this->dialog = new ConsoleDialog(
|
||||
$this->output,
|
||||
$this->discoveryRegistry,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->commandExecutor,
|
||||
$this->commandList,
|
||||
$this->container
|
||||
);
|
||||
});
|
||||
|
||||
it('parses simple command', function () {
|
||||
// Use reflection to access private parseInput method
|
||||
$reflection = new \ReflectionClass($this->dialog);
|
||||
$method = $reflection->getMethod('parseInput');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->dialog, 'demo:hello');
|
||||
|
||||
expect($result)->toHaveKey('command');
|
||||
expect($result)->toHaveKey('arguments');
|
||||
expect($result['command'])->toBe('demo:hello');
|
||||
expect($result['arguments'])->toBeEmpty();
|
||||
});
|
||||
|
||||
it('parses command with arguments', function () {
|
||||
$reflection = new \ReflectionClass($this->dialog);
|
||||
$method = $reflection->getMethod('parseInput');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->dialog, 'demo:hello arg1 arg2 arg3');
|
||||
|
||||
expect($result['command'])->toBe('demo:hello');
|
||||
expect($result['arguments'])->toBe(['arg1', 'arg2', 'arg3']);
|
||||
});
|
||||
|
||||
it('parses command with quoted arguments', function () {
|
||||
$reflection = new \ReflectionClass($this->dialog);
|
||||
$method = $reflection->getMethod('parseInput');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->dialog, 'command "arg with spaces"');
|
||||
|
||||
expect($result['command'])->toBe('command');
|
||||
expect($result['arguments'])->toHaveCount(1);
|
||||
expect($result['arguments'][0])->toBe('arg with spaces');
|
||||
});
|
||||
|
||||
it('parses command with single-quoted arguments', function () {
|
||||
$reflection = new \ReflectionClass($this->dialog);
|
||||
$method = $reflection->getMethod('parseInput');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->dialog, "command 'arg with spaces'");
|
||||
|
||||
expect($result['command'])->toBe('command');
|
||||
expect($result['arguments'])->toHaveCount(1);
|
||||
expect($result['arguments'][0])->toBe('arg with spaces');
|
||||
});
|
||||
|
||||
it('parses empty input', function () {
|
||||
$reflection = new \ReflectionClass($this->dialog);
|
||||
$method = $reflection->getMethod('parseInput');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->dialog, '');
|
||||
|
||||
expect($result['command'])->toBe('');
|
||||
expect($result['arguments'])->toBeEmpty();
|
||||
});
|
||||
|
||||
it('handles multiple spaces between arguments', function () {
|
||||
$reflection = new \ReflectionClass($this->dialog);
|
||||
$method = $reflection->getMethod('parseInput');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->dialog, 'command arg1 arg2');
|
||||
|
||||
expect($result['command'])->toBe('command');
|
||||
expect($result['arguments'])->toBe(['arg1', 'arg2']);
|
||||
});
|
||||
|
||||
it('handles escaped quotes in quoted strings', function () {
|
||||
$reflection = new \ReflectionClass($this->dialog);
|
||||
$method = $reflection->getMethod('parseInput');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$result = $method->invoke($this->dialog, 'command "arg with \\"quotes\\""');
|
||||
|
||||
expect($result['command'])->toBe('command');
|
||||
expect($result['arguments'])->toHaveCount(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConsoleDialog Command Suggestions', function () {
|
||||
beforeEach(function () {
|
||||
$this->container = new DefaultContainer();
|
||||
$this->output = new TestConsoleOutput();
|
||||
$this->discoveryRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry([]),
|
||||
templates: new TemplateRegistry([])
|
||||
);
|
||||
$this->commandHistory = new CommandHistory();
|
||||
$this->groupRegistry = new CommandGroupRegistry($this->discoveryRegistry);
|
||||
$this->commandList = new CommandList(
|
||||
new ConsoleCommand('demo:hello', 'Hello command'),
|
||||
new ConsoleCommand('demo:colors', 'Colors command'),
|
||||
new ConsoleCommand('db:migrate', 'Migrate command')
|
||||
);
|
||||
$this->commandExecutor = new DialogCommandExecutor(
|
||||
$this->output,
|
||||
new \App\Framework\Console\CommandRegistry($this->container, $this->discoveryRegistry),
|
||||
$this->commandHistory,
|
||||
'test-console'
|
||||
);
|
||||
$this->dialog = new ConsoleDialog(
|
||||
$this->output,
|
||||
$this->discoveryRegistry,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->commandExecutor,
|
||||
$this->commandList,
|
||||
$this->container
|
||||
);
|
||||
});
|
||||
|
||||
it('gets command suggestions for partial match', function () {
|
||||
$reflection = new \ReflectionClass($this->dialog);
|
||||
$method = $reflection->getMethod('getCommandSuggestions');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$suggestions = $method->invoke($this->dialog, 'demo');
|
||||
|
||||
expect($suggestions)->toBeArray();
|
||||
expect($suggestions)->toContain('demo:hello');
|
||||
expect($suggestions)->toContain('demo:colors');
|
||||
});
|
||||
|
||||
it('gets empty suggestions for no match', function () {
|
||||
$reflection = new \ReflectionClass($this->dialog);
|
||||
$method = $reflection->getMethod('getCommandSuggestions');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$suggestions = $method->invoke($this->dialog, 'nonexistent');
|
||||
|
||||
expect($suggestions)->toBeArray();
|
||||
});
|
||||
|
||||
it('gets case-insensitive suggestions', function () {
|
||||
$reflection = new \ReflectionClass($this->dialog);
|
||||
$method = $reflection->getMethod('getCommandSuggestions');
|
||||
$method->setAccessible(true);
|
||||
|
||||
$suggestions = $method->invoke($this->dialog, 'DEMO');
|
||||
|
||||
expect($suggestions)->toBeArray();
|
||||
});
|
||||
});
|
||||
|
||||
169
tests/Framework/Console/Components/ConsoleTUITest.php
Normal file
169
tests/Framework/Console/Components/ConsoleTUITest.php
Normal file
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console\Components;
|
||||
|
||||
use App\Framework\Console\CommandGroupRegistry;
|
||||
use App\Framework\Console\CommandHistory;
|
||||
use App\Framework\Console\Components\ConsoleTUI;
|
||||
use App\Framework\Console\Components\TuiCommandExecutor;
|
||||
use App\Framework\Console\Components\TuiRenderer;
|
||||
use App\Framework\Console\Components\TuiState;
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
use App\Framework\Console\SimpleWorkflowExecutor;
|
||||
use App\Framework\DI\DefaultContainer;
|
||||
use App\Framework\Discovery\Results\AttributeRegistry;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Discovery\Results\InterfaceRegistry;
|
||||
use App\Framework\Discovery\Results\TemplateRegistry;
|
||||
|
||||
describe('ConsoleTUI', function () {
|
||||
beforeEach(function () {
|
||||
$this->container = new DefaultContainer();
|
||||
$this->output = new ConsoleOutput();
|
||||
$this->discoveryRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry([]),
|
||||
templates: new TemplateRegistry([])
|
||||
);
|
||||
$this->state = new TuiState();
|
||||
$this->renderer = new TuiRenderer($this->output, $this->state);
|
||||
$this->commandExecutor = new TuiCommandExecutor(
|
||||
new \App\Framework\Console\CommandRegistry($this->container, $this->discoveryRegistry),
|
||||
$this->container,
|
||||
$this->discoveryRegistry,
|
||||
new CommandHistory(),
|
||||
new \App\Framework\Console\CommandValidator(),
|
||||
new \App\Framework\Console\CommandHelpGenerator(new \App\Framework\Console\ParameterInspector()),
|
||||
'test-console'
|
||||
);
|
||||
$this->commandHistory = new CommandHistory();
|
||||
$this->groupRegistry = new CommandGroupRegistry($this->discoveryRegistry);
|
||||
$this->workflowExecutor = new SimpleWorkflowExecutor($this->container, $this->discoveryRegistry);
|
||||
});
|
||||
|
||||
it('can be instantiated', function () {
|
||||
$tui = new ConsoleTUI(
|
||||
$this->output,
|
||||
$this->container,
|
||||
$this->discoveryRegistry,
|
||||
$this->state,
|
||||
$this->renderer,
|
||||
$this->commandExecutor,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->workflowExecutor
|
||||
);
|
||||
|
||||
expect($tui)->toBeInstanceOf(ConsoleTUI::class);
|
||||
});
|
||||
|
||||
it('has run method', function () {
|
||||
$tui = new ConsoleTUI(
|
||||
$this->output,
|
||||
$this->container,
|
||||
$this->discoveryRegistry,
|
||||
$this->state,
|
||||
$this->renderer,
|
||||
$this->commandExecutor,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->workflowExecutor
|
||||
);
|
||||
|
||||
expect(method_exists($tui, 'run'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has handleShutdownSignal method', function () {
|
||||
$tui = new ConsoleTUI(
|
||||
$this->output,
|
||||
$this->container,
|
||||
$this->discoveryRegistry,
|
||||
$this->state,
|
||||
$this->renderer,
|
||||
$this->commandExecutor,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->workflowExecutor
|
||||
);
|
||||
|
||||
expect(method_exists($tui, 'handleShutdownSignal'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has handleShutdownSignalLegacy method', function () {
|
||||
$tui = new ConsoleTUI(
|
||||
$this->output,
|
||||
$this->container,
|
||||
$this->discoveryRegistry,
|
||||
$this->state,
|
||||
$this->renderer,
|
||||
$this->commandExecutor,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->workflowExecutor
|
||||
);
|
||||
|
||||
expect(method_exists($tui, 'handleShutdownSignalLegacy'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('initializes with all required dependencies', function () {
|
||||
$tui = new ConsoleTUI(
|
||||
$this->output,
|
||||
$this->container,
|
||||
$this->discoveryRegistry,
|
||||
$this->state,
|
||||
$this->renderer,
|
||||
$this->commandExecutor,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->workflowExecutor
|
||||
);
|
||||
|
||||
expect($tui)->toBeInstanceOf(ConsoleTUI::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConsoleTUI Terminal Compatibility', function () {
|
||||
beforeEach(function () {
|
||||
$this->container = new DefaultContainer();
|
||||
$this->output = new ConsoleOutput();
|
||||
$this->discoveryRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry([]),
|
||||
templates: new TemplateRegistry([])
|
||||
);
|
||||
$this->state = new TuiState();
|
||||
$this->renderer = new TuiRenderer($this->output, $this->state);
|
||||
$this->commandExecutor = new TuiCommandExecutor(
|
||||
new \App\Framework\Console\CommandRegistry($this->container, $this->discoveryRegistry),
|
||||
$this->container,
|
||||
$this->discoveryRegistry,
|
||||
new CommandHistory(),
|
||||
new \App\Framework\Console\CommandValidator(),
|
||||
new \App\Framework\Console\CommandHelpGenerator(new \App\Framework\Console\ParameterInspector()),
|
||||
'test-console'
|
||||
);
|
||||
$this->commandHistory = new CommandHistory();
|
||||
$this->groupRegistry = new CommandGroupRegistry($this->discoveryRegistry);
|
||||
$this->workflowExecutor = new SimpleWorkflowExecutor($this->container, $this->discoveryRegistry);
|
||||
});
|
||||
|
||||
it('handles terminal compatibility check', function () {
|
||||
$tui = new ConsoleTUI(
|
||||
$this->output,
|
||||
$this->container,
|
||||
$this->discoveryRegistry,
|
||||
$this->state,
|
||||
$this->renderer,
|
||||
$this->commandExecutor,
|
||||
$this->commandHistory,
|
||||
$this->groupRegistry,
|
||||
$this->workflowExecutor
|
||||
);
|
||||
|
||||
// TUI should handle terminal compatibility internally
|
||||
expect($tui)->toBeInstanceOf(ConsoleTUI::class);
|
||||
});
|
||||
});
|
||||
|
||||
158
tests/Framework/Console/Components/InteractiveMenuTest.php
Normal file
158
tests/Framework/Console/Components/InteractiveMenuTest.php
Normal file
@@ -0,0 +1,158 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console\Components;
|
||||
|
||||
use App\Framework\Console\Components\InteractiveMenu;
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
use Tests\Framework\Console\Helpers\TestConsoleOutput;
|
||||
|
||||
describe('InteractiveMenu', function () {
|
||||
beforeEach(function () {
|
||||
$this->output = new ConsoleOutput();
|
||||
$this->menu = new InteractiveMenu($this->output);
|
||||
});
|
||||
|
||||
it('can be instantiated', function () {
|
||||
expect($this->menu)->toBeInstanceOf(InteractiveMenu::class);
|
||||
});
|
||||
|
||||
it('sets title', function () {
|
||||
$menu = $this->menu->setTitle('Test Menu');
|
||||
|
||||
expect($menu)->toBe($this->menu); // Fluent interface
|
||||
});
|
||||
|
||||
it('adds menu items', function () {
|
||||
$menu = $this->menu
|
||||
->addItem('Option 1', null, 'value1')
|
||||
->addItem('Option 2', null, 'value2');
|
||||
|
||||
expect($menu)->toBe($this->menu);
|
||||
});
|
||||
|
||||
it('adds menu items with callable actions', function () {
|
||||
$action = fn () => 'action result';
|
||||
$menu = $this->menu->addItem('Option 1', $action);
|
||||
|
||||
expect($menu)->toBe($this->menu);
|
||||
});
|
||||
|
||||
it('adds separators', function () {
|
||||
$menu = $this->menu
|
||||
->addItem('Option 1')
|
||||
->addSeparator()
|
||||
->addItem('Option 2');
|
||||
|
||||
expect($menu)->toBe($this->menu);
|
||||
});
|
||||
|
||||
it('shows numbers by default', function () {
|
||||
$menu = $this->menu->showNumbers(true);
|
||||
|
||||
expect($menu)->toBe($this->menu);
|
||||
});
|
||||
|
||||
it('hides numbers', function () {
|
||||
$menu = $this->menu->showNumbers(false);
|
||||
|
||||
expect($menu)->toBe($this->menu);
|
||||
});
|
||||
|
||||
it('has showSimple method', function () {
|
||||
expect(method_exists($this->menu, 'showSimple'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has showInteractive method', function () {
|
||||
expect(method_exists($this->menu, 'showInteractive'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('builds menu with fluent interface', function () {
|
||||
$menu = $this->menu
|
||||
->setTitle('Test Menu')
|
||||
->addItem('Option 1', null, 'val1')
|
||||
->addSeparator()
|
||||
->addItem('Option 2', null, 'val2')
|
||||
->showNumbers(true);
|
||||
|
||||
expect($menu)->toBe($this->menu);
|
||||
});
|
||||
|
||||
it('handles empty menu', function () {
|
||||
// Should not throw
|
||||
expect($this->menu)->toBeInstanceOf(InteractiveMenu::class);
|
||||
});
|
||||
|
||||
it('handles menu with only separators', function () {
|
||||
$menu = $this->menu
|
||||
->addSeparator()
|
||||
->addSeparator();
|
||||
|
||||
expect($menu)->toBe($this->menu);
|
||||
});
|
||||
|
||||
it('handles very long menu item labels', function () {
|
||||
$longLabel = str_repeat('A', 200);
|
||||
$menu = $this->menu->addItem($longLabel);
|
||||
|
||||
expect($menu)->toBe($this->menu);
|
||||
});
|
||||
|
||||
it('handles menu with many items', function () {
|
||||
$menu = $this->menu;
|
||||
for ($i = 1; $i <= 100; $i++) {
|
||||
$menu = $menu->addItem("Option {$i}", null, "value{$i}");
|
||||
}
|
||||
|
||||
expect($menu)->toBe($this->menu);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InteractiveMenu Rendering', function () {
|
||||
beforeEach(function () {
|
||||
$this->output = new TestConsoleOutput();
|
||||
$this->menu = new InteractiveMenu($this->output);
|
||||
});
|
||||
|
||||
it('renders menu title in showSimple', function () {
|
||||
$this->menu
|
||||
->setTitle('Test Menu')
|
||||
->addItem('Option 1');
|
||||
|
||||
// showSimple requires STDIN, so we can't fully test it
|
||||
// But we can verify the structure
|
||||
expect(method_exists($this->menu, 'showSimple'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('renders menu items in showSimple', function () {
|
||||
$this->menu
|
||||
->addItem('Option 1')
|
||||
->addItem('Option 2')
|
||||
->addItem('Option 3');
|
||||
|
||||
// showSimple requires STDIN
|
||||
expect(method_exists($this->menu, 'showSimple'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('renders separators in showSimple', function () {
|
||||
$this->menu
|
||||
->addItem('Option 1')
|
||||
->addSeparator()
|
||||
->addItem('Option 2');
|
||||
|
||||
// showSimple requires STDIN
|
||||
expect(method_exists($this->menu, 'showSimple'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('renders interactive menu', function () {
|
||||
$this->menu
|
||||
->setTitle('Interactive Menu')
|
||||
->addItem('Option 1')
|
||||
->addItem('Option 2');
|
||||
|
||||
// showInteractive requires STDIN and terminal capabilities
|
||||
expect(method_exists($this->menu, 'showInteractive'))->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
107
tests/Framework/Console/ConsoleColorTest.php
Normal file
107
tests/Framework/Console/ConsoleColorTest.php
Normal file
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\ConsoleColor;
|
||||
|
||||
describe('ConsoleColor', function () {
|
||||
it('has all basic text colors', function () {
|
||||
expect(ConsoleColor::BLACK->value)->toBe('30');
|
||||
expect(ConsoleColor::RED->value)->toBe('31');
|
||||
expect(ConsoleColor::GREEN->value)->toBe('32');
|
||||
expect(ConsoleColor::YELLOW->value)->toBe('33');
|
||||
expect(ConsoleColor::BLUE->value)->toBe('34');
|
||||
expect(ConsoleColor::MAGENTA->value)->toBe('35');
|
||||
expect(ConsoleColor::CYAN->value)->toBe('36');
|
||||
expect(ConsoleColor::WHITE->value)->toBe('37');
|
||||
expect(ConsoleColor::GRAY->value)->toBe('90');
|
||||
});
|
||||
|
||||
it('has all bright text colors', function () {
|
||||
expect(ConsoleColor::BRIGHT_RED->value)->toBe('91');
|
||||
expect(ConsoleColor::BRIGHT_GREEN->value)->toBe('92');
|
||||
expect(ConsoleColor::BRIGHT_YELLOW->value)->toBe('93');
|
||||
expect(ConsoleColor::BRIGHT_BLUE->value)->toBe('94');
|
||||
expect(ConsoleColor::BRIGHT_MAGENTA->value)->toBe('95');
|
||||
expect(ConsoleColor::BRIGHT_CYAN->value)->toBe('96');
|
||||
expect(ConsoleColor::BRIGHT_WHITE->value)->toBe('97');
|
||||
});
|
||||
|
||||
it('has all background colors', function () {
|
||||
expect(ConsoleColor::BG_BLACK->value)->toBe('40');
|
||||
expect(ConsoleColor::BG_RED->value)->toBe('41');
|
||||
expect(ConsoleColor::BG_GREEN->value)->toBe('42');
|
||||
expect(ConsoleColor::BG_YELLOW->value)->toBe('43');
|
||||
expect(ConsoleColor::BG_BLUE->value)->toBe('44');
|
||||
expect(ConsoleColor::BG_MAGENTA->value)->toBe('45');
|
||||
expect(ConsoleColor::BG_CYAN->value)->toBe('46');
|
||||
expect(ConsoleColor::BG_WHITE->value)->toBe('47');
|
||||
});
|
||||
|
||||
it('has combined colors', function () {
|
||||
expect(ConsoleColor::WHITE_ON_RED->value)->toBe('97;41');
|
||||
expect(ConsoleColor::BLACK_ON_YELLOW->value)->toBe('30;43');
|
||||
});
|
||||
|
||||
it('has reset color', function () {
|
||||
expect(ConsoleColor::RESET->value)->toBe('0');
|
||||
});
|
||||
|
||||
it('converts to ANSI escape sequence', function () {
|
||||
$ansi = ConsoleColor::RED->toAnsi();
|
||||
|
||||
expect($ansi)->toBe("\033[31m");
|
||||
});
|
||||
|
||||
it('generates correct ANSI codes for all colors', function () {
|
||||
expect(ConsoleColor::BLACK->toAnsi())->toBe("\033[30m");
|
||||
expect(ConsoleColor::RED->toAnsi())->toBe("\033[31m");
|
||||
expect(ConsoleColor::GREEN->toAnsi())->toBe("\033[32m");
|
||||
expect(ConsoleColor::YELLOW->toAnsi())->toBe("\033[33m");
|
||||
expect(ConsoleColor::BLUE->toAnsi())->toBe("\033[34m");
|
||||
expect(ConsoleColor::MAGENTA->toAnsi())->toBe("\033[35m");
|
||||
expect(ConsoleColor::CYAN->toAnsi())->toBe("\033[36m");
|
||||
expect(ConsoleColor::WHITE->toAnsi())->toBe("\033[37m");
|
||||
expect(ConsoleColor::GRAY->toAnsi())->toBe("\033[90m");
|
||||
});
|
||||
|
||||
it('generates correct ANSI codes for bright colors', function () {
|
||||
expect(ConsoleColor::BRIGHT_RED->toAnsi())->toBe("\033[91m");
|
||||
expect(ConsoleColor::BRIGHT_GREEN->toAnsi())->toBe("\033[92m");
|
||||
expect(ConsoleColor::BRIGHT_YELLOW->toAnsi())->toBe("\033[93m");
|
||||
expect(ConsoleColor::BRIGHT_BLUE->toAnsi())->toBe("\033[94m");
|
||||
expect(ConsoleColor::BRIGHT_MAGENTA->toAnsi())->toBe("\033[95m");
|
||||
expect(ConsoleColor::BRIGHT_CYAN->toAnsi())->toBe("\033[96m");
|
||||
expect(ConsoleColor::BRIGHT_WHITE->toAnsi())->toBe("\033[97m");
|
||||
});
|
||||
|
||||
it('generates correct ANSI codes for background colors', function () {
|
||||
expect(ConsoleColor::BG_BLACK->toAnsi())->toBe("\033[40m");
|
||||
expect(ConsoleColor::BG_RED->toAnsi())->toBe("\033[41m");
|
||||
expect(ConsoleColor::BG_GREEN->toAnsi())->toBe("\033[42m");
|
||||
expect(ConsoleColor::BG_YELLOW->toAnsi())->toBe("\033[43m");
|
||||
expect(ConsoleColor::BG_BLUE->toAnsi())->toBe("\033[44m");
|
||||
expect(ConsoleColor::BG_MAGENTA->toAnsi())->toBe("\033[45m");
|
||||
expect(ConsoleColor::BG_CYAN->toAnsi())->toBe("\033[46m");
|
||||
expect(ConsoleColor::BG_WHITE->toAnsi())->toBe("\033[47m");
|
||||
});
|
||||
|
||||
it('generates correct ANSI codes for combined colors', function () {
|
||||
expect(ConsoleColor::WHITE_ON_RED->toAnsi())->toBe("\033[97;41m");
|
||||
expect(ConsoleColor::BLACK_ON_YELLOW->toAnsi())->toBe("\033[30;43m");
|
||||
});
|
||||
|
||||
it('generates reset ANSI code', function () {
|
||||
expect(ConsoleColor::RESET->toAnsi())->toBe("\033[0m");
|
||||
});
|
||||
|
||||
it('can be used as string value', function () {
|
||||
$color = ConsoleColor::RED;
|
||||
|
||||
expect($color->value)->toBeString();
|
||||
expect($color->value)->toBe('31');
|
||||
});
|
||||
});
|
||||
|
||||
199
tests/Framework/Console/ConsoleInputTest.php
Normal file
199
tests/Framework/Console/ConsoleInputTest.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\ArgumentParser;
|
||||
use App\Framework\Console\ArgumentParserBuilder;
|
||||
use App\Framework\Console\ArgumentType;
|
||||
use App\Framework\Console\ConsoleInput;
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
|
||||
describe('ConsoleInput', function () {
|
||||
beforeEach(function () {
|
||||
$this->output = new ConsoleOutput();
|
||||
});
|
||||
|
||||
it('parses simple arguments', function () {
|
||||
$input = new ConsoleInput(['arg1', 'arg2', 'arg3'], $this->output);
|
||||
|
||||
expect($input->getArgument(0))->toBe('arg1');
|
||||
expect($input->getArgument(1))->toBe('arg2');
|
||||
expect($input->getArgument(2))->toBe('arg3');
|
||||
});
|
||||
|
||||
it('returns default value for missing arguments', function () {
|
||||
$input = new ConsoleInput(['arg1'], $this->output);
|
||||
|
||||
expect($input->getArgument(0))->toBe('arg1');
|
||||
expect($input->getArgument(1, 'default'))->toBe('default');
|
||||
expect($input->getArgument(2))->toBeNull();
|
||||
});
|
||||
|
||||
it('parses long options with equals sign', function () {
|
||||
$input = new ConsoleInput(['--option=value', '--flag'], $this->output);
|
||||
|
||||
expect($input->getOption('option'))->toBe('value');
|
||||
expect($input->hasOption('flag'))->toBeTrue();
|
||||
expect($input->getOption('flag'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('parses long options with space', function () {
|
||||
// Note: Simple parser doesn't support --option value, only --option=value
|
||||
// This test verifies the current behavior
|
||||
$input = new ConsoleInput(['--option', 'value'], $this->output);
|
||||
|
||||
// Simple parser treats 'value' as a separate argument, not as option value
|
||||
expect($input->hasOption('option'))->toBeTrue();
|
||||
expect($input->getArgument(0))->toBe('value');
|
||||
});
|
||||
|
||||
it('parses short options', function () {
|
||||
$input = new ConsoleInput(['-f', '-o', 'value'], $this->output);
|
||||
|
||||
expect($input->hasOption('f'))->toBeTrue();
|
||||
// Simple parser treats 'value' as argument, not as option value
|
||||
expect($input->getArgument(0))->toBe('value');
|
||||
});
|
||||
|
||||
it('parses mixed arguments and options', function () {
|
||||
$input = new ConsoleInput(['arg1', '--option=value', 'arg2', '-f'], $this->output);
|
||||
|
||||
expect($input->getArgument(0))->toBe('arg1');
|
||||
expect($input->getArgument(1))->toBe('arg2');
|
||||
expect($input->getOption('option'))->toBe('value');
|
||||
expect($input->hasOption('f'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns all arguments', function () {
|
||||
$input = new ConsoleInput(['arg1', 'arg2', 'arg3'], $this->output);
|
||||
|
||||
$args = $input->getArguments();
|
||||
expect($args)->toBe(['arg1', 'arg2', 'arg3']);
|
||||
});
|
||||
|
||||
it('returns all options', function () {
|
||||
$input = new ConsoleInput(['--opt1=val1', '--opt2', '-f'], $this->output);
|
||||
|
||||
$options = $input->getOptions();
|
||||
expect($options)->toHaveKey('opt1');
|
||||
expect($options)->toHaveKey('opt2');
|
||||
expect($options)->toHaveKey('f');
|
||||
});
|
||||
|
||||
it('returns default value for missing options', function () {
|
||||
$input = new ConsoleInput([], $this->output);
|
||||
|
||||
expect($input->getOption('missing', 'default'))->toBe('default');
|
||||
expect($input->getOption('missing'))->toBeNull();
|
||||
});
|
||||
|
||||
it('supports enhanced parsing with ArgumentParser', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->requiredString('name')
|
||||
->integer('age', required: false, default: 18)
|
||||
->build();
|
||||
|
||||
$input = new ConsoleInput(['--name=John', '--age=25'], $this->output, $parser);
|
||||
|
||||
expect($input->hasEnhancedParsing())->toBeTrue();
|
||||
expect($input->getString('name'))->toBe('John');
|
||||
expect($input->getInt('age'))->toBe(25);
|
||||
});
|
||||
|
||||
it('throws exception when accessing enhanced parsing without parser', function () {
|
||||
$input = new ConsoleInput(['--option=value'], $this->output);
|
||||
|
||||
expect($input->hasEnhancedParsing())->toBeFalse();
|
||||
expect(fn () => $input->getParsedArguments())
|
||||
->toThrow(\RuntimeException::class, 'Enhanced parsing not available');
|
||||
});
|
||||
|
||||
it('validates required arguments with enhanced parsing', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->requiredString('name')
|
||||
->build();
|
||||
|
||||
$input = new ConsoleInput(['--name=John'], $this->output, $parser);
|
||||
|
||||
expect($input->require('name'))->toBe('John');
|
||||
});
|
||||
|
||||
it('throws exception for missing required arguments', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->requiredString('name')
|
||||
->build();
|
||||
|
||||
// Exception is thrown during parsing, not when calling require()
|
||||
expect(fn () => new ConsoleInput([], $this->output, $parser))
|
||||
->toThrow(\InvalidArgumentException::class, "Required argument 'name' is missing");
|
||||
});
|
||||
|
||||
it('converts types correctly with enhanced parsing', function () {
|
||||
$parser = ArgumentParser::create()
|
||||
->integer('count')
|
||||
->addArgument(new \App\Framework\Console\ArgumentDefinition('active', ArgumentType::BOOLEAN))
|
||||
->addArgument(new \App\Framework\Console\ArgumentDefinition('items', ArgumentType::ARRAY))
|
||||
->build();
|
||||
|
||||
$input = new ConsoleInput([
|
||||
'--count=42',
|
||||
'--active=true',
|
||||
'--items=item1,item2,item3'
|
||||
], $this->output, $parser);
|
||||
|
||||
expect($input->getInt('count'))->toBe(42);
|
||||
expect($input->getBool('active'))->toBeTrue();
|
||||
expect($input->getArray('items'))->toBe(['item1', 'item2', 'item3']);
|
||||
});
|
||||
|
||||
it('handles kebab-case option names', function () {
|
||||
$input = new ConsoleInput(['--dry-run', '--output-file=test.txt'], $this->output);
|
||||
|
||||
expect($input->hasOption('dry-run'))->toBeTrue();
|
||||
expect($input->getOption('output-file'))->toBe('test.txt');
|
||||
});
|
||||
|
||||
it('can set argument parser after construction', function () {
|
||||
$input = new ConsoleInput(['--name=John'], $this->output);
|
||||
|
||||
$parser = ArgumentParser::create()
|
||||
->optionalString('name')
|
||||
->build();
|
||||
|
||||
$input->setArgumentParser($parser);
|
||||
|
||||
expect($input->hasEnhancedParsing())->toBeTrue();
|
||||
// Note: setArgumentParser re-parses, but needs the raw arguments
|
||||
// The simple parser already parsed --name=John, so we can verify it's available
|
||||
expect($input->getOption('name'))->toBe('John');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConsoleInput Interactive Methods', function () {
|
||||
it('has ask method that delegates to InteractivePrompter', function () {
|
||||
$output = new ConsoleOutput();
|
||||
$input = new ConsoleInput([], $output);
|
||||
|
||||
// Verify method exists - actual interactive behavior requires STDIN
|
||||
expect(method_exists($input, 'ask'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has confirm method that delegates to InteractivePrompter', function () {
|
||||
$output = new ConsoleOutput();
|
||||
$input = new ConsoleInput([], $output);
|
||||
|
||||
// Verify method exists - actual interactive behavior requires STDIN
|
||||
expect(method_exists($input, 'confirm'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has askPassword method that delegates to InteractivePrompter', function () {
|
||||
$output = new ConsoleOutput();
|
||||
$input = new ConsoleInput([], $output);
|
||||
|
||||
// Verify method exists - actual interactive behavior requires STDIN
|
||||
expect(method_exists($input, 'askPassword'))->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
208
tests/Framework/Console/ConsoleOutputTest.php
Normal file
208
tests/Framework/Console/ConsoleOutputTest.php
Normal file
@@ -0,0 +1,208 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\ConsoleColor;
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
use App\Framework\Console\ConsoleStyle;
|
||||
|
||||
describe('ConsoleOutput', function () {
|
||||
it('can be instantiated', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect($output)->toBeInstanceOf(ConsoleOutput::class);
|
||||
});
|
||||
|
||||
it('provides cursor, display, and screen managers', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect($output->cursor)->toBeInstanceOf(\App\Framework\Console\Screen\Cursor::class);
|
||||
expect($output->display)->toBeInstanceOf(\App\Framework\Console\Screen\Display::class);
|
||||
expect($output->screen)->toBeInstanceOf(\App\Framework\Console\Screen\ScreenManager::class);
|
||||
});
|
||||
|
||||
it('provides terminal capabilities', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect($output->getCapabilities())->toBeInstanceOf(\App\Framework\Console\Terminal\TerminalCapabilities::class);
|
||||
expect($output->isTerminal())->toBeBool();
|
||||
});
|
||||
|
||||
it('provides ANSI generator and link formatter', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect($output->getAnsiGenerator())->toBeInstanceOf(\App\Framework\Console\Ansi\AnsiSequenceGenerator::class);
|
||||
expect($output->getLinkFormatter())->toBeInstanceOf(\App\Framework\Console\Ansi\LinkFormatter::class);
|
||||
});
|
||||
|
||||
it('provides window title manager', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect($output->getTitleManager())->toBeInstanceOf(\App\Framework\Console\Terminal\WindowTitleManager::class);
|
||||
});
|
||||
|
||||
it('provides animation manager', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect($output->getAnimationManager())->toBeInstanceOf(\App\Framework\Console\Animation\AnimationManager::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConsoleOutput Write Methods', function () {
|
||||
it('writes text without style', function () {
|
||||
$output = new ConsoleOutput();
|
||||
ob_start();
|
||||
$output->write('Hello World');
|
||||
$content = ob_get_clean();
|
||||
|
||||
expect($content)->toContain('Hello World');
|
||||
});
|
||||
|
||||
it('writes text with ConsoleColor', function () {
|
||||
$output = new ConsoleOutput();
|
||||
ob_start();
|
||||
$output->write('Error', ConsoleColor::RED);
|
||||
$content = ob_get_clean();
|
||||
|
||||
expect($content)->toContain('Error');
|
||||
});
|
||||
|
||||
it('writes line with newline', function () {
|
||||
$output = new ConsoleOutput();
|
||||
ob_start();
|
||||
$output->writeLine('Test Line');
|
||||
$content = ob_get_clean();
|
||||
|
||||
expect($content)->toContain('Test Line');
|
||||
});
|
||||
|
||||
it('writes success message', function () {
|
||||
$output = new ConsoleOutput();
|
||||
ob_start();
|
||||
$output->writeSuccess('Operation successful');
|
||||
$content = ob_get_clean();
|
||||
|
||||
expect($content)->toContain('Operation successful');
|
||||
});
|
||||
|
||||
it('writes error message', function () {
|
||||
$output = new ConsoleOutput();
|
||||
ob_start();
|
||||
$output->writeError('Operation failed');
|
||||
$content = ob_get_clean();
|
||||
|
||||
expect($content)->toContain('Operation failed');
|
||||
});
|
||||
|
||||
it('writes warning message', function () {
|
||||
$output = new ConsoleOutput();
|
||||
ob_start();
|
||||
$output->writeWarning('Warning message');
|
||||
$content = ob_get_clean();
|
||||
|
||||
expect($content)->toContain('Warning message');
|
||||
});
|
||||
|
||||
it('writes info message', function () {
|
||||
$output = new ConsoleOutput();
|
||||
ob_start();
|
||||
$output->writeInfo('Info message');
|
||||
$content = ob_get_clean();
|
||||
|
||||
expect($content)->toContain('Info message');
|
||||
});
|
||||
|
||||
it('writes error line to stderr', function () {
|
||||
$output = new ConsoleOutput();
|
||||
ob_start();
|
||||
$output->writeErrorLine('Error to stderr');
|
||||
$content = ob_get_clean();
|
||||
|
||||
expect($content)->toContain('Error to stderr');
|
||||
});
|
||||
|
||||
it('adds newlines', function () {
|
||||
$output = new ConsoleOutput();
|
||||
ob_start();
|
||||
$output->newLine(3);
|
||||
$content = ob_get_clean();
|
||||
|
||||
// Should contain newlines
|
||||
expect($content)->toContain("\n");
|
||||
});
|
||||
|
||||
it('sets window title', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
// Should not throw
|
||||
$output->writeWindowTitle('Test Title');
|
||||
expect(true)->toBeTrue(); // Just verify it doesn't throw
|
||||
});
|
||||
|
||||
it('sets window title with different modes', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
// Test different modes
|
||||
$output->writeWindowTitle('Title 1', 0);
|
||||
$output->writeWindowTitle('Title 2', 1);
|
||||
$output->writeWindowTitle('Title 3', 2);
|
||||
|
||||
expect(true)->toBeTrue(); // Just verify it doesn't throw
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConsoleOutput Interactive Methods', function () {
|
||||
it('delegates askQuestion to InteractivePrompter', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
// In a real test, we'd mock the prompter, but for now we just verify the method exists
|
||||
expect(method_exists($output, 'askQuestion'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('delegates confirm to InteractivePrompter', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect(method_exists($output, 'confirm'))->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConsoleOutput Animation Methods', function () {
|
||||
it('has animateFadeIn method', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect(method_exists($output, 'animateFadeIn'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has animateFadeOut method', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect(method_exists($output, 'animateFadeOut'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has animateTypewriter method', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect(method_exists($output, 'animateTypewriter'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has animateMarquee method', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect(method_exists($output, 'animateMarquee'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has animateText method for custom animations', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect(method_exists($output, 'animateText'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has updateAnimations method', function () {
|
||||
$output = new ConsoleOutput();
|
||||
|
||||
expect(method_exists($output, 'updateAnimations'))->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
105
tests/Framework/Console/ExitCodeTest.php
Normal file
105
tests/Framework/Console/ExitCodeTest.php
Normal file
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\ExitCode;
|
||||
|
||||
describe('ExitCode', function () {
|
||||
it('has all standard exit codes', function () {
|
||||
expect(ExitCode::SUCCESS->value)->toBe(0);
|
||||
expect(ExitCode::GENERAL_ERROR->value)->toBe(1);
|
||||
expect(ExitCode::USAGE_ERROR->value)->toBe(2);
|
||||
expect(ExitCode::COMMAND_NOT_FOUND->value)->toBe(64);
|
||||
expect(ExitCode::INVALID_INPUT->value)->toBe(65);
|
||||
expect(ExitCode::NO_INPUT->value)->toBe(66);
|
||||
expect(ExitCode::UNAVAILABLE->value)->toBe(69);
|
||||
expect(ExitCode::SOFTWARE_ERROR->value)->toBe(70);
|
||||
expect(ExitCode::OS_ERROR->value)->toBe(71);
|
||||
expect(ExitCode::OS_FILE_ERROR->value)->toBe(72);
|
||||
expect(ExitCode::CANT_CREATE->value)->toBe(73);
|
||||
expect(ExitCode::IO_ERROR->value)->toBe(74);
|
||||
expect(ExitCode::TEMP_FAIL->value)->toBe(75);
|
||||
expect(ExitCode::PROTOCOL_ERROR->value)->toBe(76);
|
||||
expect(ExitCode::NO_PERMISSION->value)->toBe(77);
|
||||
expect(ExitCode::CONFIG_ERROR->value)->toBe(78);
|
||||
expect(ExitCode::DATABASE_ERROR->value)->toBe(79);
|
||||
expect(ExitCode::RATE_LIMITED->value)->toBe(80);
|
||||
expect(ExitCode::PERMISSION_DENIED->value)->toBe(126);
|
||||
expect(ExitCode::INTERRUPTED->value)->toBe(130);
|
||||
});
|
||||
|
||||
it('provides descriptions for all exit codes', function () {
|
||||
expect(ExitCode::SUCCESS->getDescription())->toBe('Erfolgreich abgeschlossen');
|
||||
expect(ExitCode::GENERAL_ERROR->getDescription())->toBe('Allgemeiner Fehler');
|
||||
expect(ExitCode::USAGE_ERROR->getDescription())->toBe('Falsche Verwendung oder ungültige Argumente');
|
||||
expect(ExitCode::COMMAND_NOT_FOUND->getDescription())->toBe('Kommando nicht gefunden');
|
||||
expect(ExitCode::INVALID_INPUT->getDescription())->toBe('Ungültige Eingabedaten');
|
||||
expect(ExitCode::NO_INPUT->getDescription())->toBe('Keine Eingabe vorhanden');
|
||||
expect(ExitCode::UNAVAILABLE->getDescription())->toBe('Service nicht verfügbar');
|
||||
expect(ExitCode::SOFTWARE_ERROR->getDescription())->toBe('Interner Software-Fehler');
|
||||
expect(ExitCode::OS_ERROR->getDescription())->toBe('Betriebssystem-Fehler');
|
||||
expect(ExitCode::OS_FILE_ERROR->getDescription())->toBe('Datei-/Verzeichnis-Fehler');
|
||||
expect(ExitCode::CANT_CREATE->getDescription())->toBe('Kann Datei/Verzeichnis nicht erstellen');
|
||||
expect(ExitCode::IO_ERROR->getDescription())->toBe('Ein-/Ausgabe-Fehler');
|
||||
expect(ExitCode::TEMP_FAIL->getDescription())->toBe('Temporärer Fehler');
|
||||
expect(ExitCode::PROTOCOL_ERROR->getDescription())->toBe('Protokoll-Fehler');
|
||||
expect(ExitCode::NO_PERMISSION->getDescription())->toBe('Keine Berechtigung');
|
||||
expect(ExitCode::CONFIG_ERROR->getDescription())->toBe('Konfigurationsfehler');
|
||||
expect(ExitCode::DATABASE_ERROR->getDescription())->toBe('Datenbankfehler');
|
||||
expect(ExitCode::RATE_LIMITED->getDescription())->toBe('Rate-Limit erreicht');
|
||||
expect(ExitCode::PERMISSION_DENIED->getDescription())->toBe('Zugriff verweigert');
|
||||
expect(ExitCode::INTERRUPTED->getDescription())->toBe('Unterbrochen durch Signal (SIGINT/SIGTERM)');
|
||||
});
|
||||
|
||||
it('identifies success correctly', function () {
|
||||
expect(ExitCode::SUCCESS->isSuccess())->toBeTrue();
|
||||
expect(ExitCode::SUCCESS->isError())->toBeFalse();
|
||||
});
|
||||
|
||||
it('identifies errors correctly', function () {
|
||||
expect(ExitCode::GENERAL_ERROR->isSuccess())->toBeFalse();
|
||||
expect(ExitCode::GENERAL_ERROR->isError())->toBeTrue();
|
||||
|
||||
expect(ExitCode::COMMAND_NOT_FOUND->isSuccess())->toBeFalse();
|
||||
expect(ExitCode::COMMAND_NOT_FOUND->isError())->toBeTrue();
|
||||
|
||||
expect(ExitCode::INVALID_INPUT->isSuccess())->toBeFalse();
|
||||
expect(ExitCode::INVALID_INPUT->isError())->toBeTrue();
|
||||
});
|
||||
|
||||
it('can be used as integer value', function () {
|
||||
$exitCode = ExitCode::SUCCESS;
|
||||
|
||||
expect($exitCode->value)->toBeInt();
|
||||
expect($exitCode->value)->toBe(0);
|
||||
});
|
||||
|
||||
it('follows POSIX exit code standards', function () {
|
||||
// 0 = success
|
||||
expect(ExitCode::SUCCESS->value)->toBe(0);
|
||||
|
||||
// 1 = general error
|
||||
expect(ExitCode::GENERAL_ERROR->value)->toBe(1);
|
||||
|
||||
// 2 = usage error
|
||||
expect(ExitCode::USAGE_ERROR->value)->toBe(2);
|
||||
|
||||
// 64-78 = sysexits.h standard codes
|
||||
expect(ExitCode::COMMAND_NOT_FOUND->value)->toBe(64);
|
||||
expect(ExitCode::INVALID_INPUT->value)->toBe(65);
|
||||
expect(ExitCode::NO_INPUT->value)->toBe(66);
|
||||
expect(ExitCode::UNAVAILABLE->value)->toBe(69);
|
||||
expect(ExitCode::SOFTWARE_ERROR->value)->toBe(70);
|
||||
expect(ExitCode::OS_ERROR->value)->toBe(71);
|
||||
expect(ExitCode::OS_FILE_ERROR->value)->toBe(72);
|
||||
expect(ExitCode::CANT_CREATE->value)->toBe(73);
|
||||
expect(ExitCode::IO_ERROR->value)->toBe(74);
|
||||
expect(ExitCode::TEMP_FAIL->value)->toBe(75);
|
||||
expect(ExitCode::PROTOCOL_ERROR->value)->toBe(76);
|
||||
expect(ExitCode::NO_PERMISSION->value)->toBe(77);
|
||||
expect(ExitCode::CONFIG_ERROR->value)->toBe(78);
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
227
tests/Framework/Console/InteractiveFormTest.php
Normal file
227
tests/Framework/Console/InteractiveFormTest.php
Normal file
@@ -0,0 +1,227 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\InteractiveForm;
|
||||
use App\Framework\Console\ParameterInspector;
|
||||
use Tests\Framework\Console\Helpers\TestConsoleOutput;
|
||||
|
||||
class TestCommand
|
||||
{
|
||||
public function testMethod(string $name, int $age = 18): void
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
describe('InteractiveForm', function () {
|
||||
beforeEach(function () {
|
||||
$this->output = new TestConsoleOutput();
|
||||
$this->inspector = new ParameterInspector();
|
||||
$this->form = new InteractiveForm($this->output, $this->inspector);
|
||||
});
|
||||
|
||||
it('can be instantiated', function () {
|
||||
expect($this->form)->toBeInstanceOf(InteractiveForm::class);
|
||||
});
|
||||
|
||||
it('creates form from command', function () {
|
||||
$command = new TestCommand();
|
||||
$form = InteractiveForm::forCommand($command, $this->output, 'testMethod');
|
||||
|
||||
expect($form)->toBeInstanceOf(InteractiveForm::class);
|
||||
});
|
||||
|
||||
it('adds field to form', function () {
|
||||
$form = $this->form->addField([
|
||||
'name' => 'test',
|
||||
'description' => 'Test field',
|
||||
'type' => ['name' => 'string'],
|
||||
'required' => true,
|
||||
]);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('adds multiple fields', function () {
|
||||
$form = $this->form
|
||||
->addField(['name' => 'field1', 'description' => 'Field 1'])
|
||||
->addField(['name' => 'field2', 'description' => 'Field 2']);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('merges default field configuration', function () {
|
||||
$form = $this->form->addField([
|
||||
'name' => 'test',
|
||||
'description' => 'Test',
|
||||
]);
|
||||
|
||||
// Should have default values merged
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('gets values', function () {
|
||||
// Initially empty
|
||||
expect($this->form->getValues())->toBeEmpty();
|
||||
});
|
||||
|
||||
it('checks if form is completed', function () {
|
||||
expect($this->form->isCompleted())->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns empty array when run with no fields', function () {
|
||||
$values = $this->form->run();
|
||||
|
||||
expect($values)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('has run method', function () {
|
||||
expect(method_exists($this->form, 'run'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles required fields', function () {
|
||||
$form = $this->form->addField([
|
||||
'name' => 'required_field',
|
||||
'description' => 'Required field',
|
||||
'required' => true,
|
||||
]);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('handles optional fields with defaults', function () {
|
||||
$form = $this->form->addField([
|
||||
'name' => 'optional_field',
|
||||
'description' => 'Optional field',
|
||||
'required' => false,
|
||||
'default' => 'default value',
|
||||
]);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('handles different field types', function () {
|
||||
$form = $this->form
|
||||
->addField(['name' => 'text', 'input_type' => 'text'])
|
||||
->addField(['name' => 'boolean', 'input_type' => 'boolean'])
|
||||
->addField(['name' => 'number', 'input_type' => 'number'])
|
||||
->addField(['name' => 'password', 'input_type' => 'password'])
|
||||
->addField(['name' => 'file', 'input_type' => 'file'])
|
||||
->addField(['name' => 'list', 'input_type' => 'list']);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('handles validation rules', function () {
|
||||
$form = $this->form->addField([
|
||||
'name' => 'email',
|
||||
'description' => 'Email address',
|
||||
'validation_rules' => [
|
||||
'type' => 'string',
|
||||
'format' => 'email',
|
||||
'required' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('handles min/max validation for numbers', function () {
|
||||
$form = $this->form->addField([
|
||||
'name' => 'age',
|
||||
'description' => 'Age',
|
||||
'input_type' => 'number',
|
||||
'validation_rules' => [
|
||||
'type' => 'integer',
|
||||
'min' => 0,
|
||||
'max' => 120,
|
||||
'required' => true,
|
||||
],
|
||||
]);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('handles many fields', function () {
|
||||
$form = $this->form;
|
||||
for ($i = 1; $i <= 50; $i++) {
|
||||
$form = $form->addField([
|
||||
'name' => "field{$i}",
|
||||
'description' => "Field {$i}",
|
||||
]);
|
||||
}
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InteractiveForm Field Configuration', function () {
|
||||
beforeEach(function () {
|
||||
$this->output = new TestConsoleOutput();
|
||||
$this->inspector = new ParameterInspector();
|
||||
$this->form = new InteractiveForm($this->output, $this->inspector);
|
||||
});
|
||||
|
||||
it('creates text field', function () {
|
||||
$form = $this->form->addField([
|
||||
'name' => 'name',
|
||||
'description' => 'Name',
|
||||
'input_type' => 'text',
|
||||
]);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('creates boolean field', function () {
|
||||
$form = $this->form->addField([
|
||||
'name' => 'active',
|
||||
'description' => 'Active',
|
||||
'input_type' => 'boolean',
|
||||
]);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('creates number field', function () {
|
||||
$form = $this->form->addField([
|
||||
'name' => 'count',
|
||||
'description' => 'Count',
|
||||
'input_type' => 'number',
|
||||
]);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('creates password field', function () {
|
||||
$form = $this->form->addField([
|
||||
'name' => 'password',
|
||||
'description' => 'Password',
|
||||
'input_type' => 'password',
|
||||
]);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('creates file field', function () {
|
||||
$form = $this->form->addField([
|
||||
'name' => 'file',
|
||||
'description' => 'File path',
|
||||
'input_type' => 'file',
|
||||
]);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
|
||||
it('creates list field', function () {
|
||||
$form = $this->form->addField([
|
||||
'name' => 'items',
|
||||
'description' => 'Items',
|
||||
'input_type' => 'list',
|
||||
]);
|
||||
|
||||
expect($form)->toBe($this->form);
|
||||
});
|
||||
});
|
||||
|
||||
167
tests/Framework/Console/InteractivePrompterTest.php
Normal file
167
tests/Framework/Console/InteractivePrompterTest.php
Normal file
@@ -0,0 +1,167 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\ConsoleOutput;
|
||||
use App\Framework\Console\InteractivePrompter;
|
||||
use App\Framework\Console\TextWriter;
|
||||
use App\Framework\Console\ValueObjects\ChoiceOptions;
|
||||
use App\Framework\Console\ValueObjects\MenuOptions;
|
||||
use Tests\Framework\Console\Helpers\TestConsoleOutput;
|
||||
|
||||
describe('InteractivePrompter', function () {
|
||||
beforeEach(function () {
|
||||
$this->output = new TestConsoleOutput();
|
||||
$textWriter = new TextWriter(
|
||||
new \App\Framework\Console\Ansi\AnsiSequenceGenerator(),
|
||||
new \App\Framework\Console\Terminal\TerminalCapabilities()
|
||||
);
|
||||
$this->prompter = new InteractivePrompter($this->output, $textWriter);
|
||||
});
|
||||
|
||||
it('can be instantiated', function () {
|
||||
expect($this->prompter)->toBeInstanceOf(InteractivePrompter::class);
|
||||
});
|
||||
|
||||
it('has askQuestion method', function () {
|
||||
expect(method_exists($this->prompter, 'askQuestion'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has askPassword method', function () {
|
||||
expect(method_exists($this->prompter, 'askPassword'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has confirm method', function () {
|
||||
expect(method_exists($this->prompter, 'confirm'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has choice method', function () {
|
||||
expect(method_exists($this->prompter, 'choice'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has choiceFromOptions method', function () {
|
||||
expect(method_exists($this->prompter, 'choiceFromOptions'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has menu method', function () {
|
||||
expect(method_exists($this->prompter, 'menu'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has menuFromOptions method', function () {
|
||||
expect(method_exists($this->prompter, 'menuFromOptions'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has multiSelect method', function () {
|
||||
expect(method_exists($this->prompter, 'multiSelect'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('has multiSelectFromOptions method', function () {
|
||||
expect(method_exists($this->prompter, 'multiSelectFromOptions'))->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InteractivePrompter Choice Methods', function () {
|
||||
beforeEach(function () {
|
||||
$this->output = new TestConsoleOutput();
|
||||
$textWriter = new TextWriter(
|
||||
new \App\Framework\Console\Ansi\AnsiSequenceGenerator(),
|
||||
new \App\Framework\Console\Terminal\TerminalCapabilities()
|
||||
);
|
||||
$this->prompter = new InteractivePrompter($this->output, $textWriter);
|
||||
});
|
||||
|
||||
it('delegates choice to choiceFromOptions', function () {
|
||||
$choices = ['option1' => 'Option 1', 'option2' => 'Option 2'];
|
||||
|
||||
// This will call InteractiveMenu which requires STDIN, so we just verify the method exists
|
||||
expect(method_exists($this->prompter, 'choice'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('delegates choiceFromOptions to InteractiveMenu', function () {
|
||||
$options = MenuOptions::create()
|
||||
->addOption('opt1', 'Option 1')
|
||||
->addOption('opt2', 'Option 2');
|
||||
|
||||
// This will call InteractiveMenu which requires STDIN
|
||||
// We verify the method exists and can be called
|
||||
expect(method_exists($this->prompter, 'choiceFromOptions'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles MenuOptions with separators', function () {
|
||||
$options = MenuOptions::create()
|
||||
->addOption('opt1', 'Option 1')
|
||||
->addSeparator()
|
||||
->addOption('opt2', 'Option 2');
|
||||
|
||||
// Verify structure
|
||||
expect($options->count())->toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('InteractivePrompter Menu Methods', function () {
|
||||
beforeEach(function () {
|
||||
$this->output = new TestConsoleOutput();
|
||||
$textWriter = new TextWriter(
|
||||
new \App\Framework\Console\Ansi\AnsiSequenceGenerator(),
|
||||
new \App\Framework\Console\Terminal\TerminalCapabilities()
|
||||
);
|
||||
$this->prompter = new InteractivePrompter($this->output, $textWriter);
|
||||
});
|
||||
|
||||
it('delegates menu to menuFromOptions', function () {
|
||||
$items = ['item1' => 'Item 1', 'item2' => 'Item 2'];
|
||||
|
||||
// This will call InteractiveMenu which requires STDIN
|
||||
expect(method_exists($this->prompter, 'menu'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('delegates menuFromOptions to InteractiveMenu', function () {
|
||||
$options = MenuOptions::create()
|
||||
->addOption('item1', 'Item 1')
|
||||
->addOption('item2', 'Item 2');
|
||||
|
||||
// This will call InteractiveMenu which requires STDIN
|
||||
expect(method_exists($this->prompter, 'menuFromOptions'))->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('InteractivePrompter MultiSelect Methods', function () {
|
||||
beforeEach(function () {
|
||||
$this->output = new TestConsoleOutput();
|
||||
$textWriter = new TextWriter(
|
||||
new \App\Framework\Console\Ansi\AnsiSequenceGenerator(),
|
||||
new \App\Framework\Console\Terminal\TerminalCapabilities()
|
||||
);
|
||||
$this->prompter = new InteractivePrompter($this->output, $textWriter);
|
||||
});
|
||||
|
||||
it('delegates multiSelect to multiSelectFromOptions', function () {
|
||||
$options = ['opt1' => 'Option 1', 'opt2' => 'Option 2'];
|
||||
|
||||
// This will require STDIN input
|
||||
expect(method_exists($this->prompter, 'multiSelect'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('delegates multiSelectFromOptions and returns ChoiceOptions', function () {
|
||||
$options = ChoiceOptions::create()
|
||||
->addChoice('val1', 'Value 1')
|
||||
->addChoice('val2', 'Value 2');
|
||||
|
||||
// This will require STDIN input
|
||||
expect(method_exists($this->prompter, 'multiSelectFromOptions'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('displays multiSelect options correctly', function () {
|
||||
$options = ChoiceOptions::create()
|
||||
->addChoice('val1', 'Value 1')
|
||||
->addChoice('val2', 'Value 2')
|
||||
->addChoice('val3', 'Value 3');
|
||||
|
||||
// Verify structure
|
||||
expect($options->count())->toBe(3);
|
||||
expect($options->toArray())->toHaveCount(3);
|
||||
});
|
||||
});
|
||||
|
||||
387
tests/Framework/Console/ParsedArgumentsTest.php
Normal file
387
tests/Framework/Console/ParsedArgumentsTest.php
Normal file
@@ -0,0 +1,387 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\ArgumentDefinition;
|
||||
use App\Framework\Console\ArgumentType;
|
||||
use App\Framework\Console\ParsedArguments;
|
||||
|
||||
describe('ParsedArguments', function () {
|
||||
it('gets argument values', function () {
|
||||
$definitions = [
|
||||
'name' => new ArgumentDefinition('name', ArgumentType::STRING),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(
|
||||
['name' => 'John'],
|
||||
[],
|
||||
$definitions
|
||||
);
|
||||
|
||||
expect($parsed->get('name'))->toBe('John');
|
||||
});
|
||||
|
||||
it('gets option values', function () {
|
||||
$definitions = [
|
||||
'verbose' => new ArgumentDefinition('verbose', ArgumentType::BOOLEAN),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(
|
||||
[],
|
||||
['verbose' => true],
|
||||
$definitions
|
||||
);
|
||||
|
||||
expect($parsed->get('verbose'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('returns default value when argument is missing', function () {
|
||||
$definitions = [
|
||||
'name' => new ArgumentDefinition('name', ArgumentType::STRING, default: 'Guest'),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments([], [], $definitions);
|
||||
|
||||
expect($parsed->get('name'))->toBe('Guest');
|
||||
});
|
||||
|
||||
it('returns null when argument is missing and no default', function () {
|
||||
$definitions = [
|
||||
'name' => new ArgumentDefinition('name', ArgumentType::STRING),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments([], [], $definitions);
|
||||
|
||||
expect($parsed->get('name'))->toBeNull();
|
||||
});
|
||||
|
||||
it('requires argument and throws exception when missing', function () {
|
||||
$definitions = [
|
||||
'name' => new ArgumentDefinition('name', ArgumentType::STRING, required: true),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments([], [], $definitions);
|
||||
|
||||
expect(fn () => $parsed->require('name'))
|
||||
->toThrow(\InvalidArgumentException::class, "Required argument 'name' is missing");
|
||||
});
|
||||
|
||||
it('requires argument and returns value when present', function () {
|
||||
$definitions = [
|
||||
'name' => new ArgumentDefinition('name', ArgumentType::STRING, required: true),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['name' => 'John'], [], $definitions);
|
||||
|
||||
expect($parsed->require('name'))->toBe('John');
|
||||
});
|
||||
|
||||
it('gets typed values', function () {
|
||||
$definitions = [
|
||||
'count' => new ArgumentDefinition('count', ArgumentType::INTEGER),
|
||||
'active' => new ArgumentDefinition('active', ArgumentType::BOOLEAN),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(
|
||||
['count' => '42', 'active' => 'true'],
|
||||
[],
|
||||
$definitions
|
||||
);
|
||||
|
||||
expect($parsed->getTyped('count'))->toBe(42);
|
||||
expect($parsed->getTyped('active'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('gets string value', function () {
|
||||
$definitions = [
|
||||
'name' => new ArgumentDefinition('name', ArgumentType::STRING),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['name' => 'John'], [], $definitions);
|
||||
|
||||
expect($parsed->getString('name'))->toBe('John');
|
||||
expect($parsed->getString('name'))->toBeString();
|
||||
});
|
||||
|
||||
it('gets integer value', function () {
|
||||
$definitions = [
|
||||
'count' => new ArgumentDefinition('count', ArgumentType::INTEGER),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['count' => '42'], [], $definitions);
|
||||
|
||||
expect($parsed->getInt('count'))->toBe(42);
|
||||
expect($parsed->getInt('count'))->toBeInt();
|
||||
});
|
||||
|
||||
it('throws exception for invalid integer', function () {
|
||||
$definitions = [
|
||||
'count' => new ArgumentDefinition('count', ArgumentType::INTEGER),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['count' => 'not-a-number'], [], $definitions);
|
||||
|
||||
expect(fn () => $parsed->getInt('count'))
|
||||
->toThrow(\InvalidArgumentException::class, 'not a valid integer');
|
||||
});
|
||||
|
||||
it('gets float value', function () {
|
||||
$definitions = [
|
||||
'price' => new ArgumentDefinition('price', ArgumentType::FLOAT),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['price' => '12.34'], [], $definitions);
|
||||
|
||||
expect($parsed->getFloat('price'))->toBe(12.34);
|
||||
expect($parsed->getFloat('price'))->toBeFloat();
|
||||
});
|
||||
|
||||
it('throws exception for invalid float', function () {
|
||||
$definitions = [
|
||||
'price' => new ArgumentDefinition('price', ArgumentType::FLOAT),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['price' => 'not-a-number'], [], $definitions);
|
||||
|
||||
expect(fn () => $parsed->getFloat('price'))
|
||||
->toThrow(\InvalidArgumentException::class, 'not a valid number');
|
||||
});
|
||||
|
||||
it('gets boolean value', function () {
|
||||
$definitions = [
|
||||
'active' => new ArgumentDefinition('active', ArgumentType::BOOLEAN),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['active' => 'true'], [], $definitions);
|
||||
|
||||
expect($parsed->getBool('active'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('parses various boolean string values', function () {
|
||||
$definitions = [
|
||||
'flag' => new ArgumentDefinition('flag', ArgumentType::BOOLEAN),
|
||||
];
|
||||
|
||||
$trueValues = ['true', '1', 'yes', 'on'];
|
||||
foreach ($trueValues as $value) {
|
||||
$parsed = new ParsedArguments(['flag' => $value], [], $definitions);
|
||||
expect($parsed->getBool('flag'))->toBeTrue();
|
||||
}
|
||||
});
|
||||
|
||||
it('gets array value', function () {
|
||||
$definitions = [
|
||||
'items' => new ArgumentDefinition('items', ArgumentType::ARRAY),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['items' => 'item1,item2,item3'], [], $definitions);
|
||||
|
||||
expect($parsed->getArray('items'))->toBe(['item1', 'item2', 'item3']);
|
||||
});
|
||||
|
||||
it('handles array value that is already an array', function () {
|
||||
$definitions = [
|
||||
'items' => new ArgumentDefinition('items', ArgumentType::ARRAY),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['items' => ['item1', 'item2']], [], $definitions);
|
||||
|
||||
expect($parsed->getArray('items'))->toBe(['item1', 'item2']);
|
||||
});
|
||||
|
||||
it('gets email value object', function () {
|
||||
$definitions = [
|
||||
'email' => new ArgumentDefinition('email', ArgumentType::EMAIL),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['email' => 'user@example.com'], [], $definitions);
|
||||
|
||||
$email = $parsed->getEmail('email');
|
||||
expect($email)->toBeInstanceOf(\App\Framework\Core\ValueObjects\EmailAddress::class);
|
||||
expect($email->toString())->toBe('user@example.com');
|
||||
});
|
||||
|
||||
it('throws exception for invalid email', function () {
|
||||
$definitions = [
|
||||
'email' => new ArgumentDefinition('email', ArgumentType::EMAIL),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['email' => 'invalid-email'], [], $definitions);
|
||||
|
||||
expect(fn () => $parsed->getEmail('email'))
|
||||
->toThrow(\InvalidArgumentException::class, 'not a valid email address');
|
||||
});
|
||||
|
||||
it('gets URL value object', function () {
|
||||
$definitions = [
|
||||
'url' => new ArgumentDefinition('url', ArgumentType::URL),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['url' => 'https://example.com'], [], $definitions);
|
||||
|
||||
$url = $parsed->getUrl('url');
|
||||
expect($url)->toBeInstanceOf(\App\Framework\Http\Url\Url::class);
|
||||
});
|
||||
|
||||
it('throws exception for invalid URL', function () {
|
||||
$definitions = [
|
||||
'url' => new ArgumentDefinition('url', ArgumentType::URL),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['url' => 'not-a-url'], [], $definitions);
|
||||
|
||||
expect(fn () => $parsed->getUrl('url'))
|
||||
->toThrow(\InvalidArgumentException::class, 'not a valid URL');
|
||||
});
|
||||
|
||||
it('checks if argument exists', function () {
|
||||
$definitions = [
|
||||
'name' => new ArgumentDefinition('name', ArgumentType::STRING),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['name' => 'John'], [], $definitions);
|
||||
|
||||
expect($parsed->has('name'))->toBeTrue();
|
||||
expect($parsed->has('missing'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('checks if argument has value', function () {
|
||||
$definitions = [
|
||||
'name' => new ArgumentDefinition('name', ArgumentType::STRING),
|
||||
'empty' => new ArgumentDefinition('empty', ArgumentType::STRING),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(
|
||||
['name' => 'John', 'empty' => ''],
|
||||
[],
|
||||
$definitions
|
||||
);
|
||||
|
||||
expect($parsed->hasValue('name'))->toBeTrue();
|
||||
expect($parsed->hasValue('empty'))->toBeFalse();
|
||||
expect($parsed->hasValue('missing'))->toBeFalse();
|
||||
});
|
||||
|
||||
it('validates required arguments', function () {
|
||||
$definitions = [
|
||||
'name' => new ArgumentDefinition('name', ArgumentType::STRING, required: true),
|
||||
'optional' => new ArgumentDefinition('optional', ArgumentType::STRING),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['name' => 'John'], [], $definitions);
|
||||
|
||||
// Should not throw
|
||||
$parsed->validate();
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('throws exception when required argument is missing', function () {
|
||||
$definitions = [
|
||||
'name' => new ArgumentDefinition('name', ArgumentType::STRING, required: true),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments([], [], $definitions);
|
||||
|
||||
expect(fn () => $parsed->validate())
|
||||
->toThrow(\InvalidArgumentException::class, "Required argument 'name' is missing");
|
||||
});
|
||||
|
||||
it('validates allowed values', function () {
|
||||
$definitions = [
|
||||
'mode' => new ArgumentDefinition('mode', ArgumentType::STRING, allowedValues: ['dev', 'prod', 'test']),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['mode' => 'dev'], [], $definitions);
|
||||
|
||||
// Should not throw
|
||||
$parsed->validate();
|
||||
expect(true)->toBeTrue();
|
||||
});
|
||||
|
||||
it('throws exception for invalid allowed value', function () {
|
||||
$definitions = [
|
||||
'mode' => new ArgumentDefinition('mode', ArgumentType::STRING, allowedValues: ['dev', 'prod', 'test']),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['mode' => 'invalid'], [], $definitions);
|
||||
|
||||
expect(fn () => $parsed->validate())
|
||||
->toThrow(\InvalidArgumentException::class, "Invalid value 'invalid'");
|
||||
});
|
||||
|
||||
it('returns all arguments', function () {
|
||||
$parsed = new ParsedArguments(
|
||||
['arg1' => 'value1', 'arg2' => 'value2'],
|
||||
[],
|
||||
[]
|
||||
);
|
||||
|
||||
$all = $parsed->getAllArguments();
|
||||
expect($all)->toHaveKey('arg1');
|
||||
expect($all)->toHaveKey('arg2');
|
||||
expect($all['arg1'])->toBe('value1');
|
||||
});
|
||||
|
||||
it('returns all options', function () {
|
||||
$parsed = new ParsedArguments(
|
||||
[],
|
||||
['opt1' => 'value1', 'opt2' => 'value2'],
|
||||
[]
|
||||
);
|
||||
|
||||
$all = $parsed->getAllOptions();
|
||||
expect($all)->toHaveKey('opt1');
|
||||
expect($all)->toHaveKey('opt2');
|
||||
expect($all['opt1'])->toBe('value1');
|
||||
});
|
||||
|
||||
it('returns all definitions', function () {
|
||||
$definitions = [
|
||||
'name' => new ArgumentDefinition('name', ArgumentType::STRING),
|
||||
'count' => new ArgumentDefinition('count', ArgumentType::INTEGER),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments([], [], $definitions);
|
||||
|
||||
$all = $parsed->getDefinitions();
|
||||
expect($all)->toHaveKey('name');
|
||||
expect($all)->toHaveKey('count');
|
||||
});
|
||||
|
||||
it('handles empty string as missing value', function () {
|
||||
$definitions = [
|
||||
'name' => new ArgumentDefinition('name', ArgumentType::STRING, required: true),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['name' => ''], [], $definitions);
|
||||
|
||||
expect(fn () => $parsed->validate())
|
||||
->toThrow(\InvalidArgumentException::class, "Required argument 'name' is missing");
|
||||
});
|
||||
|
||||
it('handles boolean false correctly', function () {
|
||||
$definitions = [
|
||||
'active' => new ArgumentDefinition('active', ArgumentType::BOOLEAN),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['active' => false], [], $definitions);
|
||||
|
||||
expect($parsed->getBool('active'))->toBeFalse();
|
||||
expect($parsed->hasValue('active'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('handles empty array correctly', function () {
|
||||
$definitions = [
|
||||
'items' => new ArgumentDefinition('items', ArgumentType::ARRAY),
|
||||
];
|
||||
|
||||
$parsed = new ParsedArguments(['items' => []], [], $definitions);
|
||||
|
||||
expect($parsed->getArray('items'))->toBe([]);
|
||||
// Empty array is considered a value (it's not null or empty string)
|
||||
expect($parsed->hasValue('items'))->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
224
tests/Framework/Console/Progress/ProgressTrackerTest.php
Normal file
224
tests/Framework/Console/Progress/ProgressTrackerTest.php
Normal 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');
|
||||
});
|
||||
});
|
||||
|
||||
217
tests/Framework/Console/ProgressBarTest.php
Normal file
217
tests/Framework/Console/ProgressBarTest.php
Normal file
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\ProgressBar;
|
||||
use Tests\Framework\Console\Helpers\TestConsoleOutput;
|
||||
|
||||
describe('ProgressBar', function () {
|
||||
beforeEach(function () {
|
||||
$this->output = new TestConsoleOutput();
|
||||
});
|
||||
|
||||
it('creates progress bar with default values', function () {
|
||||
$bar = new ProgressBar($this->output);
|
||||
|
||||
expect($bar)->toBeInstanceOf(ProgressBar::class);
|
||||
});
|
||||
|
||||
it('creates progress bar with custom total and width', function () {
|
||||
$bar = new ProgressBar($this->output, total: 200, width: 80);
|
||||
|
||||
expect($bar)->toBeInstanceOf(ProgressBar::class);
|
||||
});
|
||||
|
||||
it('ensures total is at least 1', function () {
|
||||
$bar = new ProgressBar($this->output, total: 0);
|
||||
|
||||
// Should not throw, total should be adjusted to 1
|
||||
expect($bar)->toBeInstanceOf(ProgressBar::class);
|
||||
});
|
||||
|
||||
it('sets format', function () {
|
||||
$bar = new ProgressBar($this->output);
|
||||
$bar->setFormat('%bar% %current%/%total%');
|
||||
|
||||
// Format should be set and placeholders should be replaced
|
||||
$bar->start();
|
||||
$bar->advance(10);
|
||||
$bar->finish();
|
||||
|
||||
$output = $this->output->getOutput();
|
||||
// Placeholders should be replaced with actual values
|
||||
expect($output)->toContain('10');
|
||||
expect($output)->toContain('100');
|
||||
});
|
||||
|
||||
it('sets bar characters', function () {
|
||||
$bar = new ProgressBar($this->output);
|
||||
$bar->setBarCharacters('#', '.', '>');
|
||||
|
||||
$bar->start();
|
||||
$bar->advance(10);
|
||||
$bar->finish();
|
||||
|
||||
// Should use custom characters
|
||||
expect($this->output->getOutput())->toContain('#');
|
||||
});
|
||||
|
||||
it('sets redraw frequency', function () {
|
||||
$bar = new ProgressBar($this->output);
|
||||
$bar->setRedrawFrequency(5);
|
||||
|
||||
// Should not throw
|
||||
expect($bar)->toBeInstanceOf(ProgressBar::class);
|
||||
});
|
||||
|
||||
it('ensures redraw frequency is at least 1', function () {
|
||||
$bar = new ProgressBar($this->output);
|
||||
$bar->setRedrawFrequency(0);
|
||||
|
||||
// Should not throw, frequency should be adjusted to 1
|
||||
expect($bar)->toBeInstanceOf(ProgressBar::class);
|
||||
});
|
||||
|
||||
it('advances progress', function () {
|
||||
$bar = new ProgressBar($this->output, total: 100);
|
||||
$bar->start();
|
||||
$bar->advance(10);
|
||||
$bar->finish();
|
||||
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('advances by custom step', function () {
|
||||
$bar = new ProgressBar($this->output, total: 100);
|
||||
$bar->start();
|
||||
$bar->advance(25);
|
||||
$bar->finish();
|
||||
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('sets progress directly', function () {
|
||||
$bar = new ProgressBar($this->output, total: 100);
|
||||
$bar->start();
|
||||
$bar->setCurrent(50);
|
||||
$bar->finish();
|
||||
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('does not exceed total', function () {
|
||||
$bar = new ProgressBar($this->output, total: 100);
|
||||
$bar->start();
|
||||
$bar->setCurrent(150);
|
||||
$bar->finish();
|
||||
|
||||
// Should cap at 100
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('does not go below zero', function () {
|
||||
$bar = new ProgressBar($this->output, total: 100);
|
||||
$bar->start();
|
||||
$bar->setCurrent(-10);
|
||||
$bar->finish();
|
||||
|
||||
// Should cap at 0
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('starts progress bar', function () {
|
||||
$bar = new ProgressBar($this->output, total: 100);
|
||||
$bar->start();
|
||||
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('finishes progress bar', function () {
|
||||
$bar = new ProgressBar($this->output, total: 100);
|
||||
$bar->start();
|
||||
$bar->advance(50);
|
||||
$bar->finish();
|
||||
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
expect($this->output->newLineCount)->toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('completes progress when finishing', function () {
|
||||
$bar = new ProgressBar($this->output, total: 100);
|
||||
$bar->start();
|
||||
$bar->advance(50);
|
||||
$bar->finish();
|
||||
|
||||
// Should complete to 100%
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('formats progress with all placeholders', function () {
|
||||
$bar = new ProgressBar($this->output, total: 100);
|
||||
$bar->setFormat('%bar% %current%/%total% %percent%% %elapsed%s %remaining%s');
|
||||
$bar->start();
|
||||
$bar->advance(50);
|
||||
$bar->finish();
|
||||
|
||||
$output = $this->output->getOutput();
|
||||
// Placeholders should be replaced with actual values
|
||||
expect($output)->toContain('50');
|
||||
expect($output)->toContain('100');
|
||||
expect($output)->toContain('%'); // Percent sign should be present
|
||||
});
|
||||
|
||||
it('handles edge case with total of 1', function () {
|
||||
$bar = new ProgressBar($this->output, total: 1);
|
||||
$bar->start();
|
||||
$bar->advance(1);
|
||||
$bar->finish();
|
||||
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('handles very large total', function () {
|
||||
$bar = new ProgressBar($this->output, total: 1000000);
|
||||
$bar->start();
|
||||
$bar->advance(100000);
|
||||
$bar->finish();
|
||||
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('respects redraw frequency', function () {
|
||||
$bar = new ProgressBar($this->output, total: 100);
|
||||
$bar->setRedrawFrequency(10);
|
||||
$bar->start();
|
||||
|
||||
// Advance multiple times - should only redraw every 10th time
|
||||
for ($i = 0; $i < 25; $i++) {
|
||||
$bar->advance(1);
|
||||
}
|
||||
|
||||
$bar->finish();
|
||||
|
||||
// Should have output
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('always redraws on finish', function () {
|
||||
$bar = new ProgressBar($this->output, total: 100);
|
||||
$bar->setRedrawFrequency(100);
|
||||
$bar->start();
|
||||
$bar->advance(50);
|
||||
$bar->finish();
|
||||
|
||||
// Should redraw on finish even if frequency not reached
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('handles negative total by adjusting to 1', function () {
|
||||
$bar = new ProgressBar($this->output, total: -10);
|
||||
|
||||
// Should not throw, total should be adjusted to 1
|
||||
expect($bar)->toBeInstanceOf(ProgressBar::class);
|
||||
});
|
||||
});
|
||||
|
||||
243
tests/Framework/Console/SpinnerTest.php
Normal file
243
tests/Framework/Console/SpinnerTest.php
Normal file
@@ -0,0 +1,243 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Console;
|
||||
|
||||
use App\Framework\Console\ConsoleColor;
|
||||
use App\Framework\Console\Spinner;
|
||||
use App\Framework\Console\SpinnerStyle;
|
||||
use Tests\Framework\Console\Helpers\TestConsoleOutput;
|
||||
|
||||
describe('Spinner', function () {
|
||||
beforeEach(function () {
|
||||
$this->output = new TestConsoleOutput();
|
||||
});
|
||||
|
||||
it('creates spinner with default values', function () {
|
||||
$spinner = new Spinner($this->output);
|
||||
|
||||
expect($spinner)->toBeInstanceOf(Spinner::class);
|
||||
});
|
||||
|
||||
it('creates spinner with custom message', function () {
|
||||
$spinner = new Spinner($this->output, 'Processing...');
|
||||
|
||||
expect($spinner)->toBeInstanceOf(Spinner::class);
|
||||
});
|
||||
|
||||
it('creates spinner with custom style', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...', SpinnerStyle::ARROW);
|
||||
|
||||
expect($spinner)->toBeInstanceOf(Spinner::class);
|
||||
});
|
||||
|
||||
it('creates spinner with custom interval', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...', SpinnerStyle::DOTS, 0.2);
|
||||
|
||||
expect($spinner)->toBeInstanceOf(Spinner::class);
|
||||
});
|
||||
|
||||
it('starts spinner', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...');
|
||||
$spinner->start();
|
||||
|
||||
// Spinner uses write() which is captured in capturedWrites
|
||||
// But it may not write immediately if interval hasn't passed
|
||||
expect($this->output->capturedWrites)->toBeArray();
|
||||
});
|
||||
|
||||
it('updates spinner frames', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...', \App\Framework\Console\SpinnerStyle::DOTS, 0.01);
|
||||
$spinner->start();
|
||||
usleep(20000); // Wait 20ms to trigger update
|
||||
$spinner->update();
|
||||
|
||||
// Spinner may write if enough time has passed
|
||||
expect($this->output->capturedWrites)->toBeArray();
|
||||
});
|
||||
|
||||
it('stops spinner without message', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...');
|
||||
$spinner->start();
|
||||
$spinner->stop();
|
||||
|
||||
expect($this->output->capturedWrites)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('stops spinner with message', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...');
|
||||
$spinner->start();
|
||||
$spinner->stop('Done!');
|
||||
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
expect($this->output->getLastLine())->toContain('Done!');
|
||||
});
|
||||
|
||||
it('stops spinner with message and color', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...');
|
||||
$spinner->start();
|
||||
$spinner->stop('Done!', ConsoleColor::GREEN);
|
||||
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('stops spinner with success message', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...');
|
||||
$spinner->start();
|
||||
$spinner->success('Completed successfully!');
|
||||
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
$output = $this->output->getOutput();
|
||||
expect($output)->toContain('✓');
|
||||
expect($output)->toContain('Completed successfully!');
|
||||
});
|
||||
|
||||
it('stops spinner with error message', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...');
|
||||
$spinner->start();
|
||||
$spinner->error('Failed to complete!');
|
||||
|
||||
expect($this->output->capturedLines)->not->toBeEmpty();
|
||||
$output = $this->output->getOutput();
|
||||
expect($output)->toContain('✗');
|
||||
expect($output)->toContain('Failed to complete!');
|
||||
});
|
||||
|
||||
it('sets message while running', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...');
|
||||
$spinner->start();
|
||||
$spinner->setMessage('Processing...');
|
||||
|
||||
// setMessage calls update() which may write if interval passed
|
||||
expect($this->output->capturedWrites)->toBeArray();
|
||||
});
|
||||
|
||||
it('does not update when not active', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...');
|
||||
$spinner->update();
|
||||
|
||||
// Should not write anything when not active
|
||||
expect($this->output->capturedWrites)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('clears spinner', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...');
|
||||
$spinner->start();
|
||||
$spinner->clear();
|
||||
|
||||
expect($this->output->capturedWrites)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('works with DOTS style', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...', SpinnerStyle::DOTS, 0.01);
|
||||
$spinner->start();
|
||||
usleep(20000);
|
||||
$spinner->update();
|
||||
|
||||
expect($this->output->capturedWrites)->toBeArray();
|
||||
});
|
||||
|
||||
it('works with LINE style', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...', SpinnerStyle::LINE, 0.01);
|
||||
$spinner->start();
|
||||
usleep(20000);
|
||||
$spinner->update();
|
||||
|
||||
expect($this->output->capturedWrites)->toBeArray();
|
||||
});
|
||||
|
||||
it('works with BOUNCE style', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...', SpinnerStyle::BOUNCE, 0.01);
|
||||
$spinner->start();
|
||||
usleep(20000);
|
||||
$spinner->update();
|
||||
|
||||
expect($this->output->capturedWrites)->toBeArray();
|
||||
});
|
||||
|
||||
it('works with ARROW style', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...', SpinnerStyle::ARROW, 0.01);
|
||||
$spinner->start();
|
||||
usleep(20000);
|
||||
$spinner->update();
|
||||
|
||||
expect($this->output->capturedWrites)->toBeArray();
|
||||
});
|
||||
|
||||
it('cycles through frames correctly', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...', SpinnerStyle::LINE);
|
||||
$spinner->start();
|
||||
|
||||
// LINE style has 4 frames
|
||||
$frames = SpinnerStyle::LINE->getFrames();
|
||||
expect($frames)->toHaveCount(4);
|
||||
});
|
||||
|
||||
it('handles very fast interval', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...', SpinnerStyle::DOTS, 0.01);
|
||||
$spinner->start();
|
||||
usleep(50000); // 50ms
|
||||
$spinner->update();
|
||||
|
||||
expect($this->output->capturedWrites)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('handles very slow interval', function () {
|
||||
$spinner = new Spinner($this->output, 'Loading...', SpinnerStyle::DOTS, 1.0);
|
||||
$spinner->start();
|
||||
$spinner->update();
|
||||
|
||||
// With slow interval, update may not write immediately
|
||||
expect($this->output->capturedWrites)->toBeArray();
|
||||
});
|
||||
});
|
||||
|
||||
describe('SpinnerStyle', function () {
|
||||
it('has all expected enum values', function () {
|
||||
expect(SpinnerStyle::DOTS->value)->toBe('dots');
|
||||
expect(SpinnerStyle::LINE->value)->toBe('line');
|
||||
expect(SpinnerStyle::BOUNCE->value)->toBe('bounce');
|
||||
expect(SpinnerStyle::ARROW->value)->toBe('arrow');
|
||||
});
|
||||
|
||||
it('provides frames for DOTS style', function () {
|
||||
$frames = SpinnerStyle::DOTS->getFrames();
|
||||
|
||||
expect($frames)->toBeArray();
|
||||
expect($frames)->not->toBeEmpty();
|
||||
expect($frames)->toContain('. ');
|
||||
expect($frames)->toContain('.. ');
|
||||
expect($frames)->toContain('...');
|
||||
});
|
||||
|
||||
it('provides frames for LINE style', function () {
|
||||
$frames = SpinnerStyle::LINE->getFrames();
|
||||
|
||||
expect($frames)->toBeArray();
|
||||
expect($frames)->toHaveCount(4);
|
||||
expect($frames)->toContain('|');
|
||||
expect($frames)->toContain('/');
|
||||
expect($frames)->toContain('-');
|
||||
expect($frames)->toContain('\\');
|
||||
});
|
||||
|
||||
it('provides frames for BOUNCE style', function () {
|
||||
$frames = SpinnerStyle::BOUNCE->getFrames();
|
||||
|
||||
expect($frames)->toBeArray();
|
||||
expect($frames)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('provides frames for ARROW style', function () {
|
||||
$frames = SpinnerStyle::ARROW->getFrames();
|
||||
|
||||
expect($frames)->toBeArray();
|
||||
expect($frames)->toHaveCount(8);
|
||||
expect($frames)->toContain('←');
|
||||
expect($frames)->toContain('↑');
|
||||
expect($frames)->toContain('→');
|
||||
expect($frames)->toContain('↓');
|
||||
});
|
||||
});
|
||||
|
||||
372
tests/Framework/Discovery/DiscoveryCacheIntegrationTest.php
Normal file
372
tests/Framework/Discovery/DiscoveryCacheIntegrationTest.php
Normal file
@@ -0,0 +1,372 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Discovery;
|
||||
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\Driver\InMemoryCache;
|
||||
use App\Framework\Cache\GeneralCache;
|
||||
use App\Framework\Console\ConsoleCommand;
|
||||
use App\Framework\Core\PathProvider;
|
||||
use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\Discovery\DiscoveryServiceBootstrapper;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Discovery\UnifiedDiscoveryService;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveryConfiguration;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveryOptions;
|
||||
use App\Framework\Discovery\ValueObjects\ScanType;
|
||||
use App\Framework\ReflectionLegacy\CachedReflectionProvider;
|
||||
use App\Framework\Serializer\Php\PhpSerializer;
|
||||
use App\Framework\Serializer\Php\PhpSerializerConfig;
|
||||
|
||||
describe('Discovery + Caching Integration', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$basePath = file_exists('/var/www/html/src') ? '/var/www/html' : '/home/michael/dev/michaelschiemer';
|
||||
$this->pathProvider = new PathProvider($basePath);
|
||||
$this->reflectionProvider = new CachedReflectionProvider();
|
||||
|
||||
$this->configuration = new DiscoveryConfiguration(
|
||||
paths: [$this->pathProvider->getSourcePath()],
|
||||
useCache: true,
|
||||
enableMemoryMonitoring: false,
|
||||
memoryLimitMB: 128,
|
||||
maxFilesPerBatch: 100
|
||||
);
|
||||
|
||||
// Clear cache between tests
|
||||
$this->cache->clear();
|
||||
});
|
||||
|
||||
it('uses cache when discovery is performed with cache enabled', function () {
|
||||
$service = new UnifiedDiscoveryService(
|
||||
pathProvider: $this->pathProvider,
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
reflectionProvider: $this->reflectionProvider,
|
||||
configuration: $this->configuration
|
||||
);
|
||||
|
||||
// First discovery - should cache
|
||||
$registry1 = $service->discover();
|
||||
expect($registry1)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
|
||||
// Second discovery - should use cache
|
||||
$registry2 = $service->discover();
|
||||
expect($registry2)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
|
||||
// Results should be equivalent
|
||||
expect($registry1->isEmpty())->toBe($registry2->isEmpty());
|
||||
});
|
||||
|
||||
it('performs fresh discovery when cache is disabled', function () {
|
||||
$noCacheConfig = new DiscoveryConfiguration(
|
||||
paths: [$this->pathProvider->getSourcePath()],
|
||||
useCache: false,
|
||||
enableMemoryMonitoring: false,
|
||||
memoryLimitMB: 128,
|
||||
maxFilesPerBatch: 100
|
||||
);
|
||||
|
||||
$service = new UnifiedDiscoveryService(
|
||||
pathProvider: $this->pathProvider,
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
reflectionProvider: $this->reflectionProvider,
|
||||
configuration: $noCacheConfig
|
||||
);
|
||||
|
||||
$registry = $service->discover();
|
||||
|
||||
expect($registry)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('invalidates cache when source files change', function () {
|
||||
$service = new UnifiedDiscoveryService(
|
||||
pathProvider: $this->pathProvider,
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
reflectionProvider: $this->reflectionProvider,
|
||||
configuration: $this->configuration
|
||||
);
|
||||
|
||||
// First discovery
|
||||
$registry1 = $service->discover();
|
||||
expect($registry1)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
|
||||
// Clear cache to simulate invalidation
|
||||
$this->cache->clear();
|
||||
|
||||
// Second discovery should perform fresh scan
|
||||
$registry2 = $service->discover();
|
||||
expect($registry2)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('handles incremental discovery with caching', function () {
|
||||
$service = new UnifiedDiscoveryService(
|
||||
pathProvider: $this->pathProvider,
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
reflectionProvider: $this->reflectionProvider,
|
||||
configuration: $this->configuration
|
||||
);
|
||||
|
||||
// Full discovery first
|
||||
$fullRegistry = $service->discover();
|
||||
expect($fullRegistry)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
|
||||
// Incremental discovery
|
||||
$incrementalRegistry = $service->incrementalDiscover();
|
||||
expect($incrementalRegistry)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('maintains cache consistency across multiple discovery calls', function () {
|
||||
$service = new UnifiedDiscoveryService(
|
||||
pathProvider: $this->pathProvider,
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
reflectionProvider: $this->reflectionProvider,
|
||||
configuration: $this->configuration
|
||||
);
|
||||
|
||||
// Multiple discoveries
|
||||
$registry1 = $service->discover();
|
||||
$registry2 = $service->discover();
|
||||
$registry3 = $service->discover();
|
||||
|
||||
expect($registry1)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
expect($registry2)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
expect($registry3)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
|
||||
// All should have same structure
|
||||
expect($registry1->isEmpty())->toBe($registry2->isEmpty());
|
||||
expect($registry2->isEmpty())->toBe($registry3->isEmpty());
|
||||
});
|
||||
|
||||
it('handles cache misses gracefully', function () {
|
||||
$service = new UnifiedDiscoveryService(
|
||||
pathProvider: $this->pathProvider,
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
reflectionProvider: $this->reflectionProvider,
|
||||
configuration: $this->configuration
|
||||
);
|
||||
|
||||
// Ensure cache is empty
|
||||
$this->cache->clear();
|
||||
|
||||
// Should perform fresh discovery
|
||||
$registry = $service->discover();
|
||||
|
||||
expect($registry)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
expect($registry->isEmpty())->toBeFalse();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Discovery + Caching Performance', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$basePath = file_exists('/var/www/html/src') ? '/var/www/html' : '/home/michael/dev/michaelschiemer';
|
||||
$this->pathProvider = new PathProvider($basePath);
|
||||
$this->reflectionProvider = new CachedReflectionProvider();
|
||||
|
||||
$this->configuration = new DiscoveryConfiguration(
|
||||
paths: [$this->pathProvider->getSourcePath()],
|
||||
useCache: true,
|
||||
enableMemoryMonitoring: false,
|
||||
memoryLimitMB: 128,
|
||||
maxFilesPerBatch: 100
|
||||
);
|
||||
|
||||
$this->cache->clear();
|
||||
});
|
||||
|
||||
it('improves performance with cache on subsequent calls', function () {
|
||||
$service = new UnifiedDiscoveryService(
|
||||
pathProvider: $this->pathProvider,
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
reflectionProvider: $this->reflectionProvider,
|
||||
configuration: $this->configuration
|
||||
);
|
||||
|
||||
// First call - no cache
|
||||
$start1 = microtime(true);
|
||||
$registry1 = $service->discover();
|
||||
$time1 = microtime(true) - $start1;
|
||||
|
||||
expect($registry1)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
|
||||
// Second call - with cache
|
||||
$start2 = microtime(true);
|
||||
$registry2 = $service->discover();
|
||||
$time2 = microtime(true) - $start2;
|
||||
|
||||
expect($registry2)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
|
||||
// Cached call should be faster (or at least not slower)
|
||||
// Note: In some cases, the difference might be minimal, so we just verify it completes
|
||||
expect($time2)->toBeLessThanOrEqual($time1 + 0.1); // Allow small margin
|
||||
});
|
||||
|
||||
it('handles large codebase discovery with caching', function () {
|
||||
$service = new UnifiedDiscoveryService(
|
||||
pathProvider: $this->pathProvider,
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
reflectionProvider: $this->reflectionProvider,
|
||||
configuration: $this->configuration
|
||||
);
|
||||
|
||||
$start = microtime(true);
|
||||
$registry = $service->discover();
|
||||
$duration = microtime(true) - $start;
|
||||
|
||||
expect($registry)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
expect($duration)->toBeLessThan(30.0); // Should complete within 30 seconds
|
||||
});
|
||||
});
|
||||
|
||||
describe('Discovery + Caching with DiscoveryServiceBootstrapper', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$basePath = file_exists('/var/www/html/src') ? '/var/www/html' : '/home/michael/dev/michaelschiemer';
|
||||
$this->pathProvider = new PathProvider($basePath);
|
||||
|
||||
$this->container = new \App\Framework\DI\DefaultContainer();
|
||||
$this->container->singleton(Cache::class, $this->cache);
|
||||
$this->container->singleton(\App\Framework\DateTime\Clock::class, $this->clock);
|
||||
$this->container->singleton(PathProvider::class, $this->pathProvider);
|
||||
|
||||
$this->cache->clear();
|
||||
});
|
||||
|
||||
it('bootstraps discovery with caching', function () {
|
||||
$bootstrapper = new DiscoveryServiceBootstrapper($this->container, $this->clock);
|
||||
|
||||
$registry = $bootstrapper->bootstrap();
|
||||
|
||||
expect($registry)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
expect($registry->isEmpty())->toBeFalse();
|
||||
});
|
||||
|
||||
it('uses cached results on subsequent bootstrap calls', function () {
|
||||
$bootstrapper = new DiscoveryServiceBootstrapper($this->container, $this->clock);
|
||||
|
||||
// First bootstrap
|
||||
$registry1 = $bootstrapper->bootstrap();
|
||||
expect($registry1)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
|
||||
// Second bootstrap should use cache
|
||||
$registry2 = $bootstrapper->bootstrap();
|
||||
expect($registry2)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
|
||||
// Results should be equivalent
|
||||
expect($registry1->isEmpty())->toBe($registry2->isEmpty());
|
||||
});
|
||||
|
||||
it('handles incremental bootstrap with caching', function () {
|
||||
$bootstrapper = new DiscoveryServiceBootstrapper($this->container, $this->clock);
|
||||
|
||||
// Full bootstrap first
|
||||
$fullRegistry = $bootstrapper->bootstrap();
|
||||
expect($fullRegistry)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
|
||||
// Incremental bootstrap
|
||||
$incrementalRegistry = $bootstrapper->incrementalBootstrap();
|
||||
expect($incrementalRegistry)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('clears cache when requested', function () {
|
||||
$bootstrapper = new DiscoveryServiceBootstrapper($this->container, $this->clock);
|
||||
|
||||
// Bootstrap and cache
|
||||
$registry1 = $bootstrapper->bootstrap();
|
||||
expect($registry1)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
|
||||
// Clear cache
|
||||
$this->cache->clear();
|
||||
|
||||
// Next bootstrap should perform fresh discovery
|
||||
$registry2 = $bootstrapper->bootstrap();
|
||||
expect($registry2)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Discovery + Caching Error Handling', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$basePath = file_exists('/var/www/html/src') ? '/var/www/html' : '/home/michael/dev/michaelschiemer';
|
||||
$this->pathProvider = new PathProvider($basePath);
|
||||
$this->reflectionProvider = new CachedReflectionProvider();
|
||||
|
||||
$this->configuration = new DiscoveryConfiguration(
|
||||
paths: [$this->pathProvider->getSourcePath()],
|
||||
useCache: true,
|
||||
enableMemoryMonitoring: false,
|
||||
memoryLimitMB: 128,
|
||||
maxFilesPerBatch: 100
|
||||
);
|
||||
});
|
||||
|
||||
it('handles corrupted cache data gracefully', function () {
|
||||
$service = new UnifiedDiscoveryService(
|
||||
pathProvider: $this->pathProvider,
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
reflectionProvider: $this->reflectionProvider,
|
||||
configuration: $this->configuration
|
||||
);
|
||||
|
||||
// Store invalid data in cache
|
||||
$options = new DiscoveryOptions(
|
||||
scanType: ScanType::FULL,
|
||||
paths: [$this->pathProvider->getSourcePath()],
|
||||
useCache: true
|
||||
);
|
||||
$context = new \App\Framework\Discovery\ValueObjects\DiscoveryContext(
|
||||
paths: [$this->pathProvider->getSourcePath()],
|
||||
scanType: ScanType::FULL,
|
||||
options: $options,
|
||||
startTime: $this->clock->now()
|
||||
);
|
||||
$key = $context->getCacheKey();
|
||||
$invalidItem = \App\Framework\Cache\CacheItem::forSet($key, 'corrupted_data');
|
||||
$this->cache->set($invalidItem);
|
||||
|
||||
// Should handle gracefully and perform fresh discovery
|
||||
$registry = $service->discover();
|
||||
|
||||
expect($registry)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('handles cache write failures gracefully', function () {
|
||||
// This is harder to test directly, but we can verify the service still works
|
||||
$service = new UnifiedDiscoveryService(
|
||||
pathProvider: $this->pathProvider,
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
reflectionProvider: $this->reflectionProvider,
|
||||
configuration: $this->configuration
|
||||
);
|
||||
|
||||
// Should still work even if cache write fails
|
||||
$registry = $service->discover();
|
||||
|
||||
expect($registry)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
});
|
||||
|
||||
853
tests/Framework/Discovery/DiscoveryCacheManagerTest.php
Normal file
853
tests/Framework/Discovery/DiscoveryCacheManagerTest.php
Normal file
@@ -0,0 +1,853 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Framework\Discovery;
|
||||
|
||||
use Mockery;
|
||||
use App\Framework\Cache\Cache;
|
||||
use App\Framework\Cache\CacheItem;
|
||||
use App\Framework\Cache\CacheKey;
|
||||
use App\Framework\Cache\CachePrefix;
|
||||
use App\Framework\Cache\CacheResult;
|
||||
use App\Framework\Cache\Driver\InMemoryCache;
|
||||
use App\Framework\Cache\GeneralCache;
|
||||
use App\Framework\Core\Events\EventDispatcher;
|
||||
use App\Framework\Core\ValueObjects\Byte;
|
||||
use App\Framework\Core\ValueObjects\Percentage;
|
||||
use App\Framework\Core\ValueObjects\Timestamp;
|
||||
use App\Framework\DateTime\Clock;
|
||||
use App\Framework\DateTime\SystemClock;
|
||||
use App\Framework\Discovery\Events\CacheCompressionEvent;
|
||||
use App\Framework\Discovery\Events\CacheHitEvent;
|
||||
use App\Framework\Discovery\Events\CacheMissEvent;
|
||||
use App\Framework\Discovery\Memory\DiscoveryMemoryManager;
|
||||
use App\Framework\Discovery\Memory\MemoryStatus;
|
||||
use App\Framework\Discovery\Memory\MemoryStatusInfo;
|
||||
use App\Framework\Discovery\Results\AttributeRegistry;
|
||||
use App\Framework\Discovery\Results\DiscoveryRegistry;
|
||||
use App\Framework\Discovery\Results\InterfaceRegistry;
|
||||
use App\Framework\Discovery\Results\TemplateRegistry;
|
||||
use App\Framework\Discovery\Storage\DiscoveryCacheManager;
|
||||
use App\Framework\Discovery\ValueObjects\CacheLevel;
|
||||
use App\Framework\Discovery\ValueObjects\CacheTier;
|
||||
use App\Framework\Discovery\ValueObjects\CompressionLevel;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveryContext;
|
||||
use App\Framework\Discovery\ValueObjects\DiscoveryOptions;
|
||||
use App\Framework\Discovery\ValueObjects\MemoryStrategy;
|
||||
use App\Framework\Discovery\ValueObjects\ScanType;
|
||||
use App\Framework\Filesystem\FileSystemService;
|
||||
use App\Framework\Filesystem\ValueObjects\FilePath;
|
||||
use App\Framework\Filesystem\ValueObjects\FileMetadata;
|
||||
use App\Framework\Serializer\Php\PhpSerializer;
|
||||
use App\Framework\Serializer\Php\PhpSerializerConfig;
|
||||
|
||||
describe('DiscoveryCacheManager - Basic Operations', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$this->fileSystemService = new FileSystemService();
|
||||
|
||||
// Use a real existing path to avoid stale detection issues
|
||||
$basePath = file_exists('/var/www/html/src') ? '/var/www/html' : '/home/michael/dev/michaelschiemer';
|
||||
$testPath = $basePath . '/src';
|
||||
|
||||
$this->cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService
|
||||
);
|
||||
|
||||
// Use a future time to avoid stale detection issues
|
||||
$futureTime = new \DateTimeImmutable('2099-01-01 00:00:00');
|
||||
$this->testContext = new DiscoveryContext(
|
||||
paths: [$testPath],
|
||||
scanType: ScanType::FULL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $futureTime
|
||||
);
|
||||
|
||||
$this->testRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry(),
|
||||
templates: new TemplateRegistry()
|
||||
);
|
||||
});
|
||||
|
||||
it('can be instantiated', function () {
|
||||
expect($this->cacheManager)->toBeInstanceOf(DiscoveryCacheManager::class);
|
||||
});
|
||||
|
||||
it('stores discovery results in cache', function () {
|
||||
$success = $this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
expect($success)->toBeTrue();
|
||||
|
||||
// Verify cache contains the data
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
expect($cached->isEmpty())->toBe($this->testRegistry->isEmpty());
|
||||
});
|
||||
|
||||
it('retrieves cached discovery results', function () {
|
||||
// Store first
|
||||
$this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
// Retrieve
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
expect($cached)->not->toBeNull();
|
||||
});
|
||||
|
||||
it('returns null for cache miss', function () {
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
|
||||
expect($cached)->toBeNull();
|
||||
});
|
||||
|
||||
it('invalidates cache for a context', function () {
|
||||
// Store first
|
||||
$this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
expect($this->cacheManager->get($this->testContext))->not->toBeNull();
|
||||
|
||||
// Invalidate
|
||||
$success = $this->cacheManager->invalidate($this->testContext);
|
||||
expect($success)->toBeTrue();
|
||||
|
||||
// Should be null now
|
||||
expect($this->cacheManager->get($this->testContext))->toBeNull();
|
||||
});
|
||||
|
||||
it('clears all discovery caches', function () {
|
||||
// Store multiple contexts
|
||||
$context1 = new DiscoveryContext(
|
||||
paths: ['/test/path1'],
|
||||
scanType: ScanType::FULL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $this->clock->now()
|
||||
);
|
||||
$context2 = new DiscoveryContext(
|
||||
paths: ['/test/path2'],
|
||||
scanType: ScanType::FULL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $this->clock->now()
|
||||
);
|
||||
|
||||
$this->cacheManager->store($context1, $this->testRegistry);
|
||||
$this->cacheManager->store($context2, $this->testRegistry);
|
||||
|
||||
expect($this->cacheManager->get($context1))->not->toBeNull();
|
||||
expect($this->cacheManager->get($context2))->not->toBeNull();
|
||||
|
||||
// Clear all
|
||||
$success = $this->cacheManager->clearAll();
|
||||
expect($success)->toBeTrue();
|
||||
|
||||
// Both should be null
|
||||
expect($this->cacheManager->get($context1))->toBeNull();
|
||||
expect($this->cacheManager->get($context2))->toBeNull();
|
||||
});
|
||||
|
||||
it('optimizes registry before caching', function () {
|
||||
$registry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry(),
|
||||
templates: new TemplateRegistry()
|
||||
);
|
||||
|
||||
// Store should call optimize internally
|
||||
$success = $this->cacheManager->store($this->testContext, $registry);
|
||||
expect($success)->toBeTrue();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiscoveryCacheManager - Cache Hit/Miss Scenarios', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$this->fileSystemService = new FileSystemService();
|
||||
|
||||
// Use a real existing path
|
||||
$basePath = file_exists('/var/www/html/src') ? '/var/www/html' : '/home/michael/dev/michaelschiemer';
|
||||
$testPath = $basePath . '/src';
|
||||
|
||||
$this->cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService
|
||||
);
|
||||
|
||||
$this->testContext = new DiscoveryContext(
|
||||
paths: ['/test/path'],
|
||||
scanType: ScanType::FULL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $this->clock->now()
|
||||
);
|
||||
|
||||
$this->testRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry(),
|
||||
templates: new TemplateRegistry()
|
||||
);
|
||||
});
|
||||
|
||||
it('handles cache hit correctly', function () {
|
||||
$this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
|
||||
expect($cached)->not->toBeNull();
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('handles cache miss when key does not exist', function () {
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
|
||||
expect($cached)->toBeNull();
|
||||
});
|
||||
|
||||
it('handles cache miss when data is corrupted', function () {
|
||||
// Store invalid data directly in cache
|
||||
$key = $this->testContext->getCacheKey();
|
||||
$invalidItem = CacheItem::forSet($key, 'invalid_data');
|
||||
$this->cache->set($invalidItem);
|
||||
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
|
||||
expect($cached)->toBeNull();
|
||||
});
|
||||
|
||||
it('handles cache miss when data type is wrong', function () {
|
||||
// Store wrong type
|
||||
$key = $this->testContext->getCacheKey();
|
||||
$wrongTypeItem = CacheItem::forSet($key, ['not' => 'a', 'registry' => 'object']);
|
||||
$this->cache->set($wrongTypeItem);
|
||||
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
|
||||
expect($cached)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiscoveryCacheManager - Stale Cache Detection', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
|
||||
// FileSystemService is final, so we need to use a real instance
|
||||
// For stale detection tests, we'll use a real path and manipulate timing
|
||||
$this->fileSystemService = new FileSystemService();
|
||||
|
||||
$this->cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService
|
||||
);
|
||||
|
||||
$this->testRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry(),
|
||||
templates: new TemplateRegistry()
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
Mockery::close();
|
||||
});
|
||||
|
||||
it('considers incremental scans as always stale', function () {
|
||||
$basePath = file_exists('/var/www/html/src') ? '/var/www/html' : '/home/michael/dev/michaelschiemer';
|
||||
$testPath = $basePath . '/src';
|
||||
|
||||
$incrementalContext = new DiscoveryContext(
|
||||
paths: [$testPath],
|
||||
scanType: ScanType::INCREMENTAL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $this->clock->now()
|
||||
);
|
||||
|
||||
$this->cacheManager->store($incrementalContext, $this->testRegistry);
|
||||
|
||||
// Incremental scans should always be considered stale
|
||||
$cached = $this->cacheManager->get($incrementalContext);
|
||||
|
||||
expect($cached)->toBeNull();
|
||||
});
|
||||
|
||||
it('detects stale cache when directory is modified', function () {
|
||||
// Use a real path that exists
|
||||
$basePath = file_exists('/var/www/html/src') ? '/var/www/html' : '/home/michael/dev/michaelschiemer';
|
||||
$testPath = $basePath . '/src';
|
||||
|
||||
$startTime = new \DateTimeImmutable('2020-01-01 00:00:00');
|
||||
$context = new DiscoveryContext(
|
||||
paths: [$testPath],
|
||||
scanType: ScanType::FULL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $startTime
|
||||
);
|
||||
|
||||
// Store cache
|
||||
$this->cacheManager->store($context, $this->testRegistry);
|
||||
|
||||
// The directory will have a modification time after startTime, so it should be stale
|
||||
// This test verifies that stale detection works
|
||||
$cached = $this->cacheManager->get($context);
|
||||
|
||||
// Should be stale because real directory modification time is after startTime
|
||||
expect($cached)->toBeNull();
|
||||
});
|
||||
|
||||
it('considers cache fresh when directory is not modified', function () {
|
||||
// Use a real path that exists
|
||||
$basePath = file_exists('/var/www/html/src') ? '/var/www/html' : '/home/michael/dev/michaelschiemer';
|
||||
$testPath = $basePath . '/src';
|
||||
|
||||
// Use a future time so the directory modification time is before it
|
||||
$futureTime = new \DateTimeImmutable('2099-01-01 00:00:00');
|
||||
$context = new DiscoveryContext(
|
||||
paths: [$testPath],
|
||||
scanType: ScanType::FULL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $futureTime
|
||||
);
|
||||
|
||||
// Store cache
|
||||
$this->cacheManager->store($context, $this->testRegistry);
|
||||
|
||||
// Should be fresh because directory modification time is before futureTime
|
||||
$cached = $this->cacheManager->get($context);
|
||||
|
||||
expect($cached)->not->toBeNull();
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('handles file system errors gracefully in stale detection', function () {
|
||||
// Use a non-existent path to trigger file system error
|
||||
$context = new DiscoveryContext(
|
||||
paths: ['/nonexistent/path/that/does/not/exist'],
|
||||
scanType: ScanType::FULL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $this->clock->now()
|
||||
);
|
||||
|
||||
$this->cacheManager->store($context, $this->testRegistry);
|
||||
|
||||
// Should assume stale on error (non-existent path throws exception)
|
||||
$cached = $this->cacheManager->get($context);
|
||||
|
||||
expect($cached)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiscoveryCacheManager - Health Status', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$this->fileSystemService = new FileSystemService();
|
||||
|
||||
$this->cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService
|
||||
);
|
||||
});
|
||||
|
||||
it('returns health status without memory manager', function () {
|
||||
$health = $this->cacheManager->getHealthStatus();
|
||||
|
||||
expect($health)->toBeArray();
|
||||
expect($health)->toHaveKey('cache_driver');
|
||||
expect($health)->toHaveKey('ttl_hours');
|
||||
expect($health)->toHaveKey('prefix');
|
||||
expect($health)->toHaveKey('memory_aware');
|
||||
expect($health['memory_aware'])->toBeFalse();
|
||||
});
|
||||
|
||||
it('returns health status with memory manager', function () {
|
||||
$memoryManager = new DiscoveryMemoryManager(
|
||||
strategy: MemoryStrategy::BATCH,
|
||||
memoryLimit: Byte::fromMegabytes(128),
|
||||
memoryPressureThreshold: 0.8,
|
||||
memoryMonitor: null,
|
||||
logger: null,
|
||||
eventDispatcher: null,
|
||||
clock: $this->clock
|
||||
);
|
||||
|
||||
$cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService,
|
||||
logger: null,
|
||||
ttlHours: 24,
|
||||
memoryManager: $memoryManager
|
||||
);
|
||||
|
||||
$health = $cacheManager->getHealthStatus();
|
||||
|
||||
expect($health)->toBeArray();
|
||||
expect($health['memory_aware'])->toBeTrue();
|
||||
expect($health)->toHaveKey('memory_management');
|
||||
expect($health['memory_management'])->toBeArray();
|
||||
expect($health['memory_management'])->toHaveKey('status');
|
||||
expect($health['memory_management'])->toHaveKey('current_usage');
|
||||
expect($health['memory_management'])->toHaveKey('memory_pressure');
|
||||
expect($health['memory_management'])->toHaveKey('cache_level');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiscoveryCacheManager - Memory-Aware Caching', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$this->fileSystemService = new FileSystemService();
|
||||
|
||||
$this->memoryManager = new DiscoveryMemoryManager(
|
||||
strategy: MemoryStrategy::BATCH,
|
||||
memoryLimit: Byte::fromMegabytes(128),
|
||||
memoryPressureThreshold: 0.8,
|
||||
memoryMonitor: null,
|
||||
logger: null,
|
||||
eventDispatcher: null,
|
||||
clock: $this->clock
|
||||
);
|
||||
|
||||
$this->cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService,
|
||||
logger: null,
|
||||
ttlHours: 24,
|
||||
memoryManager: $this->memoryManager
|
||||
);
|
||||
|
||||
$this->testContext = new DiscoveryContext(
|
||||
paths: ['/test/path'],
|
||||
scanType: ScanType::FULL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $this->clock->now()
|
||||
);
|
||||
|
||||
$this->testRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry(),
|
||||
templates: new TemplateRegistry()
|
||||
);
|
||||
});
|
||||
|
||||
it('uses memory-aware storage when memory manager is available', function () {
|
||||
$success = $this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
expect($success)->toBeTrue();
|
||||
|
||||
// Should be retrievable
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('determines cache level based on memory pressure', function () {
|
||||
// This is tested indirectly through storage, but we can verify health status
|
||||
$health = $this->cacheManager->getHealthStatus();
|
||||
|
||||
expect($health['memory_aware'])->toBeTrue();
|
||||
expect($health['memory_management'])->toBeArray();
|
||||
expect($health['memory_management'])->toHaveKey('cache_level');
|
||||
});
|
||||
|
||||
it('performs memory pressure management', function () {
|
||||
$result = $this->cacheManager->performMemoryPressureManagement();
|
||||
|
||||
expect($result)->toBeArray();
|
||||
expect($result)->toHaveKey('actions');
|
||||
expect($result)->toHaveKey('memory_status');
|
||||
expect($result)->toHaveKey('cache_level');
|
||||
});
|
||||
|
||||
it('clears cache in critical memory situations', function () {
|
||||
// Store some data
|
||||
$this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
expect($this->cacheManager->get($this->testContext))->not->toBeNull();
|
||||
|
||||
// Create memory manager with very low limit to trigger critical status
|
||||
$lowMemoryManager = new DiscoveryMemoryManager(
|
||||
strategy: MemoryStrategy::BATCH,
|
||||
memoryLimit: Byte::fromBytes(1024), // Very low limit
|
||||
memoryPressureThreshold: 0.8,
|
||||
memoryMonitor: null,
|
||||
logger: null,
|
||||
eventDispatcher: null,
|
||||
clock: $this->clock
|
||||
);
|
||||
|
||||
$cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService,
|
||||
logger: null,
|
||||
ttlHours: 24,
|
||||
memoryManager: $lowMemoryManager
|
||||
);
|
||||
|
||||
// Store with low memory manager
|
||||
$cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
// Force memory usage to trigger critical
|
||||
// Note: This is hard to test directly, but the method exists
|
||||
$result = $cacheManager->performMemoryPressureManagement();
|
||||
expect($result)->toBeArray();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiscoveryCacheManager - Tiered Caching', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$this->fileSystemService = new FileSystemService();
|
||||
|
||||
$this->memoryManager = new DiscoveryMemoryManager(
|
||||
strategy: MemoryStrategy::BATCH,
|
||||
memoryLimit: Byte::fromMegabytes(128),
|
||||
memoryPressureThreshold: 0.8,
|
||||
memoryMonitor: null,
|
||||
logger: null,
|
||||
eventDispatcher: null,
|
||||
clock: $this->clock
|
||||
);
|
||||
|
||||
$this->cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService,
|
||||
logger: null,
|
||||
ttlHours: 24,
|
||||
memoryManager: $this->memoryManager
|
||||
);
|
||||
|
||||
$this->testContext = new DiscoveryContext(
|
||||
paths: ['/test/path'],
|
||||
scanType: ScanType::FULL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $this->clock->now()
|
||||
);
|
||||
|
||||
$this->testRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry(),
|
||||
templates: new TemplateRegistry()
|
||||
);
|
||||
});
|
||||
|
||||
it('uses tiered caching when memory manager is available', function () {
|
||||
$success = $this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
expect($success)->toBeTrue();
|
||||
|
||||
// Should be retrievable from tiered cache
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('determines appropriate cache tier based on data size and access frequency', function () {
|
||||
// Store data - tier determination happens internally
|
||||
$success = $this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
expect($success)->toBeTrue();
|
||||
|
||||
// Verify it can be retrieved (tiered cache lookup)
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('applies different TTL multipliers per tier', function () {
|
||||
// Store data - TTL adjustment happens internally based on tier
|
||||
$success = $this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
expect($success)->toBeTrue();
|
||||
|
||||
// Verify retrieval works
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('tracks access patterns for tier determination', function () {
|
||||
// Store and retrieve multiple times to build access pattern
|
||||
$this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
// Multiple accesses
|
||||
$this->cacheManager->get($this->testContext);
|
||||
$this->cacheManager->get($this->testContext);
|
||||
$this->cacheManager->get($this->testContext);
|
||||
|
||||
// Should still work
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiscoveryCacheManager - Cache Compression', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$this->fileSystemService = new FileSystemService();
|
||||
|
||||
$this->memoryManager = new DiscoveryMemoryManager(
|
||||
strategy: MemoryStrategy::BATCH,
|
||||
memoryLimit: Byte::fromMegabytes(128),
|
||||
memoryPressureThreshold: 0.8,
|
||||
memoryMonitor: null,
|
||||
logger: null,
|
||||
eventDispatcher: null,
|
||||
clock: $this->clock
|
||||
);
|
||||
|
||||
$this->cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService,
|
||||
logger: null,
|
||||
ttlHours: 24,
|
||||
memoryManager: $this->memoryManager
|
||||
);
|
||||
|
||||
$this->testContext = new DiscoveryContext(
|
||||
paths: ['/test/path'],
|
||||
scanType: ScanType::FULL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $this->clock->now()
|
||||
);
|
||||
|
||||
$this->testRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry(),
|
||||
templates: new TemplateRegistry()
|
||||
);
|
||||
});
|
||||
|
||||
it('compresses data for appropriate tiers', function () {
|
||||
// Store data - compression happens internally for appropriate tiers
|
||||
$success = $this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
expect($success)->toBeTrue();
|
||||
|
||||
// Should be retrievable and decompressed
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('decompresses data when retrieving from cache', function () {
|
||||
// Store compressed data
|
||||
$this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
// Retrieve should decompress automatically
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
expect($cached->isEmpty())->toBe($this->testRegistry->isEmpty());
|
||||
});
|
||||
|
||||
it('handles uncompressed data correctly', function () {
|
||||
// Store data that might not need compression (small size, hot tier)
|
||||
$success = $this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
expect($success)->toBeTrue();
|
||||
|
||||
// Should still be retrievable
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiscoveryCacheManager - Cache Metrics', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$this->fileSystemService = new FileSystemService();
|
||||
|
||||
$this->memoryManager = new DiscoveryMemoryManager(
|
||||
strategy: MemoryStrategy::BATCH,
|
||||
memoryLimit: Byte::fromMegabytes(128),
|
||||
memoryPressureThreshold: 0.8,
|
||||
memoryMonitor: null,
|
||||
logger: null,
|
||||
eventDispatcher: null,
|
||||
clock: $this->clock
|
||||
);
|
||||
|
||||
$this->cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService,
|
||||
logger: null,
|
||||
ttlHours: 24,
|
||||
memoryManager: $this->memoryManager
|
||||
);
|
||||
|
||||
$this->testContext = new DiscoveryContext(
|
||||
paths: ['/test/path'],
|
||||
scanType: ScanType::FULL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $this->clock->now()
|
||||
);
|
||||
|
||||
$this->testRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry(),
|
||||
templates: new TemplateRegistry()
|
||||
);
|
||||
});
|
||||
|
||||
it('returns null metrics when memory manager is not available', function () {
|
||||
$cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService
|
||||
);
|
||||
|
||||
$metrics = $cacheManager->getCacheMetrics();
|
||||
|
||||
expect($metrics)->toBeNull();
|
||||
});
|
||||
|
||||
it('returns cache metrics when memory manager is available', function () {
|
||||
// Store and retrieve to generate metrics
|
||||
$this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
$this->cacheManager->get($this->testContext);
|
||||
|
||||
$metrics = $this->cacheManager->getCacheMetrics();
|
||||
|
||||
expect($metrics)->not->toBeNull();
|
||||
expect($metrics)->toBeInstanceOf(\App\Framework\Discovery\ValueObjects\CacheMetrics::class);
|
||||
});
|
||||
|
||||
it('calculates hit rate correctly', function () {
|
||||
// Store data
|
||||
$this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
// Multiple hits
|
||||
$this->cacheManager->get($this->testContext);
|
||||
$this->cacheManager->get($this->testContext);
|
||||
|
||||
$metrics = $this->cacheManager->getCacheMetrics();
|
||||
|
||||
expect($metrics)->not->toBeNull();
|
||||
// Hit rate should be calculated (exact value depends on implementation)
|
||||
});
|
||||
|
||||
it('tracks total cache size', function () {
|
||||
$this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
$metrics = $this->cacheManager->getCacheMetrics();
|
||||
|
||||
expect($metrics)->not->toBeNull();
|
||||
expect($metrics->totalSize)->toBeInstanceOf(Byte::class);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DiscoveryCacheManager - Cache Events', function () {
|
||||
beforeEach(function () {
|
||||
$cacheDriver = new InMemoryCache();
|
||||
$serializer = new PhpSerializer(PhpSerializerConfig::safe());
|
||||
$this->cache = new GeneralCache($cacheDriver, $serializer);
|
||||
$this->clock = new SystemClock();
|
||||
$this->fileSystemService = new FileSystemService();
|
||||
// EventDispatcher is final and requires a Container
|
||||
// We'll use a real instance but can't verify events directly
|
||||
$this->container = new \App\Framework\DI\DefaultContainer();
|
||||
$this->eventDispatcher = new EventDispatcher($this->container);
|
||||
|
||||
$this->memoryManager = new DiscoveryMemoryManager(
|
||||
strategy: MemoryStrategy::BATCH,
|
||||
memoryLimit: Byte::fromMegabytes(128),
|
||||
memoryPressureThreshold: 0.8,
|
||||
memoryMonitor: null,
|
||||
logger: null,
|
||||
eventDispatcher: $this->eventDispatcher,
|
||||
clock: $this->clock
|
||||
);
|
||||
|
||||
$this->cacheManager = new DiscoveryCacheManager(
|
||||
cache: $this->cache,
|
||||
clock: $this->clock,
|
||||
fileSystemService: $this->fileSystemService,
|
||||
logger: null,
|
||||
ttlHours: 24,
|
||||
memoryManager: $this->memoryManager,
|
||||
eventDispatcher: $this->eventDispatcher
|
||||
);
|
||||
|
||||
// Use a future time to avoid stale detection issues
|
||||
$futureTime = new \DateTimeImmutable('2099-01-01 00:00:00');
|
||||
$this->testContext = new DiscoveryContext(
|
||||
paths: [$testPath],
|
||||
scanType: ScanType::FULL,
|
||||
options: new DiscoveryOptions(),
|
||||
startTime: $futureTime
|
||||
);
|
||||
|
||||
$this->testRegistry = new DiscoveryRegistry(
|
||||
attributes: new AttributeRegistry(),
|
||||
interfaces: new InterfaceRegistry(),
|
||||
templates: new TemplateRegistry()
|
||||
);
|
||||
});
|
||||
|
||||
it('dispatches CacheHitEvent on cache hit', function () {
|
||||
// Store first
|
||||
$this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
|
||||
// EventDispatcher is final, so we can't verify events directly
|
||||
// But we can verify the cache hit works
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('dispatches CacheMissEvent on cache miss', function () {
|
||||
// EventDispatcher is final, so we can't verify events directly
|
||||
// But we can verify the cache miss works
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeNull();
|
||||
});
|
||||
|
||||
it('dispatches CacheCompressionEvent when compressing data', function () {
|
||||
// EventDispatcher is final, so we can't verify events directly
|
||||
// But we can verify compression works by storing and retrieving
|
||||
$success = $this->cacheManager->store($this->testContext, $this->testRegistry);
|
||||
expect($success)->toBeTrue();
|
||||
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeInstanceOf(DiscoveryRegistry::class);
|
||||
});
|
||||
|
||||
it('handles multiple miss scenarios for different reasons', function () {
|
||||
// Not found scenario
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeNull();
|
||||
|
||||
// Corrupted data scenario
|
||||
$key = $this->testContext->getCacheKey();
|
||||
$invalidItem = CacheItem::forSet($key, 'invalid');
|
||||
$this->cache->set($invalidItem);
|
||||
|
||||
$cached = $this->cacheManager->get($this->testContext);
|
||||
expect($cached)->toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user