開発日記⑪ 入力ミスをバリデーションで判定する

前回までの進捗

前回は、Reactのカスタムフックを使って状態管理と計算処理を整理した。
入力値はuseGameState、入力値から求められる結果はuseGameDerivedStateへ分けたことで、処理の役割が以前より分かりやすくなった。
今回は、入力された値がおかしくないか確認するバリデーション処理を整理した。
このアプリでは点数などを自由に入力できるため、

  • 25,050点のような100点単位ではない持ち点
  • マイナスの本場
  • ロンなのに和了者と放銃者が同じ
  • 0点のロン
  • おかしなツモ支払い

なども入力できてしまう。
そのまま計算すると意味のない結果を表示してしまうため、計算前に入力値を確認することにした。

バリデーションとは

バリデーションは、入力された値が決めたルールに合っているか確認する処理。
今回なら、

持ち点は100点単位か
本場は0以上の整数か
供託は0以上の整数か
ロンの和了者と放銃者は別か
ロン点は正しい形式か
ツモ支払いは正しい形式か

といったことを確認する。
処理はvalidation.tsへまとめた。
画面側や点数計算の中に個別で書くのではなく、入力チェックを一か所に集める形にしている。

持ち点が100点単位か確認する

麻雀の持ち点は100点単位で扱っているため、100で割り切れるか確認する。

export function isValidPointUnit(
  points: number,
): boolean {
  return (
    Number.isFinite(points) &&
    points % 100 === 0
  );
}

4人分の持ち点については、someを使って一人でも条件に合わない人がいるか確認している。

const hasInvalidScore = seats.some(
  (seat) =>
    !isValidPointUnit(scores[seat]),
);

たとえば、

東家 25000
南家 25100
西家 24900
北家 25050

なら、北家だけ100点単位になっていないのでエラーになる。

本場と供託は0以上の整数にする

本場と供託は、

0
1
2
3
...

のような整数で扱う。

そのため、

export function isValidNonNegativeInteger(
  value: number,
): boolean {
  return (
    Number.isFinite(value) &&
    Number.isInteger(value) &&
    value >= 0
  );
}

という関数でチェックする。

const hasInvalidHonba =
  !isValidNonNegativeInteger(honba);

const hasInvalidKyotaku =
  !isValidNonNegativeInteger(kyotaku);

これで、

-1本場
1.5本場
-2本の供託

などはエラーになる。

ロン時の入力を確認する

ロンでは、特有の入力ミスもある。

まず、和了者と放銃者が同じ人物になることはない。

winner === loser

になっていたらエラーにする。

さらに、ロン点についても確認する。

const isInvalidRon =
  resultType === "ron" &&
  (
    winner === loser ||
    ronPoints <= 0 ||
    !isValidPointUnit(ronPoints)
  );

つまりロンの場合は、

  • 和了者と放銃者が違う
  • ロン点が1点以上
  • 100点単位

である必要がある。
たとえば、

和了者:南家
放銃者:南家
ロン点:3900

なら、点数自体は問題なくても和了者と放銃者が同じなのでエラーになる。

ツモ時の入力を確認する

ツモも、支払い点数を確認する。

const isInvalidTsumo =
  resultType === "tsumo" &&
  (
    dealerPayment <= 0 ||
    !isValidPointUnit(dealerPayment) ||
    (
      winner !== dealerSeat &&
      (
        childPayment <= 0 ||
        !isValidPointUnit(childPayment)
      )
    )
  );

親の支払いは、

  • 1点以上
  • 100点単位

である必要がある。
子がツモした場合は、子の支払いについても同じ条件で確認する。
一方、親ツモなら全員同じ金額を支払うため、子用の支払い値は判定に使わない。
終局方法によって必要な入力が違うので、バリデーション側でもロンとツモを分けている。

エラーが一つでもあれば計算しない

それぞれの判定結果をまとめて、最終的にエラーがあるか確認する。

const hasError =
  hasInvalidScore ||
  hasInvalidHonba ||
  hasInvalidKyotaku ||
  isInvalidRon ||
  isInvalidTsumo;

どれか一つでもtrueなら、hasErrorもtrueになる。
この値を点数計算側で確認し、入力がおかしい場合は終局後の点数計算を行わないようにした。
たとえばロンの和了者と放銃者を同じにしても、その状態で無理に点数移動を計算しない。
入力値を自由に変更できる画面なので、計算処理側へ変な値を渡さないための安全装置として使っている。

エラー内容も表示する

単に「エラーです」と表示するだけでは、どこを直せばいいか分からない。
そこで、入力内容に応じてエラーメッセージも変えることにした。

if (hasInvalidScore) {
  return "エラー:持ち点は100点単位で入力してください";
}

if (hasInvalidHonba) {
  return "エラー:本場は0以上の整数で入力してください";
}

if (hasInvalidKyotaku) {
  return "エラー:供託は0以上の整数で入力してください";
}

ロンの場合なら、

if (
  resultType === "ron" &&
  winner === loser
) {
  return "エラー:和了者と放銃者が同じです";
}

とする。
ほかにも、

エラー:ロン点は1点以上を入力してください

エラー:ロン点は100点単位で入力してください

エラー:親の支払いは100点単位で入力してください

エラー:子の支払いは100点単位で入力してください

など、原因に応じたメッセージを返す。
これで、入力がおかしいことだけでなく、何を修正すればいいかも画面上で分かるようになった。

判定結果をまとめて返す

validateGameInputでは、最終的なhasErrorだけでなく、それぞれの判定結果も返している。

return {
  hasInvalidScore,
  hasInvalidHonba,
  hasInvalidKyotaku,
  isInvalidRon,
  isInvalidTsumo,
  hasError,
  errorMessage,
};

こうしておくと、

点数がおかしいのか
本場がおかしいのか
終局結果がおかしいのか

を呼び出し側でも判別できる。
必要点数の計算では持ち点・本場・供託のエラーだけ確認し、仮の終局結果の入力ミスとは分けて扱うこともできる。
最初から単純なtrueかfalseだけを返すより、あとで使いやすい形になった。

バリデーションを分けてみて

入力チェック自体は、一つ一つを見るとそれほど複雑ではない。
ただ、画面や点数計算の中へ直接書き始めると、

ここでも100点単位を確認
あそこでも本場を確認
ロンのところでも確認
ツモのところでも確認

という形になりやすい。
今回はvalidation.tsへ集めたことで、

入力値
↓
validateGameInput
↓
問題なし → 計算
問題あり → エラー表示

という流れにできた。
機能が増えて入力項目も増えてきたので、こういう処理を早めに分けておくと後から修正しやすそう。

今回できたこと

今回の進捗は以下のとおり。

  • 持ち点が100点単位か確認するようにした
  • 本場が0以上の整数か確認するようにした
  • 供託が0以上の整数か確認するようにした
  • ロン時の和了者と放銃者を確認した
  • ロン点の入力値を確認した
  • ツモ時の支払い点数を確認した
  • 入力エラー時は点数計算を行わないようにした
  • エラー原因に応じたメッセージを表示できるようにした
  • バリデーション処理を別ファイルへまとめた

見た目としてはエラーメッセージが増えた程度だけれど、自由入力できるアプリとしては必要な処理が一つ増えた。
点数計算そのものだけでなく、「おかしな値が入ってきたときにどうするか」まで考える必要があるのは、実際に画面を作ってみて分かった部分だった。

ロジカルな部分で書いてて面白いが、画面に変化がなく進捗が見えづらい。牛一。

コメント

タイトルとURLをコピーしました