diff --git a/tests/Unit/Command/AbstractCommandTest.php b/tests/Unit/Command/AbstractCommandTest.php new file mode 100644 index 0000000..30612b9 --- /dev/null +++ b/tests/Unit/Command/AbstractCommandTest.php @@ -0,0 +1,232 @@ +command = new class () extends AbstractCommand { + public function name(): string + { + return 'test'; + } + + public function description(): string + { + return 'Test command'; + } + + public function execute(Devkit $devkit, array $arguments): int + { + return 0; + } + + /** @param list $args */ + public function callHasFlag(array $args, string ...$flags): bool + { + return $this->hasFlag($args, ...$flags); + } + + /** @param list $args */ + public function callOption(array $args, string $key, ?string $default = null): ?string + { + return $this->option($args, $key, $default); + } + + /** @param list $args @return list */ + public function callPositional(array $args): array + { + return $this->positional($args); + } + + /** + * @param list $args + * @param list $consume + * @return list + */ + public function callPassthrough(array $args, array $consume = []): array + { + return $this->passthrough($args, $consume); + } + + public function callInfo(string $msg): void + { + $this->info($msg); + } + + public function callWarning(string $msg): void + { + $this->warning($msg); + } + + public function callError(string $msg): void + { + $this->error($msg); + } + + public function callLine(string $msg = ''): void + { + $this->line($msg); + } + + public function callBanner(string $title): void + { + $this->banner($title); + } + + public function callSection(string $title): void + { + $this->section($title); + } + }; + } + + // ── Identity ─────────────────────────────────────────────────── + + #[Test] + public function nameReturnsCommandName(): void + { + $this->assertSame('test', $this->command->name()); + } + + #[Test] + public function descriptionReturnsDescription(): void + { + $this->assertSame('Test command', $this->command->description()); + } + + // ── Argument helpers ─────────────────────────────────────────── + + #[Test] + public function hasFlagReturnsTrueWhenFlagPresent(): void + { + $this->assertTrue($this->command->callHasFlag(['--verbose', '--check'], '--verbose')); + } + + #[Test] + public function hasFlagReturnsFalseWhenFlagAbsent(): void + { + $this->assertFalse($this->command->callHasFlag(['--check'], '--verbose')); + } + + #[Test] + public function hasFlagMatchesAnyOfMultipleFlags(): void + { + $this->assertTrue($this->command->callHasFlag(['--dry-run'], '--check', '--dry-run')); + } + + #[Test] + public function hasFlagReturnsFalseWithEmptyArgs(): void + { + $this->assertFalse($this->command->callHasFlag([], '--any')); + } + + #[Test] + public function optionExtractsValueFromArguments(): void + { + $result = $this->command->callOption(['--level=9', '--other=x'], 'level'); + $this->assertSame('9', $result); + } + + #[Test] + public function optionReturnsDefaultWhenNotFound(): void + { + $result = $this->command->callOption(['--other=x'], 'level', 'default'); + $this->assertSame('default', $result); + } + + #[Test] + public function optionReturnsNullWhenNotFoundAndNoDefault(): void + { + $this->assertNull($this->command->callOption([], 'level')); + } + + #[Test] + public function positionalFiltersOutFlags(): void + { + $result = $this->command->callPositional(['file.php', '--verbose', 'other.php', '--dry-run']); + $this->assertSame(['file.php', 'other.php'], $result); + } + + #[Test] + public function positionalReturnsEmptyForAllFlags(): void + { + $result = $this->command->callPositional(['--a', '--b', '--c']); + $this->assertSame([], $result); + } + + #[Test] + public function passthroughRemovesConsumedFlags(): void + { + $result = $this->command->callPassthrough( + ['--verbose', '--coverage', '--check'], + ['--coverage'], + ); + $this->assertSame(['--verbose', '--check'], $result); + } + + #[Test] + public function passthroughWithNoConsumedFlagsReturnsAll(): void + { + $args = ['--verbose', '--check']; + $result = $this->command->callPassthrough($args); + $this->assertSame($args, $result); + } + + // ── Output helpers — fwrite(STDOUT/STDERR) cannot be captured by ob_start ─ + // We verify these methods exist and do not throw exceptions. + + #[Test] + public function infoDoesNotThrow(): void + { + $this->expectNotToPerformAssertions(); + $this->command->callInfo('hello'); + } + + #[Test] + public function warningDoesNotThrow(): void + { + $this->expectNotToPerformAssertions(); + $this->command->callWarning('caution'); + } + + #[Test] + public function errorDoesNotThrow(): void + { + $this->expectNotToPerformAssertions(); + $this->command->callError('something failed'); + } + + #[Test] + public function lineDoesNotThrow(): void + { + $this->expectNotToPerformAssertions(); + $this->command->callLine('some output'); + } + + #[Test] + public function bannerDoesNotThrow(): void + { + $this->expectNotToPerformAssertions(); + $this->command->callBanner('My Banner'); + } + + #[Test] + public function sectionDoesNotThrow(): void + { + $this->expectNotToPerformAssertions(); + $this->command->callSection('My Section'); + } +} diff --git a/tests/Unit/Configuration/CsFixerConfigGeneratorTest.php b/tests/Unit/Configuration/CsFixerConfigGeneratorTest.php new file mode 100644 index 0000000..06e772c --- /dev/null +++ b/tests/Unit/Configuration/CsFixerConfigGeneratorTest.php @@ -0,0 +1,97 @@ +generator = new CsFixerConfigGenerator(); + } + + private function makeContext(array $rules = ['@PSR12' => true]): ProjectContext + { + return new ProjectContext( + projectRoot: '/project', + projectName: 'test/project', + namespace: 'Test\\Project', + phpVersion: '8.4', + phpstanLevel: 9, + psalmLevel: 3, + sourceDirs: ['/project/src'], + testDirs: ['/project/tests'], + excludeDirs: [], + testSuites: [], + coverageExclude: [], + csFixerRules: $rules, + rectorSets: [], + toolVersions: [], + ); + } + + #[Test] + public function toolNameReturnsCsFixer(): void + { + $this->assertSame('cs-fixer', $this->generator->toolName()); + } + + #[Test] + public function outputPathReturnsPhpCsFixerPhp(): void + { + $this->assertSame('php-cs-fixer.php', $this->generator->outputPath()); + } + + #[Test] + public function generateContainsPhpDeclaration(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString('generator->generate($this->makeContext()); + $this->assertStringContainsString("->in(__DIR__ . '/../src')", $output); + } + + #[Test] + public function generateContainsTestDir(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString("->in(__DIR__ . '/../tests')", $output); + } + + #[Test] + public function generateContainsRulesFromContext(): void + { + $output = $this->generator->generate($this->makeContext(['@PSR12' => true, 'array_syntax' => ['syntax' => 'short']])); + $this->assertStringContainsString('@PSR12', $output); + $this->assertStringContainsString('array_syntax', $output); + } + + #[Test] + public function generateContainsCacheFile(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString('.php-cs-fixer.cache', $output); + } + + #[Test] + public function generateContainsRiskyAllowed(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString('setRiskyAllowed(true)', $output); + } +} diff --git a/tests/Unit/Configuration/PhpStanConfigGeneratorTest.php b/tests/Unit/Configuration/PhpStanConfigGeneratorTest.php new file mode 100644 index 0000000..7fb6d3b --- /dev/null +++ b/tests/Unit/Configuration/PhpStanConfigGeneratorTest.php @@ -0,0 +1,90 @@ +generator = new PhpStanConfigGenerator(); + } + + private function makeContext(int $level = 9, array $excludeDirs = []): ProjectContext + { + return new ProjectContext( + projectRoot: '/project', + projectName: 'test/project', + namespace: 'Test\\Project', + phpVersion: '8.4', + phpstanLevel: $level, + psalmLevel: 3, + sourceDirs: ['/project/src'], + testDirs: ['/project/tests'], + excludeDirs: $excludeDirs, + testSuites: [], + coverageExclude: [], + csFixerRules: [], + rectorSets: [], + toolVersions: [], + ); + } + + #[Test] + public function toolNameReturnsPhpstan(): void + { + $this->assertSame('phpstan', $this->generator->toolName()); + } + + #[Test] + public function outputPathReturnsPhpstanNeon(): void + { + $this->assertSame('phpstan.neon', $this->generator->outputPath()); + } + + #[Test] + public function generateContainsLevel(): void + { + $output = $this->generator->generate($this->makeContext(level: 8)); + $this->assertStringContainsString('level: 8', $output); + } + + #[Test] + public function generateContainsSourcePath(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString('- ../src', $output); + } + + #[Test] + public function generateContainsExcludePathsWhenSet(): void + { + $output = $this->generator->generate($this->makeContext(excludeDirs: ['src/Contract'])); + $this->assertStringContainsString('excludePaths:', $output); + $this->assertStringContainsString('- ../src/Contract', $output); + } + + #[Test] + public function generateOmitsExcludePathsWhenEmpty(): void + { + $output = $this->generator->generate($this->makeContext(excludeDirs: [])); + $this->assertStringNotContainsString('excludePaths:', $output); + } + + #[Test] + public function generateContainsTmpDir(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString('tmpDir: build/.phpstan', $output); + } +} diff --git a/tests/Unit/Configuration/PhpUnitConfigGeneratorTest.php b/tests/Unit/Configuration/PhpUnitConfigGeneratorTest.php new file mode 100644 index 0000000..1972a39 --- /dev/null +++ b/tests/Unit/Configuration/PhpUnitConfigGeneratorTest.php @@ -0,0 +1,104 @@ +generator = new PhpUnitConfigGenerator(); + $this->context = new ProjectContext( + projectRoot: '/project', + projectName: 'test/project', + namespace: 'Test\\Project', + phpVersion: '8.4', + phpstanLevel: 9, + psalmLevel: 3, + sourceDirs: ['/project/src'], + testDirs: ['/project/tests'], + excludeDirs: [], + testSuites: ['Unit' => 'tests/Unit', 'Integration' => 'tests/Integration'], + coverageExclude: ['src/Exception'], + csFixerRules: [], + rectorSets: [], + toolVersions: [], + ); + } + + #[Test] + public function toolNameReturnsPhpunit(): void + { + $this->assertSame('phpunit', $this->generator->toolName()); + } + + #[Test] + public function outputPathReturnsPhpunitXmlDist(): void + { + $this->assertSame('phpunit.xml.dist', $this->generator->outputPath()); + } + + #[Test] + public function generateContainsXmlDeclaration(): void + { + $output = $this->generator->generate($this->context); + $this->assertStringContainsString('generator->generate($this->context); + $this->assertStringContainsString('name="Unit"', $output); + $this->assertStringContainsString('name="Integration"', $output); + } + + #[Test] + public function generateContainsSourceDirectory(): void + { + $output = $this->generator->generate($this->context); + $this->assertStringContainsString('../src', $output); + } + + #[Test] + public function generateContainsCoverageExcludes(): void + { + $output = $this->generator->generate($this->context); + $this->assertStringContainsString('../src/Exception', $output); + } + + #[Test] + public function generateWithEmptyTestSuitesProducesValidXml(): void + { + $context = new ProjectContext( + projectRoot: '/project', + projectName: 'test/project', + namespace: 'Test\\Project', + phpVersion: '8.4', + phpstanLevel: 9, + psalmLevel: 3, + sourceDirs: ['/project/src'], + testDirs: ['/project/tests'], + excludeDirs: [], + testSuites: [], + coverageExclude: [], + csFixerRules: [], + rectorSets: [], + toolVersions: [], + ); + + $output = $this->generator->generate($context); + $this->assertStringContainsString('', $output); + } +} diff --git a/tests/Unit/Configuration/PsalmConfigGeneratorTest.php b/tests/Unit/Configuration/PsalmConfigGeneratorTest.php new file mode 100644 index 0000000..ff9b56a --- /dev/null +++ b/tests/Unit/Configuration/PsalmConfigGeneratorTest.php @@ -0,0 +1,89 @@ +generator = new PsalmConfigGenerator(); + } + + private function makeContext(int $psalmLevel = 3): ProjectContext + { + return new ProjectContext( + projectRoot: '/project', + projectName: 'test/project', + namespace: 'Test\\Project', + phpVersion: '8.4', + phpstanLevel: 9, + psalmLevel: $psalmLevel, + sourceDirs: ['/project/src'], + testDirs: ['/project/tests'], + excludeDirs: [], + testSuites: [], + coverageExclude: [], + csFixerRules: [], + rectorSets: [], + toolVersions: [], + ); + } + + #[Test] + public function toolNameReturnsPsalm(): void + { + $this->assertSame('psalm', $this->generator->toolName()); + } + + #[Test] + public function outputPathReturnsPsalmXml(): void + { + $this->assertSame('psalm.xml', $this->generator->outputPath()); + } + + #[Test] + public function generateContainsXmlDeclaration(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString('generator->generate($this->makeContext(psalmLevel: 5)); + $this->assertStringContainsString('errorLevel="5"', $output); + } + + #[Test] + public function generateContainsSourceDirectory(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString('directory name="../src"', $output); + } + + #[Test] + public function generateIgnoresVendorDirectory(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString('directory name="../vendor"', $output); + } + + #[Test] + public function generateContainsCacheDirectory(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString('cacheDirectory="build/.psalm"', $output); + } +} diff --git a/tests/Unit/Configuration/RectorConfigGeneratorTest.php b/tests/Unit/Configuration/RectorConfigGeneratorTest.php new file mode 100644 index 0000000..5fc58bd --- /dev/null +++ b/tests/Unit/Configuration/RectorConfigGeneratorTest.php @@ -0,0 +1,91 @@ +generator = new RectorConfigGenerator(); + } + + private function makeContext(array $rectorSets = ['LevelSetList::UP_TO_PHP_84']): ProjectContext + { + return new ProjectContext( + projectRoot: '/project', + projectName: 'test/project', + namespace: 'Test\\Project', + phpVersion: '8.4', + phpstanLevel: 9, + psalmLevel: 3, + sourceDirs: ['/project/src'], + testDirs: ['/project/tests'], + excludeDirs: [], + testSuites: [], + coverageExclude: [], + csFixerRules: [], + rectorSets: $rectorSets, + toolVersions: [], + ); + } + + #[Test] + public function toolNameReturnsRector(): void + { + $this->assertSame('rector', $this->generator->toolName()); + } + + #[Test] + public function outputPathReturnsRectorPhp(): void + { + $this->assertSame('rector.php', $this->generator->outputPath()); + } + + #[Test] + public function generateContainsPhpDeclaration(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString('generator->generate($this->makeContext()); + $this->assertStringContainsString("__DIR__ . '/../src'", $output); + $this->assertStringContainsString("__DIR__ . '/../tests'", $output); + } + + #[Test] + public function generateContainsRectorSets(): void + { + $output = $this->generator->generate($this->makeContext(['LevelSetList::UP_TO_PHP_84', 'SetList::CODE_QUALITY'])); + $this->assertStringContainsString('LevelSetList::UP_TO_PHP_84', $output); + $this->assertStringContainsString('SetList::CODE_QUALITY', $output); + } + + #[Test] + public function generateContainsWithPhpSets(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString('withPhpSets(php84: true)', $output); + } + + #[Test] + public function generateContainsWithImportNames(): void + { + $output = $this->generator->generate($this->makeContext()); + $this->assertStringContainsString('withImportNames(', $output); + } +} diff --git a/tests/Unit/Core/DevkitTest.php b/tests/Unit/Core/DevkitTest.php new file mode 100644 index 0000000..4b1ce60 --- /dev/null +++ b/tests/Unit/Core/DevkitTest.php @@ -0,0 +1,284 @@ +tmpDir = sys_get_temp_dir() . '/devkit_devkit_test_' . uniqid(); + mkdir($this->tmpDir, 0o777, true); + + // Create a minimal composer.json so ProjectDetector doesn't throw + file_put_contents($this->tmpDir . '/composer.json', json_encode([ + 'name' => 'test/project', + 'autoload' => ['psr-4' => ['Test\\' => 'src/']], + 'autoload-dev' => ['psr-4' => ['Test\\Tests\\' => 'tests/']], + ])); + + mkdir($this->tmpDir . '/src', 0o777, true); + mkdir($this->tmpDir . '/tests', 0o777, true); + + $this->detector = new ProjectDetector(); + $this->devkit = new Devkit($this->detector); + } + + protected function tearDown(): void + { + $this->removeDir($this->tmpDir); + } + + #[Test] + public function versionReturnsString(): void + { + $this->assertIsString(Devkit::version()); + $this->assertNotEmpty(Devkit::version()); + } + + #[Test] + public function contextReturnsProjectContext(): void + { + $ctx = $this->devkit->context($this->tmpDir); + $this->assertInstanceOf(ProjectContext::class, $ctx); + } + + #[Test] + public function contextIsCached(): void + { + $ctx1 = $this->devkit->context($this->tmpDir); + $ctx2 = $this->devkit->context($this->tmpDir); + $this->assertSame($ctx1, $ctx2); + } + + #[Test] + public function addGeneratorRegistersGenerator(): void + { + $generator = $this->createMock(ConfigGenerator::class); + $generator->method('toolName')->willReturn('test-gen'); + + $this->devkit->addGenerator($generator); + // No exception = registered successfully + $this->assertTrue(true); + } + + #[Test] + public function addRunnerRegistersRunner(): void + { + $runner = $this->createMock(ToolRunner::class); + $runner->method('toolName')->willReturn('test-runner'); + + $this->devkit->addRunner($runner); + $this->assertContains('test-runner', $this->devkit->registeredTools()); + } + + #[Test] + public function registeredToolsReturnsToolNames(): void + { + $runner1 = $this->createMock(ToolRunner::class); + $runner1->method('toolName')->willReturn('phpunit'); + + $runner2 = $this->createMock(ToolRunner::class); + $runner2->method('toolName')->willReturn('phpstan'); + + $this->devkit->addRunner($runner1); + $this->devkit->addRunner($runner2); + + $this->assertContains('phpunit', $this->devkit->registeredTools()); + $this->assertContains('phpstan', $this->devkit->registeredTools()); + } + + #[Test] + public function isToolAvailableReturnsTrueWhenRunnerIsAvailable(): void + { + $runner = $this->createMock(ToolRunner::class); + $runner->method('toolName')->willReturn('my-tool'); + $runner->method('isAvailable')->willReturn(true); + + $this->devkit->addRunner($runner); + $this->assertTrue($this->devkit->isToolAvailable('my-tool')); + } + + #[Test] + public function isToolAvailableReturnsFalseWhenRunnerNotAvailable(): void + { + $runner = $this->createMock(ToolRunner::class); + $runner->method('toolName')->willReturn('my-tool'); + $runner->method('isAvailable')->willReturn(false); + + $this->devkit->addRunner($runner); + $this->assertFalse($this->devkit->isToolAvailable('my-tool')); + } + + #[Test] + public function isToolAvailableReturnsFalseForUnknownTool(): void + { + $this->assertFalse($this->devkit->isToolAvailable('nonexistent-tool')); + } + + #[Test] + public function runDelegatesToRegisteredRunner(): void + { + $expectedResult = new ToolResult( + toolName: 'my-tool', + exitCode: 0, + stdout: 'ok', + stderr: '', + elapsedSeconds: 0.1, + ); + + $runner = $this->createMock(ToolRunner::class); + $runner->method('toolName')->willReturn('my-tool'); + $runner->method('run')->willReturn($expectedResult); + + $this->devkit->addRunner($runner); + $result = $this->devkit->run('my-tool', []); + $this->assertSame($expectedResult, $result); + } + + #[Test] + public function runThrowsForUnknownTool(): void + { + $this->expectException(DevkitException::class); + $this->devkit->run('nonexistent', []); + } + + #[Test] + public function qualityRunsPipelineAndReturnsReport(): void + { + $result = new ToolResult( + toolName: 'cs-fixer', + exitCode: 0, + stdout: '', + stderr: '', + elapsedSeconds: 0.1, + ); + + $runner = $this->createMock(ToolRunner::class); + $runner->method('toolName')->willReturn('cs-fixer'); + $runner->method('isAvailable')->willReturn(true); + $runner->method('run')->willReturn($result); + + $this->devkit->addRunner($runner); + $report = $this->devkit->quality(['cs-fixer']); + $this->assertInstanceOf(QualityReport::class, $report); + $this->assertTrue($report->passed); + } + + #[Test] + public function qualitySkipsUnavailableTools(): void + { + $runner = $this->createMock(ToolRunner::class); + $runner->method('toolName')->willReturn('phpstan'); + $runner->method('isAvailable')->willReturn(false); + $runner->method('run')->willThrowException(new \RuntimeException('Should not be called')); + + $this->devkit->addRunner($runner); + $report = $this->devkit->quality(['phpstan']); + $this->assertInstanceOf(QualityReport::class, $report); + } + + #[Test] + public function qualityWithEmptyOnlyToolsUsesDefaultPipeline(): void + { + // No runners registered — should return empty report without error + $report = $this->devkit->quality([]); + $this->assertInstanceOf(QualityReport::class, $report); + } + + #[Test] + public function initCreatesConfigFiles(): void + { + $generator = $this->createMock(ConfigGenerator::class); + $generator->method('toolName')->willReturn('test-gen'); + $generator->method('outputPath')->willReturn('test-config.txt'); + $generator->method('generate')->willReturn('# test config'); + + $this->devkit->addGenerator($generator); + $count = $this->devkit->init($this->tmpDir); + $this->assertSame(1, $count); + $this->assertFileExists($this->tmpDir . '/.kcode/test-config.txt'); + } + + #[Test] + public function initCreatesGitIgnore(): void + { + $this->devkit->init($this->tmpDir); + $gitignore = $this->tmpDir . '/.gitignore'; + $this->assertFileExists($gitignore); + $this->assertStringContainsString('.kcode/', file_get_contents($gitignore)); + } + + #[Test] + public function initUpdatesExistingGitIgnoreWithLegacyEntry(): void + { + file_put_contents($this->tmpDir . '/.gitignore', ".kcode/build/\n"); + $this->devkit->init($this->tmpDir); + $content = file_get_contents($this->tmpDir . '/.gitignore'); + $this->assertStringContainsString('.kcode/', $content); + } + + #[Test] + public function initSkipsGitIgnoreEntryIfAlreadyPresent(): void + { + file_put_contents($this->tmpDir . '/.gitignore', ".kcode/\n"); + $this->devkit->init($this->tmpDir); + $content = file_get_contents($this->tmpDir . '/.gitignore'); + // Should not duplicate the entry + $this->assertSame(1, substr_count($content, '.kcode/')); + } + + #[Test] + public function cleanRemovesAndRecreatsBuildDir(): void + { + $this->devkit->init($this->tmpDir); + $buildDir = $this->tmpDir . '/.kcode/build'; + file_put_contents($buildDir . '/old-file.txt', 'old'); + + $this->devkit->clean($this->tmpDir); + $this->assertDirectoryExists($buildDir); + $this->assertFileDoesNotExist($buildDir . '/old-file.txt'); + } + + private function removeDir(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($items as $item) { + /** @var \SplFileInfo $item */ + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($dir); + } +} diff --git a/tests/Unit/Core/MigrationDetectorTest.php b/tests/Unit/Core/MigrationDetectorTest.php new file mode 100644 index 0000000..b254e3f --- /dev/null +++ b/tests/Unit/Core/MigrationDetectorTest.php @@ -0,0 +1,138 @@ +tmpDir = sys_get_temp_dir() . '/devkit_migration_test_' . uniqid(); + mkdir($this->tmpDir, 0o777, true); + $this->detector = new MigrationDetector(); + } + + protected function tearDown(): void + { + $this->removeDir($this->tmpDir); + } + + #[Test] + public function detectReturnsMigrationReport(): void + { + file_put_contents($this->tmpDir . '/composer.json', json_encode([ + 'name' => 'test/project', + 'require-dev' => [], + ])); + + $report = $this->detector->detect($this->tmpDir); + $this->assertInstanceOf(MigrationReport::class, $report); + } + + #[Test] + public function detectFindsRedundantPackagesInRequireDev(): void + { + file_put_contents($this->tmpDir . '/composer.json', json_encode([ + 'name' => 'test/project', + 'require-dev' => [ + 'phpunit/phpunit' => '^12.0', + 'phpstan/phpstan' => '^1.0', + ], + ])); + + $report = $this->detector->detect($this->tmpDir); + $this->assertTrue($report->hasRedundancies); + $this->assertArrayHasKey('phpunit/phpunit', $report->redundantPackages); + $this->assertArrayHasKey('phpstan/phpstan', $report->redundantPackages); + } + + #[Test] + public function detectFindsRedundantConfigFiles(): void + { + file_put_contents($this->tmpDir . '/composer.json', json_encode(['name' => 'test/project'])); + file_put_contents($this->tmpDir . '/phpunit.xml', ''); + file_put_contents($this->tmpDir . '/phpstan.neon', 'parameters:'); + + $report = $this->detector->detect($this->tmpDir); + $this->assertContains('phpunit.xml', $report->redundantConfigFiles); + $this->assertContains('phpstan.neon', $report->redundantConfigFiles); + } + + #[Test] + public function detectFindsRedundantCachePaths(): void + { + file_put_contents($this->tmpDir . '/composer.json', json_encode(['name' => 'test/project'])); + mkdir($this->tmpDir . '/.phpunit.cache', 0o777, true); + file_put_contents($this->tmpDir . '/.php-cs-fixer.cache', ''); + + $report = $this->detector->detect($this->tmpDir); + $this->assertContains('.phpunit.cache', $report->redundantCachePaths); + $this->assertContains('.php-cs-fixer.cache', $report->redundantCachePaths); + } + + #[Test] + public function detectReturnsEmptyReportWhenNothingFound(): void + { + file_put_contents($this->tmpDir . '/composer.json', json_encode([ + 'name' => 'test/project', + 'require-dev' => ['my/custom-package' => '^1.0'], + ])); + + $report = $this->detector->detect($this->tmpDir); + $this->assertFalse($report->hasRedundancies); + $this->assertEmpty($report->redundantPackages); + $this->assertEmpty($report->redundantConfigFiles); + $this->assertEmpty($report->redundantCachePaths); + } + + #[Test] + public function detectWorksWithoutComposerJson(): void + { + $report = $this->detector->detect($this->tmpDir); + $this->assertFalse($report->hasRedundancies); + $this->assertEmpty($report->redundantPackages); + } + + #[Test] + public function detectHandlesComposerJsonWithoutRequireDev(): void + { + file_put_contents($this->tmpDir . '/composer.json', json_encode([ + 'name' => 'test/project', + ])); + + $report = $this->detector->detect($this->tmpDir); + $this->assertEmpty($report->redundantPackages); + } + + private function removeDir(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($items as $item) { + /** @var \SplFileInfo $item */ + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($dir); + } +} diff --git a/tests/Unit/Core/ProcessExecutorTest.php b/tests/Unit/Core/ProcessExecutorTest.php new file mode 100644 index 0000000..910aeac --- /dev/null +++ b/tests/Unit/Core/ProcessExecutorTest.php @@ -0,0 +1,112 @@ +projectRoot = (string) realpath(\dirname(__DIR__, 3)); + $this->executor = new ProcessExecutor($this->projectRoot); + } + + #[Test] + public function executeRunsCommandAndCapturesOutput(): void + { + $result = $this->executor->execute('php-inline', ['php', '-r', 'echo "hello";']); + $this->assertInstanceOf(ToolResult::class, $result); + $this->assertSame(0, $result->exitCode); + $this->assertStringContainsString('hello', $result->stdout); + } + + #[Test] + public function executeReturnsTrueSuccessOnExitCode0(): void + { + $result = $this->executor->execute('test-tool', ['php', '-r', 'echo "ok";']); + $this->assertTrue($result->success); + } + + #[Test] + public function executeReturnsFalseSuccessOnNonZeroExit(): void + { + $result = $this->executor->execute('false-cmd', ['php', '-r', 'exit(1);']); + $this->assertSame(1, $result->exitCode); + $this->assertFalse($result->success); + } + + #[Test] + public function executeRecordsElapsedTime(): void + { + $result = $this->executor->execute('test-tool', ['php', '-r', 'echo "ok";']); + $this->assertGreaterThanOrEqual(0.0, $result->elapsedSeconds); + } + + #[Test] + public function executeReturnsCorrectToolName(): void + { + $result = $this->executor->execute('mytool', ['php', '-r', 'echo "x";']); + $this->assertSame('mytool', $result->toolName); + } + + #[Test] + public function executeCapturesStderr(): void + { + $result = $this->executor->execute('php-err', ['php', '-r', 'fwrite(STDERR, "err-msg");']); + $this->assertStringContainsString('err-msg', $result->stderr); + } + + #[Test] + public function resolveBinaryFindsLocalVendorBin(): void + { + // phpunit is installed as a dev dependency — vendor/bin/phpunit must exist + $binary = $this->executor->resolveBinary('vendor/bin/phpunit'); + $this->assertNotNull($binary); + $this->assertStringContainsString('phpunit', $binary); + } + + #[Test] + public function resolveBinaryReturnsNullForNonExistentTool(): void + { + // Use an executor with a path where the binary won't exist locally OR globally + $executor = new ProcessExecutor('/tmp'); + $result = $executor->resolveBinary('vendor/bin/nonexistent-tool-xyz-999'); + $this->assertNull($result); + } + + #[Test] + public function executeReturnsNonZeroForFailingCommand(): void + { + $result = $this->executor->execute('exit-1', ['php', '-r', 'exit(2);']); + $this->assertSame(2, $result->exitCode); + $this->assertFalse($result->success); + } + + #[Test] + public function toolResultOutputCombinesStdoutAndStderr(): void + { + $result = $this->executor->execute('test', ['php', '-r', 'echo "out"; fwrite(STDERR, "err");']); + $output = $result->output(); + $this->assertStringContainsString('out', $output); + } + + #[Test] + public function toolResultOutputReturnsNoOutputWhenBothEmpty(): void + { + $result = new ToolResult('test', 0, '', '', 0.0); + $this->assertSame('(no output)', $result->output()); + } +} diff --git a/tests/Unit/Core/ProjectDetectorTest.php b/tests/Unit/Core/ProjectDetectorTest.php new file mode 100644 index 0000000..60da41b --- /dev/null +++ b/tests/Unit/Core/ProjectDetectorTest.php @@ -0,0 +1,159 @@ +tmpDir = sys_get_temp_dir() . '/devkit_detector_test_' . uniqid(); + mkdir($this->tmpDir, 0o777, true); + $this->detector = new ProjectDetector(); + } + + protected function tearDown(): void + { + $this->removeDir($this->tmpDir); + } + + #[Test] + public function detectThrowsWhenComposerJsonMissing(): void + { + $this->expectException(DevkitException::class); + $this->detector->detect($this->tmpDir); + } + + #[Test] + public function detectReturnsProjectContext(): void + { + $this->writeComposer([ + 'name' => 'vendor/project', + 'autoload' => ['psr-4' => ['Vendor\\Project\\' => 'src/']], + 'autoload-dev' => ['psr-4' => ['Vendor\\Project\\Tests\\' => 'tests/']], + ]); + mkdir($this->tmpDir . '/src', 0o777, true); + + $ctx = $this->detector->detect($this->tmpDir); + $this->assertInstanceOf(ProjectContext::class, $ctx); + } + + #[Test] + public function detectParsesProjectName(): void + { + $this->writeComposer(['name' => 'acme/my-lib']); + $ctx = $this->detector->detect($this->tmpDir); + $this->assertSame('acme/my-lib', $ctx->projectName); + } + + #[Test] + public function detectFallsBackToDirectoryNameWhenNoComposerName(): void + { + $this->writeComposer([]); + $ctx = $this->detector->detect($this->tmpDir); + $this->assertSame(basename($this->tmpDir), $ctx->projectName); + } + + #[Test] + public function detectParsesNamespaceFromPsr4(): void + { + $this->writeComposer([ + 'name' => 'vendor/project', + 'autoload' => ['psr-4' => ['Acme\\Lib\\' => 'src/']], + ]); + $ctx = $this->detector->detect($this->tmpDir); + $this->assertStringContainsString('Acme', $ctx->namespace); + } + + #[Test] + public function detectUsesDefaultPhpVersionWhenMissing(): void + { + $this->writeComposer(['name' => 'test/pkg']); + $ctx = $this->detector->detect($this->tmpDir); + $this->assertNotEmpty($ctx->phpVersion); + } + + #[Test] + public function detectParsesPhpVersionFromRequire(): void + { + $this->writeComposer([ + 'name' => 'test/pkg', + 'require' => ['php' => '>=8.3'], + ]); + $ctx = $this->detector->detect($this->tmpDir); + $this->assertStringContainsString('8.3', $ctx->phpVersion); + } + + #[Test] + public function detectSetsDefaultSourceDirWhenSrcNotInPsr4(): void + { + mkdir($this->tmpDir . '/src', 0o777, true); + $this->writeComposer(['name' => 'test/pkg']); + $ctx = $this->detector->detect($this->tmpDir); + $this->assertNotEmpty($ctx->sourceDirs); + } + + #[Test] + public function detectSetsDefaultTestDirWhenTestsNotInPsr4(): void + { + mkdir($this->tmpDir . '/tests', 0o777, true); + $this->writeComposer(['name' => 'test/pkg']); + $ctx = $this->detector->detect($this->tmpDir); + $this->assertNotEmpty($ctx->testDirs); + } + + #[Test] + public function detectLoadsDevkitConfigOverrides(): void + { + $this->writeComposer(['name' => 'test/pkg']); + file_put_contents($this->tmpDir . '/devkit.php', " 7];"); + + $ctx = $this->detector->detect($this->tmpDir); + $this->assertSame(7, $ctx->phpstanLevel); + } + + /** @param array $data */ + private function writeComposer(array $data): void + { + file_put_contents( + $this->tmpDir . '/composer.json', + json_encode($data, JSON_PRETTY_PRINT), + ); + } + + private function removeDir(string $dir): void + { + if (! is_dir($dir)) { + return; + } + + $items = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($dir, \FilesystemIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST, + ); + + foreach ($items as $item) { + /** @var \SplFileInfo $item */ + $item->isDir() ? rmdir($item->getPathname()) : unlink($item->getPathname()); + } + + rmdir($dir); + } +} diff --git a/tests/Unit/Exception/ConfigurationExceptionTest.php b/tests/Unit/Exception/ConfigurationExceptionTest.php index 7ae1044..f4340b1 100644 --- a/tests/Unit/Exception/ConfigurationExceptionTest.php +++ b/tests/Unit/Exception/ConfigurationExceptionTest.php @@ -8,9 +8,11 @@ use KaririCode\Devkit\Exception\DevkitException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[CoversClass(ConfigurationException::class)] +#[UsesClass(DevkitException::class)] final class ConfigurationExceptionTest extends TestCase { #[Test] diff --git a/tests/Unit/Exception/ToolExceptionTest.php b/tests/Unit/Exception/ToolExceptionTest.php index eb74bae..418e011 100644 --- a/tests/Unit/Exception/ToolExceptionTest.php +++ b/tests/Unit/Exception/ToolExceptionTest.php @@ -8,9 +8,11 @@ use KaririCode\Devkit\Exception\ToolException; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[CoversClass(ToolException::class)] +#[UsesClass(DevkitException::class)] final class ToolExceptionTest extends TestCase { #[Test] diff --git a/tests/Unit/Runner/RunnersTest.php b/tests/Unit/Runner/RunnersTest.php new file mode 100644 index 0000000..6518f0e --- /dev/null +++ b/tests/Unit/Runner/RunnersTest.php @@ -0,0 +1,226 @@ +projectRoot = (string) realpath(\dirname(__DIR__, 3)); + $this->executor = new ProcessExecutor($this->projectRoot); + $this->context = new ProjectContext( + projectRoot: $this->projectRoot, + projectName: 'kariricode/devkit', + namespace: 'KaririCode\\Devkit', + phpVersion: '8.4', + phpstanLevel: 9, + psalmLevel: 3, + sourceDirs: [$this->projectRoot . '/src'], + testDirs: [$this->projectRoot . '/tests'], + excludeDirs: [], + testSuites: ['Unit' => 'tests/Unit'], + coverageExclude: [], + csFixerRules: [], + rectorSets: [], + toolVersions: [], + ); + } + + // ── PHPUnit Runner ───────────────────────────────────────────── + + #[Test] + public function phpUnitRunnerToolNameIsPhpunit(): void + { + $runner = new PhpUnitRunner($this->executor, $this->context); + $this->assertSame('phpunit', $runner->toolName()); + } + + #[Test] + public function phpUnitRunnerIsAvailableWhenBinaryExists(): void + { + $runner = new PhpUnitRunner($this->executor, $this->context); + // phpunit is a dev dependency of this project — must be available + $this->assertTrue($runner->isAvailable()); + } + + #[Test] + public function phpUnitRunnerRunReturnsToolResult(): void + { + $runner = new PhpUnitRunner($this->executor, $this->context); + $result = $runner->run(['--version']); + $this->assertInstanceOf(ToolResult::class, $result); + $this->assertSame('phpunit', $result->toolName); + } + + // ── PHPStan Runner ───────────────────────────────────────────── + + #[Test] + public function phpStanRunnerToolNameIsPhpstan(): void + { + $runner = new PhpStanRunner($this->executor, $this->context); + $this->assertSame('phpstan', $runner->toolName()); + } + + #[Test] + public function phpStanRunnerIsAvailableWhenBinaryExists(): void + { + $runner = new PhpStanRunner($this->executor, $this->context); + $this->assertTrue($runner->isAvailable()); + } + + // ── CS Fixer Runner ──────────────────────────────────────────── + + #[Test] + public function csFixerRunnerToolNameIsCsFixer(): void + { + $runner = new CsFixerRunner($this->executor, $this->context); + $this->assertSame('cs-fixer', $runner->toolName()); + } + + #[Test] + public function csFixerRunnerIsAvailableWhenBinaryExists(): void + { + $runner = new CsFixerRunner($this->executor, $this->context); + $this->assertTrue($runner->isAvailable()); + } + + // ── Rector Runner ────────────────────────────────────────────── + + #[Test] + public function rectorRunnerToolNameIsRector(): void + { + $runner = new RectorRunner($this->executor, $this->context); + $this->assertSame('rector', $runner->toolName()); + } + + #[Test] + public function rectorRunnerIsAvailableWhenBinaryExists(): void + { + $runner = new RectorRunner($this->executor, $this->context); + $this->assertTrue($runner->isAvailable()); + } + + // ── Psalm Runner ─────────────────────────────────────────────── + + #[Test] + public function psalmRunnerToolNameIsPsalm(): void + { + $runner = new PsalmRunner($this->executor, $this->context); + $this->assertSame('psalm', $runner->toolName()); + } + + #[Test] + public function psalmRunnerIsAvailableWhenBinaryExists(): void + { + $runner = new PsalmRunner($this->executor, $this->context); + $this->assertTrue($runner->isAvailable()); + } + + // ── Composer Audit Runner ────────────────────────────────────── + + #[Test] + public function composerAuditRunnerToolNameIsComposerAudit(): void + { + $runner = new ComposerAuditRunner($this->executor, $this->context); + $this->assertSame('composer-audit', $runner->toolName()); + } + + #[Test] + public function composerAuditRunnerIsAvailableWhenComposerInPath(): void + { + $runner = new ComposerAuditRunner($this->executor, $this->context); + // composer is available in CI/dev environments + $available = $runner->isAvailable(); + $this->assertIsBool($available); + } + + // ── AbstractToolRunner — binary not found path ───────────────── + + #[Test] + public function runnerReturns127WhenBinaryNotFound(): void + { + // Use a path where no vendor/bin exists + $executorNoVendor = new ProcessExecutor('/tmp'); + $context = new ProjectContext( + projectRoot: '/tmp', + projectName: 'test/project', + namespace: 'Test', + phpVersion: '8.4', + phpstanLevel: 9, + psalmLevel: 3, + sourceDirs: ['/tmp/src'], + testDirs: ['/tmp/tests'], + excludeDirs: [], + testSuites: [], + coverageExclude: [], + csFixerRules: [], + rectorSets: [], + toolVersions: [], + ); + + // Use a custom runner that resolves to a binary that doesn't exist + $runner = new class ($executorNoVendor, $context) extends AbstractToolRunner { + public function toolName(): string + { + return 'nonexistent-tool-xyz'; + } + + protected function vendorBin(): string + { + return 'vendor/bin/nonexistent-tool-xyz'; + } + + protected function defaultArguments(): array + { + return []; + } + }; + + $result = $runner->run([]); + $this->assertSame(127, $result->exitCode); + $this->assertFalse($result->success); + $this->assertStringContainsString('Binary not found', $result->stderr); + } + + #[Test] + public function runnerCachesBinaryResolution(): void + { + $runner = new PhpUnitRunner($this->executor, $this->context); + + // Call isAvailable twice — binary resolution should only happen once (lazy cached) + $first = $runner->isAvailable(); + $second = $runner->isAvailable(); + $this->assertSame($first, $second); + } +} diff --git a/tests/Unit/ValueObject/QualityReportTest.php b/tests/Unit/ValueObject/QualityReportTest.php index 40f5ed2..be93e0f 100644 --- a/tests/Unit/ValueObject/QualityReportTest.php +++ b/tests/Unit/ValueObject/QualityReportTest.php @@ -8,9 +8,11 @@ use KaririCode\Devkit\ValueObject\ToolResult; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\UsesClass; use PHPUnit\Framework\TestCase; #[CoversClass(QualityReport::class)] +#[UsesClass(ToolResult::class)] final class QualityReportTest extends TestCase { private function makeResult(string $name, int $exitCode, float $elapsed = 0.1): ToolResult