diff --git a/app/Commands/Command.php b/app/Commands/Command.php index 0da0e63..1a0fb08 100644 --- a/app/Commands/Command.php +++ b/app/Commands/Command.php @@ -2,27 +2,65 @@ namespace App\Commands; -use App\Services\Presenter\Presenter; -use App\Services\Presenter\PresenterContract; -use Illuminate\Contracts\Container\BindingResolutionException; -use LaravelZero\Framework\Application; use LaravelZero\Framework\Commands\Command as BaseCommand; -use Symfony\Component\Console\Input\InputInterface; -use Symfony\Component\Console\Output\OutputInterface; + +use function Laravel\Prompts\select; abstract class Command extends BaseCommand { - protected PresenterContract $presenter; + /** Display an error message. */ + protected function failure(string $message): void + { + $this->output->block(sprintf(' 🚨 %s', $message), null, 'fg=white;bg=red', ' ', true); + } + + /** Display an information message. */ + protected function hint(string $message): void + { + $this->output->block(sprintf(' ℹ️ %s', $message), null, 'fg=white;bg=blue', ' ', true); + } + + /** Display a success message. */ + protected function success(string $message): void + { + $this->output->block(sprintf(' 🎉 %s', $message), null, 'fg=white;bg=green', ' ', true); + } + + /** Display a warning message. */ + protected function warning(string $message): void + { + $this->output->block(sprintf(' ⚠️ %s', $message), null, 'fg=yellow;bg=default', ' ', true); + } - protected function execute(InputInterface $input, OutputInterface $output): int + /** + * Display multiple options. + * + * @param list $options + */ + protected function select(string $question, array $options, ?string $default = null): string { - try { - $this->presenter = resolve(PresenterContract::class); // @phpstan-ignore-line - } catch (BindingResolutionException) { - $this->presenter = new Presenter($input, $output); - $this->app->singleton(PresenterContract::class, fn (Application $app) => $this->presenter); - } + $choice = select($question, $options, $default); + + assert(is_string($choice)); + + return $choice; + } - return parent::execute($input, $output); + /** Initiate a progress bar. */ + protected function progressStart(int $size): void + { + $this->output->progressStart($size); + } + + /** Advance a progress bar. */ + protected function progressAdvance(int $step = 1): void + { + $this->output->progressAdvance($step); + } + + /** Complete a progress bar. */ + protected function progressComplete(): void + { + $this->output->progressFinish(); } } diff --git a/app/Commands/Process.php b/app/Commands/Process.php index 4c4c28f..2362385 100644 --- a/app/Commands/Process.php +++ b/app/Commands/Process.php @@ -38,7 +38,7 @@ public function handle( assert(is_string($spreadsheet)); if (! is_file($spreadsheet)) { - $this->presenter->error(sprintf('No spreadsheet could be found at %s', $spreadsheet)); + $this->failure(sprintf('No spreadsheet could be found at %s', $spreadsheet)); return self::INVALID; } @@ -46,17 +46,17 @@ public function handle( try { $database->prepare(); } catch (DatabaseManagerException $exception) { - $this->presenter->error(sprintf('Database error: %s', $exception->getMessage())); + $this->failure(sprintf('Database error: %s', $exception->getMessage())); return self::FAILURE; } - $this->presenter->info(sprintf('Processing %s...', basename($spreadsheet))); + $this->hint(sprintf('Processing %s...', basename($spreadsheet))); try { - $this->presenter->progressStart(iterator_count($transactionReader->read($spreadsheet))); + $this->progressStart(iterator_count($transactionReader->read($spreadsheet))); } catch (TransactionReaderException $exception) { - $this->error($exception->getMessage()); + $this->failure($exception->getMessage()); return self::INVALID; } @@ -65,19 +65,19 @@ public function handle( try { $transactionProcessor->process($transaction); } catch (TransactionProcessorException $exception) { - $this->presenter->progressComplete(); - $this->error($exception->getMessage()); + $this->progressComplete(); + $this->failure($exception->getMessage()); return self::INVALID; } - $this->presenter->progressAdvance(); + $this->progressAdvance(); } - $this->presenter->progressComplete(); + $this->progressComplete(); - $this->presenter->success('Transactions successfully processed!'); + $this->success('Transactions successfully processed!'); - return $commandRunner->run('review'); + return $commandRunner->run(command: 'review', output: $this->output); } } diff --git a/app/Commands/Review.php b/app/Commands/Review.php index 55e9657..061da7d 100644 --- a/app/Commands/Review.php +++ b/app/Commands/Review.php @@ -11,6 +11,15 @@ final class Review extends Command { + public const SUMMARY_HEADERS = [ + 'Proceeds', + 'Cost basis', + 'Non-attributable allowable cost', + 'Total cost basis', + 'Capital gain or loss', + 'Income', + ]; + /** * The signature of the command. * @@ -34,7 +43,7 @@ public function handle(SummaryRepository $summaryRepository, TaxYearSummaryRepos try { $this->validateTaxYear($taxYear); } catch (TaxYearIdException $exception) { - $this->presenter->error($exception->getMessage()); + $this->failure($exception->getMessage()); return self::INVALID; } @@ -49,18 +58,18 @@ public function handle(SummaryRepository $summaryRepository, TaxYearSummaryRepos } if (empty($availableTaxYears)) { - $this->presenter->warning('No tax year to review. Please submit transactions first, using the `process` command'); + $this->warning('No tax year to review. Please submit transactions first, using the `process` command'); return self::SUCCESS; } if (! is_null($taxYear) && ! in_array($taxYear, $availableTaxYears)) { - $this->presenter->warning('This tax year is not available'); + $this->warning('This tax year is not available'); $taxYear = null; } if (is_null($taxYear)) { - $this->presenter->info(sprintf('Current fiat balance: %s', $summaryRepository->get()?->fiat_balance ?? '')); + $this->hint(sprintf('Current fiat balance: %s', $summaryRepository->get()?->fiat_balance ?? '')); } // Order tax years from more recent to older @@ -68,7 +77,7 @@ public function handle(SummaryRepository $summaryRepository, TaxYearSummaryRepos $taxYear ??= count($availableTaxYears) === 1 ? $availableTaxYears[0] - : $this->presenter->choice('Please select a tax year for details', $availableTaxYears, $availableTaxYears[0]); + : $this->select('Please select a tax year for details', $availableTaxYears, $availableTaxYears[0]); assert(is_string($taxYear)); @@ -83,7 +92,7 @@ private function summary(TaxYearSummaryRepository $repository, string $taxYear, assert($taxYearSummary instanceof TaxYearSummary); - $this->presenter->summary( + $this->displaySummary( taxYear: $taxYear, proceeds: (string) $taxYearSummary->capital_gain->proceeds, costBasis: (string) $taxYearSummary->capital_gain->costBasis, @@ -98,7 +107,7 @@ private function summary(TaxYearSummaryRepository $repository, string $taxYear, return self::SUCCESS; } - $taxYear = $this->presenter->choice('Review another tax year?', ['No', ...$availableTaxYears], 'No'); + $taxYear = $this->select('Review another tax year?', ['No', ...$availableTaxYears], 'No'); if ($taxYear === 'No') { return self::SUCCESS; @@ -107,6 +116,24 @@ private function summary(TaxYearSummaryRepository $repository, string $taxYear, return $this->summary($repository, $taxYear, $availableTaxYears); } + /** Display a tax year's summary. */ + private function displaySummary( + string $taxYear, + string $proceeds, + string $costBasis, + string $nonAttributableAllowableCost, + string $totalCostBasis, + string $capitalGain, + string $income, + ): void { + $this->hint(sprintf('Summary for tax year %s', $taxYear)); + + $this->table( + self::SUMMARY_HEADERS, + [[$proceeds, $costBasis, $nonAttributableAllowableCost, $totalCostBasis, $capitalGain, $income]], + ); + } + /** @throws TaxYearIdException */ private function validateTaxYear(mixed $taxYear): void { diff --git a/app/Services/CommandRunner/CommandRunner.php b/app/Services/CommandRunner/CommandRunner.php index 3d883fa..db0df0c 100644 --- a/app/Services/CommandRunner/CommandRunner.php +++ b/app/Services/CommandRunner/CommandRunner.php @@ -3,6 +3,7 @@ namespace App\Services\CommandRunner; use Illuminate\Contracts\Console\Kernel; +use Symfony\Component\Console\Output\OutputInterface; final readonly class CommandRunner implements CommandRunnerContract { @@ -10,9 +11,13 @@ public function __construct(private Kernel $artisan) { } - /** Run a command. */ - public function run(string $command): int + /** + * Run a command. + * + * @param array $parameters + */ + public function run(string $command, array $parameters = [], ?OutputInterface $output = null): int { - return $this->artisan->call($command); + return $this->artisan->call($command, $parameters, $output); } } diff --git a/app/Services/CommandRunner/CommandRunnerContract.php b/app/Services/CommandRunner/CommandRunnerContract.php index 90ca1b5..6c60321 100644 --- a/app/Services/CommandRunner/CommandRunnerContract.php +++ b/app/Services/CommandRunner/CommandRunnerContract.php @@ -2,8 +2,14 @@ namespace App\Services\CommandRunner; +use Symfony\Component\Console\Output\OutputInterface; + interface CommandRunnerContract { - /** Run a command. */ - public function run(string $command): int; + /** + * Run a command. + * + * @param array $parameters + */ + public function run(string $command, array $parameters = [], ?OutputInterface $output = null): int; } diff --git a/app/Services/Presenter/Presenter.php b/app/Services/Presenter/Presenter.php deleted file mode 100644 index ea718fa..0000000 --- a/app/Services/Presenter/Presenter.php +++ /dev/null @@ -1,93 +0,0 @@ -ui = new SymfonyStyle($input, $output); - } - - /** Display an error message. */ - public function error(string $message): void - { - $this->ui->block(sprintf(' 🚨 %s', $message), null, 'fg=white;bg=red', ' ', true); - } - - /** Display an information message. */ - public function info(string $message): void - { - $this->ui->block(sprintf(' ℹ️ %s', $message), null, 'fg=white;bg=blue', ' ', true); - } - - /** Display a success message. */ - public function success(string $message): void - { - $this->ui->block(sprintf(' 🎉 %s', $message), null, 'fg=white;bg=green', ' ', true); - } - - /** Display a warning message. */ - public function warning(string $message): void - { - $this->ui->block(sprintf(' ⚠️ %s', $message), null, 'fg=yellow;bg=default', ' ', true); - } - - /** - * Display multiple choices. - * - * @param list $choices - * - * @codeCoverageIgnore - */ - public function choice(string $question, array $choices, ?string $default = null): string - { - $choice = $this->ui->choice($question, $choices, $default); - - assert(is_string($choice)); - - return $choice; - } - - /** Display a tax year's summary. */ - public function summary( - string $taxYear, - string $proceeds, - string $costBasis, - string $nonAttributableAllowableCost, - string $totalCostBasis, - string $capitalGain, - string $income, - ): void { - $this->info(sprintf('Summary for tax year %s', $taxYear)); - - $this->ui->table( - ['Proceeds', 'Cost basis', 'Non-attributable allowable cost', 'Total cost basis', 'Capital gain or loss', 'Income'], - [[$proceeds, $costBasis, $nonAttributableAllowableCost, $totalCostBasis, $capitalGain, $income]], - ); - } - - /** Initiate a progress bar. */ - public function progressStart(int $size): void - { - $this->ui->progressStart($size); - } - - /** Advance a progress bar. */ - public function progressAdvance(int $step = 1): void - { - $this->ui->progressAdvance($step); - } - - /** Complete a progress bar. */ - public function progressComplete(): void - { - $this->ui->progressFinish(); - } -} diff --git a/app/Services/Presenter/PresenterContract.php b/app/Services/Presenter/PresenterContract.php deleted file mode 100644 index 6911381..0000000 --- a/app/Services/Presenter/PresenterContract.php +++ /dev/null @@ -1,45 +0,0 @@ - $choices - */ - public function choice(string $question, array $choices, ?string $default = null): string; - - /** Display a tax year's summary. */ - public function summary( - string $taxYear, - string $proceeds, - string $costBasis, - string $nonAttributableAllowableCost, - string $totalCostBasis, - string $capitalGain, - string $income, - ): void; - - /** Initiate a progress bar. */ - public function progressStart(int $size): void; - - /** Advance a progress bar. */ - public function progressAdvance(int $step = 1): void; - - /** Complete a progress bar. */ - public function progressComplete(): void; -} diff --git a/composer.json b/composer.json index 9ded7a9..c7e912d 100644 --- a/composer.json +++ b/composer.json @@ -28,11 +28,13 @@ "eventsauce/object-hydrator": "^1.3", "eventsauce/pest-utilities": "^3.4", "fakerphp/faker": "^1.21.0", + "illuminate/console": "^10.17", "illuminate/database": "^10.13", "intonate/tinker-zero": "^1.2", "laravel-zero/framework": "^10.0", "laravel-zero/phar-updater": "^1.3", "laravel/pint": "^1.6", + "laravel/prompts": "^0.1.5", "mockery/mockery": "^1.5.1", "nunomaduro/termwind": "^1.15", "pestphp/pest": "^2.5", diff --git a/tests/Feature/Commands/ProcessTest.php b/tests/Feature/Commands/ProcessTest.php index fa05052..8721335 100644 --- a/tests/Feature/Commands/ProcessTest.php +++ b/tests/Feature/Commands/ProcessTest.php @@ -2,14 +2,13 @@ use App\Services\CommandRunner\CommandRunnerContract; use Domain\Enums\FiatCurrency; -use LaravelZero\Framework\Commands\Command; it('can process a spreadsheet', function () { $this->instance(CommandRunnerContract::class, $commandRunner = Mockery::spy(CommandRunnerContract::class)); $path = base_path('tests/stubs/transactions/valid.csv'); - $this->artisan('process', ['spreadsheet' => $path])->assertExitCode(Command::SUCCESS); + $this->artisan('process', ['spreadsheet' => $path])->assertSuccessful(); $this->assertDatabaseCount('tax_year_summaries', 2); @@ -42,5 +41,7 @@ 'fiat_balance' => '3330', ]); - $commandRunner->shouldHaveReceived('run')->once()->with('review'); + $commandRunner->shouldHaveReceived('run') + ->withArgs(fn (string $command, array $output) => $command === 'review') + ->once(); }); diff --git a/tests/Feature/Commands/ReviewTest.php b/tests/Feature/Commands/ReviewTest.php index b4f57f6..178131e 100644 --- a/tests/Feature/Commands/ReviewTest.php +++ b/tests/Feature/Commands/ReviewTest.php @@ -1,11 +1,11 @@ create([ @@ -22,8 +22,9 @@ $this->assertDatabaseCount('tax_year_summaries', 1); $this->artisan('review') - ->expectsOutputToContain('---------- ------------ --------------------------------- ------------------ ---------------------- --------') - ->expectsOutputToContain(' Proceeds Cost basis Non-attributable allowable cost Total cost basis Capital gain or loss Income ') - ->expectsOutputToContain(' £200.00 £100.00 £75.00 £175.00 £25.00 £50.00 ') - ->assertExitCode(Command::SUCCESS); + ->expectsTable( + Review::SUMMARY_HEADERS, + [['£200.00', '£100.00', '£75.00', '£175.00', '£25.00', '£50.00']], + ) + ->assertSuccessful(); }); diff --git a/tests/Unit/Commands/ProcessTest.php b/tests/Unit/Commands/ProcessTest.php index b0ed0bc..4781304 100644 --- a/tests/Unit/Commands/ProcessTest.php +++ b/tests/Unit/Commands/ProcessTest.php @@ -51,7 +51,7 @@ function generator(array $value): Generator $this->transactionReader->shouldReceive('read')->with($this->path)->once()->andThrow($exception); $this->artisan('process', ['spreadsheet' => $this->path]) - ->expectsOutput($exception->getMessage()) + ->expectsOutputToContain($exception->getMessage()) ->assertExitCode(Command::INVALID); }); @@ -65,7 +65,7 @@ function generator(array $value): Generator $this->transactionProcessor->shouldReceive('process')->with(['foo'])->once()->andThrow($exception); $this->artisan('process', ['spreadsheet' => $this->path]) - ->expectsOutput($exception->getMessage()) + ->expectsOutputToContain($exception->getMessage()) ->assertExitCode(Command::INVALID); }); @@ -73,10 +73,13 @@ function generator(array $value): Generator $this->databaseManager->shouldReceive('prepare')->once()->andReturn(); $this->transactionReader->shouldReceive('read')->with($this->path)->once()->andReturn(generator(['foo'])); $this->transactionReader->shouldReceive('read')->with($this->path)->once()->andReturn(generator(['foo'])); - $this->commandRunner->shouldReceive('run')->with('review')->once()->andReturn(Command::SUCCESS); + $this->commandRunner->shouldReceive('run') + ->withArgs(fn (string $command, array $output) => $command === 'review') + ->once() + ->andReturn(Command::SUCCESS); $this->artisan('process', ['spreadsheet' => $this->path]) - ->assertExitCode(Command::SUCCESS); + ->assertSuccessful(); $this->transactionProcessor->shouldHaveReceived('process')->with(['foo'])->once(); }); diff --git a/tests/Unit/Commands/ReviewTest.php b/tests/Unit/Commands/ReviewTest.php index 8afe2c2..80e1e9d 100644 --- a/tests/Unit/Commands/ReviewTest.php +++ b/tests/Unit/Commands/ReviewTest.php @@ -1,6 +1,6 @@ artisan('review') ->expectsOutputToContain('No tax year to review') - ->assertExitCode(Command::SUCCESS); + ->assertSuccessful(); }); it('cannot review a tax year because there is an issue with the database', function () { @@ -41,7 +41,7 @@ $this->artisan('review') ->expectsOutputToContain('No tax year to review') - ->assertExitCode(Command::SUCCESS); + ->assertSuccessful(); }); it('cannot review a tax year because the submitted tax year is not available', function () { @@ -77,16 +77,14 @@ 'non_attributable_allowable_cost' => FiatAmount::GBP('1'), ])); - $presenter = Mockery::mock(PresenterContract::class); - $presenter->shouldReceive('info')->once()->with('Current fiat balance: £10.00')->andReturn(); - $presenter->shouldReceive('summary') - ->once() - ->with($taxYearId->toString(), '£4.00', '£2.00', '£1.00', '£3.00', '£1.00', '£10.00') - ->andReturn(); - - $this->instance(PresenterContract::class, $presenter); - - $this->artisan('review')->assertExitCode(Command::SUCCESS); + $this->artisan('review') + ->expectsOutputToContain('Current fiat balance: £10.00') + ->expectsOutputToContain('Summary for tax year 2021-2022') + ->expectsTable( + Review::SUMMARY_HEADERS, + [['£4.00', '£2.00', '£1.00', '£3.00', '£1.00', '£10.00']], + ) + ->assertSuccessful(); }); it('offers to choose a tax year', function () { @@ -100,23 +98,21 @@ ]); $this->taxYearSummaryRepository->shouldReceive('find') - ->once() ->withArgs(fn (TaxYearId $id) => $id->toString() === '2022-2023') + ->once() ->andReturn(TaxYearSummary::factory()->make()); $this->taxYearSummaryRepository->shouldReceive('find') - ->once() ->withArgs(fn (TaxYearId $id) => $id->toString() === '2021-2022') + ->once() ->andReturn(TaxYearSummary::factory()->make()); - $presenter = Mockery::mock(PresenterContract::class); - $presenter->shouldReceive('info')->once()->with('Current fiat balance: £-10.00')->andReturn(); - $presenter->shouldReceive('choice')->once()->with('Please select a tax year for details', ['2022-2023', '2021-2022'], '2022-2023')->andReturn('2022-2023'); - $presenter->shouldReceive('choice')->once()->with('Review another tax year?', ['No', '2022-2023', '2021-2022'], 'No')->andReturn('2021-2022'); - $presenter->shouldReceive('choice')->once()->with('Review another tax year?', ['No', '2022-2023', '2021-2022'], 'No')->andReturn('No'); - $presenter->shouldReceive('summary')->twice()->andReturn(); - - $this->instance(PresenterContract::class, $presenter); - - $this->artisan('review')->assertExitCode(Command::SUCCESS); + $this->artisan('review') + ->expectsOutputToContain('Current fiat balance: £-10.00') + ->expectsChoice('Please select a tax year for details', '2022-2023', ['2022-2023', '2021-2022']) + ->expectsOutputToContain('Summary for tax year 2022-2023') + ->expectsChoice('Review another tax year?', '2021-2022', ['No', '2022-2023', '2021-2022']) + ->expectsOutputToContain('Summary for tax year 2021-2022') + ->expectsChoice('Review another tax year?', 'No', ['No', '2022-2023', '2021-2022']) + ->assertSuccessful(); }); diff --git a/tests/Unit/Services/CommandRunner/CommandRunnerTest.php b/tests/Unit/Services/CommandRunner/CommandRunnerTest.php index 3ef4d38..01d655b 100644 --- a/tests/Unit/Services/CommandRunner/CommandRunnerTest.php +++ b/tests/Unit/Services/CommandRunner/CommandRunnerTest.php @@ -5,7 +5,12 @@ use LaravelZero\Framework\Commands\Command; it('can run a command', function () { - $artisan = Mockery::mock(Kernel::class)->shouldReceive('call')->once()->with('foo')->andReturn(Command::SUCCESS)->getMock(); + $artisan = Mockery::mock(Kernel::class)->shouldReceive('call') + ->with('foo', null, null) + ->once() + ->andReturn(Command::SUCCESS) + ->getMock(); + $this->instance(Kernel::class, $artisan); expect(resolve(CommandRunnerContract::class)->run('foo'))->toBeInt()->toBe(Command::SUCCESS);