diff --git a/.bundle/config b/.bundle/config new file mode 100644 index 0000000..df11c75 --- /dev/null +++ b/.bundle/config @@ -0,0 +1,2 @@ +--- +BUNDLE_DISABLE_SHARED_GEMS: '1' diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..d3d3f86 --- /dev/null +++ b/Gemfile @@ -0,0 +1,4 @@ +source 'https://rubygems.org' +gem 'rspec' +gem 'aruba' +gem 'rake' diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..cb05bed --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,39 @@ +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 (0.9.6) + 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 diff --git a/Rakefile b/Rakefile new file mode 100644 index 0000000..9d3d473 --- /dev/null +++ b/Rakefile @@ -0,0 +1,8 @@ +require 'cucumber' +require 'cucumber/rake/task' +require 'Open3' + +Cucumber::Rake::Task.new(:default) do |t| + t.cucumber_opts = "features --format pretty -x" + t.fork = false +end diff --git a/features/parsing_a_sudoku.feature b/features/parsing_a_sudoku.feature new file mode 100644 index 0000000..6187c0f --- /dev/null +++ b/features/parsing_a_sudoku.feature @@ -0,0 +1,21 @@ +Feature: parsing a sudoku + + Scenario: valid & complete + When I run `./sudoku-validator valid_complete.sudoku` + Then the stdout should contain "This sudoku is valid." + + Scenario: valid & incomplete + When I run `./sudoku-validator valid_incomplete.sudoku` + Then the stdout should contain "This sudoku is valid, but incomplete." + + Scenario: invalid & complete + When I run `./sudoku-validator invalid_complete.sudoku` + Then the stdout should contain "This sudoku is invalid." + And the stdout should contain "#4 contains duplicate 8s" + And the stdout should contain "#6 contains duplicate 2s" + + Scenario: invalid & incomplete + When I run `./sudoku-validator invalid_incomplete.sudoku` + Then the stdout should contain "This sudoku is invalid." + And the stdout should contain "#2 contains duplicate 5s" + And the stdout should contain "#5 contains duplicate 8s" 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/assesses_sudoku_completeness.rb b/lib/assesses_sudoku_completeness.rb new file mode 100644 index 0000000..eee1c54 --- /dev/null +++ b/lib/assesses_sudoku_completeness.rb @@ -0,0 +1,5 @@ +class AssessesSudokuCompleteness < Struct.new(:sudoku) + def is_complete? + sudoku.rows.all?(&:all?) + end +end diff --git a/lib/assesses_sudoku_validity.rb b/lib/assesses_sudoku_validity.rb new file mode 100644 index 0000000..cc80cde --- /dev/null +++ b/lib/assesses_sudoku_validity.rb @@ -0,0 +1,29 @@ +class AssessesSudokuValidity < Struct.new(:sudoku) + def is_valid? + [sudoku.rows, sudoku.columns, sudoku.blocks].all? { |superset| + superset.all? { |set| set_is_valid?(set) } + } + end + + def errors + [:rows, :columns, :blocks].each_with_object({}) { |set_name, errors_hash| + errors_hash[set_name] = errors_for_superset(set_name) + } + end + + private + def set_is_valid?(set) + set.compact.uniq.length == set.compact.length + end + + def errors_for_superset(set_name) + sudoku.send(set_name).each_with_index.each_with_object({}) { |(set, index), errors| + duplicates = duplicates_for_set(set) + errors[index + 1] = duplicates unless duplicates.empty? + } + end + + def duplicates_for_set(set) + set.group_by{ |number| number }.select{ |number, count| count.size > 1 }.keys.compact + end +end diff --git a/lib/sudoku.rb b/lib/sudoku.rb new file mode 100644 index 0000000..82161a3 --- /dev/null +++ b/lib/sudoku.rb @@ -0,0 +1,32 @@ +require 'matrix' + +class Sudoku + def initialize(file) + @matrix = Matrix.rows(parse_rows_from_file(file)) + end + + def rows + @matrix.row_vectors.map(&:to_a) + end + + def columns + @matrix.column_vectors.map(&:to_a) + end + + def blocks + [0, 3, 6].repeated_permutation(2).map { |x, y| + @matrix.minor(x, 3, y, 3).to_a.flatten + } + end + + private + def parse_rows_from_file(file) + file.each_line.reject{ |line| line.include?('-') }.map { |line| + numbers_row_from_line(line) + } + end + + def numbers_row_from_line(line) + line.split(/ \||\s/).map { |value| value.to_i unless value == '.' } + end +end diff --git a/spec/lib/assesses_sudoku_completeness_spec.rb b/spec/lib/assesses_sudoku_completeness_spec.rb new file mode 100644 index 0000000..8f160c4 --- /dev/null +++ b/spec/lib/assesses_sudoku_completeness_spec.rb @@ -0,0 +1,36 @@ +require_relative '../../lib/assesses_sudoku_completeness' + +describe AssessesSudokuCompleteness do + let(:sudoku) { double('sudoku') } + subject { AssessesSudokuCompleteness.new(sudoku) } + + it "returns true for complete sudokus" do + expect(sudoku).to receive(:rows).and_return([ + [1,2,3,4,5,6,7,8,9], + [2,3,4,5,6,7,8,9,1], + [3,4,5,6,7,8,9,1,2], + [4,5,6,7,8,9,1,2,3], + [5,6,7,8,9,1,2,3,4], + [6,7,8,9,1,2,3,4,5], + [7,8,9,1,2,3,4,5,6], + [8,9,1,2,3,4,5,6,7], + [9,1,2,3,4,5,6,7,8] + ]) + expect(subject.is_complete?).to be_true + end + + it "returns false for incomplete sudokus" do + expect(sudoku).to receive(:rows).and_return([ + [nil,2,3,4,5,6,7,8,9], + [2,3,4,5,6,7,8,9,1], + [3,4,5,6,7,8,9,1,2], + [4,5,6,7,8,9,1,2,3], + [5,6,7,8,9,1,2,3,4], + [6,7,8,9,1,2,3,4,5], + [7,8,9,1,2,3,4,5,6], + [8,9,1,2,3,4,5,6,7], + [9,1,2,3,4,5,6,7,8] + ]) + expect(subject.is_complete?).to be_false + end +end diff --git a/spec/lib/assesses_sudoku_validity_spec.rb b/spec/lib/assesses_sudoku_validity_spec.rb new file mode 100644 index 0000000..207ba41 --- /dev/null +++ b/spec/lib/assesses_sudoku_validity_spec.rb @@ -0,0 +1,166 @@ +require_relative '../../lib/assesses_sudoku_validity' + +describe AssessesSudokuValidity do + let(:sudoku) { double('sudoku') } + subject { AssessesSudokuValidity.new(sudoku) } + + describe "#is_valid?" do + it "returns true for valid sudokus" do + expect(sudoku).to receive(:rows).and_return([ + [8,5,9,6,1,2,4,3,7], + [7,2,3,8,5,4,1,6,9], + [1,6,4,3,7,9,5,2,8], + [9,8,6,1,4,7,3,5,2], + [3,7,5,2,6,8,9,1,4], + [2,4,1,5,9,3,7,8,6], + [4,3,2,9,8,1,6,7,5], + [6,1,7,4,2,5,8,9,3], + [5,9,8,7,3,6,2,4,1] + ]) + expect(sudoku).to receive(:columns).and_return([ + [8,7,1,9,3,2,4,6,5], + [5,2,6,8,7,4,3,1,9], + [9,3,4,6,5,1,2,7,8], + [6,8,3,1,2,5,9,4,7], + [1,5,7,4,6,9,8,2,3], + [2,4,9,7,8,3,1,5,6], + [4,1,5,3,9,7,6,8,2], + [3,6,2,5,1,8,7,9,4], + [7,9,8,2,4,6,5,3,1] + ]) + expect(sudoku).to receive(:blocks).and_return([ + [8,5,9,7,2,3,1,6,4], + [6,1,2,8,5,4,3,7,9], + [4,3,7,1,6,9,5,2,8], + [9,8,6,3,7,5,2,4,1], + [1,4,7,2,6,8,5,9,3], + [3,5,2,9,1,4,7,8,6], + [4,3,2,6,1,7,5,9,8], + [9,8,1,4,2,5,7,3,6], + [6,7,5,8,9,3,2,4,1] + ]) + expect(subject.is_valid?).to be_true + end + + it "returns false for invalid rows" do + expect(sudoku).to receive(:rows).and_return([ + [1,5,9,6,1,2,4,3,7], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil] + ]) + allow(sudoku).to receive(:columns) + allow(sudoku).to receive(:blocks) + expect(subject.is_valid?).to be_false + end + + it "returns false for invalid columns" do + expect(sudoku).to receive(:rows).and_return([ + [8,5,9,6,1,2,4,3,7], + [8,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil] + ]) + expect(sudoku).to receive(:columns).and_return([ + [8,8,nil,nil,nil,nil,nil,nil,nil], + [5,nil,nil,nil,nil,nil,nil,nil,nil], + [9,nil,nil,nil,nil,nil,nil,nil,nil], + [6,nil,nil,nil,nil,nil,nil,nil,nil], + [1,nil,nil,nil,nil,nil,nil,nil,nil], + [2,nil,nil,nil,nil,nil,nil,nil,nil], + [3,nil,nil,nil,nil,nil,nil,nil,nil], + [7,nil,nil,nil,nil,nil,nil,nil,nil] + ]) + allow(sudoku).to receive(:blocks) + expect(subject.is_valid?).to be_false + end + + it "returns false for invalid blocks" do + expect(sudoku).to receive(:rows).and_return([ + [8,5,9,nil,nil,nil,nil,nil,nil], + [6,8,2,nil,nil,nil,nil,nil,nil], + [4,3,7,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil] + ]) + expect(sudoku).to receive(:columns).and_return([ + [8,6,4,nil,nil,nil,nil,nil,nil], + [5,8,3,nil,nil,nil,nil,nil,nil], + [9,2,7,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil] + ]) + expect(sudoku).to receive(:blocks).and_return([ + [8,5,9,6,8,2,4,3,7], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil] + ]) + expect(subject.is_valid?).to be_false + end + end + + describe "#errors" do + it "returns a list of errors and their locations" do + expect(sudoku).to receive(:rows).and_return([ + [8,5,9,nil,nil,nil,nil,nil,nil], + [6,8,2,nil,nil,nil,nil,nil,nil], + [4,7,7,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil] + ]) + expect(sudoku).to receive(:columns).and_return([ + [8,6,4,nil,nil,nil,nil,nil,nil], + [5,8,7,nil,nil,nil,nil,nil,nil], + [9,2,7,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil] + ]) + expect(sudoku).to receive(:blocks).and_return([ + [8,5,9,6,8,2,4,7,7], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,nil,nil,nil,nil,nil] + ]) + expect(subject.errors).to eq({ + :rows => { 3 => [7] }, + :columns => {}, + :blocks => { 1 => [8, 7] } + }) + end + end +end diff --git a/spec/lib/sudoku_spec.rb b/spec/lib/sudoku_spec.rb new file mode 100644 index 0000000..998eb36 --- /dev/null +++ b/spec/lib/sudoku_spec.rb @@ -0,0 +1,71 @@ +require_relative '../../lib/sudoku' + +class AssessSudokuCompleteness; end + +describe Sudoku do + describe "#rows" do + it "returns the rows" do + sudoku = Sudoku.new(File.open('valid_complete.sudoku')) + expect(sudoku.rows).to eq([ + [8,5,9,6,1,2,4,3,7], + [7,2,3,8,5,4,1,6,9], + [1,6,4,3,7,9,5,2,8], + [9,8,6,1,4,7,3,5,2], + [3,7,5,2,6,8,9,1,4], + [2,4,1,5,9,3,7,8,6], + [4,3,2,9,8,1,6,7,5], + [6,1,7,4,2,5,8,9,3], + [5,9,8,7,3,6,2,4,1] + ]) + end + + it "replaces uses nil for blank values" do + sudoku = Sudoku.new(File.open('valid_incomplete.sudoku')) + expect(sudoku.rows).to eq([ + [8,5,nil,nil,nil,2,4,nil,nil], + [7,2,nil,nil,nil,nil,nil,nil,9], + [nil,nil,4,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,1,nil,7,nil,nil,2], + [3,nil,5,nil,nil,nil,9,nil,nil], + [nil,4,nil,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,8,nil,nil,7,nil], + [nil,1,7,nil,nil,nil,nil,nil,nil], + [nil,nil,nil,nil,3,6,nil,4,nil] + ]) + end + end + + describe "#columns" do + it "returns the columns" do + sudoku = Sudoku.new(File.open('valid_complete.sudoku')) + expect(sudoku.columns).to eq([ + [8,7,1,9,3,2,4,6,5], + [5,2,6,8,7,4,3,1,9], + [9,3,4,6,5,1,2,7,8], + [6,8,3,1,2,5,9,4,7], + [1,5,7,4,6,9,8,2,3], + [2,4,9,7,8,3,1,5,6], + [4,1,5,3,9,7,6,8,2], + [3,6,2,5,1,8,7,9,4], + [7,9,8,2,4,6,5,3,1] + ]) + end + end + + describe "#blocks" do + it "returns the blocks" do + sudoku = Sudoku.new(File.open('valid_complete.sudoku')) + expect(sudoku.blocks).to eq([ + [8,5,9,7,2,3,1,6,4], + [6,1,2,8,5,4,3,7,9], + [4,3,7,1,6,9,5,2,8], + [9,8,6,3,7,5,2,4,1], + [1,4,7,2,6,8,5,9,3], + [3,5,2,9,1,4,7,8,6], + [4,3,2,6,1,7,5,9,8], + [9,8,1,4,2,5,7,3,6], + [6,7,5,8,9,3,2,4,1] + ]) + end + end +end diff --git a/sudoku-validator b/sudoku-validator new file mode 100755 index 0000000..1ad36de --- /dev/null +++ b/sudoku-validator @@ -0,0 +1,27 @@ +#!/usr/bin/env ruby + +Dir.glob(Dir.pwd + '/lib/*', &method(:require)) + +sudoku = Sudoku.new(File.open(ARGV[0])) + +validator = AssessesSudokuValidity.new(sudoku) + +if validator.is_valid? + if AssessesSudokuCompleteness.new(sudoku).is_complete? + puts "This sudoku is valid." + else + puts "This sudoku is valid, but incomplete." + end +else + puts "This sudoku is invalid." + + puts "Errors :-" + validator.errors.each do |set, errors| + puts "\n#{set.upcase}" unless errors.empty? + errors.each do |index, duplicates| + duplicates.each do |duplicate| + puts "##{index} contains duplicate #{duplicate}s" + end + end + end +end