Skip to content

Commit

Permalink
Add Board unit tests; Add must_match_array assertion
Browse files Browse the repository at this point in the history
To support testing Board#clamp_coordinates, I wanted to use the
`must_match_array` assertion, vs just `must_equal`, when comparing the
starting and ending Coordinates arrays. This is ... more correct. It
prevents variations in sort order from causing test failures.
- For more detail: https://stackoverflow.com/a/20334260/171183
- But, to support this, I had to implement sorting on Coordinates
  (by defining the `<=>` operator). Then I added tests for this as well.
  - I considered, but chose not to `include Comparable` into
    Coordinates. This would add `<`, `<=`, `>`, `>=`, etc operators for
    free, based on the `<=>` operator... but it's not needed, so would
    just be bloat. Here's a test for it, though, in case we do want to
    add this in the future for some reason:

```
it "returns the expected results for X/Y comparisons" do
  # rubocop:disable Lint/BinaryOperatorWithIdenticalOperands
  _(unit_class[0, 0] < unit_class[0, 0]).must_equal(false)
  _(unit_class[0, 0] < unit_class[0, 1]).must_equal(true)
  _(unit_class[0, 0] < unit_class[1, 0]).must_equal(true)

  _(unit_class[0, 0] == unit_class[0, 0]).must_equal(true)
  _(unit_class[0, 0] == unit_class[0, 1]).must_equal(false)
  _(unit_class[0, 0] == unit_class[1, 0]).must_equal(false)

  _(unit_class[0, 0] > unit_class[0, 0]).must_equal(false)
  _(unit_class[0, 1] > unit_class[0, 0]).must_equal(true)
  _(unit_class[1, 0] > unit_class[0, 0]).must_equal(true)
  # rubocop:enable Lint/BinaryOperatorWithIdenticalOperands
end
```

Also, fix a few small issues found while writing the unit tests for
Board.
- Board#clamp_coordinates was being too lenient on the upper bounds of
  our columns and rows ranges. Fix: change `..` to `...` on the Range
  syntax.
- Raise an exception when attempting to place mines on a new Board
  record.
- Only allow Board#check_for_victory when the associated Game is in
  status Sweep in Progress. (Shouldn't be possible when the associated
  Game is in status Standing By.)
  • Loading branch information
pdobb committed Sep 6, 2024
1 parent d3e3517 commit efc2ba3
Show file tree
Hide file tree
Showing 5 changed files with 273 additions and 36 deletions.
13 changes: 5 additions & 8 deletions app/models/board.rb
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,6 @@ class Board < ApplicationRecord

# @attr difficulty_level [DifficultyLevel]
def self.build_for(game:, difficulty_level:)
build_for_custom(game:, difficulty_level:)
end

