Day 28 - 100 Days of Swift

16 minute read

Project 5 (part 2)

Day 28 is the second part of the fifth project. Today you finish up the submit method. You build a couple of helper methods to check the submitted word, and you present some alerts to the user based on why their answer was rejected.

The first thing you do is stub out the helper methods:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Checks if it is possible to make the word
// out of the letters in the start word
private func isPossible(_ word: String) -> Bool {
    return true
}

// Checks that the word hasn't already been submitted
private func isOriginal(_ word: String) -> Bool {
    return true
}

// Checks that this is a real word
private func isReal(_ word: String) -> Bool {
    return true
}

Then he has you write out the submit function, using these helper functions to validate the word, and, if it is valid, add it to the usedWords array and update the table view:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
func submit(_ answer: String) {
    let lowerAnswer = answer.lowercased()

    if isPossible(word: lowerAnswer) {
        if isOriginal(word: lowerAnswer) {
            if isReal(word: lowerAnswer) {
                usedWords.insert(answer, at: 0)

                let indexPath = IndexPath(row: 0, section: 0)
                tableView.insertRows(at: [indexPath],
                                     with: .automatic)
            }
        }
    }
}

I don’t really like how that looks, and I try to avoid nested logic like that whenever I can, so I rewrote it using guard statements:

1
2
3
4
5
6
7
8
9
10
11
12
13
func checkAnswer(_ answer: String) {
    let lowerAnswer = answer.lowercased()

    guard isPossible(lowerAnswer) else { return }

    guard isOriginal(lowerAnswer) else { return }

    guard isReal(lowerAnswer) else { return }

    usedWords.insert(answer, at: 0)
    let indexPath = IndexPath(row: 0, section: 0)
    tableView.insertRows(at: [indexPath], with: .automatic)
}

Next, you actually fill out the stubs for checking valid words. Originality is a simple check:

1
2
3
private func isOriginal(_ word: String) -> Bool {
    return !usedWords.contains(word)
}

Possibility is a little more complex:

1
2
3
4
5
6
7
8
9
10
11
12
private func isPossible(_ word: String) -> Bool {
    var tempWord = startWord.lowercased()

    for letter in word {
        if let position = tempWord.firstIndex(of: letter) {
            tempWord.remove(at: position)
        } else {
            return false
        }
    }
    return true
}

Reality would be a lot harder if Apple didn’t provide us with the convenient UITextChecker class, but fortunately they do:

1
2
3
4
5
6
7
8
9
10
11
12
private func isReal(_ word: String) -> Bool {
    let checker = UITextChecker()
    let range = NSRange(location: 0,
                        length: word.utf16.count)
    let misspelledRange =
        checker.rangeOfMisspelledWord(in: word,
                                      range: range,
                                      startingAt: 0,
                                      wrap: false,
                                      language: "en")
    return misspelledRange.location == NSNotFound
}

The app works at this point, but it doesn’t give the user any feedback if their answer is rejected, so you add a couple of alerts to let them know why their answer was rejected. He has you do this in the corresponding else statement for each of the checks made earlier:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
func submit(answer: String) {
    let lowerAnswer = answer.lowercased()

    let errorTitle: String
    let errorMessage: String

    if isPossible(word: lowerAnswer) {
        if isOriginal(word: lowerAnswer) {
            if isReal(word: lowerAnswer) {
                usedWords.insert(answer, at: 0)

                let indexPath = IndexPath(row: 0,
                                          section: 0)
                tableView.insertRows(at: [indexPath],
                                     with: .automatic)

                return
            } else {
                errorTitle = "Word not recognised"
                errorMessage = "You can't just make them up, you know!"
            }
        } else {
            errorTitle = "Word used already"
            errorMessage = "Be more original!"
        }
    } else {
        guard let title = title?.lowercased() else { return }
        errorTitle = "Word not possible"
        errorMessage = "You can't spell that word from \(title)"
    }

    let ac = UIAlertController(title: errorTitle,
                               message: errorMessage,
                               preferredStyle: .alert)
    ac.addAction(UIAlertAction(title: "OK",
                               style: .default))
    present(ac, animated: true)
}

Since I refactored into guard statements, my solution to this needed to look a little different. First, I added a presentAlert() method that takes an error title and message:

1
2
3
4
5
6
7
8
9
10
func presentAlert(title: String, message: String) {
    let alertController =
        UIAlertController(title: title,
                          message: message,
                          preferredStyle: .alert)
    let okAction = UIAlertAction(title: "OK",
                                 style: .default)
    alertController.addAction(okAction)
    present(alertController, animated: true)
}

