[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その4 スペア・ストライクへの対応)

ボーリングアプリ(スコアを記録するアプリ)を、SwiftUI と TDD で作ってみます。
[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その1 モデル作成 Part1) [TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その2 View と ViewModelの作成 Part1) [TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その3 スコア計算 モデルの追加実装)

これまでの経緯は、上記記事を参照ください。

アプリ設計:振り返り

ここまでは、当初の設計どおりに作れていますが、スペア・ストライクへの対応で少し検討し直さなければいけない点が出てくると予想しています。

スペア・ストライクへの対応 とは?

以下を仕様として、順番に対応していきます。

  • スペア表示 = フレームの2投目で合計10ピンになった時、表示を "/" にする
  • ストライク表示 = フレームの1投目で10ピン倒した時、表示を "X" にする
  • スペア スコア計算(1) = スペアであれば、次のフレームの1投をスコアに足して、当該フレームのスコアとする
  • スペア スコア計算(2) = スペアの関係でスコアの計算ができないときは、"-" 表示を行う
  • ストライク スコア計算(1) = ストライクであれば、次の2投をスコアに足したものを、当該フレームのスコアとする(次フレームだけでなく次々フレームも参照するかもしれない)
  • ストライク スコア計算(2) = ストライクの関係でスコアの計算ができないときは、"-" 表示を行う

10フレーム目には、以下のような特殊ルールがありますが、上記対応ができた後に考えることとします。

  • スペアの場合は、さらに1投投げられる
  • 1投目がストライクの場合は、さらに2投を投げられる
  • 1、2投目がストライクの場合は、さらに1投投げられる

スペア対応

スペアのテストケースを作る

スペア表示のテストとして、最初の1フレームでスペアとなった時の表示をテストするものを作ります。

さらには、2フレーム目も入力して、スペアを考慮したスコア計算も合わせてテストすることとします。

setUpWithError 中の continueAfterFailure を true としておくことで、途中の Failure で止まることなく実行されるため、問題点を俯瞰しやすくなります。

スペア表示・スコア計算のテスト

    func test_ScoreAtFirst2Frames_From0To1WithSpareAtFirstFrame_CorrectlyDisplayed() {
        bowlButtons[5].tap()
        bowlButtons[5].tap() // Spare !
        XCTAssertEqual(frameBowlLabels[0][0].label, "5")

        // (1) : display spare mark
        XCTAssertEqual(frameBowlLabels[0][1].label, "/")

        // (2) : still un-calculatable
        XCTAssertEqual(frameScoreLabels[0].label, "-")
        
        bowlButtons[6].tap()
        // (3) : now can calculate frameScoreLabels[0]
        XCTAssertEqual(frameScoreLabels[0].label, "16") 
        
        bowlButtons[2].tap()
        // (4) : 2nd frame score also should be correct
        XCTAssertEqual(frameScoreLabels[1].label, "24")
    }

上記コードを実行すると、これまでのコードでは、いずれも failure となります。順番に Pass するようにしていきます。

スペア表示

これまでのピン数表示は、以下のものでした。

BowlingGameViewModel

    func bowlAsText(frame:Int, bowl: Int) -> String {
        if let num = game.bowlResult(frame: frame, bowl: bowl) {
            return String(num)
        }
        return "-"
    }

上記は、ViewModel 中のコードなので、この中で、1投目であれば・・・、2投目なら・・・という判断をあまり行いたくありません。

そこで、以下のような enum を作り、Model(BowlingGame) 側に、フレームの状態を返すようにしました。

FrameState

enum FrameState {
    case Others
    case Spare
    case Strike
}
frameState in Model

    func frameState(frame:Int) -> FrameState {
        guard let bowl1 = bowlResult(frame: frame, bowl: 0)  else { return .Others }
        if bowl1 == 10 { return .Strike }
        guard let bowl2 = bowlResult(frame: frame, bowl: 1) else { return .Others }
        if bowl1 + bowl2 == 10 { return .Spare }
        return .Others
    }

モデルの fraemState メソッドを使って、フレームが、ストライク or スペア or 通常/未投 がわかるので、ViewModel では、以下のようなコードで適切な表示用文字列を返せるようになります。

bowlAsText in ViewModel

    func bowlAsText(frame:Int, bowl: Int) -> String {
        switch game.frameState(frame: frame) {
            case .Strike:
                return bowl == 0 ? "X" : ""
            case .Spare:
                if bowl == 1 { return "/" }
                fallthrough
            case .Others:
                if let num = game.bowlResult(frame: frame, bowl: bowl)  {
                    return String(num)
                }
                return "-"
        }
    }

ここまでのコードで、上記テストの(1)スペア表示 については、パスするようになり、スペアについては仕様通りに表示されるようになります。

スペア表示

「スペア表示」

スペアのスコア計算

ここまでのスコア計算は、単純に加算するもので、以下のようなコードです。

