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

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

どんなモデルとビューを作ったかは、上記記事を参照ください。

アプリ設計:振り返り

ここまでに、MVVM の基本的なところを実装し、以下ができるようになっています。

  • ボーリングスコア風の表示
  • 1〜10フレームのスコア記録

初期の設計で困るところは発生していませんので、このまま実装していきます。

今回は、「スコア計算」です。

アプリ設計:追加設計

スペアとストライクの扱いはまだ着手せずに進めていきます。

つまり、スコア計算は、それまでの各投球でのピン数を合計すれば良いことになります。

ただし、以下を決定しておかないといけません。

  • 投球が完了していないフレームでの合計スコア表示

1投しただけの状態で、その1投目までの合計を表示すべきか、2投目が終わるまで、"-" 表示で良いかを決める必要があります。

ここでは、2投目が終わるまでは(スペア、ストライクを考慮した時には、必要な情報が集められるようになるまでは)、スコア表示しないこととして、代わりに"-" を表示することとしました。

MEMO
少し調べたのですが、どのように表示するのが正式なのかわかりませんでした。

もしかするとボーリングのルールとしては、途中での計算は決めていないのかもしれません。

スコア計算もチェックするテスト

前回のテストを改良して、第1フレームの2投を入力後にスコア表示をチェックするように修正しましょう。

まずは、テストでチェックできるように Accessibility ID を付与する必要があります。

ここでは、FrameScoreView0 〜 FrameScoreView9 という ID を付与するようにしています。

FrameScoreView に accessibility ID 付与

struct FrameScoreView: View {
    @ObservedObject var viewModel: BowlingGameViewModel
    let frameIndex: Int
    var body: some View {
        Text(viewModel.scoreAsText(frame: frameIndex))
            .accessibility(identifier: "FrameScoreView\(frameIndex)")
            .frame(width: 50, height: 20)
            .border(Color.gray.opacity(0.5))
    }
}

以下は、2投した状態で、チェックするテストです。
1投目は1ピン、2投目は5ピン倒した結果を入力として、表示内容をチェックしています。

test_RecordOneFrame_TwoBowl_ShouldBeRecordedAndAtFirstFrame

    func test_RecordOneFrame_TwoBowl_ShouldBeRecordedAndAtFirstFrame() throws {
        // UI tests must launch the application that they test.
        let app = XCUIApplication()
        app.launch()

        let button1 = app.buttons["Button1"]
        XCTAssertTrue(button1.exists)
        let button5 = app.buttons["Button5"]
        XCTAssertTrue(button5.exists)

        let frame0bowl0Label = app.staticTexts["FrameBowlView0-0"]
        XCTAssertTrue(frame0bowl0Label.exists)
        let frame0bowl1Label = app.staticTexts["FrameBowlView0-1"]
        XCTAssertTrue(frame0bowl1Label.exists)
        let frame0ScoreLabel = app.staticTexts["FrameScoreView0"]
        XCTAssertTrue(frame0ScoreLabel.exists)

        XCTAssertEqual(frame0bowl0Label.label, "-")
        XCTAssertEqual(frame0bowl1Label.label, "-")
        XCTAssertEqual(frame0ScoreLabel.label, "-")

        button1.tap()
        XCTAssertEqual(frame0bowl0Label.label, "1")
        XCTAssertEqual(frame0bowl1Label.label, "-")
        XCTAssertEqual(frame0ScoreLabel.label, "-")

        button5.tap()
        XCTAssertEqual(frame0bowl0Label.label, "1")
        XCTAssertEqual(frame0bowl1Label.label, "5")
        XCTAssertEqual(frame0ScoreLabel.label, "6")
    }

setupWithError 内の、continueAfterFailure を = true とすると、Assert が Failure しても最後までテストを実行するようになります。

そうすることで、各投球の表示は期待通りになっていて、スコア表示のテストで Failure していることがわかります。

ここまでのスコア計算の実装状況

Model ではなにも実装していません。以下のように、ViewModel が、"0" を返しているという実装です。ですので、初期状態でも、テストが Failure しています。

現在のスコア計算@ViewModel

    func scoreAsText(frame:Int) -> String {
        return "0"
    }

ここでは、ViewModel が計算するのではなく、Model に問い合わせて結果を View に渡すという実装にします。

スコア計算の実装

BowlingGame へ実装

モデル (BowlingGame) へスコア計算を実装していきます。

