[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その6 使い勝手を整える)

     
⌛️ 2 min.
ボーリングアプリ(スコアを記録するアプリ)を、SwiftUI と TDD で作ってみます。

[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その1 モデル作成 Part1)

[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その2 View と ViewModelの作成 Part1)

[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その3 スコア計算 モデルの追加実装)

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

[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その5 10フレーム対応)

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

アプリ設計:振り返り

10フレームでの3投にも対応しましたので、機能的には完成しています。

1フレームでは、最大10ピンまでしか倒せないので、入力もそれに合わせて、入力できるボタンのみを Enable にするようにします。

必要なボタンのみを押せるようにする

ボタンを押せるかテストする

red-green-refactoring のサイクルに従って、ボタンを押せるかどうかのテストから作ります。

テストすべきケースとしては、以下を想定します。

  • 1投目は、0〜10の全てのボタンが押せる
  • 2投目は、1投目を考慮して合計10になるまでのボタンが押せる(1投目が3ならば、0〜7のボタンが押せる)
  • 10フレーム目は、以下の特殊ルール
    • 1投目は、全てのボタンが押せる(他のフレームと同じ)
    • 2投目は、1投目がストライクでなければ、1投目を考慮して合計10になるまでのボタンが押せる(他のフレームと同じ)
    • 2投目は、1投目がストライクでも全てのボタンが押せる(10フレーム向け特別処理)
    • 3投目は、2投目がスペアもしくは、ストライクでなければ、全てのボタンが押せない
    • 3投目は、1投目ストライク、2投目通常投球であれば、2投目を考慮して合計10になるまでのボタンが押せる
    • 3投目は、1,2投目がストライクであれば、全てのボタンが押せる
  • 10フレーム終了時には、全てのボタンが押せなくなる

まずは、0〜9フレームでのボタンの Enable/Disable の確認は、以下のようになります。

0〜9フレーム共通テスト


    func test_buttonAvailable_initial_allShouldBeAvailable() {
        // because this is first bowl, all button should be available
        for index in 0...10 {
            XCTAssertTrue(bowlButtons[index].isEnabled, "Button\(index) should be Enabled")
        }
    }
    
    func test_buttonAvailablability_afterBowl1_0_AvailabilityDepends() {
        bowlButtons[0].tap()
        // still all button should be available
        for index in 0...10 {
            XCTAssertTrue(bowlButtons[index].isEnabled, "Button\(index) should be Enabled")
        }
    }

    func test_buttonAvailablability_afterBowl1_4_AvailabilityDepends() {
        bowlButtons[4].tap()
        // 0-6 available, 7-10 unavailable
        for index in 0...6 {
            XCTAssertTrue(bowlButtons[index].isEnabled, "Button\(index) should be Enabled")
        }
        for index in 7...10 {
            XCTAssertFalse(bowlButtons[index].isEnabled, "Button\(index) should not be Enabled")
        }
    }

10フレームは、少し複雑なので、以下のようにテストが増えます。

10フレームでのテスト


    func test_buttonAvailablability_atFrame10StartWith4Then1_AvailabilityDepends() {
        // finish 9 frames
        for _ in 0...8 {  bowlButtons[10].tap() }
        
        // at 10-th frame
        bowlButtons[4].tap()
        for index in 0...6 {
            XCTAssertTrue(bowlButtons[index].isEnabled, "Button\(index) should be Enabled")
        }
        for index in 7...10 {
            XCTAssertFalse(bowlButtons[index].isEnabled, "Button\(index) should not be Enabled")
        }
        
        bowlButtons[1].tap()
        for index in 0...10 {
            XCTAssertFalse(bowlButtons[index].isEnabled, "Button\(index) should not be Enabled")
        }
    }
    
    func test_buttonAvailablability_atFrame10With4ThenSpareThen3_AvailabilityDepends() {
        // finish 9 frames
        for _ in 0...8 {  bowlButtons[10].tap() }
        
        // at 10-th frame
        bowlButtons[4].tap()
        for index in 0...6 {
            XCTAssertTrue(bowlButtons[index].isEnabled, "Button\(index) should be Enabled")
        }
        for index in 7...10 {
            XCTAssertFalse(bowlButtons[index].isEnabled, "Button\(index) should not be Enabled")
        }
        
        bowlButtons[6].tap()
        for index in 0...10 {
            XCTAssertTrue(bowlButtons[index].isEnabled, "Button\(index) should be Enabled")
        }
        bowlButtons[3].tap()
        for index in 0...10 {
            XCTAssertFalse(bowlButtons[index].isEnabled, "Button\(index) should not be Enabled")
        }
    }

    func test_buttonAvailablability_atFrame10StrikeThen3ThenSpare_AvailabilityDepends() {
        for _ in 0...8 {  bowlButtons[10].tap() }

        // at 10-th frame
        bowlButtons[10].tap()
        for index in 0...10 {
            XCTAssertTrue(bowlButtons[index].isEnabled, "Button\(index) should be Enabled")
        }

        bowlButtons[3].tap()
        for index in 0...7 {
            XCTAssertTrue(bowlButtons[index].isEnabled, "Button\(index) should be Enabled")
        }
        for index in 8...10 {
            XCTAssertFalse(bowlButtons[index].isEnabled, "Button\(index) should not be Enabled")
        }

        bowlButtons[7].tap()
        for index in 0...10 {
            XCTAssertFalse(bowlButtons[index].isEnabled, "Button\(index) should not be Enabled")
        }
    }

    func test_buttonAvailablability_atFrame102StrikeThen3_AvailabilityDepends() {
        for _ in 0...8 {  bowlButtons[10].tap() }

        // at 10-th frame
        bowlButtons[10].tap()
        for index in 0...10 {
            XCTAssertTrue(bowlButtons[index].isEnabled, "Button\(index) should be Enabled")
        }

        bowlButtons[10].tap()
        for index in 0...10 {
            XCTAssertTrue(bowlButtons[index].isEnabled, "Button\(index) should be Enabled")
        }

        bowlButtons[3].tap()
        for index in 0...10 {
            XCTAssertFalse(bowlButtons[index].isEnabled, "Button\(index) should not be Enabled")
        }
    }

