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);