PHP Kata: Bank OCR the first user story completion


It is a continuation of a series on PHP Kata named Bank OCR. In this article, I will guide you on how to finish the first user story.

Foreword

It is the third article in the series about solving PHP Kata Bank OCR. If you didn't read the previous ones - please do it right now:

The final countdown

Do you know what left to make our first user story completed? Only one thing - the program part:

Your first task is to write a program that can take this file and parse it into actual account numbers.

In other words, we need something we can execute - and yes, unit tests we did does not count ;) We need something simple to use for our client. Our first requirement says nothing on how this program should work - how about we do a good, old-fashioned command-line tool? Do you also like this idea? Great - let's do it!

But. Why there always need to be a but? Never mind, my but is about taking a shortcut to make this article a little bit shorter than the previous one. And another but - but I promise we fix/refactor it later. Sound mysterious - don't be scare - I only want to put business logic straight into the command.

Symfony command line

Symfony is one of the most significant PHP Frameworks - at least in my opinion. Some time ago, Symfony decided to split a big juice framework into smaller and independent components. I find it the best decision they could make. And now we can benefit from it. How? We will use Symfony Console component.

Let's code something!. Before we can develop our command, we need to add proper dependencies with Composer. Open up a console, go to the project root - folder with composer.json - and execute this:

composer require symfony/console

After executing it, composer.jscon file should have additional entry in require section:

    "require": {
        "php": ">=8.0",
        "symfony/console": "^5.3"
    },

At the time I was writing this article, version 5.3 was the current one.

Creating a console application

Developing a console application based on the Symfony Console component is an easy thing. We need two things. The first one is responsible for Symfony application initialization - and we start from this one. Before that - how you want to name this app? Will bank-ocr be ok for you? I hope so. Create an empty file in the project's root folder. Name it bank-ocr - remember - no extension.

#!/usr/bin/env php
<?php

declare(strict_types=1);

namespace WeBee\School\BankOcrKata;

$possibleFiles = [
    __DIR__.'/../../autoload.php',
    __DIR__.'/../autoload.php',
    __DIR__.'/vendor/autoload.php',
];
$file = null;

foreach ($possibleFiles as $possibleFile) {
    if (file_exists($possibleFile)) {
        $file = $possibleFile;
        break;
    }
}

if (null === $file) {
    throw new \RuntimeException('Unable to locate autoload.php file.');
}

require_once $file;

unset($possibleFiles, $possibleFile, $file);

use Symfony\Component\Console\Application;

$app = new Application('Bank OCR', '1.0');
$app->run();

__halt_compiler();

Before I explain what this file does - try if it works:

## Execute this Command in the project's root directory
./bank-ocr

And now promised explanations. In the first line, there is the so-called shebang. Long story short, it is instruction for Unix-like operating system on what execution processor to use for file execution. In our case, we provided the default path to the PHP processor. Thanks to this - you do not have to put php in front of a file name.

Above is not necessary but makes your life easier. The next thing is mandatory. We need a PSR-4 autoloader. And because we are not sure about its location, we must check all possibilities. Our code depends on autoloader - so if it is missing - throw an exception.

When autoloader is in place - we need to initialize the console application and then run it.

The last line might be interesting for you. It halts the execution of the compiler - everything after this line will not be compiled and executed.

We need to do one more thing. We need to tell the Composer which file is executable - vendor binary.

    "bin": [
        "bank-ocr"
    ]

Own Command

To create a command, we need a class that extends Symfony\Component\Console\Command\Command. But before that, we need appropriate unit tests. Testing Symfony-based code is much simpler in the PHP Unit - but we manage to do it in Kahlan. Before that, we must make some assumptions:

  • Assumption 1: we will put our code into class ParseCommand located in WeBee\School\BankOcrKata\Command namespace;
  • Assumption 2: command name will be parse;
  • Assumption 3: command will return 0 on success and 1 for failure (as Symfony expects);
  • Assumption 4: command (for now at least) will print parsing results to the output;

Ok. We can start with test spec now. Create a new test file in spec/ParseCommand.spec.php:

<?php

declare(strict_types=1);

namespace WeBee\School\BankOcrKata\Spec;

use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;

describe(
    'Bank statement parse command',
    function () {
        given(
            'app',
            function () {
                $app = new Application('Test Bank OCR', '1.0');
                $app->setAutoExit(false);

                return $app;
            }
        );

        given(
            'output',
            function () {
                return new BufferedOutput();
            }
        );

        given(
            'input',
            function () {
                return new ArrayInput(['command' => 'parse']);
            }
        );

        it(
            'can be executed',
            function () {
                $result = $this->app->run($this->input, $this->output);

                expect($this->output->fetch())->toBeEmpty();
                expect($result)->toBe(0);
            }
        );
    }
);

