-
-
Notifications
You must be signed in to change notification settings - Fork 36
Outside-In with Aruba/RSpec for SudokuValidator Exercise #8
base: master
Are you sure you want to change the base?
Changes from all commits
1c390ad
faad0b5
060ed25
37e1af6
8f3f42a
df3a0dc
cf3a99d
17dea6d
d0f951f
18bd9c8
a260fd7
ce92e3d
c1797a1
dd4a140
21c2e69
47f941c
bfc9f7d
9b821e7
3ac7e3c
68e5126
e17fd1f
013686d
604ac80
6044015
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| tmp/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| --color | ||
| --format documentation | ||
| --require spec_helper |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| source 'https://rubygems.org' | ||
| gemspec |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| PATH | ||
| remote: . | ||
| specs: | ||
| sudoku_validator (0.0.1) | ||
|
|
||
| GEM | ||
| remote: https://rubygems.org/ | ||
| specs: | ||
| aruba (0.5.3) | ||
| childprocess (>= 0.3.6) | ||
| cucumber (>= 1.1.1) | ||
| rspec-expectations (>= 2.7.0) | ||
| builder (3.2.2) | ||
| childprocess (0.3.9) | ||
| ffi (~> 1.0, >= 1.0.11) | ||
| cucumber (1.3.10) | ||
| builder (>= 2.1.2) | ||
| diff-lcs (>= 1.1.3) | ||
| gherkin (~> 2.12) | ||
| multi_json (>= 1.7.5, < 2.0) | ||
| multi_test (>= 0.0.2) | ||
| diff-lcs (1.2.5) | ||
| ffi (1.9.3) | ||
| gherkin (2.12.2) | ||
| multi_json (~> 1.3) | ||
| multi_json (1.8.2) | ||
| multi_test (0.0.2) | ||
| rake (10.1.0) | ||
| rspec (2.14.1) | ||
| rspec-core (~> 2.14.0) | ||
| rspec-expectations (~> 2.14.0) | ||
| rspec-mocks (~> 2.14.0) | ||
| rspec-core (2.14.7) | ||
| rspec-expectations (2.14.4) | ||
| diff-lcs (>= 1.1.3, < 2.0) | ||
| rspec-mocks (2.14.4) | ||
|
|
||
| PLATFORMS | ||
| ruby | ||
|
|
||
| DEPENDENCIES | ||
| aruba | ||
| rake | ||
| rspec | ||
| sudoku_validator! |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| require 'cucumber' | ||
| require 'cucumber/rake/task' | ||
|
|
||
| Cucumber::Rake::Task.new(:features) do |t| | ||
| t.cucumber_opts = "features --format pretty -x" | ||
| t.fork = false | ||
| end | ||
|
|
||
| task :default => :features |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| #!/usr/bin/env ruby | ||
|
|
||
| require_relative '../lib/sudoku_fileparser' | ||
| require_relative '../lib/sudoku_puzzle' | ||
| require_relative '../lib/sudoku_validator' | ||
|
|
||
| exit unless filename = ARGV[0] | ||
|
|
||
| filepath = File.expand_path(filename) | ||
| if File.exist?(filepath) | ||
| parser = Sudoku::FileParser.new(File.read(filepath)) | ||
| puzzle = Sudoku::Puzzle.new(parser.parse_rows) | ||
| validator = Sudoku::Validator.new(puzzle) | ||
|
|
||
| puts validator.status | ||
| if validator.errors.any? | ||
| puts "Errors:" | ||
| validator.errors.each.with_index(1) do |error, index| | ||
| puts " #{index}) #{error}" | ||
| end | ||
| end | ||
| else | ||
| puts "Could not find file #{filename}" | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| Feature: Validating .sudoku files | ||
|
|
||
| Scenario: A valid, complete sudoku | ||
| When I successfully run `sudoku-validator ./valid_complete.sudoku` | ||
| Then the output should contain "This sudoku is valid." | ||
| Then the output should not contain "Errors:" | ||
|
|
||
| Scenario: A valid, incomplete sudoku | ||
| When I successfully run `sudoku-validator ./valid_incomplete.sudoku` | ||
| Then the output should contain "This sudoku is valid, but incomplete." | ||
| Then the output should not contain "Errors:" | ||
|
|
||
| Scenario: An invalid, complete sudoku | ||
| When I successfully run `sudoku-validator ./invalid_complete.sudoku` | ||
| Then the output should contain "This sudoku is invalid." | ||
| Then the output should contain "Column 4 contains a duplicate 8 in squares 2 and 5" | ||
| Then the output should contain "Column 6 contains a duplicate 2 in squares 1 and 5" | ||
|
|
||
| Scenario: An invalid, incomplete sudoku | ||
| When I successfully run `sudoku-validator ./invalid_incomplete.sudoku` | ||
| Then the output should contain "This sudoku is invalid." | ||
| Then the output should contain "Column 2 contains a duplicate 5 in squares 1 and 7" | ||
| Then the output should contain "Column 5 contains a duplicate 8 in squares 2 and 7" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| require 'aruba/cucumber' | ||
|
|
||
| Before do | ||
| @dirs = ["."] | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,28 @@ | ||
| module Sudoku | ||
| class FileParser | ||
| def initialize(file = nil) | ||
| raise(ArgumentError, "A file is required.") if file.nil? | ||
| @file = file | ||
| end | ||
|
|
||
| def parse_rows | ||
| @file.strip.each_line.map do |line| | ||
| parse_line(line) unless separator?(line) | ||
| end.compact | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def parse_line(line) | ||
| parse_empties(line).split(/[^\d]+/).map(&:to_i) | ||
| end | ||
|
|
||
| def parse_empties(line) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 for small well-named private methods here. Makes the source very readable.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks! |
||
| line.gsub(".", "0") | ||
| end | ||
|
|
||
| def separator?(line) | ||
| line.start_with?('-') | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| require_relative 'sudoku_unit' | ||
| require 'matrix' | ||
|
|
||
| module Sudoku | ||
| class Puzzle | ||
| def initialize(rows = []) | ||
| raise(ArgumentError, "An array of rows is required to create a puzzle.") if rows.empty? | ||
| @grid = create_grid(rows) | ||
| end | ||
|
|
||
| def rows | ||
| Hash[@grid.row_vectors.map.with_index(1) do |row, index| | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nice use of I notice that building an array of rows and an array of columns is almost identical. This is probably a good candidate for a private method. Something like: def rows
Hash[build_units(@grid.row_vectors, Row)]
end
def columns
Hash[build_units(@grid.column_vectors, Column)]
end
private
def build_units(vectors, unit_class)
vectors.map.with_index(1) do |unit, index|
[index, unit_class[*unit]]
end
endThoughts?
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yeah I thought about this. The only thing that stopped me doing it was how I like the |
||
| [index, Row[*row]] | ||
| end] | ||
| end | ||
|
|
||
| def columns | ||
| Hash[@grid.column_vectors.map.with_index(1) do |column, index| | ||
| [index, Column[*column]] | ||
| end] | ||
| end | ||
|
|
||
| def boxes | ||
| Hash[[0, 3, 6].repeated_permutation(2).map.with_index(1) do |(x, y), index| | ||
| [index, Box[*@grid.minor(x, 3, y, 3).to_a.flatten]] | ||
| end] | ||
| end | ||
|
|
||
| def complete? | ||
| rows.all? { |index, row| row.complete? } | ||
| end | ||
|
|
||
| def valid? | ||
| all_units.all?(&:valid?) | ||
| end | ||
|
|
||
| def eql?(other) | ||
| hash == other.hash | ||
| end | ||
|
|
||
| def hash | ||
| @grid.hash | ||
| end | ||
|
|
||
| alias_method :==, :eql? | ||
|
|
||
| private | ||
|
|
||
| def create_grid(input_rows) | ||
| Matrix[*input_rows] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 Nice use of the |
||
| end | ||
|
|
||
| def all_units | ||
| rows.values + columns.values + boxes.values | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| module Sudoku | ||
| class Unit | ||
| def self.[](*values) | ||
| new(values) | ||
| end | ||
|
|
||
| def initialize(values) | ||
| @squares = values | ||
| end | ||
|
|
||
| def positions | ||
| @squares.each.with_index(1).inject ({}) do |h, (number, position)| | ||
| h.tap { |h| h[number] = h[number] ? [h[number]].flatten << position : position } | ||
| end | ||
| end | ||
|
|
||
| def valid? | ||
| non_empty_squares.uniq.count === non_empty_squares.count | ||
| end | ||
|
|
||
| def complete? | ||
| @squares.none?(&:zero?) | ||
| end | ||
|
|
||
| def eql?(other) | ||
| self.class == other.class && hash == other.hash | ||
| end | ||
|
|
||
| def hash | ||
| @squares.hash | ||
| end | ||
|
|
||
| alias_method :==, :eql? | ||
|
|
||
| private | ||
|
|
||
| def non_empty_squares | ||
| @squares.reject(&:zero?) | ||
| end | ||
| end | ||
|
|
||
| class Row < Unit | ||
| def type | ||
| "Row" | ||
| end | ||
| end | ||
|
|
||
| class Column < Unit | ||
| def type | ||
| "Column" | ||
| end | ||
| end | ||
|
|
||
| class Box < Unit | ||
| def type | ||
| "Box" | ||
| end | ||
| end | ||
| end |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| module Sudoku | ||
| class Validator | ||
| attr_reader :puzzle | ||
|
|
||
| def initialize(puzzle = nil) | ||
| raise(ArgumentError, "A Puzzle is required.") if puzzle.nil? | ||
| @puzzle = puzzle | ||
| end | ||
|
|
||
| def status | ||
| case [puzzle.valid?, puzzle.complete?] | ||
| when [true, true] then "This sudoku is valid." | ||
| when [true, false] then "This sudoku is valid, but incomplete." | ||
|
|
||
| else "This sudoku is invalid." | ||
| end | ||
| end | ||
|
|
||
| def errors | ||
| return [] if puzzle.valid? | ||
|
|
||
| [:rows, :columns, :boxes].map do |message| | ||
| puzzle.send(message).map do |index, unit| | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think you could save yourself a level of nesting here by using
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yep, thanks @JoelQ. I will have to promote |
||
| duplicates(unit).map do |number, positions| | ||
| DuplicateError.new(unit.type, index, number, positions) | ||
| end | ||
| end | ||
| end.flatten | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def duplicates(unit) | ||
| unit.positions.select do |number, position| | ||
| non_empty?(number) && duplicate?(position) | ||
| end | ||
| end | ||
|
|
||
| def non_empty?(number) | ||
| number != 0 | ||
| end | ||
|
|
||
| def duplicate?(position) | ||
| position.is_a?(Array) | ||
| end | ||
| end | ||
|
|
||
| class DuplicateError < Struct.new(:type, :index, :number, :positions) | ||
| def to_s | ||
| "#{type} #{index} contains a duplicate #{number} in squares #{list(positions)}" | ||
| end | ||
|
|
||
| private | ||
|
|
||
| def list(positions) | ||
| "#{positions[0...-1].join(', ')} and #{positions.last}" | ||
| end | ||
| end | ||
| end | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| require 'sudoku_fileparser' | ||
|
|
||
| module Sudoku | ||
| describe FileParser do | ||
| it "throws an ArgumentError if instantiated without a file" do | ||
| expect { FileParser.new }.to raise_error(ArgumentError) | ||
| end | ||
|
|
||
| it "can parse rows out of the file into a simple array format (strings are converted to numbers)" do | ||
| parser = FileParser.new(example_file) | ||
| parser.parse_rows.should eq [ | ||
| [0, 5, 9, 6, 1, 2, 4, 3, 0], | ||
| [7, 0, 3, 8, 5, 4, 1, 0, 9], | ||
| [1, 6, 0, 3, 7, 9, 0, 2, 8], | ||
| [9, 8, 6, 0, 4, 0, 3, 5, 2], | ||
| [3, 7, 5, 2, 0, 8, 9, 1, 4], | ||
| [2, 4, 1, 0, 9, 0, 7, 8, 6], | ||
| [4, 3, 0, 9, 8, 1, 0, 7, 5], | ||
| [6, 0, 7, 4, 2, 5, 8, 0, 3], | ||
| [0, 9, 8, 7, 3, 6, 2, 4, 0] | ||
| ] | ||
| end | ||
|
|
||
| def example_file | ||
| %Q{ | ||
| . 5 9 |6 1 2 |4 3 . | ||
| 7 . 3 |8 5 4 |1 . 9 | ||
| 1 6 . |3 7 9 |. 2 8 | ||
| ------+------+------ | ||
| 9 8 6 |. 4 . |3 5 2 | ||
| 3 7 5 |2 . 8 |9 1 4 | ||
| 2 4 1 |. 9 . |7 8 6 | ||
| ------+------+------ | ||
| 4 3 . |9 8 1 |. 7 5 | ||
| 6 . 7 |4 2 5 |8 . 3 | ||
| . 9 8 |7 3 6 |2 4 . | ||
| } | ||
| end | ||
| end | ||
| end |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What if
Sudoku::FileParser#parse_rowsreturned aSudoku::Puzzleobject rather than an array of arrays? That would eliminate this intermediate state. Thoughts?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, I like that. At the time I was thinking along the lines of, "What if we needed a
Sudoku::JSONParser?" and so thought I needed a common format between the parser and thePuzzle, but you're right – any object that implements the 'Parser' interface/role could always return aPuzzleobject.In terms of testing this with RSpec, would you use
expect_any_instance_of(Sudoku::Puzzle).to receive(:new)?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@danscotton I agree, all of the parsers could return
Puzzleinstances.As far as testing, I would probably do something like
expect(parser.parse).to eq my_puzzle. This will work because you re-definedPuzzle#==to check for value rather than identity.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's true, thanks. If my
Puzzleobject wasn't a value object, would you go down theany_instance_ofroute then? I'm sure I've read something from thoughtbot about that being a bit of a smell but I'm not sure. I'll ask it in the forums.