スコア計算@モデル

    func frameResult(frame: Int) -> Int? {
        if let lastResult = frame == 0 ? 0 : frameResult(frame: frame - 1) {
            if let bowl0 = bowlResult(frame: frame, bowl: 0) {
                if let bowl1 = bowlResult(frame: frame, bowl: 1) {
                    return lastResult + bowl0 + bowl1
                }
            }
        }
        return nil
    }

先ほど導入した frameState を使って、計算を分けていきます。

スコア計算@モデル スペア計算対応

    func frameResult(frame: Int) -> Int? {
        if let lastResult = frame == 0 ? 0 : frameResult(frame: frame - 1) {
            switch frameState(frame: frame) {
                case .Spare:
                    if let nextBowl = bowlResult(frame: frame+1, bowl: 0) {
                        return lastResult + 10 + nextBowl
                    }
                    return nil // need further info
                case .Strike: fallthrough
                case .Others:
                    if let bowl0 = bowlResult(frame: frame, bowl: 0) {
                        if let bowl1 = bowlResult(frame: frame, bowl: 1) {
                            return lastResult + bowl0 + bowl1
                        }
                    }
            }
        }
        return nil
    }

# ストライクについては、enum だけ作っている状態で、未対応です。

こうすることで、スペアについての計算もできました。

テストを実行してみると、すべてパスすることが確認できます。

ストライク対応

次に、ストライクへの対応を作っていきます。

例によって、テストから作ります。

ストライクのテストケースを作る

スペアの時と同じように、表示だけでなく、スコア計算も合わせてテストするケースを作ります。

スペアはスコア計算時に次の1投を考慮しましたが、ストライクは次の2投を考慮しなければなりません。ケースによっては、2投目は次々フレームかもしれませんので、注意が必要です。

なんと、0 - 9 のボタンしか用意されていなかったので、ストライクが入力できないことに気づきました。合わせて、10のボタンも追加します。

以下のテストは、X X 6/ 32 という第1フレームから第4フレームまでの表示とスコア計算をテストしています。

ストライク表示・スコア計算のテスト code

    func test_ScoreAtFirst4Frames_From0To3WithStrikeStrikeSpareNormal_CorrectlyDisplayed() {
        // Frame : 1 (Strike)
        bowlButtons[10].tap()
        XCTAssertEqual(frameBowlLabels[0][0].label, "X")
        XCTAssertEqual(frameBowlLabels[0][1].label, "")
        XCTAssertEqual(frameScoreLabels[0].label, "-") // still un-calculatable

        // Frame : 2 (Strike)
        bowlButtons[10].tap()
        XCTAssertEqual(frameBowlLabels[1][0].label, "X")
        XCTAssertEqual(frameBowlLabels[1][1].label, "")
        XCTAssertEqual(frameScoreLabels[1].label, "-") // still un-calculatable

        // Frame : 3 (Spare)
        bowlButtons[6].tap()
        XCTAssertEqual(frameBowlLabels[2][0].label, "6")
        XCTAssertEqual(frameBowlLabels[2][1].label, "-")
        XCTAssertEqual(frameScoreLabels[2].label, "-") // still un-calculatable

        // Frame1 score now calculatable
        XCTAssertEqual(frameScoreLabels[0].label, "26")

        bowlButtons[4].tap()
        XCTAssertEqual(frameBowlLabels[2][0].label, "6")
        XCTAssertEqual(frameBowlLabels[2][1].label, "/")
        XCTAssertEqual(frameScoreLabels[2].label, "-") // still un-calculatable

        // Frame2 score now calculatable
        XCTAssertEqual(frameScoreLabels[1].label, "46") // still un-calculatable

        // Frame : 4 (Normal)
        bowlButtons[3].tap()
        XCTAssertEqual(frameBowlLabels[3][0].label, "3")
        XCTAssertEqual(frameBowlLabels[3][1].label, "-")
        XCTAssertEqual(frameScoreLabels[3].label, "-") // still un-calculatable

        // Frame3 score now calculatable
        XCTAssertEqual(frameScoreLabels[2].label, "59") // still un-calculatable

        bowlButtons[2].tap()
        XCTAssertEqual(frameBowlLabels[3][0].label, "3")
        XCTAssertEqual(frameBowlLabels[3][1].label, "2")
        XCTAssertEqual(frameScoreLabels[3].label, "64") // still un-calculatable

    }

ストライクへの対応には、表示の前に、ストライクを入力された時に、そのフレームの第2投は、不要になるルールを実装しなければいけません。

投球結果を追加する関数 addBowlResult にストライクへの対応を追加します。

ストライクだった時(第1投が10ピンのとき)に、第2投を不要とマークします。

