『ドデリド』の受け入れテスト駆動開発によるエンジニア向けのルール説明

## はじめに

この記事は以前執筆した [『おばけキャッチ』の受け入れテスト駆動開発による実装（或いは、TypeScript + Karma + Jasmine によるフロントエンドTDDのサンプル）](https://qiita.com/kyoyababa/items/f270f97833a6dd9e15b3) の、カジュアルな続編にあたる。

さて、飲み会でやるゲームといえば、そこらへんのリア充ども[^リア充]は王様ゲームとかマリオパーティとかなのであろうが、我々内向型ソフトウェア・エンジニアは、遊戯王[^遊戯王]かボードゲームかカードゲームくらいしか選択肢がない。

手軽にできる瞬発系カードゲームのひとつとして、モビウスゲームの『ドデリド[^ドデリド]』があるが、なかなかルールを理解してくれないメンバーがおり、いっしょに遊べないという重大な問題が発生しており、彼にルールを理解させることが最優先プロダクト・バックログ・アイテムである、と私の所属組織のプロダクトオーナーにより決定された[^プロダクトオーナーによる決定]。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/105252/c18c0613-2fdc-6627-f334-a7bffe2c1ef6.png)

## コントローラの準備

まずはユニットテストを書くために、最低限必要な型・クラスを準備する。「わに」の場合に色を「null」として扱うべきかどうか迷ったが、とりあえずこれでOK。

```typescript:最低限必要な型・クラスの準備（TypeScript/ES6）
type Type = 'しまうま' | 'らくだ' | 'かめ' | 'ふらみんご' | 'ぺんぎん' | 'わに';
type Color = 'あお' | 'ぴんく' | 'しろ' | 'きいろ' | 'みどり' | null;

export interface Card {
  type: Type;
  color: Color;
}

export class DodelidoController {
  public generateCorrectAnswer(cards: [Card, Card, Card]): string {

  }
}
```

## ユニットテストの記載

ここで、『ドデリド』のルール上存在しうるすべてのパターンをテストケースとして網羅する。書き方は人によっていろいろあるだろうが、私は今回以下のように用意した。

```typescript:『ドデリド』のルール上存在しうるすべてのパターンのテストケース
import { DodelidoController, Card } from './dodelido';

describe('DodelidoController', () => {
  let ctrl: DodelidoController;

  beforeEach(() => {
    ctrl = new DodelidoController();
  });

  describe('generateCorrectAnswer', () => {
    [
      {
        arg: [
          // 絵柄が答えとなるケース
          { type: 'しまうま', color: 'あお' },
          { type: 'しまうま', color: 'ぴんく' },
          { type: 'ふらみんご', color: 'しろ' }
        ],
        expected: 'しまうま'
      }, {
        arg: [
          // 色が答えとなるケース
          { type: 'ぺんぎん', color: 'あお' },
          { type: 'らくだ', color: 'ぴんく' },
          { type: 'ふらみんご', color: 'ぴんく' }
        ],
        expected: 'ぴんく'
      }, {
        arg: [
          // ドデリドが答えとなるケース
          { type: 'ふらみんご', color: 'あお' },
          { type: 'しまうま', color: 'しろ' },
          { type: 'ふらみんご', color: 'しろ' }
        ],
        expected: 'どでりど'
      }, {
        // 絵柄が答えとなるケース＋かめ
        arg: [
          { type: 'ぺんぎん', color: 'きいろ' },
          { type: 'ぺんぎん', color: 'ぴんく' },
          { type: 'かめ', color: 'みどり' }
        ],
        expected: 'かめぺんぎん'
      }, {
        arg: [
          // ドデリドが答えとなるケース＋かめ
          { type: 'ぺんぎん', color: 'ぴんく' },
          { type: 'かめ', color: 'ぴんく' },
          { type: 'かめ', color: 'みどり' }
        ],
        expected: 'かめかめどでりど'
      }, {
        arg: [
          // ワニを隠すのが答えとなるケース
          { type: 'しまうま', color: 'あお' },
          { type: 'しまうま', color: 'ぴんく' },
          { type: 'わに', color: null }
        ],
        expected: 'かくす'
      }
    ].forEach(f => {
      const firstCard = `${f.arg[0].type}-${f.arg[0].color}`;
      const secondCard = `${f.arg[1].type}-${f.arg[1].color}`;
      const thirdCard = `${f.arg[2].type}-${f.arg[2].color}`;

      it(`場のカードが ${firstCard}, ${secondCard}, ${thirdCard} の場合 ${f.expected} が答えとなること`, () => {
        const actual = ctrl.generateCorrectAnswer(<[Card, Card, Card]>f.arg);
        expect(actual).toBe(f.expected);
      });
    });
  });
});
```

実装コードにはまだ何も実装していないので、当然どのパターンでも返り値が `undefined` になってしまうためテストがfailする。それでは、テストがpassするように実装コードを書いていこう。

## 実装の結果と解説

