今日の個人開発2~Rust製CLIゲームを改良しよう~
はじめに
この記事は、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