ReactでSlotMachineを作る(Step3: 仕様追加、リファクタリング実施)

[前回](https://qiita.com/koji0705/items/7c4fe495252728e5457c)までの記事の続きです。(シリーズ化予定)

▪️実装Step
[Step1:基本編](https://qiita.com/koji0705/items/be905a51d5b9674f4d46)
[Step2:Coin機能実装編](https://qiita.com/koji0705/items/7c4fe495252728e5457c)
[Step3: 仕様追加、リファクタリング実施](https://qiita.com/koji0705/items/bb1708c50978218607ec)　←いまここ
[Step4:ワイルドカード、セブンカードの追加](https://qiita.com/koji0705/items/037a0fd7ea93bf6c63f3)
[Step5:ディレクトリ整理/リファクタリング](https://qiita.com/koji0705/items/a8b12ea24b774129f784)

## 成果物
![slot_step2.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/599049/7a0ae8fb-9363-7bb2-3984-b89567ddb0c2.gif)

https://github.com/kouji0705/react_slot_machine/tree/feat/Step3_add_requirements

追加仕様
・当たった果物に応じて、コインの獲得枚数が変化する。

:::note info
ビジネスロジックが複雑化する上で、実装難易度が上がりデバッグにも時間がかかり、開発生産性が低下します。
今回はビジネスロジックの切り出しとJestを使った検証をスコープにします。
:::


## ソースコード
```shell:ディレクトリ構成
~/develop/react/react_slot_machine$ tree -I node_modules 
.
├── README.md
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.tsx
│   ├── components
│   │   ├── Reel.tsx
│   │   ├── SlotMachine.test.tsx
│   │   ├── SlotMachine.tsx
│   │   ├── const.ts
│   │   ├── func.test.ts
│   │   ├── func.ts
│   │   └── type.ts
│   ├── index.tsx
│   ├── logo.svg
│   └── setupTests.ts
├── tsconfig.json
└── yarn.lock

4 directories, 21 files
```

```ts:src/components/const.ts
import {SymbolTile} from "./type";

export const INITIAL_COINS = 100;
export const BET_COINS = 3;

export const CHERRY_ID = "cherry";
export const LEMON_ID = "lemon";
export const ORANGE_ID = "orange";
export const GRAPE_ID = "grape";
export const WATERMELON_ID = "watermelon";

export const CHERRY_SYMBOL = "🍒";
export const LEMON_SYMBOL = "🍋";
export const ORANGE_SYMBOL = "🍊";
export const GRAPE_SYMBOL = "🍇";
export const WATERMELON_SYMBOL = "🍉";

export const SYMBOLS: SymbolTile[] = [
  {
    id: CHERRY_ID,
    label: CHERRY_SYMBOL,
    point: 10,
  },
  {
    id: LEMON_ID,
    label: LEMON_SYMBOL,
    point: 10,
  },
  {
    id: ORANGE_ID,
    label: ORANGE_SYMBOL,
    point: 10,
  },
  {
    id: GRAPE_ID,
    label: GRAPE_SYMBOL,
    point: 20,
  },
  {
    id: WATERMELON_ID,
    label: WATERMELON_SYMBOL,
    point: 30,
  },
];
```

```ts:src/components/type.ts
import {
  CHERRY_ID,
  CHERRY_SYMBOL,
  GRAPE_ID,
  GRAPE_SYMBOL,
  LEMON_ID,
  LEMON_SYMBOL,
  ORANGE_ID,
  ORANGE_SYMBOL,
  WATERMELON_ID,
  WATERMELON_SYMBOL,
} from "./const";

export type SymbolTile = {
  id: SymbolId;
  label: SymbolLabel;
  point: number;
};

export type SymbolId =
  | typeof CHERRY_ID
  | typeof LEMON_ID
  | typeof ORANGE_ID
  | typeof GRAPE_ID
  | typeof WATERMELON_ID;

export type SymbolLabel =
  | typeof CHERRY_SYMBOL
  | typeof LEMON_SYMBOL
  | typeof ORANGE_SYMBOL
  | typeof GRAPE_SYMBOL
  | typeof WATERMELON_SYMBOL;
```

```tsx:src/components/SlotMachine.tsx
import React, {useState} from "react";
import {Reel} from "./Reel";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import {BET_COINS, INITIAL_COINS, SYMBOLS} from "./const"; // SymbolTileのインポート
import {SymbolTile} from "./type";
import {spinReels} from "./func";

export const SlotMachine: React.FC = () => {
  const [coins, setCoins] = useState(INITIAL_COINS);
  const [reels, setReels] = useState<SymbolTile[]>(Array(3).fill(SYMBOLS[0])); // SymbolTile型の配列

  const handleSpin = () => {
    const {newReels, win, points} = spinReels(reels, SYMBOLS, BET_COINS);
    setReels(newReels);
    setCoins((prev) => prev - BET_COINS + (win ? points : 0));
  };
  // TODO リファクタリング前
  //   const handleSpin = () => {
  //     const newReels = reels.map(
  //       () => SYMBOLS[Math.floor(Math.random() * SYMBOLS.length)]
  //     );
  //     setReels(newReels);
  //     setCoins((prev) => prev - BET_COINS);
  //     // 当たり判定
  //     if (new Set(newReels.map((tile) => tile.id)).size === 1) {
  //       setCoins((prev) => prev - BET_COINS + newReels[0].point); // 当たりならコインを追加
  //     }
  //   };

  const isWin = new Set(reels.map((tile) => tile.id)).size === 1;

  return (
    <Box sx={{textAlign: "center", marginTop: 4}}>
      <Box>Coinの枚数：{coins}枚</Box>
      <Box sx={{display: "flex", justifyContent: "center", gap: 2}}>
        {reels.map((tile, index) => (
          <Reel key={index} symbol={tile.label} />
        ))}
      </Box>
      <Button
        variant="contained"
        color="primary"
        style={{marginTop: "16px"}}
        onClick={handleSpin}
        disabled={coins < BET_COINS}
      >
        スピン
      </Button>
      {isWin && <Box sx={{marginTop: 2}}>勝利！</Box>}
    </Box>
  );
};
```

:::note info
注目して欲しいのが、handleSpinの箇所です。
ビジネスロジックが複雑化するにつれ、handleSpinも行数が増え検証に時間がかかります。
フロントエンド開発でデバッグは「コード改修→レンダリング→ブラウザ操作→コンポーネント・ログ確認」で実施する事が一般的です。
ただコードが複雑化するにつれて回数も増えるため、デバッグの時間が長時間化します。そのため、ビジネスロジックは外部関数化し、関数はjest等で検証する方が効率的です。
:::


```ts:src/components/func.ts
import {SymbolTile} from "./type";

export const spinReels = (
  currentReels: SymbolTile[],
  symbols: SymbolTile[],
  betCoins: number
): {newReels: SymbolTile[]; win: boolean; points: number} => {
  const newReels = currentReels.map(
    () => symbols[Math.floor(Math.random() * symbols.length)]
  );
  const win = new Set(newReels.map((tile) => tile.id)).size === 1;
  const points = win ? newReels[0].point : 0;

  return {newReels, win, points};
};

```
:::note info
複雑化しそうなロジックは外部関数化しました。
この後、Jestで本関数の妥当性を検証していきます。
:::


### テストコード
今回は外部関数化したコードをJestで検証したいと思います。

```ts:src/components/func.test.ts
import {spinReels} from "./func";
import {SymbolTile} from "./type";

describe("spinReels function", () => {
  // テスト用のシンボルデータ
  const testSymbols: SymbolTile[] = [
    {id: "cherry", label: "🍒", point: 10},
    {id: "lemon", label: "🍋", point: 5},
  ];

  test("should return win true and points when all reels match", () => {
    // モック関数を使用して常に同じシンボルを返すようにする
    jest.spyOn(global.Math, "random").mockReturnValue(0);

    const {newReels, win, points} = spinReels(testSymbols, testSymbols, 3);

    expect(win).toBe(true);
    expect(points).toBe(testSymbols[0].point);
    expect(newReels.every((tile) => tile.id === testSymbols[0].id)).toBe(true);

    // モックをリセット
    jest.spyOn(global.Math, "random").mockRestore();
  });

  test("should return win false and points zero when not all reels match", () => {
    // ランダムな結果を得るためにモックを使用しない
    const {win, points} = spinReels(testSymbols, testSymbols, 3);

    expect(win).toBe(false);
    expect(points).toBe(0);
  });
});
```
```shell:実行結果
~/develop/react/react_slot_machine$ yarn test src/components/func.test.ts
 PASS  src/components/func.test.ts
  spinReels function
    ✓ should return win true and points when all reels match (1 ms)
    ✓ should return win false and points zero when not all reels match

Test Suites: 1 passed, 1 total
Tests:       2 passed, 2 total
Snapshots:   0 total
Time:        0.285 s, estimated 1 s
Ran all test suites matching /src\/components\/func.test.ts/i.

Active Filters: filename /src/components/func.test.ts/
```