From dca4519e00e5d7b150fdf85ffe817f83a74140b2 Mon Sep 17 00:00:00 2001 From: dimarik82 Date: Fri, 15 May 2026 17:05:29 +0000 Subject: [PATCH 1/3] add command/tools test suite passing phpstan level 9 Introduce PHPUnit tests for cmd presentation commands and console output tools, with hand-written fakes. All test code is clean under phpstan level 9 (src + tests + example): - FakeConsoleOutput: type iterable messages per Symfony contract - CommandOptionsTest: route nullable capturedOptions through a typed assertNotNull helper - PrettyConsoleOutputTest: non-empty-string action matching the event - TraceConsoleOutputTest: fresh fake per case instead of array reset Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 2 + Makefile | 7 + phpcs.xml.dist | 9 + tests/Fakes/FakeConsoleOutput.php | 103 +++++ tests/Fakes/FakeMigrator.php | 87 ++++ tests/Fakes/FakeMigratorException.php | 18 + tests/cmd/presentation/CommandOptionsTest.php | 386 +++++++++++++++++ .../CommandOptionsTestFixture.php | 47 +++ tests/cmd/presentation/CreateCommandTest.php | 107 +++++ tests/cmd/presentation/DownCommandTest.php | 107 +++++ tests/cmd/presentation/FixtureCommandTest.php | 86 ++++ tests/cmd/presentation/InitCommandTest.php | 69 +++ tests/cmd/presentation/RedoCommandTest.php | 107 +++++ tests/cmd/presentation/UpCommandTest.php | 107 +++++ tests/cmd/presentation/VerifyCommandTest.php | 107 +++++ tests/tools/PrettyConsoleOutputTest.php | 392 ++++++++++++++++++ tests/tools/TraceConsoleOutputTest.php | 179 ++++++++ 17 files changed, 1920 insertions(+) create mode 100644 tests/Fakes/FakeConsoleOutput.php create mode 100644 tests/Fakes/FakeMigrator.php create mode 100644 tests/Fakes/FakeMigratorException.php create mode 100644 tests/cmd/presentation/CommandOptionsTest.php create mode 100644 tests/cmd/presentation/CommandOptionsTestFixture.php create mode 100644 tests/cmd/presentation/CreateCommandTest.php create mode 100644 tests/cmd/presentation/DownCommandTest.php create mode 100644 tests/cmd/presentation/FixtureCommandTest.php create mode 100644 tests/cmd/presentation/InitCommandTest.php create mode 100644 tests/cmd/presentation/RedoCommandTest.php create mode 100644 tests/cmd/presentation/UpCommandTest.php create mode 100644 tests/cmd/presentation/VerifyCommandTest.php create mode 100644 tests/tools/PrettyConsoleOutputTest.php create mode 100644 tests/tools/TraceConsoleOutputTest.php diff --git a/.gitignore b/.gitignore index c356681..aecd873 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # phpstorm project files .idea .zed +.claude # Mac DS_Store Files .DS_Store @@ -18,4 +19,5 @@ phpunit.phar # local *.local.php +*.env .auth.json diff --git a/Makefile b/Makefile index 82f1255..4f55614 100644 --- a/Makefile +++ b/Makefile @@ -143,3 +143,10 @@ _volume_remove: docker volume rm -f \ migrator_pg_data \ migrator_mysql_data + +## AI +-include .claude/Makefile + +# Спец-правило, чтобы Makefile не ругался на неизвестные команды (аргументы) +%: + @: diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 58b99a3..b1a3588 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -43,4 +43,13 @@ + + + + tests/ + + + + tests/ + diff --git a/tests/Fakes/FakeConsoleOutput.php b/tests/Fakes/FakeConsoleOutput.php new file mode 100644 index 0000000..c7b937c --- /dev/null +++ b/tests/Fakes/FakeConsoleOutput.php @@ -0,0 +1,103 @@ + */ + public array $lines = []; + + /** @param string|iterable $messages */ + public function writeln(string | iterable $messages, int $options = 0): void + { + if (is_iterable($messages)) { + foreach ($messages as $message) { + $this->lines[] = $message; + } + } else { + $this->lines[] = $messages; + } + } + + /** @param string|iterable $messages */ + public function write(string | iterable $messages, bool $newline = false, int $options = 0): void + { + // Not needed for our tests + } + + public function getErrorOutput(): OutputInterface + { + return $this; + } + + public function setErrorOutput(OutputInterface $error): void + { + // Not needed for our tests + } + + public function section(): ConsoleSectionOutput + { + throw new LogicException('Not implemented in fake'); + } + + public function setVerbosity(int $level): void + { + // Not needed for our tests + } + + public function getVerbosity(): int + { + return self::VERBOSITY_NORMAL; + } + + public function isQuiet(): bool + { + return false; + } + + public function isVerbose(): bool + { + return false; + } + + public function isVeryVerbose(): bool + { + return false; + } + + public function isDebug(): bool + { + return false; + } + + public function setDecorated(bool $decorated): void + { + // Not needed for our tests + } + + public function isDecorated(): bool + { + return false; + } + + public function setFormatter(OutputFormatterInterface $formatter): void + { + // Not needed for our tests + } + + public function getFormatter(): OutputFormatterInterface + { + throw new LogicException('Not implemented in fake'); + } +} diff --git a/tests/Fakes/FakeMigrator.php b/tests/Fakes/FakeMigrator.php new file mode 100644 index 0000000..ee37e79 --- /dev/null +++ b/tests/Fakes/FakeMigrator.php @@ -0,0 +1,87 @@ +initCallCount++; + if ($this->initException instanceof Throwable) { + throw $this->initException; + } + } + + public function create(InputOptions $args = new InputOptions()): void + { + $this->lastCreateOptions = $args; + if ($this->createException instanceof Throwable) { + throw $this->createException; + } + } + + public function up(InputOptions $args = new InputOptions()): void + { + $this->lastUpOptions = $args; + if ($this->upException instanceof Throwable) { + throw $this->upException; + } + } + + public function down(InputOptions $args = new InputOptions()): void + { + $this->lastDownOptions = $args; + if ($this->downException instanceof Throwable) { + throw $this->downException; + } + } + + public function fixture(InputOptions $args = new InputOptions()): void + { + $this->lastFixtureOptions = $args; + if ($this->fixtureException instanceof Throwable) { + throw $this->fixtureException; + } + } + + public function redo(InputOptions $args = new InputOptions()): void + { + $this->lastRedoOptions = $args; + if ($this->redoException instanceof Throwable) { + throw $this->redoException; + } + } + + public function verify(InputOptions $args = new InputOptions()): void + { + $this->lastVerifyOptions = $args; + if ($this->verifyException instanceof Throwable) { + throw $this->verifyException; + } + } +} diff --git a/tests/Fakes/FakeMigratorException.php b/tests/Fakes/FakeMigratorException.php new file mode 100644 index 0000000..af439cf --- /dev/null +++ b/tests/Fakes/FakeMigratorException.php @@ -0,0 +1,18 @@ +makeCommandWithLimit(); + $tester = new CommandTester($command); + + // When + $tester->execute($inputValue === null ? [] : ['--limit' => $inputValue]); + + // Then + self::assertSame($expected, $this->capturedOptions($command)->limit); + } + + /** @return array */ + public static function valid_limit_cases(): array + { + return [ + 'happy path: no limit given uses default zero' => [null, 0], + 'happy path: explicit zero limit' => [0, 0], + 'happy path: positive integer limit' => [5, 5], + 'happy path: numeric string is cast to int' => ['3', 3], + ]; + } + + #[Test] + public function negative_limit_throws_invalid_argument_exception(): void + { + // Given + $command = $this->makeCommandWithLimit(); + $tester = new CommandTester($command); + + // Then + $this->expectException(InvalidArgumentException::class); + + // When — CommandTester re-throws exceptions by default + $tester->execute(['--limit' => -1]); + } + + #[Test] + public function negative_limit_exception_message_contains_hint(): void + { + // Given + $command = $this->makeCommandWithLimit(); + $tester = new CommandTester($command); + + // When + try { + $tester->execute(['--limit' => -1]); + self::fail('Expected InvalidArgumentException'); + } catch (InvalidArgumentException $e) { + // Then — the plan says to assert message text only for the specific + // documented string "Argument (limit) must be greater than to 0." + self::assertSame('Argument (limit) must be greater than to 0.', $e->getMessage()); + } + } + + // ----------------------------------------------------------------------- + // dry-run flag + // ----------------------------------------------------------------------- + + #[Test] + #[DataProvider('dry_run_cases')] + public function dry_run_flag_maps_to_input_options(bool $pass, bool $expected): void + { + // Given + $command = $this->makeCommandWithDryRun(); + $tester = new CommandTester($command); + + // When + $tester->execute($pass ? ['--dry-run' => true] : []); + + // Then + self::assertSame($expected, $this->capturedOptions($command)->dryRun); + } + + /** @return array */ + public static function dry_run_cases(): array + { + return [ + 'happy path: flag passed -> dryRun true' => [true, true], + 'error: flag absent -> dryRun false' => [false, false], + ]; + } + + // ----------------------------------------------------------------------- + // latest-version flag + // ----------------------------------------------------------------------- + + #[Test] + #[DataProvider('latest_version_cases')] + public function latest_version_flag_maps_to_apply_latest_version(bool $pass, bool $expected): void + { + // Given + $command = $this->makeCommandWithLatestVersion(); + $tester = new CommandTester($command); + + // When + $tester->execute($pass ? ['--latest-version' => true] : []); + + // Then + // hasApplyLatestVersion() returns true when flag is set AND limit==0 AND version==0 + self::assertSame($expected, $this->capturedOptions($command)->hasApplyLatestVersion()); + } + + /** @return array */ + public static function latest_version_cases(): array + { + return [ + 'happy path: flag passed -> hasApplyLatestVersion true' => [true, true], + 'error: flag absent -> hasApplyLatestVersion false' => [false, false], + ]; + } + + // ----------------------------------------------------------------------- + // exactly-all flag + // ----------------------------------------------------------------------- + + #[Test] + #[DataProvider('exactly_all_cases')] + public function exactly_all_flag_maps_to_input_options(bool $pass, bool $expected): void + { + // Given + $command = $this->makeCommandWithExactlyAll(); + $tester = new CommandTester($command); + + // When + $tester->execute($pass ? ['--exactly-all' => true] : []); + + // Then + self::assertSame($expected, $this->capturedOptions($command)->exactlyAll); + } + + /** @return array */ + public static function exactly_all_cases(): array + { + return [ + 'happy path: flag passed -> exactlyAll true' => [true, true], + 'error: flag absent -> exactlyAll false' => [false, false], + ]; + } + + // ----------------------------------------------------------------------- + // with-repeatable flag + // ----------------------------------------------------------------------- + + #[Test] + #[DataProvider('with_repeatable_cases')] + public function with_repeatable_flag_maps_to_has_repeatable(bool $pass, bool $expected): void + { + // Given + $command = $this->makeCommandWithRepeatable(); + $tester = new CommandTester($command); + + // When + $tester->execute($pass ? ['--with-repeatable' => true] : []); + + // Then + // hasRepeatable() requires dryRun===false (default) AND hasRepeatable===true + self::assertSame($expected, $this->capturedOptions($command)->hasRepeatable()); + } + + /** @return array */ + public static function with_repeatable_cases(): array + { + return [ + 'happy path: flag passed -> hasRepeatable true' => [true, true], + 'error: flag absent -> hasRepeatable false' => [false, false], + ]; + } + + // ----------------------------------------------------------------------- + // db option + // ----------------------------------------------------------------------- + + #[Test] + #[DataProvider('db_option_cases')] + public function db_option_maps_to_db_name(mixed $inputValue, ?string $expected): void + { + // Given + $command = $this->makeCommandWithDb(); + $tester = new CommandTester($command); + + // When + $tester->execute($inputValue === null ? [] : ['--db' => $inputValue]); + + // Then + self::assertSame($expected, $this->capturedOptions($command)->dbName); + } + + /** @return array */ + public static function db_option_cases(): array + { + return [ + 'happy path: non-empty string is returned' => ['mydb', 'mydb'], + 'error: empty string becomes null' => ['', null], + 'error: option absent becomes null' => [null, null], + ]; + } + + // ----------------------------------------------------------------------- + // name argument + // ----------------------------------------------------------------------- + + #[Test] + #[DataProvider('name_argument_cases')] + public function name_argument_maps_to_migration_name(string $inputValue, ?string $expected): void + { + // Given + $command = $this->makeCommandWithName(); + $tester = new CommandTester($command); + + // When + $tester->execute(['name' => $inputValue]); + + // Then + self::assertSame($expected, $this->capturedOptions($command)->migrationName); + } + + /** @return array */ + public static function name_argument_cases(): array + { + return [ + 'happy path: non-empty name is returned' => ['my_migration', 'my_migration'], + 'error: empty string becomes null' => ['', null], + ]; + } + + // ----------------------------------------------------------------------- + // hasOption/hasArgument guard: unregistered options have defaults + // ----------------------------------------------------------------------- + + #[Test] + public function command_without_limit_option_produces_default_limit_zero(): void + { + // Given — a command that registers NO options at all + $command = $this->makeMinimalCommand(); + $tester = new CommandTester($command); + + // When + $tester->execute([]); + + // Then — limit stays at the InputOptions default of 0 + self::assertSame(0, $this->capturedOptions($command)->limit); + } + + #[Test] + public function command_without_db_option_produces_null_db_name(): void + { + // Given + $command = $this->makeMinimalCommand(); + $tester = new CommandTester($command); + + // When + $tester->execute([]); + + // Then + self::assertNull($this->capturedOptions($command)->dbName); + } + + #[Test] + public function command_without_dry_run_option_produces_dry_run_false(): void + { + // Given + $command = $this->makeMinimalCommand(); + $tester = new CommandTester($command); + + // When + $tester->execute([]); + + // Then + self::assertFalse($this->capturedOptions($command)->dryRun); + } + + // ----------------------------------------------------------------------- + // Factory helpers that build minimal concrete test Commands + // ----------------------------------------------------------------------- + + private function capturedOptions(CommandOptionsTestFixture $command): InputOptions + { + self::assertNotNull( + $command->capturedOptions, + 'execute() must have run and captured InputOptions before assertion', + ); + + return $command->capturedOptions; + } + + private function makeMinimalCommand(): CommandOptionsTestFixture + { + return new CommandOptionsTestFixture( + configureCallback: static function (Command $cmd): void { + // No options registered — tests hasOption/hasArgument guards + } + ); + } + + private function makeCommandWithLimit(): CommandOptionsTestFixture + { + return new CommandOptionsTestFixture( + configureCallback: static function (Command $cmd): void { + $cmd->addOption('limit', null, InputOption::VALUE_OPTIONAL, 'Limit'); + } + ); + } + + private function makeCommandWithDryRun(): CommandOptionsTestFixture + { + return new CommandOptionsTestFixture( + configureCallback: static function (Command $cmd): void { + $cmd->addOption('dry-run', null, InputOption::VALUE_NONE, 'Dry run'); + } + ); + } + + private function makeCommandWithLatestVersion(): CommandOptionsTestFixture + { + return new CommandOptionsTestFixture( + configureCallback: static function (Command $cmd): void { + $cmd->addOption('latest-version', null, InputOption::VALUE_NONE, 'Latest version'); + } + ); + } + + private function makeCommandWithExactlyAll(): CommandOptionsTestFixture + { + return new CommandOptionsTestFixture( + configureCallback: static function (Command $cmd): void { + $cmd->addOption('exactly-all', null, InputOption::VALUE_NONE, 'Exactly all'); + } + ); + } + + private function makeCommandWithRepeatable(): CommandOptionsTestFixture + { + return new CommandOptionsTestFixture( + configureCallback: static function (Command $cmd): void { + $cmd->addOption('with-repeatable', null, InputOption::VALUE_NONE, 'With repeatable'); + } + ); + } + + private function makeCommandWithDb(): CommandOptionsTestFixture + { + return new CommandOptionsTestFixture( + configureCallback: static function (Command $cmd): void { + $cmd->addOption('db', null, InputOption::VALUE_OPTIONAL, 'DB name'); + } + ); + } + + private function makeCommandWithName(): CommandOptionsTestFixture + { + return new CommandOptionsTestFixture( + configureCallback: static function (Command $cmd): void { + $cmd->addArgument('name', InputArgument::OPTIONAL, 'Migration name'); + } + ); + } +} diff --git a/tests/cmd/presentation/CommandOptionsTestFixture.php b/tests/cmd/presentation/CommandOptionsTestFixture.php new file mode 100644 index 0000000..8a3f8ee --- /dev/null +++ b/tests/cmd/presentation/CommandOptionsTestFixture.php @@ -0,0 +1,47 @@ +configureCallback = $configureCallback; + parent::__construct(); + } + + protected function configure(): void + { + ($this->configureCallback)($this); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $this->capturedOptions = $this->getOptions($input); + return Command::SUCCESS; + } +} diff --git a/tests/cmd/presentation/CreateCommandTest.php b/tests/cmd/presentation/CreateCommandTest.php new file mode 100644 index 0000000..02ca7bb --- /dev/null +++ b/tests/cmd/presentation/CreateCommandTest.php @@ -0,0 +1,107 @@ +execute(['name' => 'create_users_table']); + + // Then + self::assertSame(Command::SUCCESS, $exitCode); + self::assertNotNull($migrator->lastCreateOptions); + self::assertSame('create_users_table', $migrator->lastCreateOptions->migrationName); + } + + #[Test] + public function migrator_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->createException = new FakeMigratorException('create failed'); + $command = new CreateCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute(['name' => 'some_migration']); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('create failed', $tester->getDisplay()); + } + + #[Test] + public function invalid_argument_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->createException = new InvalidArgumentException('bad arg'); + $command = new CreateCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute(['name' => 'some_migration']); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('bad arg', $tester->getDisplay()); + } + + #[Test] + public function generic_throwable_returns_failure(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->createException = new RuntimeException('unexpected'); + $command = new CreateCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute(['name' => 'some_migration']); + + // Then + self::assertSame(Command::FAILURE, $exitCode); + } + + #[Test] + public function db_option_is_passed_through_to_migrator(): void + { + // Given + $migrator = new FakeMigrator(); + $command = new CreateCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([ + 'name' => 'add_orders_table', + '--db' => 'mydb', + ]); + + // Then + self::assertSame(Command::SUCCESS, $exitCode); + self::assertNotNull($migrator->lastCreateOptions); + self::assertSame('mydb', $migrator->lastCreateOptions->dbName); + } +} diff --git a/tests/cmd/presentation/DownCommandTest.php b/tests/cmd/presentation/DownCommandTest.php new file mode 100644 index 0000000..e112f3f --- /dev/null +++ b/tests/cmd/presentation/DownCommandTest.php @@ -0,0 +1,107 @@ +execute([]); + + // Then + self::assertSame(Command::SUCCESS, $exitCode); + self::assertNotNull($migrator->lastDownOptions); + } + + #[Test] + public function migrator_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->downException = new FakeMigratorException('down failed'); + $command = new DownCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('down failed', $tester->getDisplay()); + } + + #[Test] + public function invalid_argument_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->downException = new InvalidArgumentException('bad arg'); + $command = new DownCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('bad arg', $tester->getDisplay()); + } + + #[Test] + public function initialization_exception_writes_hint_and_returns_invalid(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->downException = new InitializationException('not initialized', new RuntimeException()); + $command = new DownCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString( + 'Calling the command "migrate:init" may help fix the error.', + $tester->getDisplay() + ); + } + + #[Test] + public function generic_throwable_returns_failure(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->downException = new RuntimeException('unexpected'); + $command = new DownCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::FAILURE, $exitCode); + } +} diff --git a/tests/cmd/presentation/FixtureCommandTest.php b/tests/cmd/presentation/FixtureCommandTest.php new file mode 100644 index 0000000..554a214 --- /dev/null +++ b/tests/cmd/presentation/FixtureCommandTest.php @@ -0,0 +1,86 @@ +execute([]); + + // Then + self::assertSame(Command::SUCCESS, $exitCode); + self::assertNotNull($migrator->lastFixtureOptions); + } + + #[Test] + public function migrator_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->fixtureException = new FakeMigratorException('fixture failed'); + $command = new FixtureCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('fixture failed', $tester->getDisplay()); + } + + #[Test] + public function invalid_argument_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->fixtureException = new InvalidArgumentException('bad arg'); + $command = new FixtureCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('bad arg', $tester->getDisplay()); + } + + #[Test] + public function generic_throwable_returns_failure(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->fixtureException = new RuntimeException('unexpected'); + $command = new FixtureCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::FAILURE, $exitCode); + } +} diff --git a/tests/cmd/presentation/InitCommandTest.php b/tests/cmd/presentation/InitCommandTest.php new file mode 100644 index 0000000..4f127b0 --- /dev/null +++ b/tests/cmd/presentation/InitCommandTest.php @@ -0,0 +1,69 @@ +execute([]); + + // Then + self::assertSame(Command::SUCCESS, $exitCode); + self::assertSame(1, $migrator->initCallCount); + } + + #[Test] + public function migrator_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->initException = new FakeMigratorException('init failed'); + $command = new InitCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('init failed', $tester->getDisplay()); + } + + #[Test] + public function generic_throwable_returns_failure(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->initException = new RuntimeException('unexpected'); + $command = new InitCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::FAILURE, $exitCode); + } +} diff --git a/tests/cmd/presentation/RedoCommandTest.php b/tests/cmd/presentation/RedoCommandTest.php new file mode 100644 index 0000000..08504f0 --- /dev/null +++ b/tests/cmd/presentation/RedoCommandTest.php @@ -0,0 +1,107 @@ +execute([]); + + // Then + self::assertSame(Command::SUCCESS, $exitCode); + self::assertNotNull($migrator->lastRedoOptions); + } + + #[Test] + public function migrator_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->redoException = new FakeMigratorException('redo failed'); + $command = new RedoCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('redo failed', $tester->getDisplay()); + } + + #[Test] + public function invalid_argument_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->redoException = new InvalidArgumentException('bad arg'); + $command = new RedoCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('bad arg', $tester->getDisplay()); + } + + #[Test] + public function initialization_exception_writes_hint_and_returns_invalid(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->redoException = new InitializationException('not initialized', new RuntimeException()); + $command = new RedoCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString( + 'Calling the command "migrate:init" may help fix the error.', + $tester->getDisplay() + ); + } + + #[Test] + public function generic_throwable_returns_failure(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->redoException = new RuntimeException('unexpected'); + $command = new RedoCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::FAILURE, $exitCode); + } +} diff --git a/tests/cmd/presentation/UpCommandTest.php b/tests/cmd/presentation/UpCommandTest.php new file mode 100644 index 0000000..74f4165 --- /dev/null +++ b/tests/cmd/presentation/UpCommandTest.php @@ -0,0 +1,107 @@ +execute([]); + + // Then + self::assertSame(Command::SUCCESS, $exitCode); + self::assertNotNull($migrator->lastUpOptions); + } + + #[Test] + public function migrator_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->upException = new FakeMigratorException('something went wrong'); + $command = new UpCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('something went wrong', $tester->getDisplay()); + } + + #[Test] + public function invalid_argument_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->upException = new InvalidArgumentException('bad argument'); + $command = new UpCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('bad argument', $tester->getDisplay()); + } + + #[Test] + public function initialization_exception_writes_hint_and_returns_invalid(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->upException = new InitializationException('not initialized', new RuntimeException()); + $command = new UpCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString( + 'Calling the command "migrate:init" may help fix the error.', + $tester->getDisplay() + ); + } + + #[Test] + public function generic_throwable_returns_failure(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->upException = new RuntimeException('unexpected error'); + $command = new UpCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::FAILURE, $exitCode); + } +} diff --git a/tests/cmd/presentation/VerifyCommandTest.php b/tests/cmd/presentation/VerifyCommandTest.php new file mode 100644 index 0000000..8b1295a --- /dev/null +++ b/tests/cmd/presentation/VerifyCommandTest.php @@ -0,0 +1,107 @@ +execute([]); + + // Then + self::assertSame(Command::SUCCESS, $exitCode); + self::assertNotNull($migrator->lastVerifyOptions); + } + + #[Test] + public function migrator_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->verifyException = new FakeMigratorException('verify failed'); + $command = new VerifyCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('verify failed', $tester->getDisplay()); + } + + #[Test] + public function invalid_argument_exception_returns_invalid_and_writes_message(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->verifyException = new InvalidArgumentException('bad arg'); + $command = new VerifyCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString('bad arg', $tester->getDisplay()); + } + + #[Test] + public function initialization_exception_writes_hint_and_returns_invalid(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->verifyException = new InitializationException('not initialized', new RuntimeException()); + $command = new VerifyCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::INVALID, $exitCode); + self::assertStringContainsString( + 'Calling the command "migrate:init" may help fix the error.', + $tester->getDisplay() + ); + } + + #[Test] + public function generic_throwable_returns_failure(): void + { + // Given + $migrator = new FakeMigrator(); + $migrator->verifyException = new RuntimeException('unexpected'); + $command = new VerifyCommand($migrator); + $tester = new CommandTester($command); + + // When + $exitCode = $tester->execute([]); + + // Then + self::assertSame(Command::FAILURE, $exitCode); + } +} diff --git a/tests/tools/PrettyConsoleOutputTest.php b/tests/tools/PrettyConsoleOutputTest.php new file mode 100644 index 0000000..1ac46fa --- /dev/null +++ b/tests/tools/PrettyConsoleOutputTest.php @@ -0,0 +1,392 @@ +output->defaultTo('buffer'); + + /** @var Buffer $buffer */ + $buffer = $climate->output->get('buffer'); + + return [$climate, $buffer->get(...)]; + } + + // ----------------------------------------------------------------------- + // subscriptions() + // ----------------------------------------------------------------------- + + #[Test] + public function subscriptions_covers_every_event_case(): void + { + // Given + $pretty = new PrettyConsoleOutput(new CLImate()); + + // When + $subscriptions = $pretty->subscriptions(); + + // Then + $expectedKeys = array_map(static fn(Event $e): string => $e->value, Event::cases()); + sort($expectedKeys); + + $actualKeys = array_keys($subscriptions); + sort($actualKeys); + + self::assertSame($expectedKeys, $actualKeys); + } + + #[Test] + public function subscriptions_maps_migrate_success_to_success_handler(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $context = new Context(dbName: 'db', filename: 'V1.sql', query: 'SELECT 1'); + $event = new MigrateSuccessEvent(action: 'up', context: $context); + + // When + $subscriptions = $pretty->subscriptions(); + ($subscriptions[Event::MigrateSuccess->value])(Event::MigrateSuccess, $event); + + // Then + self::assertStringContainsString('db', $readBuffer()); + } + + #[Test] + public function subscriptions_maps_migrate_error_to_error_handler(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $context = new Context(dbName: 'db', filename: 'V1.sql', query: 'SELECT 1'); + $event = new MigrateErrorEvent( + action: 'up', + context: $context, + exception: new RuntimeException('some error'), + ); + + // When + $subscriptions = $pretty->subscriptions(); + ($subscriptions[Event::MigrateError->value])(Event::MigrateError, $event); + + // Then + self::assertStringContainsString('error', $readBuffer()); + } + + #[Test] + public function subscriptions_maps_filesystem_notice_to_notice_handler(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $event = new ExceptionEvent(dbName: 'db', exception: new RuntimeException('file notice')); + + // When + $subscriptions = $pretty->subscriptions(); + ($subscriptions[Event::FilesystemNotice->value])(Event::FilesystemNotice, $event); + + // Then + self::assertStringContainsString('notice', $readBuffer()); + } + + #[Test] + public function subscriptions_maps_remaining_events_to_failure_handler(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $event = new ExceptionEvent(dbName: 'db', exception: new RuntimeException('connection lost')); + + $failureEvents = array_filter( + Event::cases(), + static fn(Event $e): bool => !in_array($e, [ + Event::MigrateSuccess, + Event::MigrateError, + Event::FilesystemNotice, + ], true) + ); + + $subscriptions = $pretty->subscriptions(); + + foreach ($failureEvents as $case) { + [$climate2, $readBuffer2] = $this->makeBufferedClimate(); + $pretty2 = new PrettyConsoleOutput($climate2); + $subscriptions2 = $pretty2->subscriptions(); + + // When + ($subscriptions2[$case->value])($case, $event); + + // Then — failure() format contains "error:" + $msg = "Event {$case->value} should use failure handler"; + self::assertStringContainsString('error', $readBuffer2(), $msg); + } + + // Mark the outer $subscriptions usage to avoid risky test + self::assertNotEmpty($subscriptions); + } + + // ----------------------------------------------------------------------- + // success() — long format (up / down / repeatable) + // ----------------------------------------------------------------------- + + /** @param non-empty-string $action */ + #[Test] + #[DataProvider('long_format_action_cases')] + public function success_uses_long_format_for_migration_actions(string $action): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $context = new Context( + dbName: 'testdb', + filename: 'V5__migration.sql', + query: 'SELECT 1', + version: 5, + dryRun: false, + ); + $event = new MigrateSuccessEvent(action: $action, context: $context); + + // When + $pretty->success(Event::MigrateSuccess, $event); + + // Then — long format includes "vers:" and the version number + $output = $readBuffer(); + self::assertStringContainsString('vers:', $output); + self::assertStringContainsString('5', $output); + self::assertStringContainsString('testdb', $output); + self::assertStringContainsString('V5__migration.sql', $output); + } + + /** @return array */ + public static function long_format_action_cases(): array + { + return [ + 'happy path: up action uses long format' => ['up'], + 'happy path: down action uses long format' => ['down'], + 'happy path: repeatable action uses long format' => ['repeatable'], + ]; + } + + #[Test] + public function success_long_format_shows_done_when_not_dry_run(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $context = new Context(dbName: 'db', filename: 'V1.sql', query: 'SELECT 1', dryRun: false); + $event = new MigrateSuccessEvent(action: 'up', context: $context); + + // When + $pretty->success(Event::MigrateSuccess, $event); + + // Then + self::assertStringContainsString('done', $readBuffer()); + } + + #[Test] + public function success_long_format_shows_dry_run_label_when_dry_run_enabled(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $context = new Context(dbName: 'db', filename: 'V1.sql', query: 'SELECT 1', dryRun: true); + $event = new MigrateSuccessEvent(action: 'up', context: $context); + + // When + $pretty->success(Event::MigrateSuccess, $event); + + // Then + self::assertStringContainsString('dry-run', $readBuffer()); + } + + // ----------------------------------------------------------------------- + // success() — short format (other actions) + // ----------------------------------------------------------------------- + + #[Test] + public function success_uses_short_format_for_non_migration_actions(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $context = new Context(dbName: 'mydb', filename: 'fixture.sql', query: 'INSERT INTO x VALUES(1)'); + $event = new MigrateSuccessEvent(action: 'fixture', context: $context); + + // When + $pretty->success(Event::MigrateSuccess, $event); + + // Then — short format does NOT contain "vers:" + $output = $readBuffer(); + self::assertStringNotContainsString('vers:', $output); + self::assertStringContainsString('mydb', $output); + self::assertStringContainsString('fixture.sql', $output); + } + + #[Test] + public function success_short_format_shows_done_when_not_dry_run(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $context = new Context(dbName: 'db', filename: 'f.sql', query: 'SELECT 1', dryRun: false); + $event = new MigrateSuccessEvent(action: 'create', context: $context); + + // When + $pretty->success(Event::MigrateSuccess, $event); + + // Then + self::assertStringContainsString('done', $readBuffer()); + } + + #[Test] + public function success_short_format_shows_dry_run_label_when_dry_run_enabled(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $context = new Context(dbName: 'db', filename: 'f.sql', query: 'SELECT 1', dryRun: true); + $event = new MigrateSuccessEvent(action: 'create', context: $context); + + // When + $pretty->success(Event::MigrateSuccess, $event); + + // Then + self::assertStringContainsString('dry-run', $readBuffer()); + } + + // ----------------------------------------------------------------------- + // error() + // ----------------------------------------------------------------------- + + #[Test] + public function error_writes_error_line_and_exception_message_and_query(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $context = new Context( + dbName: 'proddb', + filename: 'V3__bad.sql', + query: 'DROP DATABASE prod', + ); + $exception = new RuntimeException('table does not exist'); + $event = new MigrateErrorEvent(action: 'up', context: $context, exception: $exception); + + // When + $pretty->error(Event::MigrateError, $event); + + // Then + $output = $readBuffer(); + self::assertStringContainsString('proddb', $output); + self::assertStringContainsString('V3__bad.sql', $output); + self::assertStringContainsString('table does not exist', $output); + self::assertStringContainsString('DROP DATABASE prod', $output); + } + + #[Test] + public function error_output_contains_error_marker(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $context = new Context(dbName: 'db', filename: 'V1.sql', query: 'SELECT 1'); + $event = new MigrateErrorEvent( + action: 'up', + context: $context, + exception: new RuntimeException('query failed'), + ); + + // When + $pretty->error(Event::MigrateError, $event); + + // Then + self::assertStringContainsString('error', $readBuffer()); + } + + // ----------------------------------------------------------------------- + // failure() + // ----------------------------------------------------------------------- + + #[Test] + public function failure_writes_event_name_and_message(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $event = new ExceptionEvent(dbName: 'mydb', exception: new RuntimeException('connection refused')); + + // When + $pretty->failure(Event::ConnectionError, $event); + + // Then + $output = $readBuffer(); + self::assertStringContainsString($event->getName(), $output); + self::assertStringContainsString($event->getMessage(), $output); + } + + // ----------------------------------------------------------------------- + // notice() + // ----------------------------------------------------------------------- + + #[Test] + public function notice_writes_event_name_and_message(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $event = new ExceptionEvent(dbName: 'filedb', exception: new RuntimeException('file already exists')); + + // When + $pretty->notice(Event::FilesystemNotice, $event); + + // Then + $output = $readBuffer(); + self::assertStringContainsString($event->getName(), $output); + self::assertStringContainsString($event->getMessage(), $output); + } + + #[Test] + public function notice_output_contains_notice_keyword(): void + { + // Given + [$climate, $readBuffer] = $this->makeBufferedClimate(); + $pretty = new PrettyConsoleOutput($climate); + $event = new ExceptionEvent(dbName: 'db', exception: new RuntimeException('noticed something')); + + // When + $pretty->notice(Event::FilesystemNotice, $event); + + // Then + self::assertStringContainsString('notice', $readBuffer()); + } +} diff --git a/tests/tools/TraceConsoleOutputTest.php b/tests/tools/TraceConsoleOutputTest.php new file mode 100644 index 0000000..17d4231 --- /dev/null +++ b/tests/tools/TraceConsoleOutputTest.php @@ -0,0 +1,179 @@ +subscriptions(); + + // Then — every Event case must have a subscription + $expectedKeys = array_map(static fn(Event $e): string => $e->value, Event::cases()); + sort($expectedKeys); + + $actualKeys = array_keys($subscriptions); + sort($actualKeys); + + self::assertSame($expectedKeys, $actualKeys); + } + + #[Test] + public function subscriptions_maps_migrate_success_to_success_handler(): void + { + // Given + $output = new FakeConsoleOutput(); + $trace = new TraceConsoleOutput($output); + + // When + $subscriptions = $trace->subscriptions(); + + // Then — calling the MigrateSuccess callable writes the success message format (no "[name]" prefix) + $context = new Context(dbName: 'testdb', filename: 'V1__init.sql', query: 'SELECT 1'); + $event = new MigrateSuccessEvent(action: 'up', context: $context); + + ($subscriptions[Event::MigrateSuccess->value])(Event::MigrateSuccess, $event); + + self::assertCount(1, $output->lines); + // success() writes event->getMessage() which is "[testdb] up: V1__init.sql, vers: 0" + self::assertSame($event->getMessage(), $output->lines[0]); + } + + #[Test] + public function subscriptions_maps_non_success_events_to_error_handler(): void + { + // Given + $context = new Context(dbName: 'testdb', filename: 'V1__init.sql', query: 'DROP TABLE x'); + $error = new MigrateErrorEvent( + action: 'up', + context: $context, + exception: new RuntimeException('column missing'), + ); + + // Then — every non-MigrateSuccess event callable writes "[name] message" format + foreach (Event::cases() as $case) { + if ($case === Event::MigrateSuccess) { + continue; + } + + // When — a fresh fake per case keeps the lines isolated + $output = new FakeConsoleOutput(); + $subscriptions = (new TraceConsoleOutput($output))->subscriptions(); + + ($subscriptions[$case->value])($case, $error); + self::assertCount(1, $output->lines, "Expected one writeln for event {$case->value}"); + self::assertStringContainsString($error->getName(), $output->lines[0]); + self::assertStringContainsString($error->getMessage(), $output->lines[0]); + } + } + + // ----------------------------------------------------------------------- + // success() + // ----------------------------------------------------------------------- + + #[Test] + public function success_writes_event_message_via_writeln(): void + { + // Given + $output = new FakeConsoleOutput(); + $trace = new TraceConsoleOutput($output); + $context = new Context(dbName: 'mydb', filename: 'V2__add_users.sql', query: 'CREATE TABLE users (id INT)'); + $event = new MigrateSuccessEvent(action: 'up', context: $context); + + // When + $trace->success(Event::MigrateSuccess, $event); + + // Then + self::assertCount(1, $output->lines); + self::assertSame($event->getMessage(), $output->lines[0]); + } + + #[Test] + public function success_message_includes_db_name_action_filename_and_version(): void + { + // Given + $output = new FakeConsoleOutput(); + $trace = new TraceConsoleOutput($output); + $context = new Context(dbName: 'analytics', filename: 'V10__schema.sql', query: 'SELECT 1', version: 10); + $event = new MigrateSuccessEvent(action: 'down', context: $context); + + // When + $trace->success(Event::MigrateSuccess, $event); + + // Then + self::assertCount(1, $output->lines); + self::assertStringContainsString('analytics', $output->lines[0]); + self::assertStringContainsString('V10__schema.sql', $output->lines[0]); + self::assertStringContainsString('10', $output->lines[0]); + } + + // ----------------------------------------------------------------------- + // error() + // ----------------------------------------------------------------------- + + #[Test] + public function error_writes_formatted_name_and_message_via_writeln(): void + { + // Given + $output = new FakeConsoleOutput(); + $trace = new TraceConsoleOutput($output); + $context = new Context(dbName: 'proddb', filename: 'V3__bad.sql', query: 'DROP DATABASE prod'); + $event = new MigrateErrorEvent( + action: 'up', + context: $context, + exception: new RuntimeException('query failed'), + ); + + // When + $trace->error(Event::MigrateError, $event); + + // Then + self::assertCount(1, $output->lines); + $expected = sprintf('[%s] %s', $event->getName(), $event->getMessage()); + self::assertSame($expected, $output->lines[0]); + } + + #[Test] + public function error_message_contains_db_name_in_brackets(): void + { + // Given + $output = new FakeConsoleOutput(); + $trace = new TraceConsoleOutput($output); + $context = new Context(dbName: 'staging', filename: 'V5__rollback.sql', query: 'ROLLBACK'); + $event = new MigrateErrorEvent( + action: 'down', + context: $context, + exception: new RuntimeException('connection lost'), + ); + + // When + $trace->error(Event::MigrateError, $event); + + // Then + self::assertStringContainsString('[staging', $output->lines[0]); + } +} From e7ac12dd82358d81186caf00f88efb11c7d21b77 Mon Sep 17 00:00:00 2001 From: dimarik82 Date: Fri, 15 May 2026 20:13:58 +0300 Subject: [PATCH 2/3] fix dependencies --- .github/workflows/tests.yml | 41 +++++++++++++++++++++++++++++++++++ Makefile | 43 ------------------------------------- composer.json | 1 - runtime/.gitignore | 2 ++ 4 files changed, 43 insertions(+), 44 deletions(-) create mode 100644 .github/workflows/tests.yml create mode 100644 runtime/.gitignore diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..703dbe5 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,41 @@ +name: PHPUnit + +on: [ pull_request ] + +jobs: + tests: + name: unit tests + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + php-version: + - "8.3" + - "8.4" + - "8.5" + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: pdo xdebug + coverage: xdebug + env: + fail-fast: true + COMPOSER_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Composer install dependencies + uses: ramsey/composer-install@v3 + with: + dependency-versions: "highest" + composer-options: "--optimize-autoloader" + + - name: PHPUnit + run: vendor/bin/phpunit --configuration phpunit.xml.dist --coverage-clover ./coverage.xml + env: + XDEBUG_MODE: coverage diff --git a/Makefile b/Makefile index 4f55614..2e9f985 100644 --- a/Makefile +++ b/Makefile @@ -65,49 +65,6 @@ fix: phpcbf rector ## run fix tools check: phpcs psalm phpstan ## run analysis tools -infection: - docker build \ - --build-arg PHP_VERSION=$(PHP_VERSION) \ - --build-arg USER=$(USER) \ - --build-arg WORKDIR=/app \ - --target tests \ - -t app_cli .docker/php/cli - - docker run --init -it --rm \ - --add-host=host.docker.internal:host-gateway \ - -u $(USER) \ - -v "$$(pwd):/app" \ - -w /app \ - app_cli ./vendor/bin/infection - docker image rm -f app_cli - -tests: - docker build \ - --build-arg PHP_VERSION=$(PHP_VERSION) \ - --build-arg USER=$(USER) \ - --build-arg WORKDIR=/app \ - --target tests \ - -t app_cli .docker/php/cli - - docker run --init -it --rm \ - --add-host=host.docker.internal:host-gateway \ - -u $(USER) \ - -v "$$(pwd):/app" \ - -w /app \ - app_cli ./vendor/bin/phpunit \ - --configuration phpunit.xml.dist \ - --coverage-xml=/app/runtime/coverage/coverage-xml \ - --coverage-clover=/app/runtime/coverage/clover.xml \ - --log-junit=/app/runtime/coverage/phpunit.junit.xml - - docker run --init -it --rm \ - --add-host=host.docker.internal:host-gateway \ - -u $(USER) \ - -v "$$(pwd):/app" \ - -w /app \ - app_cli ./vendor/bin/infection \ - --coverage=/app/runtime/coverage \ - --threads=max \ - --skip-initial-tests - docker image rm -f app_cli - ## Application cli: diff --git a/composer.json b/composer.json index e86726b..6ea1518 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,6 @@ }, "require-dev": { "buggregator/trap": "^1.15", - "infection/infection": "^0.32.4", "phpstan/phpstan": "^2.0", "phpunit/phpunit": "^12.5", "rector/rector": "^2.0", diff --git a/runtime/.gitignore b/runtime/.gitignore new file mode 100644 index 0000000..d6b7ef3 --- /dev/null +++ b/runtime/.gitignore @@ -0,0 +1,2 @@ +* +!.gitignore From 26aa205c598634f34cd6c18d65864580907fbe81 Mon Sep 17 00:00:00 2001 From: dimarik82 Date: Fri, 15 May 2026 17:19:46 +0000 Subject: [PATCH 3/3] fix: use CoversTrait for CommandOptions trait coverage target CommandOptions is a trait, so #[CoversClass] reported it as an invalid coverage target and failed the suite under failOnWarning. Switch to #[CoversTrait]. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/cmd/presentation/CommandOptionsTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/cmd/presentation/CommandOptionsTest.php b/tests/cmd/presentation/CommandOptionsTest.php index 7960b16..cbde640 100644 --- a/tests/cmd/presentation/CommandOptionsTest.php +++ b/tests/cmd/presentation/CommandOptionsTest.php @@ -5,7 +5,7 @@ namespace dbschemix\migrator\tests\cmd\presentation; use InvalidArgumentException; -use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\CoversTrait; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; use PHPUnit\Framework\TestCase; @@ -16,7 +16,7 @@ use dbschemix\core\InputOptions; use dbschemix\migrator\cmd\presentation\CommandOptions; -#[CoversClass(CommandOptions::class)] +#[CoversTrait(CommandOptions::class)] final class CommandOptionsTest extends TestCase { // -----------------------------------------------------------------------