# @attr difficulty_level [DifficultyLevel]
def self.build_for_custom(game:, difficulty_level:)
game.
build_board(
columns: difficulty_level.columns,
Expand All @@ -36,6 +31,7 @@ def self.build_for_custom(game:, difficulty_level:)
end

def place_mines(seed_cell:)
raise(Error, "mines can't be placed on an unsaved Board") if new_record?
raise(Error, "mines have already been placed") if any_mines?

cells.excluding(seed_cell).by_random.limit(mines).update_all(mine: true)
Expand All @@ -44,9 +40,10 @@ def place_mines(seed_cell:)
end

def check_for_victory
return if game.over?
return self unless game.status_sweep_in_progress?

game.end_in_victory if all_safe_cells_have_been_revealed?

self
end

Expand Down Expand Up @@ -85,8 +82,8 @@ def in_bounds?(coordinates)
columns_range.include?(coordinates.x) && rows_range.include?(coordinates.y)
end

def columns_range = 0..columns
def rows_range = 0..rows
def columns_range = 0...columns
def rows_range = 0...rows

# Board::Console acts like a {Board} but otherwise handles IRB
# Console-specific methods/logic.
Expand Down
9 changes: 9 additions & 0 deletions app/models/coordinates.rb
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,15 @@ def neighbors
# rubocop:enable Layout/SpaceInsideParens
# rubocop:enable Layout/MultilineArrayLineBreaks

# Allow sorting with other Coordinates objects.
def <=>(other)
unless other.is_a?(self.class)
raise(TypeError, "can't compare with non-Coordinates objects")
end

[x, y] <=> [other.x, other.y]
end

# Coordinates::Console acts like a {Coordinates} but otherwise handles IRB
# Console-specific methods/logic.
class Console
Expand Down
205 changes: 201 additions & 4 deletions test/models/board_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,205 @@

require "test_helper"

class BoardTest < ActiveSupport::TestCase # rubocop:todo Minitest/NoTestCases
# test "the truth" do
# assert true
# end
class BoardTest < ActiveSupport::TestCase
describe "Board" do
let(:unit_class) { Board }

let(:win1_board) { boards(:win1_board) }
let(:loss1_board) { boards(:loss1_board) }
let(:standing_by1_board) { boards(:standing_by1_board) }
let(:new_board) {
unit_class.build_for(
game: new_game,
difficulty_level: difficulty_level_test)
}

let(:new_game) { Game.new }
let(:difficulty_level_test) { DifficultyLevel.new("Test") }

context "Class Methods" do
subject { unit_class }

describe ".build_for" do
it "orchestrates building of the expected object graph and returns "\
"the new Board" do
result =
subject.build_for(
game: new_game,
difficulty_level: difficulty_level_test)

_(result).must_be_instance_of(unit_class)
_(result.game).must_be_same_as(new_game)
_(result.cells.sample).must_be_instance_of(Cell)
end
end
end

describe "#cells" do
subject { [new_board, win1_board].sample }

it "sorts the association by least recent" do
result = subject.cells.to_sql
_(result).must_include(%(ORDER BY "cells"."created_at" ASC))
end
end

describe "#place_mines" do
context "GIVEN a new Board" do
subject { new_board }

it "raises Board::Error" do
exception =
_(-> {
subject.place_mines(seed_cell: nil)
}).must_raise(Board::Error)
_(exception.message).must_equal(
"mines can't be placed on an unsaved Board")
end
end

context "GIVEN mines have already been placed" do
subject { win1_board }

it "raises Board::Error" do
exception =
_(-> {
subject.place_mines(seed_cell: nil)
}).must_raise(Board::Error)
_(exception.message).must_equal("mines have already been placed")
end
end

context "GIVEN mines have not yet been placed" do
subject { standing_by1_board }

let(:standing_by1_board_cell1) { cells(:standing_by1_board_cell1) }

it "places the expected number of mines and returns the Board" do
result =
_(-> {
subject.place_mines(seed_cell: standing_by1_board_cell1)
}).must_change("subject.mines_count", from: 0, to: 1)
_(result).must_be_same_as(subject)
end

it "doesn't place the a mine in the seed Cell" do
subject.cells.excluding(standing_by1_board_cell1).delete_all
_(subject.cells.size).must_equal(1)

_(-> {
subject.place_mines(seed_cell: standing_by1_board_cell1)
}).wont_change("subject.mines_count")
end

# TODO: I'm not sure how to test for random placement...
end
end

describe "#check_for_victory" do
context "GIVEN the associated Game#status_in_progress? = false" do
before do
MuchStub.on_call(subject.game, :end_in_victory) { |call|
@end_in_victory_call = call
}
end

subject { [standing_by1_board, win1_board, loss1_board].sample }

it "returns the Game without orchestrating any changes" do
result = subject.check_for_victory
_(result).must_be_same_as(subject)
_(@end_in_victory_call).must_be_nil
end
end

context "GIVEN the associated Game#status_in_progress? = true" do
context "GIVEN the Board is not yet in a victorious state" do
before do
subject.game.start(seed_cell: nil)
end

subject { standing_by1_board }

it "doesn't end the associated Game, and returns the Board" do
result =
_(-> { subject.check_for_victory }).wont_change(
"subject.game.status")
_(result).must_be_same_as(subject)
end
end

context "GIVEN the Board is in a victorious state" do
before do
subject.game.start(seed_cell: nil)
subject.cells.is_not_mine.update_all(revealed: true)
end

subject { standing_by1_board }

it "ends the associated Game in victory, and returns the Board" do
result =
_(-> { subject.check_for_victory }).must_change(
"subject.game.status",
to: Game.status_alliance_wins)
_(result).must_be_same_as(subject)
end
end
end
end

describe "#mines_count" do
subject { win1_board }

it "returns the expected Integer" do
_(subject.mines_count).must_equal(1)
end
end

describe "#flags_count" do
subject { win1_board }

it "returns the expected Integer" do
_(subject.flags_count).must_equal(1)
end
end

describe "#grid" do
before do
MuchStub.on_call(Grid, :build_for) { |call| @build_for_call = call }
end

subject { standing_by1_board }

it "forwards to Grid.build_for" do
subject.grid
_(@build_for_call).wont_be_nil
_(@build_for_call.pargs).wont_be_empty
_(@build_for_call.kargs).must_equal({ context: nil })
end
end

describe "#clamp_coordinates" do
let(:coordinates_array) {
# rubocop:disable all
[
Coordinates[-1, -1], Coordinates[-1, 0], Coordinates[0, -1],
*valid_coordiantes,
Coordinates[ 0, 3], Coordinates[ 3, 0], Coordinates[3, 3],
]
# rubocop:disable all
}
let(:valid_coordiantes) {
[Coordinates[0, 0], Coordinates[1, 1], Coordinates[2, 2]]
}

subject { new_board }

it "returns an Array that includes Coordinates inside of the Board, "\
"while excluding Coordinates outside of the Board" do
result = subject.clamp_coordinates(coordinates_array)
_(result).must_match_array(valid_coordiantes)
end
end
end
end
53 changes: 36 additions & 17 deletions test/models/coordinates_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,13 @@ class CoordinatesTest < ActiveSupport::TestCase
describe "Coordinates" do
let(:unit_class) { Coordinates }

# rubocop:disable Layout/ExtraSpacing
# rubocop:disable Layout/MultilineArrayLineBreaks
# rubocop:disable Style/TrailingCommaInArrayLiteral
context "GIVEN an upper-left Coordinates" do
subject { unit_class[0, 0] }
describe "#neighbors" do
# rubocop:disable Layout/ExtraSpacing
# rubocop:disable Layout/MultilineArrayLineBreaks
# rubocop:disable Style/TrailingCommaInArrayLiteral
context "GIVEN an upper-left Coordinates" do
subject { unit_class[0, 0] }

describe "#neighbors" do
it "returns the expected Array" do
_(subject.neighbors).must_equal([
unit_class[-1, -1], unit_class[0, -1], unit_class[1, -1],
Expand All @@ -21,12 +21,10 @@ class CoordinatesTest < ActiveSupport::TestCase
])
end
end
end

context "GIVEN a middle Coordinates" do
subject { unit_class[1, 1] }
context "GIVEN a middle Coordinates" do
subject { unit_class[1, 1] }

describe "#neighbors" do
it "returns the expected Array" do
_(subject.neighbors).must_equal([
unit_class[0, 0], unit_class[1, 0], unit_class[2, 0],
Expand All @@ -35,12 +33,10 @@ class CoordinatesTest < ActiveSupport::TestCase
])
end
end
end

context "GIVEN a lower-right Coordinates" do
subject { unit_class[2, 2] }
context "GIVEN a lower-right Coordinates" do
subject { unit_class[2, 2] }

describe "#neighbors" do
it "returns the expected Array" do
_(subject.neighbors).must_equal([
unit_class[1, 1], unit_class[2, 1], unit_class[3, 1],
Expand All @@ -49,9 +45,32 @@ class CoordinatesTest < ActiveSupport::TestCase
])
end
end
# rubocop:enable Style/TrailingCommaInArrayLiteral
# rubocop:enable Layout/MultilineArrayLineBreaks
# rubocop:enable Layout/ExtraSpacing
end

describe "#<=>" do
let(:coordinates_array) {
[unit_class[1, 1], unit_class[0, 0], unit_class[0, 1]]
}

it "allows for sorting of Coordinates" do
_(coordinates_array.sort).must_equal(
[unit_class[0, 0], unit_class[0, 1], unit_class[1, 1]])
end

context "GIVEN a non-Coordinates comparison object" do
let(:other) { Object.new }

subject { unit_class[0, 0] }

it "raises TypeError" do
exception = _(-> { subject <=> other }).must_raise(TypeError)
_(exception.message).must_equal(
"can't compare with non-Coordinates objects")
end
end
end
# rubocop:enable Style/TrailingCommaInArrayLiteral
# rubocop:enable Layout/MultilineArrayLineBreaks
# rubocop:enable Layout/ExtraSpacing
end
end
Loading

0 comments on commit efc2ba3

Please sign in to comment.