[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その1 モデル作成 Part1)
[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その2 View と ViewModelの作成 Part1)
[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その3 スコア計算 モデルの追加実装)
[TDD][SwiftUI] SwiftUI と TDD で作る ボーリングスコアアプリ(その4 スペア・ストライクへの対応)
ここまでの経緯は、上記記事を参照ください。
Sponsor Link
アプリ設計:振り返り
これまでのところ、前回作った設計に沿って作ってこれています。
今回は、10フレームの特別処理への対応を行います。
10フレームの特別処理とは、以下のことです。
- スペアの場合は、さらに1投投げられる(3投目を投げられる)
- 1投目がストライクの場合は、さらに2投を投げられる
- 1、2投目がストライクの場合は、さらに1投投げられる
スコア計算も少し特別です。
- 10フレームでの計算は、ストライク・スペアにかかわらず、2投もしくは3投の合計とする
# 詳細は、ボーリングのスコア計算を調べてみてください
10フレームでの3投を記録できるように
これまでの機能追加と同じように、<テスト追加>→<テスト失敗確認>→<機能実装追加>→<テスト成功確認>→<リファクタリング> というサイクルを実行していきます。
TDD では、テスト結果の色の変化をイメージして red-green-refactoring サイクルと呼ばれたりします。
10フレーム目に3投するテストの追加
ルールでは、10フレームは、最大3投投げられるのですが、今回作っているモデルでは、そのための処理を追加していないので、投げられません。
まずは、10フレームで3投投げられることをテストします。
3投できるケースは、以下のケースです。
- ストライクx3
- ストライクx2 +1投
- ストライク+2投
- スペア+1投
パーフェクトと呼ばれますが、全ての投球がストライクだった時には、合計で12回(各1回@1〜9フレーム+3回@10フレーム)のストライクが記録されます。
上記の4ケースのテストを作ります。
func test_recordScore_12Strikes_shouldBeRecorded() {
var bowlingGame = BowlingGame()
for index in 0..12 {
XCTAssertTrue(bowlingGame.addBowlResult(10), "failed to record \(index+1)-th strike ")
}
}
func test_recordScore_11StrikesPlus1_shouldBeRecorded() {
var bowlingGame = BowlingGame()
for index in 0..11 {
XCTAssertTrue(bowlingGame.addBowlResult(10), "failed to record \(index+1)-th strike ")
}
XCTAssertTrue(bowlingGame.addBowlResult(3), "failed to record 3rd throw in 10th frame")
}
func test_recordScore_10StrikesPlus2_shouldBeRecorded() {
var bowlingGame = BowlingGame()
for index in 0..10 {
XCTAssertTrue(bowlingGame.addBowlResult(10), "failed to record \(index+1)-th strike ")
}
XCTAssertTrue(bowlingGame.addBowlResult(3), "failed to record 2nd throw in 10th frame")
XCTAssertTrue(bowlingGame.addBowlResult(3), "failed to record 3rd throw in 10th frame")
}
func test_recordScore_9StrikesPlusSparePlus1_shouldBeRecorded() {
var bowlingGame = BowlingGame()
for index in 0..9 {
XCTAssertTrue(bowlingGame.addBowlResult(10), "failed to record \(index+1)-th strike ")
}
XCTAssertTrue(bowlingGame.addBowlResult(4), "failed to record 1st throw in 10th frame")
XCTAssertTrue(bowlingGame.addBowlResult(6), "failed to record 2nd throw in 10th frame")
XCTAssertTrue(bowlingGame.addBowlResult(5), "failed to record 3rd throw in 10th frame")
}
実行してみると最初のテストでは、”XCTAssertTrue failed – failed to record 11-th strike” というエラーになります。11番目のストライクは 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
ストライクだった時に、2投目を .NoNeed に設定するだけで、10フレーム目の特別ルールを実装していないため、1投目がストライクであれば、2投目が記録できなくなっています。
10フレーム目でのストライクの記録に対応する
ということで、10フレーム目のときの処理を追加します。
追加する処理は、以下です。
- 10フレーム目の1投目がストライクであっても2投目を .NotYet のままにし、3投目を .NoNeed から .NotYet に変更する
- 10フレーム目の2投目でスペアになったら、3投目を .NoNeed から .NotYet に変更する
10フレーム目の2投目を .NotYet にキープするだけでなく、3投目を記録可能にするように処理しておきます。
少し複雑ですが、以下のようになりました。
mutating func addBowlResult(_ num: Int) -> Bool {
if let addIndex = self.findRecordableFrameBowl() {
self.frames[addIndex.frameIndex].bowls[addIndex.bowlIndex] = .Done(num)
if (addIndex.frameIndex == 9) { // new
if addIndex.bowlIndex != 2 {
if num == 10 {
self.frames[9].bowls[2] = .NotYet
}
if addIndex.bowlIndex == 1 {
if let bowl1 = bowlResult(frameIndex: 9, bowlIndex: 0) {
if bowl1 + num == 10 {
self.frames[9].bowls[2] = .NotYet
}
}
}
}
return true
} // new
// case Strike
if (num == 10) && (addIndex.bowlIndex == 0) { // Strike !
self.frames[addIndex.frameIndex].bowls[1] = .NoNeed
}
return true
}
return false
}
10フレーム目に3投するテスト
上記のコードで、先に追加した4つのテストケースはパスすることがわかります。
10フレーム目で3投するケースでのスコア計算
さきの 10フレーム目で3投する4つのケースで、スコア計算も正しいかを確認することにします。
10フレーム目が3投になっても、3投を合計するだけで、スペアやストライクの計算は行いません。
ただし、9フレーム目がストライクであれば、10フレーム目の1投目2投目を考慮して計算します。1投目2投目がストライクであれば、ターキーとなるように計算するということです。
テストに、スコア計算も追加
プログラミングの知識ではなく、ボーリングの知識が必要となります・・・
それぞれの正しいスコアを計算して、チェックするようにしました。10フレームの影響が考えられる、9フレーム目、10フレーム目のスコアをテスト対象としています。
チェック対象は、スコアの値だけでなく、どのタイミングで計算可能になっているかもテストしています。
func test_recordScore_12Strikes_shouldBeRecorded() {
var bowlingGame = BowlingGame()
for index in 0..9 {
XCTAssertTrue(bowlingGame.addBowlResult(10), "failed to record \(index+1)-th strike ")
}
XCTAssertTrue(bowlingGame.addBowlResult(10), "failed to record 10-th strike ")
XCTAssertNil(bowlingGame.frameScore(frameIndex: 8))
XCTAssertNil(bowlingGame.frameScore(frameIndex: 9))
XCTAssertTrue(bowlingGame.addBowlResult(10), "failed to record 11-th strike ")
XCTAssertEqual(bowlingGame.frameScore(frameIndex: 8), 270)
XCTAssertNil(bowlingGame.frameScore(frameIndex: 9))
XCTAssertTrue(bowlingGame.addBowlResult(10), "failed to record 12-th strike ")
XCTAssertEqual(bowlingGame.frameScore(frameIndex: 9), 300)
}
func test_recordScore_11StrikesPlus1_shouldBeRecorded() {
var bowlingGame = BowlingGame()
for index in 0..11 {
XCTAssertTrue(bowlingGame.addBowlResult(10), "failed to record \(index+1)-th strike ")
}
XCTAssertEqual(bowlingGame.frameScore(frameIndex: 8), 270)
XCTAssertNil(bowlingGame.frameScore(frameIndex: 9))
XCTAssertTrue(bowlingGame.addBowlResult(3), "failed to record 3rd throw in 10th frame")
XCTAssertEqual(bowlingGame.frameScore(frameIndex: 9), 293)
}
func test_recordScore_10StrikesPlus2_shouldBeRecorded() {
var bowlingGame = BowlingGame()
for index in 0..10 {
XCTAssertTrue(bowlingGame.addBowlResult(10), "failed to record \(index+1)-th strike ")
}
XCTAssertNil(bowlingGame.frameScore(frameIndex: 8))
XCTAssertNil(bowlingGame.frameScore(frameIndex: 9))
XCTAssertTrue(bowlingGame.addBowlResult(3), "failed to record 2nd throw in 10th frame")
XCTAssertEqual(bowlingGame.frameScore(frameIndex: 8), 263)
XCTAssertNil(bowlingGame.frameScore(frameIndex: 9))
XCTAssertTrue(bowlingGame.addBowlResult(3), "failed to record 3rd throw in 10th frame")
XCTAssertEqual(bowlingGame.frameScore(frameIndex: 9), 279)
}
func test_recordScore_9StrikesPlusSparePlus1_shouldBeRecorded() {
var bowlingGame = BowlingGame()
for index in 0..9 {
XCTAssertTrue(bowlingGame.addBowlResult(10), "failed to record \(index+1)-th strike ")
}
XCTAssertNil(bowlingGame.frameScore(frameIndex: 8))
XCTAssertTrue(bowlingGame.addBowlResult(4), "failed to record 1st throw in 10th frame")
XCTAssertNil(bowlingGame.frameScore(frameIndex: 8))
XCTAssertNil(bowlingGame.frameScore(frameIndex: 9))
XCTAssertTrue(bowlingGame.addBowlResult(6), "failed to record 2nd throw in 10th frame")
XCTAssertEqual(bowlingGame.frameScore(frameIndex: 8), 254)
XCTAssertNil(bowlingGame.frameScore(frameIndex: 9))
XCTAssertTrue(bowlingGame.addBowlResult(5), "failed to record 3rd throw in 10th frame")
XCTAssertEqual(bowlingGame.frameScore(frameIndex: 9), 269)
}
実行すると、テストが失敗するのではなく、Index out of range という Fatal Error が発生します。
ストライクやスペアのスコア計算をするために、次の投球や、次々の投球の値が必要になりますが、10フレーム目での次の投球や次々の投球の値を取得しようとして、11フレーム目を探してエラーとなっています。
10フレーム目で3投するスコア計算の実装
テストでエラーになることを確認したので、実装していきます。
特定の投球結果を取得する bowlResult と フレームでのスコアを計算する frameScore は、以下のような実装になっています。
func bowlResult(frameIndex: Int, bowlIndex: Int) -> Int? {
let bowl = frames[frameIndex].bowls[bowlIndex]
switch bowl {
case .Done(let num):
return num
default:
return nil
}
}
func frameScore(frameIndex: Int) -> Int? {
if let lastResult = frameIndex == 0 ? 0 : frameScore(frameIndex: frameIndex - 1) {
switch frameState(frameIndex: frameIndex) {
case .Spare:
if let nextBowl = bowlResult(frameIndex: frameIndex+1, bowlIndex: 0) {
return lastResult + 10 + nextBowl
}
return nil // need further info
case .Strike:
if let nextBowl = bowlResult(frameIndex: frameIndex+1, bowlIndex: 0) {
switch frameState(frameIndex: frameIndex+1) {
case .Strike:
if let nextNextBowl = bowlResult(frameIndex: frameIndex+2, bowlIndex: 0) {
return lastResult + 10 + 10 + nextNextBowl
}
case .Spare:
return lastResult + 10 + 10
case .Others:
if let nextNextBowl = bowlResult(frameIndex: frameIndex+1, bowlIndex: 1) {
return lastResult + 10 + nextBowl + nextNextBowl
}
}
}
return nil // need further info
case .Others:
if let bowl0 = bowlResult(frameIndex: frameIndex, bowlIndex: 0) {
if let bowl1 = bowlResult(frameIndex: frameIndex, bowlIndex: 1) {
return lastResult + bowl0 + bowl1
}
}
}
}
return nil
}
}
みてわかるように、単純に次の投球を探して辿っています。
現在の実装に追加していっても良いのですが、コードが複雑になりそうですので、ストライクのケース、スペアのケース、通常ケースに分けて、前者2つを別メソッドにしてしまいましょう。
以下が、分割した、frameScore です。(単純に該当部分を外部メソッドに分割しただけです)
func frameScore(frameIndex: Int) -> Int? {
if let prevFrameResult = frameIndex == 0 ? 0 : frameScore(frameIndex: frameIndex - 1) {
switch frameState(frameIndex: frameIndex) {
case .Spare:
return calcSpareFrame(frameIndex: frameIndex, prevFrameResult: prevFrameResult)
case .Strike:
return calcStrikeFrame(frameIndex: frameIndex, prevFrameResult: prevFrameResult)
case .Others:
if let bowl0 = bowlResult(frameIndex: frameIndex, bowlIndex: 0) {
if let bowl1 = bowlResult(frameIndex: frameIndex, bowlIndex: 1) {
return prevFrameResult + bowl0 + bowl1
}
}
}
}
return nil
}
func calcSpareFrame(frameIndex:Int, prevFrameResult: Int) -> Int? {
if let nextBowl = bowlResult(frameIndex: frameIndex+1, bowlIndex: 0) {
return prevFrameResult + 10 + nextBowl
}
return nil // need further info
}
func calcStrikeFrame(frameIndex:Int, prevFrameResult: Int) -> Int? {
if let nextBowl = bowlResult(frameIndex: frameIndex+1, bowlIndex: 0) {
switch frameState(frameIndex: frameIndex+1) {
case .Strike:
if let nextNextBowl = bowlResult(frameIndex: frameIndex+2, bowlIndex: 0) {
return prevFrameResult + 10 + 10 + nextNextBowl
}
case .Spare:
return prevFrameResult + 10 + 10
case .Others:
if let nextNextBowl = bowlResult(frameIndex: frameIndex+1, bowlIndex: 1) {
return prevFrameResult + 10 + nextBowl + nextNextBowl
}
}
}
return nil // need further info
}
}
10フレームの計算は、単純に、2投分もしくは3投分を計算すれば良いので、簡単です。以下のようなメソッドを作り、10フレーム目の計算を要求された時に使用します。
func frameScoreFor10th(prevFrameResult:Int) -> Int? {
if let f10b1 = bowlResult(frameIndex: 9, bowlIndex: 0) {
if let f10b2 = bowlResult(frameIndex: 9, bowlIndex: 1) {
if let f10b3 = bowlResult(frameIndex: 9, bowlIndex: 2) {
return prevFrameResult + f10b1 + f10b2 + f10b3
}
return prevFrameResult + f10b1 + f10b2
}
}
return nil
}
func frameScore(frameIndex: Int) -> Int? {
if let prevFrameResult = frameIndex == 0 ? 0 : frameScore(frameIndex: frameIndex - 1) {
if frameIndex == 9 {
return frameScoreFor10th(prevFrameResult: prevFrameResult) // NEW !!
}
switch frameState(frameIndex: frameIndex) {
case .Spare:
return calcSpareFrame(frameIndex: frameIndex, prevFrameResult: prevFrameResult)
case .Strike:
return calcStrikeFrame(frameIndex: frameIndex, prevFrameResult: prevFrameResult)
case .Others:
if let bowl0 = bowlResult(frameIndex: frameIndex, bowlIndex: 0) {
if let bowl1 = bowlResult(frameIndex: frameIndex, bowlIndex: 1) {
return prevFrameResult + bowl0 + bowl1
}
}
}
}
return nil
}
10フレームの途中であれば、計算終了できないので、nil を返しています。
次に、10フレームが影響を与える9フレームのスコア計算のケースを考えると、以下のようになります。
- 9フレーム目がストライクだった時は、10フレーム目で次の投球と次々の投球を探す。このとき:10フレーム目の1投目と2投目が必要な情報となる(10フレーム目の1投目がストライクかどうかにかかわらず)
- 9フレーム目がスペアだったときは、10フレーム目で次の投球を探す。このとき、10フレーム目の1投目が必要な情報となる。が、これは、通常の処理と変わらない
上記より、9フレーム目がストライクだった時に特殊処理を入れれば良いはずなので、ストライク時の計算を行う calcStrikeFrame に特殊処理を追加。
func calcStrikeFrame(frameIndex:Int, prevFrameResult: Int) -> Int? {
if (frameIndex == 8) {
if let f10b1 = bowlResult(frameIndex: 9, bowlIndex: 0) {
if let f10b2 = bowlResult(frameIndex: 9, bowlIndex: 1) {
return prevFrameResult + 10 + f10b1 + f10b2
}
}
return nil
}
if let nextBowl = bowlResult(frameIndex: frameIndex+1, bowlIndex: 0) {
switch frameState(frameIndex: frameIndex+1) {
case .Strike:
if let nextNextBowl = bowlResult(frameIndex: frameIndex+2, bowlIndex: 0) {
return prevFrameResult + 10 + 10 + nextNextBowl
}
case .Spare:
return prevFrameResult + 10 + 10
case .Others:
if let nextNextBowl = bowlResult(frameIndex: frameIndex+1, bowlIndex: 1) {
return prevFrameResult + 10 + nextBowl + nextNextBowl
}
}
}
return nil // need further info
}
}
改めて、テストを通してみると、全てパスすることが確認できます。
UI を使って、試そうとすると、10フレーム目の表示がおかしい & 3投分の表示枠がないことに気づきます。
ビューを10フレームの3投に対応
10フレーム3投目の表示枠
FrameView で、10フレームの時だけ、3つ表示するようにします。
struct FrameView: View {
@ObservedObject var viewModel: BowlingGameViewModel
let index:Int
var body: some View {
VStack(spacing:0) {
FrameIndexView(index: index)
HStack(spacing: 0) {
FrameBowlView(viewModel: viewModel, frameIndex: index, bowlIndex: 0)
FrameBowlView(viewModel: viewModel, frameIndex: index, bowlIndex: 1)
if (index == 9) { // NEW
FrameBowlView(viewModel: viewModel, frameIndex: index, bowlIndex: 2) // NEW
} // NEW
}
FrameScoreView(viewModel: viewModel, frameIndex: index)
}
}
}
これまでは、frame を固定値指定だったので、少し表示が崩れます。そこを整えるようにして以下のような表示になります。
10フレーム目の3投の表示をテスト
モデルで使ったテストをベースに、各投球の表示と各フレームのスコアをテストしています。 (長いので、全投球ストライクのテストケースのみ抜粋しました)
func test_recordScore_12Strikes_displayedCorrectly() {
for _ in 0..9 {
bowlButtons[10].tap()
}
bowlButtons[10].tap()
XCTAssertEqual(frameScoreLabels[8].label, "-")
XCTAssertEqual(frameScoreLabels[9].label, "-")
XCTAssertEqual(frameBowlLabels[9][0].label, "X")
XCTAssertEqual(frameBowlLabels[9][1].label, "-")
XCTAssertEqual(frameBowlLabels[9][2].label, "-")
bowlButtons[10].tap()
XCTAssertEqual(frameScoreLabels[8].label, "270")
XCTAssertEqual(frameScoreLabels[9].label, "-")
XCTAssertEqual(frameBowlLabels[9][0].label, "X")
XCTAssertEqual(frameBowlLabels[9][1].label, "X")
XCTAssertEqual(frameBowlLabels[9][2].label, "-")
bowlButtons[10].tap()
XCTAssertEqual(frameScoreLabels[9].label, "300")
XCTAssertEqual(frameBowlLabels[9][0].label, "X")
XCTAssertEqual(frameBowlLabels[9][1].label, "X")
XCTAssertEqual(frameBowlLabels[9][2].label, "X")
}
このテストを動かしてみると 第10フレームの2投目、3投目の表示で失敗していることがわかります。通常のフレームであれば、1投目がストライクであれば、2投目の表示は、””になります。
その点が、10フレーム目での期待する表示と異なるために失敗となっています。
ビューの変更
この振る舞いは、FrameBowlView ビューではなく、BowlingGameViewModel で制御されています。
func bowlAsText(frameIndex:Int, bowlIndex: Int) -> String {
switch game.frameState(frameIndex: frameIndex) {
case .Strike:
return bowlIndex == 0 ? "X" : ""
case .Spare:
if bowlIndex == 1 { return "/" }
fallthrough
case .Others:
if let num = game.bowlResult(frameIndex: frameIndex, bowlIndex: bowlIndex) {
return String(num)
}
return "-"
}
}
表示要素を決める時に、フレームの状態を最初にチェックしているため、1投目がストライクのフレームでは、2投目の枠が””になっています。
特殊な表示を必要とするのは、10フレーム目だけなので、分岐させて処理してしまいます。
10フレーム目の表示をまとめると、以下になります。
- 1投目が10であれば、ストライク表示、それ以外は、該当数字表示
- 2投目は、1投目がストライクであれば、2投目は、該当数字表示(ストライクであればストライク表示)
- 2投目は、1投目が通常投球であれば、スペア表示するか、該当数字表示
- 3投目は、ストライク表示 or (該当すれば)スペア表示 or 該当数字表示
func bowlAsText(frameIndex:Int, bowlIndex: Int) -> String {
if (frameIndex == 9) {
switch bowlIndex {
case 0:
if let bowl0 = game.bowlResult(frameIndex: 9, bowlIndex: 0) {
if bowl0 == 10 {
return "X"
}
return String(bowl0)
}
return "-"
case 1:
if let bowl0 = game.bowlResult(frameIndex: 9, bowlIndex: 0) {
if let bowl1 = game.bowlResult(frameIndex: 9, bowlIndex: 1) {
if bowl0 == 10 {
if bowl1 == 10 { return "X" }
return String(bowl1)
}
if (bowl0 + bowl1 == 10) { return "/" }
return String(bowl1)
}
}
return "-"
case 2:
if let bowl1 = game.bowlResult(frameIndex: 9, bowlIndex: 1) {
if let bowl2 = game.bowlResult(frameIndex: 9, bowlIndex: 2) {
if bowl2 == 10 { return "X" }
if bowl1 + bowl2 == 10 { return "/" }
return String(bowl2)
}
}
return "-"
default:
return "-"
}
}
switch game.frameState(frameIndex: frameIndex) {
case .Strike:
return bowlIndex == 0 ? "X" : ""
case .Spare:
if bowlIndex == 1 { return "/" }
fallthrough
case .Others:
if let num = game.bowlResult(frameIndex: frameIndex, bowlIndex: bowlIndex) {
return String(num)
}
return "-"
}
}
上記コードで、UITest も通るようになりました。
リファクタリング
先ほどの、bowlAsText メソッドで、10フレーム対応のコードの方が、通常フレーム処理のコードよりも大きくなってしまっていますので、別メソッドに分けることにしました。
func bowlAsTextForFrame10(bowlIndex: Int) -> String {
switch bowlIndex {
case 0:
if let bowl0 = game.bowlResult(frameIndex: 9, bowlIndex: 0) {
if bowl0 == 10 {
return "X"
}
return String(bowl0)
}
return "-"
case 1:
if let bowl0 = game.bowlResult(frameIndex: 9, bowlIndex: 0) {
if let bowl1 = game.bowlResult(frameIndex: 9, bowlIndex: 1) {
if bowl0 == 10 {
if bowl1 == 10 { return "X" }
return String(bowl1)
}
if (bowl0 + bowl1 == 10) { return "/" }
return String(bowl1)
}
}
return "-"
case 2:
if let bowl1 = game.bowlResult(frameIndex: 9, bowlIndex: 1) {
if let bowl2 = game.bowlResult(frameIndex: 9, bowlIndex: 2) {
if bowl2 == 10 { return "X" }
if bowl1 + bowl2 == 10 { return "/" }
return String(bowl2)
}
}
return "-"
default:
return "-"
}
}
func bowlAsText(frameIndex:Int, bowlIndex: Int) -> String {
if (frameIndex == 9) {
return bowlAsTextForFrame10(bowlIndex: bowlIndex)
}
switch game.frameState(frameIndex: frameIndex) {
case .Strike:
return bowlIndex == 0 ? "X" : ""
case .Spare:
if bowlIndex == 1 { return "/" }
fallthrough
case .Others:
if let num = game.bowlResult(frameIndex: frameIndex, bowlIndex: bowlIndex) {
return String(num)
}
return "-"
}
}
メインとなるフローが見やすくなりました。
まとめ その5
ほとんど完成していますが、手動でアプリをテストしていると、1フレームに対して合計で10を超える入力ができてしまうことが目につきました。
次回、入力できないボタンを disable にするのと、ボタンの見た目を少し変更して終わりにしようかと思います。
説明は以上です。次回に続きます。Happy Coding!
Sponsor Link