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/.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..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:
@@ -143,3 +100,10 @@ _volume_remove:
docker volume rm -f \
migrator_pg_data \
migrator_mysql_data
+
+## AI
+-include .claude/Makefile
+
+# Спец-правило, чтобы Makefile не ругался на неизвестные команды (аргументы)
+%:
+ @:
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/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/runtime/.gitignore b/runtime/.gitignore
new file mode 100644
index 0000000..d6b7ef3
--- /dev/null
+++ b/runtime/.gitignore
@@ -0,0 +1,2 @@
+*
+!.gitignore
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]);
+ }
+}