From 3ba8f1088bb9538ea6d514940e4859056c584e8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 14:44:50 +0000 Subject: [PATCH 1/4] Initial plan From 0c85fe0e5fe76cc551d0a049cc9c5027e049fbec Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 14:48:44 +0000 Subject: [PATCH 2/4] Add comprehensive test coverage for Report and ReportCommand Co-authored-by: makomweb <1567373+makomweb@users.noreply.github.com> --- tests/Unit/ReportCommandTest.php | 246 +++++++++++++++++++++++++++++++ tests/Unit/ReportNormalizer.php | 40 +++++ tests/Unit/ReportTest.php | 132 +++++++++++++++++ 3 files changed, 418 insertions(+) create mode 100644 tests/Unit/ReportCommandTest.php create mode 100644 tests/Unit/ReportNormalizer.php create mode 100644 tests/Unit/ReportTest.php diff --git a/tests/Unit/ReportCommandTest.php b/tests/Unit/ReportCommandTest.php new file mode 100644 index 0000000..5d79fa4 --- /dev/null +++ b/tests/Unit/ReportCommandTest.php @@ -0,0 +1,246 @@ +tempDir = sys_get_temp_dir().'/tactix_test_'.uniqid(); + mkdir($this->tempDir, 0777, true); + } + + protected function tearDown(): void + { + parent::tearDown(); + if (is_dir($this->tempDir)) { + $this->removeDirectory($this->tempDir); + } + } + + private function createSerializer(): Serializer + { + return new Serializer([new ReportNormalizer(), new GetSetMethodNormalizer()], [new JsonEncoder()]); + } + + #[Test] + public function command_should_succeed_with_valid_folder(): void + { + $command = new ReportCommand($this->createSerializer()); + $commandTester = new CommandTester($command); + + $folder = __DIR__.'/../Data'; + $commandTester->execute([ + 'folder' => $folder, + '--out-dir' => $this->tempDir, + ]); + + self::assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $output = $commandTester->getDisplay(); + self::assertStringContainsString('Report for', $output); + self::assertStringContainsString('Report written to:', $output); + } + + #[Test] + public function command_should_generate_report_files(): void + { + $command = new ReportCommand($this->createSerializer()); + $commandTester = new CommandTester($command); + + $folder = __DIR__.'/../Data'; + $commandTester->execute([ + 'folder' => $folder, + '--out-dir' => $this->tempDir, + ]); + + $reportDir = $this->tempDir.'/report'; + self::assertFileExists($reportDir.'/index.html'); + self::assertFileExists($reportDir.'/report.js'); + self::assertFileExists($reportDir.'/chart.js'); + self::assertFileExists($reportDir.'/styles.css'); + } + + #[Test] + public function command_should_generate_valid_report_js(): void + { + $command = new ReportCommand($this->createSerializer()); + $commandTester = new CommandTester($command); + + $folder = __DIR__.'/../Data'; + $commandTester->execute([ + 'folder' => $folder, + '--out-dir' => $this->tempDir, + ]); + + $reportJsPath = $this->tempDir.'/report/report.js'; + $content = file_get_contents($reportJsPath); + + self::assertStringStartsWith('const reportData = ', $content); + self::assertStringContainsString('"folder":', $content); + self::assertStringContainsString('"classes":', $content); + self::assertStringContainsString('"forbidden":', $content); + } + + #[Test] + public function command_should_generate_valid_html_structure(): void + { + $command = new ReportCommand($this->createSerializer()); + $commandTester = new CommandTester($command); + + $folder = __DIR__.'/../Data'; + $commandTester->execute([ + 'folder' => $folder, + '--out-dir' => $this->tempDir, + ]); + + $htmlPath = $this->tempDir.'/report/index.html'; + $content = file_get_contents($htmlPath); + + self::assertStringContainsString('', $content); + self::assertStringContainsString('Charts Report', $content); + self::assertStringContainsString('id="pieChart"', $content); + self::assertStringContainsString('id="barChart"', $content); + self::assertStringContainsString('id="classesTableContainer"', $content); + self::assertStringContainsString('', $content); + self::assertStringContainsString('', $content); + } + + #[Test] + public function command_should_use_current_directory_when_no_out_dir_specified(): void + { + $command = new ReportCommand($this->createSerializer()); + $commandTester = new CommandTester($command); + + // Change to temp directory before running command + $originalDir = getcwd(); + chdir($this->tempDir); + + try { + $folder = __DIR__.'/../Data'; + $commandTester->execute(['folder' => $folder]); + + self::assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + self::assertFileExists($this->tempDir.'/report/index.html'); + } finally { + chdir($originalDir); + } + } + + #[Test] + public function command_should_display_uncategorized_classes_when_present(): void + { + $command = new ReportCommand($this->createSerializer()); + $commandTester = new CommandTester($command); + + $folder = __DIR__.'/../Data'; + $commandTester->execute([ + 'folder' => $folder, + '--out-dir' => $this->tempDir, + ]); + + $output = $commandTester->getDisplay(); + self::assertStringContainsString('Uncategorized Classes', $output); + } + + #[Test] + public function command_should_complete_successfully_and_show_sections(): void + { + $command = new ReportCommand($this->createSerializer()); + $commandTester = new CommandTester($command); + + $folder = __DIR__.'/../Data'; + $commandTester->execute([ + 'folder' => $folder, + '--out-dir' => $this->tempDir, + ]); + + self::assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + $output = $commandTester->getDisplay(); + // The command should always show the report title and table + self::assertStringContainsString('Report for', $output); + self::assertStringContainsString('FQCN', $output); + } + + #[Test] + public function command_should_create_output_directory_if_not_exists(): void + { + $command = new ReportCommand($this->createSerializer()); + $commandTester = new CommandTester($command); + + $nonExistentDir = $this->tempDir.'/nested/path/that/does/not/exist'; + self::assertDirectoryDoesNotExist($nonExistentDir); + + $folder = __DIR__.'/../Data'; + $commandTester->execute([ + 'folder' => $folder, + '--out-dir' => $nonExistentDir, + ]); + + self::assertSame(Command::SUCCESS, $commandTester->getStatusCode()); + self::assertFileExists($nonExistentDir.'/report/index.html'); + } + + #[Test] + public function command_should_fail_gracefully_with_exception(): void + { + $command = new ReportCommand($this->createSerializer()); + $commandTester = new CommandTester($command); + + // Use a non-existent folder that will cause an error + $folder = '/nonexistent/folder/that/does/not/exist'; + $commandTester->execute([ + 'folder' => $folder, + '--out-dir' => $this->tempDir, + ]); + + self::assertSame(Command::FAILURE, $commandTester->getStatusCode()); + $output = $commandTester->getDisplay(); + self::assertStringContainsString('[ERROR]', $output); + } + + #[Test] + public function command_should_include_class_distribution_table(): void + { + $command = new ReportCommand($this->createSerializer()); + $commandTester = new CommandTester($command); + + $folder = __DIR__.'/../Data'; + $commandTester->execute([ + 'folder' => $folder, + '--out-dir' => $this->tempDir, + ]); + + $output = $commandTester->getDisplay(); + // The table should have headers + self::assertStringContainsString('FQCN', $output); + self::assertStringContainsString('Tag', $output); + } + + private function removeDirectory(string $dir): void + { + if (!is_dir($dir)) { + return; + } + + $files = array_diff(scandir($dir), ['.', '..']); + foreach ($files as $file) { + $path = $dir.'/'.$file; + is_dir($path) ? $this->removeDirectory($path) : unlink($path); + } + rmdir($dir); + } +} diff --git a/tests/Unit/ReportNormalizer.php b/tests/Unit/ReportNormalizer.php new file mode 100644 index 0000000..02abb3c --- /dev/null +++ b/tests/Unit/ReportNormalizer.php @@ -0,0 +1,40 @@ + $object->aggregateRoots, + 'entities' => $object->entities, + 'factories' => $object->factories, + 'repositories' => $object->repositories, + 'services' => $object->services, + 'valueObjects' => $object->valueObjects, + 'interfaces' => $object->interfaces, + 'exceptions' => $object->exceptions, + 'uncategorized' => $object->uncategorized, + ]; + } + + public function supportsNormalization(mixed $data, ?string $format = null, array $context = []): bool + { + return $data instanceof Report; + } + + public function getSupportedTypes(?string $format): array + { + return [ + Report::class => true, + ]; + } +} diff --git a/tests/Unit/ReportTest.php b/tests/Unit/ReportTest.php new file mode 100644 index 0000000..64a1a96 --- /dev/null +++ b/tests/Unit/ReportTest.php @@ -0,0 +1,132 @@ +aggregateRoots); + self::assertSame([], $report->entities); + self::assertSame([], $report->factories); + self::assertSame([], $report->repositories); + self::assertSame([], $report->services); + self::assertSame([], $report->valueObjects); + self::assertSame([], $report->interfaces); + self::assertSame([], $report->exceptions); + self::assertSame([], $report->uncategorized); + } + + #[Test] + public function report_should_classify_interface(): void + { + $report = Report::initial()->withClassName(MyInterface::class); + + self::assertSame([MyInterface::class], $report->interfaces); + self::assertSame([], $report->entities); + self::assertSame([], $report->valueObjects); + self::assertSame([], $report->exceptions); + self::assertSame([], $report->uncategorized); + } + + #[Test] + public function report_should_classify_exception(): void + { + $report = Report::initial()->withClassName(MyException::class); + + self::assertSame([MyException::class], $report->exceptions); + self::assertSame([], $report->interfaces); + self::assertSame([], $report->entities); + self::assertSame([], $report->valueObjects); + self::assertSame([], $report->uncategorized); + } + + #[Test] + public function report_should_classify_entity(): void + { + $report = Report::initial()->withClassName(MyEntity::class); + + self::assertSame([MyEntity::class], $report->entities); + self::assertSame([], $report->interfaces); + self::assertSame([], $report->valueObjects); + self::assertSame([], $report->exceptions); + self::assertSame([], $report->uncategorized); + } + + #[Test] + public function report_should_classify_value_object(): void + { + $report = Report::initial()->withClassName(MyValueObject::class); + + self::assertSame([MyValueObject::class], $report->valueObjects); + self::assertSame([], $report->interfaces); + self::assertSame([], $report->entities); + self::assertSame([], $report->exceptions); + self::assertSame([], $report->uncategorized); + } + + #[Test] + public function report_should_classify_uncategorized(): void + { + $report = Report::initial()->withClassName(MyWithoutTag::class); + + self::assertSame([MyWithoutTag::class], $report->uncategorized); + self::assertSame([], $report->interfaces); + self::assertSame([], $report->entities); + self::assertSame([], $report->valueObjects); + self::assertSame([], $report->exceptions); + } + + #[Test] + public function report_should_accumulate_multiple_classes(): void + { + $report = Report::initial() + ->withClassName(MyInterface::class) + ->withClassName(MyEntity::class) + ->withClassName(MyValueObject::class) + ->withClassName(MyException::class) + ->withClassName(MyWithoutTag::class); + + self::assertSame([MyInterface::class], $report->interfaces); + self::assertSame([MyEntity::class], $report->entities); + self::assertSame([MyValueObject::class], $report->valueObjects); + self::assertSame([MyException::class], $report->exceptions); + self::assertSame([MyWithoutTag::class], $report->uncategorized); + } + + #[Test] + public function report_should_accumulate_multiple_classes_of_same_type(): void + { + $report = Report::initial() + ->withClassName(MyEntity::class) + ->withClassName(MyValueObject::class); + + self::assertSame([MyEntity::class], $report->entities); + self::assertSame([MyValueObject::class], $report->valueObjects); + } + + #[Test] + public function report_should_be_immutable(): void + { + $initial = Report::initial(); + $withEntity = $initial->withClassName(MyEntity::class); + + self::assertNotSame($initial, $withEntity); + self::assertSame([], $initial->entities); + self::assertSame([MyEntity::class], $withEntity->entities); + } +} From 5b1f0ead81c5c9644414617d030c4ecc7f052c8b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 14:49:45 +0000 Subject: [PATCH 3/4] Fix PHPStan type safety issues in tests Co-authored-by: makomweb <1567373+makomweb@users.noreply.github.com> --- tests/Unit/ReportCommandTest.php | 3 +++ tests/Unit/ReportNormalizer.php | 3 +++ 2 files changed, 6 insertions(+) diff --git a/tests/Unit/ReportCommandTest.php b/tests/Unit/ReportCommandTest.php index 5d79fa4..3c9c934 100644 --- a/tests/Unit/ReportCommandTest.php +++ b/tests/Unit/ReportCommandTest.php @@ -88,6 +88,7 @@ public function command_should_generate_valid_report_js(): void $reportJsPath = $this->tempDir.'/report/report.js'; $content = file_get_contents($reportJsPath); + self::assertNotFalse($content, 'Failed to read report.js file'); self::assertStringStartsWith('const reportData = ', $content); self::assertStringContainsString('"folder":', $content); @@ -109,6 +110,7 @@ public function command_should_generate_valid_html_structure(): void $htmlPath = $this->tempDir.'/report/index.html'; $content = file_get_contents($htmlPath); + self::assertNotFalse($content, 'Failed to read index.html file'); self::assertStringContainsString('', $content); self::assertStringContainsString('Charts Report', $content); @@ -127,6 +129,7 @@ public function command_should_use_current_directory_when_no_out_dir_specified() // Change to temp directory before running command $originalDir = getcwd(); + self::assertNotFalse($originalDir, 'Failed to get current working directory'); chdir($this->tempDir); try { diff --git a/tests/Unit/ReportNormalizer.php b/tests/Unit/ReportNormalizer.php index 02abb3c..ec08031 100644 --- a/tests/Unit/ReportNormalizer.php +++ b/tests/Unit/ReportNormalizer.php @@ -9,6 +9,9 @@ final class ReportNormalizer implements NormalizerInterface { + /** + * @return array + */ public function normalize(mixed $object, ?string $format = null, array $context = []): array { assert($object instanceof Report); From 44e284dc1420f954e7ab4fded69f94f9e6e8b7e8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 25 Jan 2026 14:51:04 +0000 Subject: [PATCH 4/4] Fix scandir error handling in test cleanup Co-authored-by: makomweb <1567373+makomweb@users.noreply.github.com> --- tests/Unit/ReportCommandTest.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/Unit/ReportCommandTest.php b/tests/Unit/ReportCommandTest.php index 3c9c934..d0619d4 100644 --- a/tests/Unit/ReportCommandTest.php +++ b/tests/Unit/ReportCommandTest.php @@ -239,7 +239,12 @@ private function removeDirectory(string $dir): void return; } - $files = array_diff(scandir($dir), ['.', '..']); + $files = scandir($dir); + if (false === $files) { + return; + } + + $files = array_diff($files, ['.', '..']); foreach ($files as $file) { $path = $dir.'/'.$file; is_dir($path) ? $this->removeDirectory($path) : unlink($path);