addBowlResult ストライク対応

    mutating func addBowlResult(_ num: Int) -> Bool {
        if let addIndex = self.findRecordableFrameBowl() {
            self.frames[addIndex.frameIndex].bowls[addIndex.bowlIndex] = .Done(num)
            // case Strike
            if (num == 10) && (addIndex.bowlIndex == 0) { // Strike !
                self.frames[addIndex.frameIndex].bowls[1] = .NoNeed
            }
            return true
        }
        return false
    }

以下のように、先のスペア表示対応時に、ストライク表示も入れていたので、表示については、うまく動作してパスします。

ストライク表示にも対応しているコード

    func bowlAsText(frame:Int, bowl: Int) -> String {
        switch game.frameState(frame: frame) {
            case .Strike:
                return bowl == 0 ? "X" : ""
            case .Spare:
                if bowl == 1 { return "/" }
                fallthrough
            case .Others:
                if let num = game.bowlResult(frame: frame, bowl: bowl)  {
                    return String(num)
                }
                return "-"
        }
    }

ストライクのスコア計算

ここまで作ってきているモデル(BowlingModel) 中の frameResult を、ストライク対応させることで必要です。

ストライク対応できていないスコア計算メソッド frameResult

    func frameResult(frame: Int) -> Int? {
        if let lastResult = frame == 0 ? 0 : frameResult(frame: frame - 1) {
            switch frameState(frame: frame) {
                case .Spare:
                    if let nextBowl = bowlResult(frame: frame+1, bowl: 0) {
                        return lastResult + 10 + nextBowl
                    }
                    return nil // need further info
                case .Strike: fallthrough
                case .Others:
                    if let bowl0 = bowlResult(frame: frame, bowl: 0) {
                        if let bowl1 = bowlResult(frame: frame, bowl: 1) {
                            return lastResult + bowl0 + bowl1
                        }
                    }
            }
        }
        return nil
    }

case .Strike を fallthrough にしていますので、ここに実装していきます。

スペアの場合は、次の1投がまだ投げられていないようであれば、計算できないという意味で、nil を返していましたが、ストライクの場合は、
続く2投分のデータが取得できないようであれば、まだ計算できないということで、nil を返すようにします。

スペアの次の1投は、必ず次のフレームの第1投ですが、ストライクの場合も次の1投は、必ず次のフレームの第1投です。その次の1投は、次フレームの1投によることになります。

ストライク対応したスコア計算 frameResult

    func frameResult(frame: Int) -> Int? {
        if let lastResult = frame == 0 ? 0 : frameResult(frame: frame - 1) {
            switch frameState(frame: frame) {
                case .Spare:
                    if let nextBowl = bowlResult(frame: frame+1, bowl: 0) {
                        return lastResult + 10 + nextBowl
                    }
                    return nil // need further info
                case .Strike:
                    if let nextBowl = bowlResult(frame: frame+1, bowl: 0) {
                        switch frameState(frame: frame+1) {
                            case .Strike:
                                if let nextNextBowl = bowlResult(frame: frame+2, bowl: 0) {
                                    return lastResult + 10 + 10 + nextNextBowl
                                }
                            case .Spare:
                                return lastResult + 10 + 10
                            case .Others:
                                if let nextNextBowl = bowlResult(frame: frame+1, bowl: 1) {
                                    return lastResult + 10 + nextBowl + nextNextBowl
                                }
                        }
                    }
                    return nil // need further info
                case .Others:
                    if let bowl0 = bowlResult(frame: frame, bowl: 0) {
                        if let bowl1 = bowlResult(frame: frame, bowl: 1) {
                            return lastResult + bowl0 + bowl1
                        }
                    }
            }
        }
        return nil
    }
}

テストを実行しているところです。

ストライク・スペア込みテスト

「ストライク・スペア込みテスト」

Refactoring

10フレームまでのテストを追加する前に、単語がすこしぶれていたので、見直しします。

Result は、ピン数 を扱う時の単語とし、Score は、スコアを扱う時の単語とします。これまで、frameResult としていたメソッドを frameScore と改名しました。

いくつかのメソッド内で、frame: Int や bowl: Int として、 frameIndex や bowlIndex を省略していましたが、省略せずに書くようにしました。

コードの修正が終わったら、改めて全てのテストを実行して、意図せずコードを破壊していないか確認します。

現時点では、不具合あります

どのようなテストが良いかを考えながら、アプリを動かしていると落ちるケースを見つけました。例えば、全てストライクにすると、10フレーム目に入力しようとして落ちてしまいます。

ストライクのケースのスコア計算で、次々フレームを見にいくのですが、第9フレーム分の計算をしようとして、第11フレームを探しにいって、落ちてます。

ただ 第10フレームは、特殊な対応が必要なので、第10フレームの特殊処理対応と合わせて、修正しようと思います。

まとめ その4

スペア・ストライクの記録とスコア計算ができるようになりました。

この部分を使っているだけで、なんとなく楽しいです。

次回は、10フレームでの特殊処理対応を行う予定です。

説明は以上です。Happy Coding!

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です