diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fec32c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +tmp/ diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..b83d9b7 --- /dev/null +++ b/.rspec @@ -0,0 +1,3 @@ +--color +--format documentation +--require spec_helper diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..851fabc --- /dev/null +++ b/Gemfile @@ -0,0 +1,2 @@ +source 'https://rubygems.org' +gemspec diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..c9bd8d1 --- /dev/null +++ b/Gemfile.lock @@ -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! diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..c92204b --- /dev/null +++ b/Rakefile @@ -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 diff --git a/bin/sudoku-validator b/bin/sudoku-validator new file mode 100755 index 0000000..e884ffe --- /dev/null +++ b/bin/sudoku-validator @@ -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 diff --git a/features/sudoku_validator.feature b/features/sudoku_validator.feature new file mode 100644 index 0000000..59496b1 --- /dev/null +++ b/features/sudoku_validator.feature @@ -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" diff --git a/features/support/env.rb b/features/support/env.rb new file mode 100644 index 0000000..e52a59f --- /dev/null +++ b/features/support/env.rb @@ -0,0 +1,5 @@ +require 'aruba/cucumber' + +Before do + @dirs = ["."] +end diff --git a/lib/sudoku_fileparser.rb b/lib/sudoku_fileparser.rb new file mode 100644 index 0000000..9322abc --- /dev/null +++ b/lib/sudoku_fileparser.rb @@ -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) + line.gsub(".", "0") + end + + def separator?(line) + line.start_with?('-') + end + end +end diff --git a/lib/sudoku_puzzle.rb b/lib/sudoku_puzzle.rb new file mode 100644 index 0000000..795f14a --- /dev/null +++ b/lib/sudoku_puzzle.rb @@ -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| + [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] + end + + def all_units + rows.values + columns.values + boxes.values + end + end +end diff --git a/lib/sudoku_unit.rb b/lib/sudoku_unit.rb new file mode 100644 index 0000000..07e5ebf --- /dev/null +++ b/lib/sudoku_unit.rb @@ -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 diff --git a/lib/sudoku_validator.rb b/lib/sudoku_validator.rb new file mode 100644 index 0000000..04327cb --- /dev/null +++ b/lib/sudoku_validator.rb @@ -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| + 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 diff --git a/spec/lib/sudoku_fileparser_spec.rb b/spec/lib/sudoku_fileparser_spec.rb new file mode 100644 index 0000000..7ff6451 --- /dev/null +++ b/spec/lib/sudoku_fileparser_spec.rb @@ -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 diff --git a/spec/lib/sudoku_puzzle_spec.rb b/spec/lib/sudoku_puzzle_spec.rb new file mode 100644 index 0000000..baca4f3 --- /dev/null +++ b/spec/lib/sudoku_puzzle_spec.rb @@ -0,0 +1,98 @@ +require 'sudoku_puzzle' + +module Sudoku + describe Puzzle do + it "throws an ArgumentError if not passed an array of rows" do + expect { Puzzle.new }.to raise_error(ArgumentError) + end + + it "is a value object (two puzzles with the same numbers are equal)" do + p1 = Puzzle.new(input_rows) + p2 = Puzzle.new(input_rows) + + expect(p1).to eq p2 + end + + describe ":rows" do + it "returns a hash of Row objects indexed" do + puzzle = Puzzle.new(input_rows) + + expect(puzzle.rows.count).to eq 9 + expect(puzzle.rows[1]).to eq Row[0, 5, 9, 6, 1, 2, 4, 3, 0] + expect(puzzle.rows[9]).to eq Row[0, 9, 8, 7, 3, 6, 2, 4, 0] + end + end + + describe ":columns" do + it "returns an array of Column objects indexed" do + puzzle = Puzzle.new(input_rows) + + expect(puzzle.columns.count).to eq 9 + expect(puzzle.columns[2]).to eq Column[5, 0, 6, 8, 7, 4, 3, 0, 9] + expect(puzzle.columns[8]).to eq Column[3, 0, 2, 5, 1, 8, 7, 0, 4] + end + end + + describe ":boxes" do + it "returns an array of Box objects indexed" do + puzzle = Puzzle.new(input_rows) + + expect(puzzle.boxes.count).to eq 9 + expect(puzzle.boxes[3]).to eq Box[4, 3, 0, 1, 0, 9, 0, 2, 8] + expect(puzzle.boxes[7]).to eq Box[4, 3, 0, 6, 0, 7, 0, 9, 8] + end + end + + describe "completeness" do + specify "a puzzle is complete if all of its rows are complete" do + puzzle = Puzzle.new([[1, 2, 3], [4, 9, 8], [5, 7, 6]]) + expect(puzzle).to be_complete + end + + specify "a puzzle is incomplete if any of its rows are incomplete" do + puzzle = Puzzle.new(input_rows) + expect(puzzle).not_to be_complete + end + end + + describe "validity" do + specify "a puzzle is invalid if any of its rows, columns or boxes are invalid" do + puzzle = Puzzle.new(input_rows) + expect(puzzle).not_to be_valid + end + + specify "a puzzle is valid if all of its rows, columns and boxes are valid" do + puzzle = Puzzle.new(valid_incomplete) + expect(puzzle).to be_valid + end + end + + def input_rows + [ + [0, 5, 9, 6, 1, 2, 4, 3, 0], + [7, 0, 3, 8, 5, 1, 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 valid_incomplete + [ + [8, 5, 0, 0, 0, 2, 4, 0, 0], + [7, 2, 0, 0, 0, 0, 0, 0, 9], + [0, 0, 4, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 1, 0, 7, 0, 0, 2], + [3, 0, 5, 0, 0, 0, 9, 0, 0], + [0, 4, 0, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 8, 0, 0, 7, 0], + [0, 1, 7, 0, 0, 0, 0, 0, 0], + [0, 0, 0, 0, 3, 6, 0, 4, 0] + ] + end + end +end diff --git a/spec/lib/sudoku_sudoku_validator_spec.rb b/spec/lib/sudoku_sudoku_validator_spec.rb new file mode 100644 index 0000000..034aa6b --- /dev/null +++ b/spec/lib/sudoku_sudoku_validator_spec.rb @@ -0,0 +1,68 @@ +require 'sudoku_validator' +require 'sudoku_unit' + +module Sudoku + describe Validator do + it "throws an ArgumentError if a puzzle is not passed in" do + expect { Validator.new }.to raise_error(ArgumentError) + end + + describe "status" do + it "returns 'This sudoku is valid.' if the puzzle is valid and complete" do + puzzle = double(:valid? => true, :complete? => true) + expect(Validator.new(puzzle).status).to eq "This sudoku is valid." + end + + it "returns 'This sudoku is valid, but incomplete.' if the puzzle is valid, but incomplete" do + puzzle = double(:valid? => true, :complete? => false) + expect(Validator.new(puzzle).status).to eq "This sudoku is valid, but incomplete." + end + + it "returns 'This sudoku is invalid.' if the puzzle is invalid, but complete." do + puzzle = double(:valid? => false, :complete? => true) + expect(Validator.new(puzzle).status).to eq "This sudoku is invalid." + end + + it "returns 'This sudoku is invalid.' if the puzzle is invalid and incomplete." do + puzzle = double(:valid? => false, :complete? => false) + expect(Validator.new(puzzle).status).to eq "This sudoku is invalid." + end + end + + describe "errors" do + context "a valid puzzle" do + specify "contains no errors" do + puzzle = double(:valid? => true, :complete? => false) + expect(Validator.new(puzzle).errors).to be_empty + end + end + + context "an invalid puzzle" do + let(:puzzle) { double(:valid? => false, :complete? => false) } + let(:errors) { Validator.new(puzzle).errors.map(&:to_s) } + + before(:each) do + [:rows, :columns, :boxes].each do |message| + allow(puzzle).to receive(message).and_return({}) + end + end + + it "returns an error for a unit that contains a duplicate number" do + puzzle.stub(:rows).and_return({ 1 => Row[1, 8, 2, 3, 4, 5, 6, 7, 8] }) + expect(errors).to include "Row 1 contains a duplicate 8 in squares 2 and 9" + end + + it "ignores duplicate 0s as they are empty squares" do + puzzle.stub(:columns).and_return({ 4 => Column[0, 1, 0, 2, 0, 3, 0, 4, 0] }) + expect(errors).to be_empty + end + + it "returns errors for a unit that contains multiple duplicate numbers" do + puzzle.stub(:boxes).and_return({ 7 => Box[2, 3, 1, 6, 2, 7, 3, 3, 0] }) + expect(errors).to include "Box 7 contains a duplicate 2 in squares 1 and 5" + expect(errors).to include "Box 7 contains a duplicate 3 in squares 2, 7 and 8" + end + end + end + end +end diff --git a/spec/lib/sudoku_unit_spec.rb b/spec/lib/sudoku_unit_spec.rb new file mode 100644 index 0000000..f530d27 --- /dev/null +++ b/spec/lib/sudoku_unit_spec.rb @@ -0,0 +1,66 @@ +require 'sudoku_unit' + +shared_examples "a Unit" do |type| + context "completeness" do + specify "a unit is incomplete if it contains empty squares" do + expect(described_class[0, 5, 9, 6, 1, 2, 4, 3, 8]).not_to be_complete + end + + specify "a unit is complete if all squares are filled in" do + expect(described_class[7, 5, 9, 6, 1, 2, 4, 3, 8]).to be_complete + end + end + + context "validity" do + specify "a unit is invalid if it contains duplicates (excludes empty squares)" do + expect(described_class[0, 0, 9, 6, 1, 0, 9, 3, 8]).not_to be_valid + end + + specify "a unit is valid if it contains no duplicates" do + expect(described_class[0, 0, 2, 6, 1, 0, 9, 3, 8]).to be_valid + end + end + + describe "listing square values against their positions in the unit" do + specify ":positions can list empty squares" do + output = { + 0 => 8, 1 => 5, 2 => 6, + 4 => 7, 5 => 2, 6 => 4, + 7 => 1, 8 => 9, 9 => 3 + } + expect(described_class[7, 5, 9, 6, 1, 2, 4, 0, 8].positions).to eq output + end + + specify ":positions can list duplicate squares as an array" do + output = { + 0 => [6, 8], 1 => 5, 4 => 7, 5 => 2, + 7 => [1, 4], 8 => 9, 9 => 3 + } + expect(described_class[7, 5, 9, 7, 1, 0, 4, 0, 8].positions).to eq output + end + end + + context "immutability (value objects)" do + specify "two units of the same class with the same values are equal" do + expect(described_class[7, 5, 9, 6, 1, 2, 4, 3, 8]).to eq described_class[7, 5, 9, 6, 1, 2, 4, 3, 8] + end + end + + it "has the right type" do + expect(described_class[1, 2, 3, 4, 5, 6, 7, 8, 9].type).to eq type + end +end + +module Sudoku + describe Row do + it_behaves_like "a Unit", "Row" + end + + describe Column do + it_behaves_like "a Unit", "Column" + end + + describe Box do + it_behaves_like "a Unit", "Box" + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..dbc4f1a --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,17 @@ +# This file was generated by the `rspec --init` command. Conventionally, all +# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`. +# Require this file using `require "spec_helper"` to ensure that it is only +# loaded once. +# +# See http://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration +RSpec.configure do |config| + config.treat_symbols_as_metadata_keys_with_true_values = true + config.run_all_when_everything_filtered = true + config.filter_run :focus + + # Run specs in random order to surface order dependencies. If you find an + # order dependency and want to debug it, you can fix the order by providing + # the seed, which is printed after each run. + # --seed 1234 + config.order = 'random' +end diff --git a/sudoku_validator.gemspec b/sudoku_validator.gemspec new file mode 100644 index 0000000..a954cb4 --- /dev/null +++ b/sudoku_validator.gemspec @@ -0,0 +1,14 @@ +Gem::Specification.new do |s| + s.name = 'sudoku_validator' + s.version = '0.0.1' + s.summary = 'Sudoku Validator exercise from Thoughtbot' + s.author = 'Dan Scotton' + s.email = 'danscotton@gmail.com' + + s.add_development_dependency 'aruba' + s.add_development_dependency 'rake' + s.add_development_dependency 'rspec' + + s.executables << 'sudoku-validator' + s.require_path = "lib" +end