Skip to main content

PHP event sourcing aan de kaarttafel

Vandaag ga ik eens wat dieper in op de inner workings van WDEBELEK, de webtoepassing die ik schreef om online te kaarten (wiezen en gelijkaardige spelletjes).

Want het is niet alleen erg fijn dat we in tijden van quarantaine en avondklok nog kunnen kaarten, het is ook bijzonder cool dat de achterliggende software gebruik maakt van event sourcing; dat is toch alleszins wat ik probeer.

Mijn bedoeling is om jullie in al dit moois in te leiden aan de hand van de wijzigingen in de code die nodig waren voor een nieuwe feature die ik onlangs inbouwde: het opnieuw open leggen van een slag die eigenlijk al opgeraapt was.

playing cards

Deze blog post is uiteindelijk veel langer geworden dan ik origineel gepland had. Misschien splits ik hem op termijn nog wel eens op. Om er wat structuur in te brengen, hieronder alvast een soort van inhoudstafel.

Event sourcing?

WDEBELEK is een event sourced webtoepassing. In grote lijnen wil dat zeggen dat heel de toestand van alle kaarttafels, en van alle spelletjes die gespeeld zijn, beschreven is op basis van events, dingen die gebeurd zijn. Al die events worden bewaard in de event store.

Om hier een idee van te krijgen: ziehier een aantal opeenvolgende events, uit de event store:

*************************** 1. row ***************************
               id: 150704
aggregate_root_id: 72207dca-cab2-5a83-a99f-37329cf995ac
aggregate_version: 77
       event_type: App\Domain\WriteModel\Game\Event\VotedAsWinner
          payload: {"gameIdentifier":"72207dca-cab2-5a83-a99f-37329cf995ac","voter":"4898ac43-3501-5ead-b9b5-c6b708b73b6a","winner":"03e4dba1-d71b-558b-a1c4-9a31d5e1858d","trickNumber":6}
       created_at: 2021-02-19 23:08:42
*************************** 2. row ***************************
               id: 150705
aggregate_root_id: 72207dca-cab2-5a83-a99f-37329cf995ac
aggregate_version: 78
       event_type: App\Domain\WriteModel\Game\Event\TrickWon
          payload: {"gameIdentifier":"72207dca-cab2-5a83-a99f-37329cf995ac","winner":"03e4dba1-d71b-558b-a1c4-9a31d5e1858d","trickNumber":6,"trick":{"4898ac43-3501-5ead-b9b5-c6b708b73b6a":20,"4469038e-9fca-5246-927a-3881dcc8c478":19,"af792b62-00ac-545b-9c92-64be738dc74c":38,"03e4dba1-d71b-558b-a1c4-9a31d5e1858d":23},"tableIdentifier":"003c5249-b26e-5e59-b394-ef39b2a0fe58"}
       created_at: 2021-02-19 23:08:42
*************************** 3. row ***************************
               id: 150706
aggregate_root_id: 72207dca-cab2-5a83-a99f-37329cf995ac
aggregate_version: 79
       event_type: App\Domain\WriteModel\Game\Event\CardPlayed
          payload: {"gameIdentifier":"72207dca-cab2-5a83-a99f-37329cf995ac","playerIdentifier":"03e4dba1-d71b-558b-a1c4-9a31d5e1858d","card":4,"trickNumber":7,"tableIdentifier":"003c5249-b26e-5e59-b394-ef39b2a0fe58"}
       created_at: 2021-02-19 23:08:44

Deze 3 events omschrijven wat er op 19 februari, om 23:08:42 is gebeurd. Speler 4898ac duidde aan dat speler 03e4db de 6de slag had gewonnen van spel 72207d. Hij was blijkbaar de 3de speler die dat vond, want daarna werd de slag effectief aan speler 03e4db toegkend. Daarna speelde speler 03e4db kaart 4 (schoppen 4) voor slag 7.

Op die manier zit alles wat er ooit gebeurde in WDEBELEK in de event store.

