diff --git a/tests/Unit/ReportCommandTest.php b/tests/Unit/ReportCommandTest.php
new file mode 100644
index 0000000..d0619d4
--- /dev/null
+++ b/tests/Unit/ReportCommandTest.php
@@ -0,0 +1,254 @@
+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::assertNotFalse($content, 'Failed to read report.js file');
+
+ 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::assertNotFalse($content, 'Failed to read index.html file');
+
+ 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();
+ self::assertNotFalse($originalDir, 'Failed to get current working directory');
+ 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 = 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);
+ }
+ rmdir($dir);
+ }
+}
diff --git a/tests/Unit/ReportNormalizer.php b/tests/Unit/ReportNormalizer.php
new file mode 100644
index 0000000..ec08031
--- /dev/null
+++ b/tests/Unit/ReportNormalizer.php
@@ -0,0 +1,43 @@
+
+ */
+ public function normalize(mixed $object, ?string $format = null, array $context = []): array
+ {
+ assert($object instanceof Report);
+
+ return [
+ 'aggregateRoots' => $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);
+ }
+}