[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その1 モデル作成 Part1)
[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その2 View と ViewModelの作成 Part1)
[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その3 スコア計算 モデルの追加実装)
これまでの経緯は、上記記事を参照ください。
Sponsor Link
アプリ設計:振り返り
ここまでは、当初の設計どおりに作れていますが、スペア・ストライクへの対応で少し検討し直さなければいけない点が出てくると予想しています。
スペア・ストライクへの対応 とは?
以下を仕様として、順番に対応していきます。
- スペア表示 = フレームの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 するようにしていきます。
スペア表示
これまでのピン数表示は、以下のものでした。
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) 側に、フレームの状態を返すようにしました。
enum FrameState {
case Others
case Spare
case Strike
}
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 では、以下のようなコードで適切な表示用文字列を返せるようになります。
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フレームまでの表示とスコア計算をテストしています。
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投を不要とマークします。
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 を、ストライク対応させることで必要です。
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投によることになります。
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!
Sponsor Link