Dart – Ponorme sa hlbšie

Dnes naimplementujeme klasickú hru Pexeso. Názorne si predvedieme, ako písať aplikácie s unittestami, pracovať s knižnicami, triedami a typmi. Nemá zmysel otáľať, hor sa do roboty!

Seriál: Úvod do Dartu (9 dílů)

  1. Dart – Čo? Prečo? 2.8.2013
  2. Dart – Úvod do jazyka 23.8.2013
  3. Dart – Ponorme sa hlbšie 6.9.2013
  4. Dart – v DOMe 19.9.2013
  5. Dart – Futures 4.10.2013
  6. Dart ­– Streams 17.10.2013
  7. Dart – Používame JavaScript 1.11.2013
  8. Dart Typesystem 19.11.2013
  9. Dart – Neznesiteľná ľahkosť asynchrónneho bytia 2.12.2013

Viete, že PEXESO je akronym PEKELNĚ SE SOUSTŘEĎ? To aspoň tvrdí prvé číslo 84. ročníku časopisu Naše řeč. V dnešnom dieli si jednoduchú verziu tejto hry naimplementujeme v Darte, výsledok si môžete zahrať tu a kompletné zdrojové kódy nájdete na githube.

Štruktúra

Spustíme si Dart Editor a z menu zvolíme File→New application→Web application. Začneme vytvorením súboru pubspec.yaml a adresárov lib, web a test. Otvoríme pubspec.yaml v editore a medzi dependencies pridáme browser, shuffle a unittest, posledný menovaný ako dev dependency. Podrobnejšie o adresárovej štruktúre pojednáva dokumentácia pub.

Miešanie kariet

Držiac sa vzoru MVC, oddelíme logiku aplikácie od ovládania. Vytvoríme knižnicu pexeso_model, zodpovednú za počítanie skóre hráčov, miešania kariet i kontrolu, kto je na ťahu.

Karty reprezentujeme ako List<int> čísel od 0 po počet párov - 1, každé číslo je použité práve dvakrát. V Darte List najlepšie zodpovedá Array v JavaScripte, v zátvorkách <int> môžeme (nemusíme) uviesť typ objektov, ktoré sa budú v poli nachádzať.

Na začiatku hry treba rozdať karty. Vytvorme funkciu createShuffledCards, ktorá pripraví balíček zamiešaných pexesových kariet. Obsah súboru lib/pexeso_model.dart zatiaľ vyzerá nasledovne:

/**
 * A library that provides all logic for the pexeso game.
 */
library pexeso_model;

import 'package:shuffle/shuffle.dart';

/**
 * Returns randomly shuffled [List] of numbers 0..[numOfPairs]-1 containing each
 * number exactly twice.
 */
List<int> createShuffledCards(int numOfPairs) {

}

Predtým, než sa pustíme do implementácie, napíšeme jednoduchý unittest v súbore test/pexeso_test.dart.

/**
 * Unittests for pexeso game.
 */

import 'package:unittest/unittest.dart';
import 'package:pexeso/pexeso_model.dart';

void main() {

  group("Test of model.", () {
    test("Cards provided by createShuffledCards are enumerated from 0 to"
        " numOfPairs - 1 and each card is contained exactly twice.", () {
      expect(
        createShuffledCards(8),
        unorderedEquals([0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7])
      );

    });
  });
}

Balíček unittest poskytuje sympatický spôsob písania testov. Narozdiel od bežných testovacích frameworkov nepotrebujeme vytvárať žiadne triedy. Súvisiace testy jednoducho spájame pomocou funkcie group, ktorá berie dva argumenty: názov skupiny a funkciu (my sme použili Dartovu syntax pre anonymné funkcie) vo svojom tele obsahujúcu testy. Skupiny testov sa môžu ľubovoľne hierarchicky radiť, takže vnútri group môžeme mať ďalšie groups, prípadne group nemusíme používať vôbec.

Nižšie v hierarchii sa nachádza samotný test, syntax je podobná ako pri group. Telo testovacej funkcie môže obsahovať ľubovoľný kód a okrem toho niekoľko expect direktív. Jednotlivé testy bežia nezávisle, pád jedného testu neovplyvní ostatné, no samotný test sa zastaví na prvom nenaplnenom expect.

Direktíva expect očakáva dva parametre: hodnotu a matcher. V našom príklade sme použili matcher unorderedEquals, ktorý porovnáva List na zhodu prvkov neberúc do úvahy poradie.

Keď teraz test spustíme, v konzole sa nám zjaví nasledovný výstup:

