[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フレーム対応)
これまでの経緯は、上記記事を参照ください。
Sponsor Link
アプリ設計:振り返り
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 の確認は、以下のようになります。
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フレームは、少し複雑なので、以下のようにテストが増えます。
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 を制御する という方針としました。
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 経由で使います。
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 を繋いでいます。
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 での開発が広がると、良いなぁと思ってます。
説明は以上です。
Sponsor Link