Telkens als er een speler voor een bepaald spel iets wil doen, gaat dat spel eerst herbekijken welke events er allemaal gebeurd zijn. En als hetgeen de speler wil doen dan ook effectief mogelijk is (de slag moet kloppen, kaarten die je wilt spelen, moet je op handen hebben, en dergelijke), dan wordt er een nieuw event gepubliceerd op de event bus. Telkens bij het publiceren van nieuwe events, wordt de informatie op het scherm van de spelers bijgewerkt, en eventueel worden er automatische processen gestart. (Bijvoorbeeld: als de kaarten opnieuw bijeen gedaan zijn na het spel, wordt een nieuw spel gestart.)

Ok, tot daar de theorie, we nemen de code erbij.

Het event: TrickReopened

Als er iets nieuws moet kunnen gebeuren in een event-sourced toepassing, dan heb je eerst en vooral een event nodig dat omschrijft dat het gebeurd is. In dit geval is dat het event TrickReopened, en de broncode vind je in src/Domain/WriteModel/Game/Event/TrickReopened.php:

final class TrickReopened implements ClientRelevantTableEvent
{
    public function __construct(
        private GameIdentifier $gameIdentifier,
        private PlayerIdentifier $reopenedBy,
        private PlayerIdentifier $winner,
        private int $trickNumber,
        private Trick $trick,
        private TableIdentifier $tableIdentifier
    ) {
    }

    public function getGameIdentifier(): GameIdentifier
    {
        return $this->gameIdentifier;
    }

    public function getReopenedBy(): PlayerIdentifier
    {
        return $this->reopenedBy;
    }

    public function getWinner(): PlayerIdentifier
    {
        return $this->winner;
    }

    public function getTrickNumber(): int
    {
        return $this->trickNumber;
    }

    public function getTrick(): Trick
    {
        return $this->trick;
    }

    public function getTableIdentifier(): TableIdentifier
    {
        return $this->tableIdentifier;
    }

    public function getAggregateRootIdentifier(): AggregateRootIdentifier
    {
        return $this->getGameIdentifier();
    }
}

