🎯

今日の個人開発2~Rust製CLIゲームを改良しよう~

2023/02/18に公開

はじめに

この記事は、coloname33が不定期で行う小規模個人開発の記録です。

本日のテーマ

前回の続きです。

リファクタリングをしていきます。

環境

Rust

% cargo --version
cargo 1.67.1 (8ecd4f20a 2023-01-10)

作るもの

本日の目標は、以下の2つになります。

  • ファイルの分割・コードの整理
  • 単体テストの追加

ファイル構成

AnswerとGuessで同じ構造のものを管理するのは共通化したいので、二つ合わせてCodeとし、moduleにします。

また、CheckResultもmoduleとし、check関数はCheckResultの初期化処理と解釈してこちらに移動させます。

単体テストの追加

ファイルごとに、単体テストを書いていきます。

実装

ファイル構成・実装の変更

AnswerとGuessの初期化処理をくっつけたCodeを作成します。

ついでに、newtypeを用いてより単純な Vec<u8>のラッパーとして機能するように実装しました。

ここで、from_randについては、0..10のVecから指定の個数の値を選択するchoose_multipleを使うことで、数字の被りが生まれないようにしています。

use rand::rngs::ThreadRng;
use rand::seq::SliceRandom;

pub struct Code(pub Vec<u8>);

impl Code {
    pub fn from_rand(rng: &mut ThreadRng) -> Self {
        let choices = (0..10).collect::<Vec<u8>>();

        Code(choices.choose_multiple(rng, 4).cloned().collect())
    }

    pub fn from_string(s: String) -> Result<Self, String> {
        let mut vec = vec![];

        for c in s.trim().chars() {
            match c.to_digit(10) {
                Some(d) => vec.push(d as u8),
                None => return Err(format!("数字として解釈できない文字があります。c={}", c)),
            };
        }

        Ok(Code(vec))
    }
}

また、CheckResultも別ファイルに移動し、check関数を加えます。

use crate::code::Code;
use std::fmt;

pub struct CheckResult {
    hit: u8,
    blow: u8,
}

impl CheckResult {
    pub fn check(answer: &Code, guess: &Code) -> Result<Self, String> {
        let mut hit = 0;
        let mut blow = 0;

        if answer.0.len() != guess.0.len() {
            return Err(format!(
                "長さが間違っています。ans={}, guess={}",
                answer.0.len(),
                guess.0.len()
            ));
        }

        for (idx, &val) in guess.0.iter().enumerate() {
            if let Some(ans_idx) = answer.0.iter().position(|&ans| ans == val) {
                if ans_idx == idx {
                    hit += 1;
                } else {
                    blow += 1;
                }
            }
        }

        Ok(CheckResult {
            hit: hit,
            blow: blow,
        })
    }

    pub fn correct(&self) -> bool {
        self.hit == 4 && self.blow == 0
    }
}

impl fmt::Display for CheckResult {
    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
        write!(f, "Result: {}hit, {}blow", self.hit, self.blow)
    }
}

最後に、main関数でもこれを参照するようにコードを改良します。

mod check_result;
mod code;

use check_result::CheckResult;
use code::Code;

fn main() {
    let answer = Code::from_rand(&mut rng);

    loop {
        let guess = match Code::from_string(guess) {}

        let result = match CheckResult::check(&answer, &guess) {}
    }
}

単体テスト項目の検討

テスト項目を検討していきます。

今回新しくファイルを分けたCode, CheckResultの2つの型が持つメソッドを対象とします。

Code::from_rand

  • 以下の条件を満たすVecが返ってくるか
    • 長さ4
    • 全て10以下
      • 今回は型がunsignedのため0以上は保証される
    • 各桁の数字が互いに異なる

Code::from_string

  • 数字がちゃんと変換できるか
  • 数字以外の文字があったときエラーになるか

CheckResult::check

  • 0123と0369で結果が1hit&1blowと一致するか
  • 0123と0123で結果が4hit&0blowと一致するか
  • 0123と4567で結果が0hit&0blowと一致するか
  • Codeの長さが違う時エラーになるか

CheckResult::correct

  • hit=4, blow=0の時trueになるか
  • hit=1, blow=0の時falseになるか

テストを書く