FAIL: Test of model. Cards provided by createShuffledCards are enumerated from 0 to numOfPairs - 1 and each card is contained exactly twice.
  Expected: equals [0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7] unordered
    Actual: <null>   Which: not iterable

  package:unittest/src/expect.dart 78:29  expect
  pexeso_test.dart 20:13                  main.<fn>.<fn>
  dart:async                              _createTimer.<fn>
  timer_impl.dart 96:21                   _Timer._createTimerHandler._handleTimeout
  timer_impl.dart 112:23                  _Timer._createTimerHandler.<fn>
  dart:isolate                            _ReceivePortImpl._handleMessage

0 PASSED, 1 FAILED, 0 ERRORS

Po doplnení implementácie

List<int> createShuffledCards(int numOfPairs) {
  var cards = [];
  for (var i = 0; i < numOfPairs; i++) {
    cards.add(i);
    cards.add(i);
  }
  return shuffle(cards);
}

unittesty krásne zbehnú.

PASS: Test of model. Cards provided by createShuffledCards are enumerated from 0 to numOfPairs - 1 and each card is contained exactly twice.

All 1 tests passed.
unittest-suite-success

 

Reprezentácia hry

Na reprezentáciu hry vytvoríme triedu Game, ktorá si bude držať informácie o počte hráčov, skóre jednotlivých hráčov, hráčovi na ťahu a o zozname pexesových kariet.

/**
 * Class representing one play of the pexeso game.
 */
class Game {
  final int numPlayers;
  final List<int> score;
  final List cards;

  int playerOnTurn;
}

Všimnime si, že niektoré prvky triedy, ako napríklad numPlayers, majú pri svojej deklarácií uvedený modifikátor final. Toto je v Darte spôsob, ako vytvoriť read-only premennú. Premenné označené ako final musia byť inicializované pri deklarácii, alebo v prípade tried skôr, ako sa zavolá telo konštruktora.

Všimnime si taktiež rozdiel medzi score a cards. Kým score je deklarované ako List integerov, cards môže byť List ľubovoľných (aj navzájom typovo rôznych) prvkov. Keby sme chceli, mohli by sme deklarovať nejaký prvok typu dynamic, čo je spôsob, ako v Darte explicitne povedať, že pre premennú sú povolené hodnoty ľubovoľného typu.

class Game {
  final int numPlayers;
  final List<int> score;
  final List cards;

  int playerOnTurn;

  /**
   * Creates new instance of [Game] with number [numPlayers] of players and with
   * pexeso cards [this.cards].
   */
  Game(numPlayers, playerOnTurn, cards) {
    this.numPlayers = numPlayers;
    this.score = new List.filled(numPlayers, 0);
    this.playerOnTurn = playerOnTurn;
    this.cards = cards;
  }

}

Pridali sme do triedy konštruktor. Syntax je priamočiara – konštruktor je funkcia, ktorá sa volá rovnako ako trieda. V Darte je navyše možnosť vytvoriť pre jednu triedu viacero pomenovaných konštruktorov vo forme meno_triedy.meno_konštruktora, v našom prípade napríklad Game.another(param1, param2).

Máme však problém, napísaný kód nie je korektný. V tele konštruktora priraďujeme do final fieldov triedy, no tie musia byť inicializované skôr, ako sa spúšťa telo konštruktora.

Pre tieto prípady môžeme mať konštruktor v Darte initializer list, to je vlastne sada priradení do fieldov triedy oddelená čiarkami. Priradenia v initializer liste majú ešte jedno obmedzenie, v initializer liste sa na pravej strane priradenia nemožno odkazovať na this.

Game(numPlayers, playerOnTurn, cards)
    : this.numPlayers = numPlayers,
      this.score = new List.filled(numPlayers, 0),
      this.playerOnTurn = playerOnTurn,
      this.cards = cards;

Nakoľko sme všetky priradenia vložili do initializer listu, telo konštruktora ostalo prázdne. Dart nám v takých prípadoch umožňuje miesto písania prázdnych zložených zátvoriek  jednoducho ukončiť definíciu konštruktora bodkočiarkou.

Teraz je už kód korektný, no dá sa zjednodušiť. Dart ponúka skratkovú syntax pre priradenia tvaru this.cards = cards. Ak uvedieme v argumentoch konštruktora meno fieldu ako this.field, automaticky sa hodnota argumentu priradí do príslušného fieldu.

Game(numPlayers, this.playerOnTurn, this.cards)
      : this.numPlayers = numPlayers,
        score = new List.filled(numPlayers, 0);

Je príjemné, najmä z hľadiska testovania, že náš základný konštruktor sa nesnaží si závislosti (ako napríklad balíček kariet, alebo začínajúci hráč) vyrobiť sám, ale očakáva ich ako parametre. Často by sme však chceli vytvoriť triedu Game poznajúc počet hráčov a počet párov pexesa. Pre tento účel si správime jednoduchú statickú factory metódu v Game.