Our test fails for two reasons: the result is 1, and on output, we received information that parse Command is not defined. Now we add a code that makes our test pass by creating an empty command. Create new file src/Command/ParseCommand.php:

<?php

declare(strict_types=1);

namespace WeBee\School\BankOcrKata\Command;

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class ParseCommand extends Command
{
    protected static $defaultName = 'parse';

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        return Command::SUCCESS;
    }
}

The above command does nothing except executing without errors. But our test still doesn't work. No problem, we can fix it by registering our command in the console application:

use Symfony\Component\Console\Input\ArrayInput;
use Symfony\Component\Console\Output\BufferedOutput;
use WeBee\School\BankOcrKata\Command\ParseCommand;

describe(
    'Bank statement parse command',
    function () {
        given(
            'app',
            function () {
                $app = new Application('Bank OCR', '1.0');
                $app->setAutoExit(false);
                $app->add(new ParseCommand());

                return $app;
            }
        );

And now the test is passing. As we have a base of parsing command - we can also add it to bank-ocr binary:

use Symfony\Component\Console\Application;
use WeBee\School\BankOcrKata\Command\ParseCommand;

$app = new Application('Bank OCR', '1.0');
$app->add(new ParseCommand());

$app->run();

__halt_compiler();

From now on - you can execute this command directly from the console, like this:

./bank-ocr parse

Great work! Now it is time to do actual bank statement parsing.

Working with files

Have you asked yourself how this Command knows what file to parse? It doesn't - yet :) We must define a parameter for this. This is done in configure method. But update test first:

        given(
            'input',
            function () {
                return new ArrayInput([
                    'command' => 'parse',
                    'file' => __DIR__.'//test_files//Numbers//123456789.txt',
                ]);
            }
        );

        it(
            'will return error with no file argument passed',
            function () {
                $inputWithNoArgument = new ArrayInput(['command' => 'parse']);

                $result = $this->app->run($inputWithNoArgument, $this->output);

                expect($result)->toBe(1);
            }
        );

The additional test we added is only to verify if command will return 1 (failure) on missing the input argument. I hate to duplicate my code. I hope you also? As you can see, we can reuse the test file. Combine it all together and result can be like this:

use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class ParseCommand extends Command
{
    private const FILE_ARGUMENT_NAME = 'file';

    protected static $defaultName = 'parse';

    protected function configure(): void
    {
        $this->addArgument(
            self::FILE_ARGUMENT_NAME,
            InputArgument::REQUIRED,
            'Path to file with statement'
        );
    }
}

Well done! All tests are green. Command know about a file path. It is time to parse some statements! For now, I assume that a user will always provide a correct path to the existing file, with proper access rights, the file will be in the valid format, etc., etc., etc. Why? I want to keep it simple - at least during this article. The only validation we do right now is to check if a file exists. And only because of realpath function, which returns false on failures. So as simple as possible: read a file, combine every four lines, pass it to a number, get the result, and put it to the output. But like always, start with the test:

        it(
            'can parse statement file correctly',
            function () {
                $result = $this->app->run($this->input, $this->output);

                expect($this->output->fetch())->toBe('123456789'.PHP_EOL);
                expect($result)->toBe(0);
            }
        );

And then code:

    protected function execute(InputInterface $input, OutputInterface $output)
    {
        $filePath = realpath($input->getArgument(self::FILE_ARGUMENT_NAME));

        if (false === $filePath) {
            return Command::FAILURE;
        }

        $file = new \SplFileObject($filePath);
        $rawNumber = '';
        $lineNumber = 0;

        while (!$file->eof()) {
            $rawNumber .= $file->fgets();

            if (4 !== ++$lineNumber) {
                continue;
            }

            $number = new Number($rawNumber);
            $output->writeln($number->get());
            $lineNumber = 0;
            $rawNumber = '';
        }

        return Command::SUCCESS;
    }

Run tests - and - almost good. We messed up with previous test. This is because our code actually works - and in previous test we expect it not to. Fix it by removing unnecessary assertion, so test will look like this:

        it(
            'can be executed',
            function () {
                $result = $this->app->run($this->input, $this->output);

                expect($result)->toBe(0);
            }
        );

Congratulations! You have just completed the first requirement. It is dirty, not well secured and tested - but mission accomplished - it works!.

You can play with it via command line like this:

./bank-ocr parse path/to/statement_file

In the next article, I will guide you on how to refactor this mess. We also add more tests, and we start with another user story.

Keep calm and code with WeBee!

Full code listing

You can find complete code for this Bank OCR Kata in PHP on my github repository.

Not enough?


Would you like me to build and conduct training tailored to your needs? I'm waiting for your call or message.

address icon

WeBee.Online Adam Wojciechowski
ul. Władysława Łokietka 5/2
70-256 Szczecin, Poland