これまでに Button を disable にしている処理を入れていないので、Disable であることをテストしている箇所全てで失敗します。

ボタンを Disable にする処理を追加

実装は、Model(BowlingGame) で次の投球で取り得る値の範囲を計算する。Viewは、ViewModel 経由でその情報を使って、各ボタンの Enable/Disable を制御する という方針としました。

Model への追加


    func availableRangeForNextThrow() -> Range? {
        if let index = findRecordableFrameBowl() {
            if index.frameIndex != 9 {
                if index.bowlIndex == 0 {
                    return Range(0...10)
                }
                if let prevBowl = bowlResult(frameIndex: index.frameIndex, bowlIndex: 0) {
                    return Range(0...(10-prevBowl))
                }
            } else {
                let bowl0 = bowlResult(frameIndex: 9, bowlIndex: 0)
                let bowl1 = bowlResult(frameIndex: 9, bowlIndex: 1)
                switch index.bowlIndex {
                    case 0:
                        return Range(0...10)
                    case 1:
                        if bowl0 == 10 { return Range(0...10)}
                        return Range(0...(10-bowl0!))
                    case 2:
                        if (bowl0! == 10)&&(bowl1! == 10) { return Range(0...10)}
                        if (bowl0! == 10)&&(bowl1!  10) { return Range(0...(10-bowl1!))}
                        if (bowl0! + bowl1! == 10) { return Range(0...10) }
                    default:
                        fatalError()
                }
            }
        }
        return nil
    }

availableRangeForNextThrow は、次の投球ができないのならば、nil を返します。そうでないときは、取り得る値の Range を返します。

この関数の結果を、View の Button は、ViewModel 経由で使います。

View Button の表示制御


struct InputView: View {
    @ObservedObject var viewModel: BowlingGameViewModel
    var body: some View {
        HStack {
            ForEach(0..11) { index in
                Button(action: {
                    viewModel.addBowlResult(num: index)
                }, label: { Text(String(index)) } )
                    .accessibility(identifier: String("Button\(index)"))
                .disabled(!viewModel.isButtonAvailable(index: index))  // NEW !
            }
        }
        .font(.largeTitle)
    }
}

ViewModel では、以下のように、Model と View を繋いでいます。

isButtonAvailable @ ViewModel


    func isButtonAvailable(index: Int) -> Bool {
        if let availableRange = game.availableRangeForNextThrow() {
            return availableRange.contains(index)
        }
        return false
    }

これらのコードで、作成したテストすべてをパスすることが確認できます。

最終コード

以下の GitHub で見ることができます。

https://github.com/tyagishi/TDDBowling

まとめ その5 完成

TDD(Test Driven Development) 的に、ボーリングスコアアプリを作ってきました。

もう少しテストケースの分析が必要なケースがありそうですが、TDD での開発の雰囲気は 見えてきたのではないでしょうか。

世の中に、もっと TDD での開発が広がると、良いなぁと思ってます。

説明は以上です。

コメントを残す

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