Then, in each of the guard statements, I can just write the error title and messages and present the alert:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
func checkAnswer(_ answer: String) {
    let lowerAnswer = answer.lowercased()
    guard let startWord = title else { return }

    guard isPossible(lowerAnswer) else {
        let title = "Word not possible"
        let message = "You can't spell that word from \(startWord)"
        presentAlert(title: title, message: message)
        return
    }

    guard isOriginal(lowerAnswer) else {
        let title = "Word used already"
        let message = "Be more original!"
        presentAlert(title: title, message: message)
        return
    }

    guard isReal(lowerAnswer) else {
        let title = "Word not recognised"
        let message = "You can't just make them up, you know!"
        presentAlert(title: title, message: message)
        return
    }

    usedWords.insert(answer, at: 0)
    let indexPath = IndexPath(row: 0, section: 0)
    tableView.insertRows(at: [indexPath], with: .automatic)
}

It ends up being about the same amount of code, but I think the logic is much more straightforward.

That’s pretty much all there is as far as the project goes, but I had some free time and I wanted to get some practice writing tests, so I took things a little farther today. First, I thought it would be a good idea to pull out all the game logic into its own class, so I made a new class called GameController. I basically just copied over all the game-related code and went line by line fixing the compiler errors. The one thing I changed was I renamed submit to checkAnswer and I made it return an optional UIAlertController. Here’s what that ended up looking like :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
class GameController {

    private var allWords: [String] = []
    private var usedWords: [String] = []
    var startWord: String = ""

    init() {
        // Pull the list of words out of the file
        if let startWordsURL = Bundle.main.url(forResource: "start",
                                               withExtension: "txt"),
            let startWords = try? String(contentsOf: startWordsURL) {
            allWords = startWords.components(separatedBy: "\n")
        }

        // Provide a default in case something goes wrong.
        if allWords.isEmpty {
            allWords = ["silkworm"]
        }
    }

    // MARK: - Tableview Data Source Helpers
    func numberOfRows(in section: Int = 0) -> Int {
        return usedWords.count
    }

    func word(for indexPath: IndexPath) -> String {
        return usedWords[indexPath.row]
    }

    // MARK: - Public API
    func startGame() {
        startWord = allWords.randomElement() ?? "silkworm"
        usedWords.removeAll()
    }

    func checkAnswer(_ answer: String) -> UIAlertController? {
        let lowerAnswer = answer.lowercased()

        guard isPossible(lowerAnswer) else {
            let title = "Word not possible"
            let message = "You can't spell that word from \(startWord)"
            return buildErrorAlert(title: title, message: message)
        }

        guard isOriginal(lowerAnswer) else {
            let title = "Word used already"
            let message = "Be more original!"
            return buildErrorAlert(title: title, message: message)
        }

        guard isReal(lowerAnswer) else {
            let title = "Word not recognised"
            let message = "You can't just make them up, you know!"
            return buildErrorAlert(title: title, message: message)
        }

        usedWords.insert(answer, at: 0)

        return nil
    }

    private func isPossible(_ word: String) -> Bool {
        var tempWord = startWord.lowercased()

        for letter in word {
            if let position = tempWord.firstIndex(of: letter) {
                tempWord.remove(at: position)
            } else {
                return false
            }
        }
        return true
    }

    private func isOriginal(_ word: String) -> Bool {
        return !usedWords.contains(word)
    }

    private func isReal(_ word: String) -> Bool {
        let checker = UITextChecker()
        let range = NSRange(location: 0,
                            length: word.utf16.count)
        let misspelledRange =
            checker.rangeOfMisspelledWord(in: word,
                                          range: range,
                                          startingAt: 0,
                                          wrap: false,
                                          language: "en")
        return misspelledRange.location == NSNotFound
    }

    private func buildErrorAlert(title: String,
                                 message: String) -> UIAlertController {
        let alertController = UIAlertController(title: title,
                                                message: message,
                                                preferredStyle: .alert)
        let okAction = UIAlertAction(title: "OK", style: .default)
        alertController.addAction(okAction)
        return alertController
    }
}

Then I just pulled out the stuff I didn’t need anymore in ViewController, and replaced it with methods from GameController where applicable:

1
2
3
4
5
6
7
8
9
10
11
12
13
// At top of class
let gameController = GameController()

// In startGame()
gameController.startGame()
title = gameController.startWord
tableView.reloadData()

// In numberOfRowsInSection
return gameController.numberOfRows(in: section)

// In cellForRowAt
cell.textLabel?.text = gameController.word(for: indexPath)

And, of course, the main thing that needed to change was the submit method:

1
2
3
4
5
6
7
8
func submit(_ answer: String) {
    if let alert = gameController.checkAnswer(answer) {
        present(alert, animated: true)
    } else {
        let indexPath = IndexPath(row: 0, section: 0)
        tableView.insertRows(at: [indexPath], with: .automatic)
    }
}

Because we will always get an alert if the word doesn’t pass the check, we can just present that if it exists, otherwise we know to update the table view with the newly added word.

Now that all the game logic was pulled out on its own, I could write some tests. I added a Unit Test Target to the project and made these setUp and tearDown methods for my test case:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Project5Tests: XCTestCase {

    var gameController: GameController!
    let startWord = "gumdrops"

    override func setUp() {
        gameController = GameController()
        gameController.startWord = startWord
    }

    override func tearDown() {
        gameController = nil
    }
}