Code::from_rand

確認したいのは、前にあげた3つの条件です。

このうち、長さについては簡単にassert_eqで比較できます。

assert_eq!(code.0.len(), 4);

各桁が10以下であることは、一つずつ中身を確認して保証します。

for i in &code.0 {
    assert!(*i < 10);
}

最後に、被りがあるかどうかについては、HashSetを用いて各桁の値をinsertしていくことで確認します。
参考:https://doc.rust-jp.rs/rust-by-example-ja/std/hash/hashset.html

まとめると、以下のようになります。

#[cfg(test)]
mod tests {
    use super::Code;
    use std::collections::HashSet;

    #[test]
    fn from_rand() {
        let mut rng = rand::thread_rng();
        let code = Code::from_rand(&mut rng);

        assert_eq!(code.0.len(), 4);

        let mut set = HashSet::new();

        for i in &code.0 {
            assert!(*i < 10);
            assert!(set.insert(*i));
        }
    }
}

Code::from_string

成功例は、実際にCode::from_stringにテキストを入れて作ったものと、直接中身のvecを指定して作ったものを比較します。

まず、Codeという独自の構造体で比較ができるようにするため、deriveを用います。
詳しくはこちら:https://qiita.com/Papillon6814/items/97c175fd94f0107d3821

#[derive(Debug, PartialEq, Eq)]
pub struct Code(pub Vec<u8>);

なお、Result型が返ってくるので、Okで包む(かunwrapする)必要があります。

今回は、assert_eqとは別の場所で例外がでて止まるのを避けるため、unwrapせずに比較する実装にしました。(慣れた方教えて。。。)

#[test]
fn from_string() {
    assert_eq!(
        Code::from_string("0123".to_string()),
        Ok(Code(vec![0, 1, 2, 3]))
    );
}

次に、失敗パターンを書きます。

現在の実装ではerrが文字列で返ってくるため、文章全体の比較が必要です。(独自のエラーを作成したい。。。)

assert_eq!(
    Code::from_string("01a3".to_string()),
    Err("数字として解釈できない文字があります。c=a".to_string())
);

CheckResult::check

まずはCodeと同様に、比較できるようなderiveをつけます。

#[derive(Debug, PartialEq, Eq)]
pub struct CheckResult {
    hit: u8,
    blow: u8,
}

次に、先に挙げた4つのパターンでanswerとguessを生成し、比較します。

    #[test]
    fn check() {
        let answer = Code::from_string("0123".to_string()).unwrap();

        let guess = Code::from_string("0123".to_string()).unwrap();
        assert_eq!(
            CheckResult::check(&answer, &guess),
            Ok(CheckResult { hit: 4, blow: 0 })
        );

        let guess = Code::from_string("0369".to_string()).unwrap();
        assert_eq!(
            CheckResult::check(&answer, &guess),
            Ok(CheckResult { hit: 4, blow: 0 })
        );

        let guess = Code::from_string("4567".to_string()).unwrap();
        assert_eq!(
            CheckResult::check(&answer, &guess),
            Ok(CheckResult { hit: 4, blow: 0 })
        );

        let guess = Code::from_string("01234".to_string()).unwrap();
        assert_eq!(
            CheckResult::check(&answer, &guess),
            Err("長さが間違っています。ans=4, guess=5".to_string())
        );
    }

CheckResult::correct

以下のように実装しました。

結果がfalseになる時assert!(!f())にするかassert_eq!(f(), false)にするか、地味に迷いましたが今回はこちらにしました。

    #[test]
    fn correct() {
        let result = CheckResult { hit: 4, blow: 0 };
        assert!(result.correct());

        let result = CheckResult { hit: 1, blow: 0 };
        assert_eq!(result.correct(), false);
    }

これで完成しました。

実際のコードは以下になります。

https://github.com/kyuki3rain/hit-and-blow-rust/tree/release/0291f9e0e2fa64

最後に

今回はrustで前回作ったCLIゲームのリファクタリングと単体テストの追加を行いました。

続きはこちら:
今日の個人開発3~Github ActionsでRustのCIをやってみよう~

記事については、もっと読みやすくなるような修正を適宜していくつもりです。

実装こっちの方がいいよ!などあればコメントください。

Discussion