スペア・ストライクを考慮しないのであれば、フレーム分の2投後でのスコアは、
「前のフレームまでのスコア+2投で倒したピン数」となります。

また、関数返り値を Int? として、まだ計算するための情報が揃っていない時には、nil を返す仕様とします。

スコア計算@BowlingGame

    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
    }

ViewModel は、Model の計算結果を使って、View に返すように実装を修正します。

スコア計算@BowlingGameViewModel

    func scoreAsText(frame:Int) -> String {
        if let result = game.frameResult(frame: frame)  {
            return String(result)
        }
        return "-"
    }

先ほどの1フレームのテストは、うまく Pass します。

シミュレータでの実行は、以下のようになります。

スコア計算実装その1

「スコア計算実装その1」

スペア・ストライクをチェックしていないので、入力された数字の合計がスコアになっています。

いまは、1フレームしかテストしていませんので、(スペア・ストライクでない)10フレームまでの入力で、スコアが正しいことを確認するテストを追加しましょう。

Refactoring

テストを追加する前に、将来的なテストに対しての準備を兼ねて、すこし Refactoring しておきましょう。

ボタンとテキストは、常に参照すると思いますので、Setup で取得して、配列で保持しておくことにします。

テキストが存在するかを確認するテストも別途作成しました。(test_LabelExists)

ボタンをテキストを事前に取得しておくことで、これまでのテスト test_RecordOneFrame_TwoBowl_ShouldBeRecordedAndAtFirstFrame は、シンプルになりました。

TDDBowlingUITests refactoring

//
//  TDDBowlingUITests.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/08
//  © 2020  SmallDeskSoftware
//

import XCTest

class TDDBowlingUITests: XCTestCase {
    var bowlButtons: [XCUIElement] = []
    var frameBowlLabels:[[XCUIElement]] = [[XCUIElement]]()
    var frameScoreLabels: [XCUIElement] = []
    var totalScoreLabel: XCUIElement!
    
    override func setUpWithError() throws {
        // In UI tests it is usually best to stop immediately when a failure occurs.
        continueAfterFailure = true

        let app = XCUIApplication()
        app.launch()

        for index in 0..<10 {
            let button = app.buttons["Button\(index)"]
            bowlButtons.append(button)
            let bowl0 = app.staticTexts["FrameBowlView\(index)-0"]
            let bowl1 = app.staticTexts["FrameBowlView\(index)-1"]
            frameBowlLabels.append([bowl0, bowl1])
            let score = app.staticTexts["FrameScoreView\(index)"]
            frameScoreLabels.append(score)
        }
        totalScoreLabel = app.staticTexts["TotalScoreView"]
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }

    func test_LabelExists() throws {
        for index in 0..<10 {
            XCTAssertTrue(frameBowlLabels[index][0].exists)
            XCTAssertTrue(frameBowlLabels[index][1].exists)
            XCTAssertTrue(frameScoreLabels[index].exists)
        }
        XCTAssertTrue(totalScoreLabel.exists)
    }

    func test_RecordOneFrame_TwoBowl_ShouldBeRecordedAndAtFirstFrame() throws {
        XCTAssertEqual(frameBowlLabels[0][0].label, "-")
        XCTAssertEqual(frameBowlLabels[0][1].label, "-")
        XCTAssertEqual(frameScoreLabels[0].label,   "-")

        bowlButtons[1].tap()
        XCTAssertEqual(frameBowlLabels[0][0].label, "1")
        XCTAssertEqual(frameBowlLabels[0][1].label, "-")
        XCTAssertEqual(frameScoreLabels[0].label,   "-")

        bowlButtons[5].tap()
        XCTAssertEqual(frameBowlLabels[0][0].label, "1")
        XCTAssertEqual(frameBowlLabels[0][1].label, "5")
        XCTAssertEqual(frameScoreLabels[0].label,   "6")
    }
}

テストの追加

Refactoring して、コードが見易くなり、追加もしやすくなりましたので、10フレームまでの新しいテストを追加して、確認することにします。

テスト用のユーティリティ関数も追加しました。前回のテストは、1行になっています。

example code

//
//  TDDBowlingUITests.swift
//
//  Created by : Tomoaki Yagishita on 2020/11/08
//  © 2020  SmallDeskSoftware
//

import XCTest