This insures that every test will have its own instance of GameController, whose startWord is “gumdrops”.

The first test I wrote was to check a valid word:

1
2
3
4
5
6
7
8
9
10
func testValidWord() {
    let prevCount = gameController.numberOfRows()

    let alert = gameController.checkAnswer("gumdrop")

    XCTAssertNil(alert, "Returned an alert for a valid word.")
    XCTAssertEqual(gameController.numberOfRows(), prevCount + 1)
    let indexPath = IndexPath(row: 0, section: 0)
    XCTAssertEqual(gameController.word(for: indexPath), "gumdrop")
}

This passed when I first ran it, but to test that the test worked, I changed isOriginal to always return false, and the test failed. Now that I knew the test failed when checkAnswer didn’t do the right thing, I put the code back and knew that a passing test showed that my code did what I thought it did. I repeated this process for each of the kinds of word that would be disqualified and came up with these tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
func testRepeatedWord() {
    let prevCount = gameController.numberOfRows()

    gameController.checkAnswer("drops")
    let alert = gameController.checkAnswer("drops")

    XCTAssertNotNil(alert)
    XCTAssertEqual(alert?.title, "Word used already")
    XCTAssertEqual(gameController.numberOfRows(), prevCount + 1)

}

func testUnrealWord() {
    let prevCount = gameController.numberOfRows()

    let alert = gameController.checkAnswer("dropus")

    XCTAssertNotNil(alert)
    XCTAssertEqual(alert?.title, "Word not recognized")
    XCTAssertEqual(gameController.numberOfRows(), prevCount)
}

func testImpossibleWord() {
    let prevCount = gameController.numberOfRows()

    let alert = gameController.checkAnswer("raindrop")

    XCTAssertNotNil(alert)
    XCTAssertEqual(alert?.title, "Word not possible")
    XCTAssertEqual(gameController.numberOfRows(), prevCount)
}

Once I had all those tests passing, I was pretty confident that my code was doing what it was supposed to, at least in the edge cases that I had thought of. So I went about one more refactoring change that occurred to me as I was writing tests. I realized that there was a limited set of reasons a word might be disqualified, so I thought this would be a good use for an enum. I also thought keeping all the error messages in the enum would further clarify the logic, and make it easier to find the thing you were looking for:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
enum AnswerError {
    case unoriginal
    case impossible (comparedTo: String)
    case unreal

    func title() -> String {
        switch (self) {
        case .impossible:
            return "Word not possible"
        case .unoriginal:
            return "Word used already"
        case .unreal:
            return "Word not recognized"
        }
    }

    func message() -> String {
        switch (self) {
        case .impossible(let word):
            return "You can't spell that word from '\(word)'"
        case .unoriginal:
            return "Be more original!"
        case .unreal:
            return "You can't just make them up, you know!"
        }
    }   
}

Then I rewrote buildErrorAlert() to take an AnswerError, instead of a title and message:

1
2
3
4
5
6
7
8
9
10
private func buildErrorAlert(for error: AnswerError) -> UIAlertController {
    let alertController =
        UIAlertController(title: error.title(),
                          message: error.message(),
                          preferredStyle: .alert)
    let okAction = UIAlertAction(title: "OK",
                                 style: .default)
    alertController.addAction(okAction)
    return alertController
}

After those two changes, I could simplify checkAnswer even farther:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
func checkAnswer(_ answer: String) -> UIAlertController? {
    let lowerAnswer = answer.lowercased()

    guard isPossible(lowerAnswer) else {
        return buildErrorAlert(for: .impossible(comparedTo: startWord))
    }

    guard isOriginal(lowerAnswer) else {
        return buildErrorAlert(for: .unoriginal)
    }

    guard isReal(lowerAnswer) else {
        return buildErrorAlert(for: .unreal)
    }

    usedWords.insert(answer, at: 0)

    return nil
}

And refactor my tests to be less reliant on string literals:

1
2
3
4
5
6
7
8
// In testRepeatedWord()
XCTAssertEqual(alert?.title, AnswerError.unoriginal.title())

// In testUnrealWord()
XCTAssertEqual(alert?.title, AnswerError.unreal.title())

// in testImpossibleWord()
XCTAssertEqual(alert?.title, AnswerError.impossible(comparedTo: startWord).title())

And with all my tests passing, and all my refactoring done, here is what the app looks like (spoiler alert, from the user’s perspective, it looks exactly the same as it did at the end of Paul’s walkthrough):

Screenshots of adding a valid word in working app
Screenshots of adding an unreal word in working app
Screenshots of adding a impossible word in working app
Screenshots of adding a repeated word in working app

You can find my version of this project at the end of day 28 on Github here.

Reflections

Obviously I took it a little farther than he intended today, but I feel like I’ve got my code pretty well compartmentalized, and that the main functionality is pretty testable, so that is cool. It was definitely good practice thinking through what to test and how to test it. I think the project as a whole is set up well for whatever challenges he is going to throw my way tomorrow.