/**
 * Creates an instance of [Game] for number [numPlayers] of players and with
 * randomly shuffled [numOfPairs] pairs of cards.
 */
static CreateWithCards(numPlayers, numOfPairs) {
  return new Game(numPlayers, 0, createShuffledCards(numOfPairs));
}

Teraz, keď budeme chcieť vyrobiť hru pre dvoch hráčov s 16 pármi pexesa, stačí zavolať var game = Game.CreateWithCards(2, 16);. Pre zvýšenie prehľadnosti sa vývojový tím Dartu rozhodol oddeliť bežné statické metódy od factory metód zavedením kľúčového slova factory. Jediný rozdiel spočíva v syntaxi.

factory Game.withCards(numPlayers, numOfPairs) {
  return new Game(numPlayers, 0, createShuffledCards(numOfPairs));
}

Pred chvíľou uvedený príklad vytvorenia hry sa zmení na var game = new Game.withCards(2, 16).

Herná logika

Pridajme do triedy Game metódu turnCards. Metóda dostane ako parametre pozície kariet z balíčka, ktoré hráč otočil a ak sa budú zhodovať, zvýši hráčovi na ťahu skóre. V opačnom prípade hráčov ťah končí a na rad sa dostáva ďalší v poradí.

class Game {
...
  /**
   * Checks whether the [first] and [second] cards match and if so, increases
   * the score of [playerOnTurn] by 1 and return true. Otherwise return false
   * and update [playerOnTurn].
   */
  turnCards(int first, int second) {
  }
...
}

Ako správni TDD nadšenci skôr, ako sa pustíme do implementácie, pridáme do pexeso_test.dart príslušný test.

  group("Test of model.", () {
  ...
    group("Turning cards", () {

      Game game;
      var numPlayers, playerOnTurn, nextPlayer;
      List cards;

      setUp(() {
        numPlayers = 4;
        playerOnTurn = 3;
        nextPlayer = 0;
        cards = [0, 1, 2, 0, 1, 2];
        game = new Game(numPlayers, playerOnTurn, cards);
      });

      test("that are the same increases the score by 1 and do not change the"
          " playerOnTurn. The return value is true.", () {
        expect(game.turnCards(0, 3), isTrue);
        expect(game.playerOnTurn, equals(playerOnTurn));
        expect(game.score[playerOnTurn], equals(1));
      });

      test("that do not match does not affect the score and next player gets"
          " turn. The return value is false.", () {
        expect(game.turnCards(0, 1), isFalse);
        expect(game.playerOnTurn, equals(nextPlayer));
        expect(game.score, orderedEquals(new List.filled(numPlayers, 0)));
      });
    });
  ...
  });

Vnútri každej group môžeme zaregistrovať setUp a tearDown funkcie, ktoré sa spustia pred respektíve po každom teste vnútri group. Ak máme v kóde vnorené group z ktorých každá definuje svoj setUp a tearDown, zavolajú sa najskôr vonkajšie setUp a neskôr vnútorné setUp, naopak najskôr vnútorné tearDown a potom vonkajšie tearDown. Najlepšie si to ukážeme na príklade:

group("outer", () {
  setUp(() => print("outer setUp"));
  tearDown(() => print("outer tearDown"));
  test("outer test", () => print("outer test"));
  group("inner", () {
    setUp(() => print("inner setUp"));
    tearDown(() => print("inner tearDown"));
    test("inner test 1", () => print("inner test 1"));
    test("inner test 2", () => print("inner test 2"));
  });
});

Výstup po spustení tohto kódu by bol:

outer setUp
outer test
outer tearDown
outer setUp
inner setUp
inner test 1
inner tearDown
outer tearDown
outer setUp
inner setUp
inner test 2
inner tearDown
outer tearDown

Ostáva nám doplniť implementáciu turnCards, aby testy zbehli.

/**
 * Checks whether the [first] and [second] cards match and if so, increases
 * the score of [playerOnTurn] by 1 and return true. Otherwise return false
 * and update [playerOnTurn].
 */
turnCards(int first, int second) {
  if (cards[first] == cards[second]) {
    score[playerOnTurn]++;
    return true;
  } else {
    playerOnTurn = (playerOnTurn + 1) % numPlayers;
    return false;
  }
}

Pokračovanie nabudúce…

V tejto chvíli máme naprogramovanú a otestovanú logiku hry. O tom, ako to celé rozhýbať, si povieme nabudúce.

S kamošmi som založil VacuumLabs, špecializujeme sa na webové aplikácie. Momentálne frčíme na Pythone a pokukujeme po Darte. Nikdy neodmietam pozvanie na dobré české pivo.

Zdroj: https://www.zdrojak.cz/?p=9664