class TDDBowlingUITests: XCTestCase {
    var bowlButtons: [XCUIElement] = []
    var frameBowlLabels:[[XCUIElement]] = [[XCUIElement]]()
    var frameScoreLabels: [XCUIElement] = []
    var totalScoreLabel: XCUIElement!
    
    override func setUpWithError() throws {
        // In UI tests it is usually best to stop immediately when a failure occurs.
        continueAfterFailure = true

        let app = XCUIApplication()
        app.launch()

        for index in 0..<10 {
            let button = app.buttons["Button\(index)"]
            bowlButtons.append(button)
            let bowl0 = app.staticTexts["FrameBowlView\(index)-0"]
            let bowl1 = app.staticTexts["FrameBowlView\(index)-1"]
            frameBowlLabels.append([bowl0, bowl1])
            let score = app.staticTexts["FrameScoreView\(index)"]
            frameScoreLabels.append(score)
        }
        totalScoreLabel = app.staticTexts["TotalScoreView"]
    }

    override func tearDownWithError() throws {
        // Put teardown code here. This method is called after the invocation of each test method in the class.
    }
    
    func test_LabelExists() throws {
        for index in 0..<10 {
            XCTAssertTrue(frameBowlLabels[index][0].exists)
            XCTAssertTrue(frameBowlLabels[index][1].exists)
            XCTAssertTrue(frameScoreLabels[index].exists)
        }
        XCTAssertTrue(totalScoreLabel.exists)
    }

    func test_RecordOneFrame_TwoBowl_ShouldBeRecordedAndAtFirstFrame() throws {
        checkAndThrowFrame(prevResult: 0, frameIndex: 0, num1: 1, num2: 5)
//        XCTAssertEqual(frameBowlLabels[0][0].label, "-")
//        XCTAssertEqual(frameBowlLabels[0][1].label, "-")
//        XCTAssertEqual(frameScoreLabels[0].label,     "-")
//
//        bowlButtons[1].tap()
//        XCTAssertEqual(frameBowlLabels[0][0].label, "1")
//        XCTAssertEqual(frameBowlLabels[0][1].label, "-")
//        XCTAssertEqual(frameScoreLabels[0].label,     "-")
//
//        bowlButtons[5].tap()
//        XCTAssertEqual(frameBowlLabels[0][0].label, "1")
//        XCTAssertEqual(frameBowlLabels[0][1].label, "5")
//        XCTAssertEqual(frameScoreLabels[0].label,     "6")
    }
    
    func test_ScoreAtEachFrame_From0To9FrameNoSpareNoStrike_CorrectlyDisplayed() {
        var localScore:Int = 0
        for index in 0..<10 {
            let (bowl0, bowl1) = randomBowlForFrame()
            checkAndThrowFrame(prevResult: localScore, frameIndex: index, num1: bowl0, num2: bowl1)
            localScore = localScore + bowl0 + bowl1
        }
        XCTAssertEqual(totalScoreLabel.label, String(localScore))
    }
    
    // utility
    func checkAndThrowFrame(prevResult: Int, frameIndex:Int, num1:Int, num2:Int) {
        XCTAssertEqual(frameBowlLabels[frameIndex][0].label, "-")
        XCTAssertEqual(frameBowlLabels[frameIndex][1].label, "-")
        XCTAssertEqual(frameScoreLabels[frameIndex].label, "-")

        self.bowlButtons[num1].tap()
        XCTAssertEqual(frameBowlLabels[frameIndex][0].label, String(num1))
        XCTAssertEqual(frameBowlLabels[frameIndex][1].label, "-")
        XCTAssertEqual(frameScoreLabels[frameIndex].label, "-")

        self.bowlButtons[num2].tap()
        XCTAssertEqual(frameBowlLabels[frameIndex][0].label, String(num1))
        XCTAssertEqual(frameBowlLabels[frameIndex][1].label, String(num2))
        XCTAssertEqual(frameScoreLabels[frameIndex].label, String(prevResult + num1 + num2))
    }
    
    func randomBowlForFrame() -> (Int, Int) {
        let bowl0 = Int.random(in: 0..<10)
        let bowl1 = Int.random(in: 0..<(10-bowl0))
        return (bowl0, bowl1)
    }
}

シミュレータで動作させると、どんどんスコアが追加されていくのが見えて、面白いです。

まとめ その3

10フレームまで記録できるようにはなりました。

次に、スペア・ストライクの表示対応とスコア計算対応と進めていく予定です。

説明は以上です。次回に続きます。Happy Coding!

コメントを残す

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