```typescript
public generateCorrectAnswer(cards: [Card, Card, Card]): string {
  const theMostFrequentType = this.getTheMostFrequentType(cards.map(c => c.type));
  const theMostFrequentColor = this.getTheMostFrequentColor(cards.map(c => c.color));
  const prefix = this.getPrefix(cards.map(c => c.type));

  if (this.hasWani(cards.map(c => c.type))) {
    return 'かくす';
  } else if (theMostFrequentType.count > theMostFrequentColor.count) {
    return `${prefix}${theMostFrequentType.type}`;
  } else if (theMostFrequentType.count < theMostFrequentColor.count) {
    return `${prefix}${theMostFrequentColor.color}`;
  } else if (theMostFrequentType.count > 0 && theMostFrequentType.count === theMostFrequentColor.count) {
    return `${prefix}どでりど`;
  } else {
    return 'なし';
  }
}

private hasWani(arg: [Type, Type, Type]): boolean {
  return arg.some(a => a === 'わに');
}

private getTheMostFrequentType(arg: [Type, Type, Type]): { type: Type, count: number } {
  const theMostFrequentType = arg.sort((a, b) => {
    return arg.filter(v => v === a).length - arg.filter(v => v === b).length;
  }).pop();

  return {
    type: theMostFrequentType,
    count: arg.filter(a => a === theMostFrequentType).length
  }
}

private getTheMostFrequentColor(arg: [Color, Color, Color]): { color: Color, count: number } {
  const theMostFrequentColor = arg.sort((a, b) => {
    return arg.filter(v => v === a).length - arg.filter(v => v === b).length;
  }).pop();

  return {
    color: theMostFrequentColor,
    count: arg.filter(a => a === theMostFrequentColor).length
  }
}

private getPrefix(arg: [Type, Type, Type]): string {
  return new Array(arg.filter(a => a === 'かめ').length).fill('かめ').join('');
}
```

### 解説：generateCorrectAnswer()

少しだけ解説を加えよう。まず `generateCorrectAnswer()` 内のif文だが、こちらはインラインでコメントして説明するものとしよう。

```typescript:generateCorrectAnswer()
  if (this.hasWani(cards.map(c => c.type))) {
    // ワニが場に現れた場合は、無条件で隠す必要があるので、最初のif文とする
    return 'かくす';
  } else if (theMostFrequentType.count > theMostFrequentColor.count) {
    // 一致する色数より、一致する絵柄数が多い場合が、絵柄が解となる
    return `${prefix}${theMostFrequentType.type}`;
  } else if (theMostFrequentType.count < theMostFrequentColor.count) {
    // 一致する絵柄数より、一致する色数が多い場合が、色が解となる
    return `${prefix}${theMostFrequentColor.color}`;
  } else if (theMostFrequentType.count > 0 && theMostFrequentType.count === theMostFrequentColor.count) {
    // 一致する色数と一致する絵柄数が同値の場合は、ドデリドが解となる
    // ただし、0 === 0 が評価されてはいけないので、一方が正であることをあらかじめ条件として加える
    return `${prefix}どでりど`;
  } else {
    // 上記のいずれにも満たない場合、解なしだ
    return 'なし';
```

### 解説：getTheMostFrequentType()

以下は少し難解かもしれない。与えられたカードの絵柄のうち、最頻値の絵柄とその枚数を返すにあたり、まず最頻値の絵柄が何かを特定しなければならない。今回は、 `sort()` メソッドで最頻値が配列の最後にくるようにした。filterした結果を再帰的に減算し続けることで、数が少ない順番に配列がソートされる。

```typescript:getTheMostFrequentType()
  const theMostFrequentType = arg.sort((a, b) => {
    return arg.filter(v => v === a).length - arg.filter(v => v === b).length;
  }).pop();
```

### 解説：getPrefix()

公式ルールでは「カメの数だけ、解の前に『オー』をつける」となっているが、我々グループでは『オー』があまり面白くない、として、そのまま『かめ』とつけることとしているので、ローカルルールに準じているコードだ。

`arg.filter(a => a === 'かめ').length` でカメの枚数が算出されることは説明の必要がないだろうが、その結果を `new Array()` に代入することで、空の配列を生成できる。これにより、例えばカメが2枚の場合は `[undefined, undefined]` が出力される。
この配列を `.fill('かめ')` することで、 `['かめ', 'かめ']` と代入させることができるというわけだ。
その結果を空文字で `.join()` することで、最終的に `かめかめ` という文字列をprefixとして返せる。

```typescript:getPrefix()
  return new Array(arg.filter(a => a === 'かめ').length).fill('かめ').join('');
```

### テスト結果

```tsx
DodelidoController
  generateCorrectAnswer
    ✓ 場のカードが しまうま-あお, しまうま-ぴんく, ふらみんご-しろ の場合 しまうま が答えとなること
    ✓ 場のカードが ぺんぎん-あお, らくだ-ぴんく, ふらみんご-ぴんく の場合 ぴんく が答えとなること
    ✓ 場のカードが ふらみんご-あお, しまうま-しろ, ふらみんご-しろ の場合 どでりど が答えとなること
    ✓ 場のカードが ぺんぎん-きいろ, ぺんぎん-ぴんく, かめ-みどり の場合 かめぺんぎん が答えとなること
    ✓ 場のカードが ぺんぎん-ぴんく, かめ-ぴんく, かめ-みどり の場合 かめかめどでりど が答えとなること
    ✓ 場のカードが しまうま-あお, しまうま-ぴんく, わに-null の場合 かくす が答えとなること
    ✓ 場のカードが しまうま-あお, ふらみんご-ぴんく, らくだ-しろ の場合 なし が答えとなること
```


## おわりに

おそらくこれにより、「なかなかルールを理解してくれないメンバー」もルールを理解してくれるであろう。東京都の緊急事態宣言があけた暁に飲み会が開催されたならば、この記事の可読性が評価されることを期待しよう。

[^リア充]: 現実に存在する《リア充》に私は出会ったことがないので、あくまでも筆者の想像上のものである。
[^遊戯王]: 私の所属する開発組織では、開発中のデモデータを遊戯王関連のワードで作成することが流行っている。ちなみに私がよく使っているデモユーザー名は「お願い、死なないで城之内！」と「インセクター羽蛾」である。
[^ドデリド]: https://www.mobius-games.co.jp/DreiMagier/DoDeLIDo.html
[^プロダクトオーナーによる決定]: 当然だが、ジョークである。