(Heb je't gezien? PHP8 🤩) Zo'n event is een eenvoudige klasse. Ze bevat enkel informatie over wat er precies is gebeurd. In dit geval: Voor welk spel is de slag terug opengelegd, wie heeft dat gedaan, wie won die vorige slag, het nummer van de slag, de slag zelf, en dan nog de tafel waaraan dat gebeurd is.

De belangrijkste afweging bij het maken van een event, is welke informatie je er wel of niet instopt. Het is zeker niet de bedoeling dat elk event heel de toestand van je toepassing bevat, maar de ervaring leerde me dat je ook niet moet besparen op de informatie in je events.

Hier bijvoorbeeld: de winnaar van vorige slag, of zelfs de slag zelf, zijn eigenlijk redundante informatie, want dat zijn dingen die je al weet op basis van wat er in het verleden gebeurde. Toch is het handig dat je die informatie direct bij de hand hebt, op het moment dat je je event gaat afhandelen.

De aggregate: Game

Nu gaan we naar de plaats waar het allemaal gebeurt, en dat is in de 'Game-aggregate', die zit in src/Domain/WriteModel/Game/Game.php.

De Game-aggregate verzamelt alle events voor een bepaald spel, en zorgt ervoor dat er nieuwe events gebeuren in het spel, tenminste als dat mogelijk is.

We bekijken eerst de functie Game::reopenTrick().

    public function reopenTrick(PlayerIdentifier $playerIdentifier, int $trickNumber): void
    {
        $this->guardPlayer($playerIdentifier);

        if ($this->currentTrickNumber - 1 === $trickNumber && $this->currentTrick->isEmpty()) {
            $latestTrick = $this->playedTricks->getByTrickNumber($trickNumber);

            $this->applyAndRecord(
                new WinnerVotesInvalidated(
                    $this->gameIdentifier,
                    $this->currentTrickNumber - 1
                )
            );
            $this->applyAndRecord(new TrickReopened(
                $this->gameIdentifier,
                $playerIdentifier,
                $latestTrick->getWinner(),
                $latestTrick->getTrickNumber(),
                $latestTrick->getTrick(),
                $this->tableIdentifier
            ));

            return;
        }

        throw CannotReopenTrick::create($this->gameIdentifier, $trickNumber);
    }

De functie reopenTrick() zorgt ervoor dat de speler met gegeven identifier de gegeven slag opnieuw open legt. Eerst kijkt het spel na of de gegeven speler effectief meespeelt; dat doet de Game::guardPlayer()-functie. Daarna wordt er geverifieerd dat de gevraagde slag effectief de vorige slag is, en dat er momenteel geen slagen op tafel liggen (currentTrick->isEmpty()). Deze controles doet het Game-object op basis van zijn internal state. Dat zijn private member variables, waar alleen het Game-object toegang toe heeft, en op basis waarvan we weten wat er aan de hand is. In dit geval bijvoorbeeld $this->currentTrickNumber, $this->currentTrick en $this->playedTricks.

Als er checks falen, dan wordt er een exception opgegooid, en dan gebeurt er verder niets. Maar als aan alle voorwaarden voldaan zijn: de speler speelt mee, hij wil de vorige slag open leggen, en er liggen geen gespeelde kaarten op tafel, dan worden er 2 events gepubliceerd op de event bus:

Het eerste event, WinnerVotesInvalidated, is een event dat niet is bijgemaakt voor deze nieuwe feature; dat bestond al. Het zorgt ervoor dat de spelers opnieuw gaan moeten bepalen welke kaart de winnende kaart is. Dat heeft te maken met hoe WDEBELEK werkt: WDEBELEK kent geen spelregels, en de winnaar van een slag wordt bepaald door een 'stemmig' door de deelnemers. WinnerVotesInvalidated zegt dat de stemmen die al uitgebracht waren voor de vorige slag, niet meer geldig zijn.

En dan het tweede event: TrickReopened, het event dat we net maakten, met alle relevante informatie over de opnieuw open gelegde slag.

Van zodra het event TrickReopened gepubliceerd is, is de slag terug open gelegd.

Dus, nu de slag weer opengelegd is, moet het Game-object zijn internal state bijwerken: er liggen weer kaarten op tafel, en het nummer van de huidige slag, is eentje minder dan daarnet. Dat wordt geregeld in Table::applyTrickReopened():

    public function applyTrickReopened(TrickReopened $event): void
    {
        $this->currentTrick = $event->getTrick();
        $this->playedTricks = $this->playedTricks->withoutTrick($event->getTrickNumber());
        --$this->currentTrickNumber;
    }

De slag die je terug openlegde, wordt hier de huidige slag, en verdwijnt van de stapel met gespeelde slagen. En het huidige slagnummer, is nu dus eentje minder dan daarnet.

Zo bestaat er voor elk event dat van belang is voor de internal state van een spel, een apply-functie in de Game klasse. Bij iedere gevraagde verandering aan het spel, zal m.b.v. de apply-funties eerst de internal state volledig opnieuw worden opgebouwd, om dan te kunnen controleren of de actie wel degelijk uitgevoerd kan worden.

De leeskant (read side)

Alle informatie die aan de gebruiker getoond moet worden, of alle informatie die de front-end nodig heeft om de gebruiker een zinvolle interface te tonen, komt uit zogenaamde 'read models'. Een read model levert hapklare informatie aan voor de front-end, zoals bijvoorbeeld head read model TrickAtTable. Dat read model bevat per tafel de slag die er op dat moment gespeeld wordt.

De informatie van zo'n read model, moet natuurlijk up to date gehouden worden, en dat gebeurt door zogenaamde projectoren. Een projector houdt zijn read model up to date door te luisteren naar de events die op de event bus voorbij komen. Is een event van toepassing op zijn read model, dan zal de projector zijn read model bijwerken.

In het geval van de TrickAtTableProjector gebeurt dat in de functie TrickAtTableProjector::applyTrickReopened(), zie src/Domain/ReadModel/TrickAtTable/TrickAtTableProjector.php:

    public function applyTrickReopened(TrickReopened $event): void
    {
        $this->tricksAtTables->replaceTrickAtTable(
            $event->getTableIdentifier(),
            $event->getTrick()
        );
    }

$this->tricksAtTables is hier een instantie van TrickAtTableRepository, en de functie replaceTrickAtTable zal in een databasetabel de huidige slag op de gegeven tafel vervangen.

De leeskant is wat ik persoonlijk het moeilijkste vind aan event sourcing. Enerzijds heb je graag dat een read model precies de informatie oplevert die je nodig hebt voor een of andere pagina of weergave in je toepassing. Anderzijds bestaat er zoiets als het DRY-principe (don't repeat yourself); je wilt niet graag 5 gelijkaardige read models maken als je op 5 plaatsen in je programma gelijkaardige maar net verschillende data nodig hebt.

Ook het afbakenen van de verantwoordelijkheden aan de read side, vind ik niet makkelijk. Wat is de verantwoordelijkheid van de repository? Wat is de verantwoordelijkheid van de projector? En zit daar nog iets tussen?

Aan de read kant heb je volgens mij veel vrijheid, en daarom vind ik het vooral belangrijk goede interfaces te definiëren. Dan kun je achteraf nog vaak van gedacht veranderen over hoe je het implementeert. Ik gebruik ook graag eenvoudige interfaces, dat is makkelijk als je er eens eentje moet mocken voor een test.

Voor het TrickAtTable-read model heb ik bijvoorbeeld een eenvoudige interface TricksAtTables, zie src/Domain/ReadModel/TrickAtTable/TricksAtTables.php:

interface TricksAtTables
{
    public function getTrickAtTable(TableIdentifier $tableIdentifier): Trick;
}

Dit dient om de huidige slag op te vragen aan een gegeven tafel. In src/Domain/ReadModel/TrickAtTable/TrickAtTableRepository.php vind je TrickAtTableRepository, deze interface laat ook toe om zo'n slag te vervangen.

interface TrickAtTableRepository extends TricksAtTables
{
    public function replaceTrickAtTable(TableIdentifier $tableIdentifier, Trick $trick): void;
}

Het is die laatste interface die ik in mijn projector gebruik.

Laat ons ook eens bekijken welke andere projecties ik heb moeten maken voor TrickReopened:

Eentje in PlayingPlayerProjector, want in het read model PlayingPlayer zit hoeveel slagen een speler al gehaald heeft, en bij de winnaar van de recentste slag moet dat er dus eentje minder worden:

    public function applyTrickReopened(TrickReopened $event): void
    {
        $player = $this->repository->getPlayingPlayer(
            $event->getTableIdentifier(),
            $event->getWinner()
        );

        $this->repository->savePlayingPlayer(
            $player->withOneTrickLess()
        );
    }

En ook eentje in TableStateProjector, want het TableState-read model bevat wat algemene informatie over de toestand van iedere tafel, onder meer het nummer van de huidige slag. En dat moet dus ook eentje minder worden.

    public function applyTrickReopened(TrickReopened $event): void
    {
        $tableState = $this->tableStateRepository->getTableState($event->getTableIdentifier());
        $this->tableStateRepository->saveTableState(
            $tableState->forPreviousTrick()
        );
    }

Command en handler

WDEBELEK heeft niet alleen een event bus, voor de gepubliceerde events, maar ook een command bus. Naar zo'n command bus kun je commands sturen. Een command wordt dan opgepikt door een command handler, die - je raadt het al - dat command afhandelt. Door een beperkte set van commands te voorzien, en de toestand van je programma enkel aan te passen met commands, is het makkelijk om op een reproduceerbare manier een situatie te (re)creëren. Ik vind de command bus vooral handig als ik softwarematig een situatie moet creëren voor een automatische test.

Om de vorige slag open te kunnen leggen, maakte ik dus ook een ReopenTrick-command, zie src/Domain/WriteModel/Game/Command/ReopenTrick.php:

final class ReopenTrick implements Command
{
    public function __construct(
        private GameIdentifier $gameIdentifier,
        private PlayerIdentifier $reopeningPlayer,
        private int $trickNumber,
    ) {
    }

    public function getGameIdentifier(): GameIdentifier
    {
        return $this->gameIdentifier;
    }

    public function getReopeningPlayer(): PlayerIdentifier
    {
        return $this->reopeningPlayer;
    }

    public function getTrickNumber(): int
    {
        return $this->trickNumber;
    }
}

Het ReopenTrick-command heeft wel wat weg van het TrickReopened-event, maar er zijn belangrijke verschillen:

Als het command wordt afgevuurd, dan weten we nog niet of de slag effectief open gelegd zal zijn. Misschien wordt er straks een exception opgegooid omdat er een voorwaarde geschonden is. Pas als er een TrickReopened event op de event bus passeert, dán ben je zeker dat de slag opengelegd is. Dan pas is het effectief gebeurd.

Waar ik voor een event zei dat er daar gerust wat extra informatie in mag, hou je dat voor een command best zo beperkt mogelijk. Want hoe meer informatie in het command zit, des te meer moet je controleren of die informatie wel consequent is. Als je in ReopenTrick ook de slag zou meesturen die je terug wilt open leggen, dan zou je in Game ook moeten nakijken of je effectief de goede slag meegegeven hebt. En dat is niet nodig, want een Game weet perfect welke de laatst gespeelde slag was.

Ik geef in mijn commando's wel typisch het slagnummer mee (trickNumber), om problemen te vermijden als bijvoorbeeld bij een van de spelers het netwerk even weggevallen was, en die speler nog een oude spelsituatie ziet. Als de speler dan een commando doorstuurt, kan de Game-aggregate zien dat dat nog een commando was dat op de vorige slag van toepassing was, zo gebeuren er geen ongewenste wijzigingen.

Ok, tot daar het commando, dat commando moet ook nog afgehandeld worden, en dat gebeurt door de ReopenTrickHandler:

final class ReopenTrickHandler extends AbstractGameCommandHandler
{
    public function __invoke(ReopenTrick $command): void
    {
        /** @var Game $game */
        $game = $this->gameRepository->get($command->getGameIdentifier());
        $game->reopenTrick(
            $command->getReopeningPlayer(),
            $command->getTrickNumber()
        );
        $this->gameRepository->save($game);
    }
}

Hier halen we de Game-aggregate op. Merk op dat de gameRepository hier geen gewone repository is zoals die van een database tabel; GameRepository dit is een write model repository. $this->gameRepository->get() zal alle events voor het spel met de gegeven ID ophalen uit de event store, en op basis daarvan de internal state van het spel opbouwen (via de apply-functies die we hierboven al bespraken).

Als reopenTrick faalt, dan wordt er een exception opgegooid, en gebeurt er verder niets. In het andere geval zal $this->gameRepository->save() de events WinnerVotesInvalidated en TrickReopened publiceren op de event bus. En dan pas zijn ze echt gebeurd (controle op concurrency gebeurt hier ook, maar daar hebben we het een andere keer nog wel eens over), en gaan de projectors in gang schieten.

Api

Hiermee hebben we alles aan de backend gehad. Maar jammer genoeg is er ook een frontend nodig 😉. Dat is niet zo mijn comfort zone, maar het is nodig. Dus ik voegde gauw een api-actie toe: TrickApiController::reopenTrick()

    /**
     * @Route("/api/game/{gameId}/winner/{trickNumber}/vote/{playerSecret}", name="wdebelek.api.reopen_trick", methods="DELETE", options={"expose"=true})
     */
    public function reopenTrick(
        string $gameId,
        string $playerSecret,
        int $trickNumber
    ): Response {
        $this->commandBus->dispatch(
            new ReopenTrick(
                GameIdentifier::fromString($gameId),
                PlayerIdentifier::withSecret(Secret::fromString($playerSecret)),
                $trickNumber
            )
        );

        return new JsonResponse(null, Response::HTTP_NO_CONTENT);
    }

Ik gebruik geen API Platform en ik doe ook niets fancy met authenticatie/authorisatie. Dat komt omdat ik me vooral wou concentreren op het schrijven van een event sourced application.

Wat er hier in essentie gebeurt, is dat ik een commando op de command bus zet. Als het ReopenTrick-commando goed afgehandeld kan worden, dan resulteert dat in een TrickReopened-event, dat de read side updatet. Dat event zal dan via Mercure ook terug naar de browser gaan, maar ook dat is iets om een andere keer verder op in te gaan.

Frontend

Over de frontend ga ik niet veel zeggen, behalve dat ik daar vue.js gebruik, en dat ik maar wat doe, tot het lijkt te werken. Ik moest aanpassingen doen in CardMat.vue en ik voegde een paar lijntjes toe aan trickApi.js. Die aanpassingen zijn vrij eenvoudig, maar of het allemaal volgens de regels van de kunst is, daar heb ik geen idee van.

Unit tests

Ik heb de gewoonte om tests te schrijven. Unit tests zijn bijvoorbeeld handig om te zien of alles blijft werken met een nieuwe versie van php. Ik ambieer niet om elke lijn code gedekt te hebben in een test; ik ben er ook niet zeker van of dat de investering waard is.

Het is dus wat afwegen wat wel of niet te testen, en dat is moeilijk. Zelf schrijf ik gewoonlijk tests voor de stukken code waarin ik fouten maakte. Ik ben zelf nogal verstrooid, en daardoor heb ik dus wel wat tests. Door die tests te schrijven, probeer ik te vermijden dat ik twee keer dezelfde fout maak.

Voor dit merge requests voegde ik 2 unit tests toe aan tests/unit/Domain/WriteModel/Game/GameTest.php. Eentje die nakijkt of de verwachte events effectief uitgezonden worden als een speler een slag weer open legt:

    /** @test */
    public function itReopensTrick(): void
    {
        $game = $this->getGameWithFirstTrickWon();
        $game->clearUncommittedEvents();
        $game->reopenTrick(
            TestPlayers::ddb(),
            1
        );
        $expectedEvents = [
            new WinnerVotesInvalidated(
                $this->gameIdentifier,
                1
            ),
            new TrickReopened(
                $this->gameIdentifier,
                TestPlayers::ddb(),
                TestPlayers::penningmeester(),
                1,
                Trick::empty()
                    ->withPlayedCard(TestPlayers::ddb(), new Card(13, Card::SPADES))
                    ->withPlayedCard(TestPlayers::penningmeester(), new Card(1, Card::SPADES))
                    ->withPlayedCard(TestPlayers::secretaris(), new Card(2, Card::SPADES))
                    ->withPlayedCard(TestPlayers::dtl(), new Card(6, Card::SPADES)),
                $this->tableIdentifier
                ),
        ];
        $actualEvents = array_values($game->getUncommittedEvents());

        $this->assertEquals($expectedEvents, $actualEvents);
    }

Deze test start vanuit een spel waarbij de eerste slag gewonnen is. (Welke die eerste slag was, staat vast, die kun je ook vinden in GameTest.php. Daarna roep ik reopenTrick aan, en controleer ik via $game->getUncommittedEvents() of de events die ik verwacht, effectief te wachten staan om gepubliceerd te worden.

In een tweede test controleer ik of er, nadat de vorige slag opnieuw open gelegd is, opnieuw gestemd kan worden wie er wint:

    /** @test */
    public function itRevotesWinnerWhenTrickReopened(): void
    {
        $game = $this->getGameWithFirstTrickWon();
        $game->reopenTrick(
            TestPlayers::ddb(),
            1
        );
        $game->clearUncommittedEvents();
        $game->voteWinner(
            TestPlayers::ddb(),
            TestPlayers::penningmeester(),
            1
        );

        $expectedEvents = [
            new VotedAsWinner(
                $this->gameIdentifier,
                TestPlayers::ddb(),
                TestPlayers::penningmeester(),
                1
            ),
        ];
        $actualEvents = array_values($game->getUncommittedEvents());

        $this->assertEquals($expectedEvents, $actualEvents);
    }

Ik vertrek vanuit dezelfe tafel, en ik leg de slag opnieuw open met reopenTrick. De aanroep van clearUncommittedEvents is een truukje, om de nog te publiceren events terug leeg te maken. Zoiets doe je niet in je echte code, maar om te testen is het handig. Dan probeer ik om met voteWinner opnieuw een winnaar aan te duiden, en ik verwacht het overeenkomstige event.

Web tests

Omdat ik niet zo handig ben met javscript en frontend, heb ik ook graag wat web tests. Als ik die niet zou hebben, zou ik bij elke nieuwe release een half uur in het rond moeten klikken, om te zien of ik niets heb stuk gemaakt. Nu gebeurt dat automatisch, en dat is de investering echt al meer dan waard geweest.

Voor de web tests van WDEBELEK gebruik ik codeception met webdriver. Maar het had ook met met cypress gekund; want daar heb ik voor een ander project goede ervaringen mee. Al hebben beide frameworks ook wel hun issues en bugs.

De nieuwe test zit in tests/acceptance/TrickCest.php:

    public function checkIReopenPreviousTrick(AcceptanceTester $i): void
    {
        // http://localhost:8080/en/table/e2add500-241e-5e93-843d-99a837276195/play/ce29fba8-a25e-4ed6-abdf-3128daf90542
        $i->amOnPage('/en/table/'.TestTables::table31()->toString().'/play/'.TestSecrets::penningmeester()->toString());

        $i->waitForElement('.review-request');
        $i->click('.review-request');

        $i->waitForText('Reopen previous trick');
        $i->click('Reopen previous trick');

        // Waiting for ace of diamonds to reappear
        $i->waitForElement(".other-player-card span[data-card-number='27']");

        // I should be able to click it again as winner (check whether card mat is functional again)
        $i->waitForElementClickable("span[data-card-number='27']");
        $i->click("span[data-card-number='27']");
        $i->waitForElement("span[data-card-number='27'].winner");

        // Now let's do this for 2 other players as well, and see whether trick is picked up again.
        $i->amOnPage('/en/table/'.TestTables::table31()->toString().'/play/'.TestSecrets::dtl()->toString());
        $i->waitForElement("span[data-card-number='27']");
        $i->click("span[data-card-number='27']");

        $i->amOnPage('/en/table/'.TestTables::table31()->toString().'/play/'.TestSecrets::dpb()->toString());
        $i->waitForElement("span[data-card-number='27']");
        $i->click("span[data-card-number='27']");

        $i->waitForElementNotVisible("span[data-card-number='27']");
    }

Dit soort codeception tests zijn in mijn ogen tamelijk leesbaar. In dit geval surf ik naar de pagina van speler 'penningmeester' aan tafel 31, en ik leg daar een slag terug open door op 'Reopen previous trick' te klikken.

Daarna, laat ik de spelers 'penningmeester', 'dtl' en 'dpb' opnieuw stemmen op de winnende kaart (ruiten aas), om te kijken of de kaarten dan opnieuw opgeraapt worden.

De test wordt uitgevoerd aan 'tafel 31'; deze tafel maakt deel uit van de testdata. Alvorens de webtests uitgevoerd worden, genereer ik telkens identiek dezelfde testtafels, om de webtests voorspelbaar te houden. Voor deze test was het aanmaken van die testtafel tamelijk eenvouding: Dat zijn drie lijntjes in TestDataCommand.php:

        $this->createTableOf5WithFirstTrickCollected(
            TestTables::table31()
        );

Als je wat in TestDataCommand.php gaat grasduinen, dan zul je zien dat ik nogal uitvoerig gebruik maak van de command bus, die we al eerder tegen kwamen.

Tenslotte

Zo, nu het programma automatisch getest wordt, hebben we zelf meer tijd om te spelen, en dat kan op kaart.rijkvanafdronk.be.

Als je zin gekregen zou hebben om verder in de code te duiken, of om mee te programmeren, ga dan eens kijken op de gitlab-pagina. Of als je twijfelt of het online kaarten wel gaat blijven duren na Corona, maar je wilt toch graag eens iets doen met event sourced PHP: we hebben ook een scoreblad: score.rijkvanafdronk.be, ook event sourced, en ook beschikbaar op gitlab.

Heb je vragen of opmerkingen over deze uitgebreide blog post, geef ze zeker door. Het kan bijvoorbeeld zijn dat iets duidelijker uitgelegd kan worden. Misschien mis ik best practices, of heb ik dingen volledig verkeerd begrepen. Geef het door, ik kan er alleen maar van leren. En hoewel ik deze tekst zeker 10 keer heb nagelezen, er zullen allicht nog wel typfouten in staan, slechte zinnen of verkeerde url's. Vind je dit soort fouten, laat het ook weten, of in zo'n geval kun je misschien een merge request op gitlab maken.

Comments

Comments powered by Disqus