diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d646835 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +*.pyc +__pycache__/ diff --git a/puzzles.py b/puzzles.py old mode 100755 new mode 100644 index 1d7dc00..62f5199 --- a/puzzles.py +++ b/puzzles.py @@ -1,67 +1,58 @@ -#!env python3 +#!/usr/bin/env python3 # Eryn Wells + ''' Parser for puzzles in the ./puzzles directory. ''' -import argparse -import os.path -import sys +import sudoku -from sudoku import Sudoku, solvers euler = [] norvig = [] -def parse_puzzle_library(path, quiet=True): - if not quiet: - print('Parsing puzzles in {}'.format(path)) - puzzles = _get_puzzles(path, quiet) - return puzzles -def _get_puzzles(filename, quiet): +def parse_euler(filename='./puzzles/euler.txt'): with open(filename, 'r') as f: - puzzles = f.readlines() - return (_parse_puzzle(p, quiet) for p in puzzles if p) + puzzle_lines = f.readlines() -def _parse_puzzle(puzzle, quiet): - puzzle = puzzle.strip() - if len(puzzle) == 81: - if not quiet: - print("Parsing '{}'".format(puzzle)) - board = (int('0' if x == '.' else x) for x in puzzle) - return Sudoku(board=board) - else: - if not quiet: - print("Skipping '{}'".format(puzzle)) - return None + for puzzle in puzzle_lines: + # Chop the newline + if puzzle[-1] == '\n': + puzzle = puzzle[:-1] + print('Parsing puzzle: {}'.format(puzzle)) + if len(puzzle) != 81: + continue + kwargs = {} + for idx in range(len(puzzle)): + sq = puzzle[idx] + if sq not in '1234567890.': + continue + sq_int = 0 if sq == '.' else int(sq) + x, y = int(idx % 9), int(idx / 9) + if sq_int != 0: + kwargs[sudoku.Board.xy_kwargs_key(x, y)] = sq_int + euler.append(sudoku.Board(**kwargs)) -def parse_args(args): - parser = argparse.ArgumentParser() - parser.add_argument('--solver', '-s', default=None, - help='The solver to use to solve this puzzle.') - parser.add_argument('--verbose', '-v', action='store_true', default=False, - help='Print extra information when parsing puzzle libraries.') - parser.add_argument('library', - help='A library file containing puzzles, one per line.') - parser.add_argument('indexes', metavar='N', nargs='+', type=int, - help='0-based indexes of puzzles in the library') - return parser.parse_args(args) -def main(): - args = parse_args(sys.argv[1:]) - puzzle_library = list(parse_puzzle_library(args.library, quiet=not args.verbose)) - for i in args.indexes: - puzzle = puzzle_library[i] - print(puzzle) - if args.solver is not None: - try: - solver = getattr(solvers, args.solver) - puzzle.solve(solver.solve) - except AttributeError: - print('No solver named {}'.format(args.solver)) - print(puzzle) - return 0 +def parse_norvig(filename='./puzzles/norvig.txt'): + with open(filename, 'r') as f: + puzzle_lines = f.readlines() -if __name__ == '__main__': - sys.exit(main()) + for puzzle in puzzle_lines: + # Chop the newline + if puzzle[-1] == '\n': + puzzle = puzzle[:-1] + print('Parsing puzzle: {}'.format(puzzle)) + if len(puzzle) != 81: + continue + kwargs = {} + for idx in range(len(puzzle)): + sq = puzzle[idx] + if sq not in '1234567890.': + continue + sq_int = 0 if sq == '.' else int(sq) + x, y = int(idx % 9), int(idx / 9) + if sq_int != 0: + kwargs[sudoku.Board.xy_kwargs_key(x, y)] = sq_int + norvig.append(sudoku.Board(**kwargs)) diff --git a/puzzles/norvig.txt b/puzzles/norvig.txt index a68d542..c8429cc 100644 --- a/puzzles/norvig.txt +++ b/puzzles/norvig.txt @@ -93,3 +93,4 @@ .5..9....1.....6.....3.8.....8.4...9514.......3....2..........4.8...6..77..15..6. .....2.......7...17..3...9.8..7......2.89.6...13..6....9..5.824.....891.......... 3...8.......7....51..............36...2..4....7...........6.13..452...........8.. + \ No newline at end of file diff --git a/sudoku.py b/sudoku.py new file mode 100644 index 0000000..964e6be --- /dev/null +++ b/sudoku.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +# Eryn Wells + +''' +A Sudoku solver. +''' + +import copy +import logging +import math + +LOG = logging.getLogger(__name__) + + +class Board(dict): + def __init__(self, size=9, **kwargs): + self.size = size + + # Verify size is a perfect square. The sqrt(size) is also the dimension of each box on the + # Sudoku grid. + self.box_size = int(math.sqrt(size)) + assert self.box_size == int(self.box_size), 'Invalid size; value must be a perfect square' + + # The range of possible values for a square. + self.possible_values = range(1, self.size + 1) + + # Make the grid. + super(Board, self).__init__([(Board.xy_key(x, y), list(self.possible_values)) + for x in range(self.size) + for y in range(self.size)]) + for square in self: + initial_value = kwargs.get(Board.xy_kwargs_key(*square)) + if initial_value is None: + continue + if initial_value not in self.possible_values: + raise ValueError('Invalid initial value for square ({}, {}): {}'.format(x, y, initial_value)) + LOG.debug('Setting initial value of {} to {}'.format(square, initial_value)) + self.assign(square, [initial_value]) + + @classmethod + def xy_key(self, x, y): + '''Given {x} and {y}, generate a key to refer to the square at coordinate (x, y) in the grid.''' + return (int(x), int(y)) + + @classmethod + def xy_kwargs_key(self, x, y): + ''' + Given {x} and {y}, generate a key to refer to the square in the __init__ kwargs at coordinate (x, y) in the + grid. + ''' + return 'x{}y{}'.format(x, y) + + @property + def solved(self): + ''' + Determines if the board has been solved. First, determine if all squares have no more than one value (i.e. they + have had values assigned to them). If not, return False. If so, check each unit (rows, columns, and boxes) to + make sure that each has one and only one of each value in the range of possible values for a unit. If not, + return False; otherwise, return True. + ''' + if not all(len(s) == 1 for s in self.values()): + return False + + def validate_unit(unit): + ''' + Validate {unit} by ensuring it has exactly 1 of each value in the range of possible values. + ''' + necessary_values = list(self.possible_values) + for square, values in unit.items(): + v = values[0] + if v in necessary_values: + necessary_values.remove(v) + else: + return False + if len(necessary_values) != 0: + return False + return True + + return ( all(validate_unit(self.row(r)) for r in range(self.size)) + and all(validate_unit(self.col(c)) for c in range(self.size)) + and all(validate_unit(self.box(x, y)) + for x in range(0, self.size, self.box_size) + for y in range(0, self.size, self.box_size))) + + def row(self, idx): + ''' + Return a dict of all squares in the {idx}'th row. + ''' + assert idx >= 0 and idx < self.size, 'Invalid row index; value must be >= 0 and < {}'.format(self.size) + # key[1] is Y coordinate in the grid. + return {k: v for k, v in self.items() if k[1] == idx} + + def col(self, idx): + ''' + Return a dict of all squares in the {idx}'th column. + ''' + assert idx >= 0 and idx < self.size, 'Invalid column index; value must be >= 0 and < {}'.format(self.size) + # key[0] is X coordinate in the grid. + return {k: v for k, v in self.items() if k[0] == idx} + + def box(self, x, y): + ''' + Given a square at coordinates ({x}, {y}), return a dictionary containing the squares that make up the box that + contains the point. + ''' + bx = int(x / self.box_size) * self.box_size + by = int(y / self.box_size) * self.box_size + bx_range = range(bx, bx + self.box_size) + by_range = range(by, by + self.box_size) + return {k: v for k, v in self.items() if k[0] in bx_range and k[1] in by_range} + + def peers(self, x, y): + ''' + Generate and return a list of peers for the square at coordinates ({x}, {y}). Peers are defined as all the + squares in the same row, column, and box as the given square. + ''' + # Generate a dictionary of all the squares in the row, column, and box containing the given square. + peers = dict(list(self.row(y).items()) + list(self.col(x).items()) + list(self.box(x, y).items())) + # Remove the given square. + del peers[Board.xy_key(x, y)] + return peers + + def clear(self, squares=None): + ''' + Clear the board. Resets each square's possible value list to the full range of possible values for this board. + ''' + for square in (self if squares is None else squares): + self.assign(square, list(self.possible_values)) + + def solve(self): + return self.search() + + def search(self): + ''' + Search for a solution, depth-first. Return the solved Board as a new instance of this class. + ''' + if self.solved: + return self + + # Chose the square with the fewest possible values. + _, smallest = min((len(self[sq]), sq) for sq in self if len(self[sq]) > 1) + # Deepcopy the board. + trials = [] + for v in self[smallest]: + trial_board = copy.deepcopy(self) + if trial_board.assign(smallest, [v]): + trials.append(trial_board) + trial_board.search() + else: + trials.append(None) + for t in trials: + if t is not None: + return t + return False + + + def assign(self, square, value): + ''' + Assign {value} to {square}. {value} is expected to be an iterable (a list). {key} should be a valid (x, y) + coordinate pair for referencing a square on the board. + ''' + LOG.debug('Assigning values {} to square {}.'.format(value, square)) + removed_values = set(self[square]) - set(value) + for v in removed_values: + if not self.eliminate(square, v): + return False + return True + + def eliminate(self, square, value): + ''' + Eliminate {value} from {square}, propagating changes to the squares peers as necessary. This process involves + two logical rules of Sudoku. First, if a square is reduced to a single possible value, assign that value to the + square and remove the value from the possible value lists of its peers. Second, if a unit has only one square + that can hold a value, put the value in that square and propagate that change to its peers. + ''' + if value not in self[square]: + LOG.debug('Value {} not in square {}; skipping'.format(value, square)) + return True + + # Update peer value list. + super(Board, self).__setitem__(square, [v for v in self[square] if v != value]) + LOG.debug('Eliminating {} from square {}'.format(value, square)) + + # (1) If a square is reduced to one value, eliminate that value from its peers. + if len(self[square]) == 0: + # Whoops. Removed the last value... We have a contradiction now. + LOG.error('Removed last value from square {}'.format(square)) + return False + elif len(self[square]) == 1: + # One value left in this square. Propagate changes to its peers. + LOG.debug('One value left in square {}; eliminating {} from its peers'.format(square, self[square][0])) + for peer in self.peers(*square): + if not self.eliminate(peer, self[square][0]): + return False + + # (2) If a unit has only one square for a value, put it there. + for unit in (self.row(square[1]), self.col(square[0]), self.box(*square)): + places = [sq for sq in unit if value in unit[sq]] + if len(places) == 0: + LOG.error('No place for value {} to go in unit {}'.format(value, unit)) + return False + elif len(places) == 1: + LOG.debug('One place for value {} to be in unit {}; setting'.format(value, unit)) + self.assign(places[0], [value]) + + return True + + def __delitem__(self, key): + # Don't allow deleting keys from self. + pass + + def __str__(self): + lines = [] + box_lines = [] + square_size = len(str(self.size)) + for y in range(self.size): + row_squares = [] + box_squares = [] + for x in range(self.size): + square = self.get(Board.xy_key(x, y)) + if len(square) == 1: + box_squares.append(str(square[0]).center(square_size)) + else: + box_squares.append('.'.center(square_size)) + if len(box_squares) == self.box_size: + row_squares.append(' '.join(box_squares)) + box_squares = [] + # Print a divider between boxes. + box_lines.append(' | '.join(row_squares)) + if len(box_lines) == self.box_size: + lines.append('\n'.join(box_lines)) + box_lines = [] + if y < self.size - 1: + # Don't print a divider on the last line. + box_dividers = ['-' * ((square_size + 1) * self.box_size - 1) for box in range(self.box_size)] + lines.append('\n{}\n'.format('-+-'.join(box_dividers))) + return ''.join(lines) diff --git a/sudoku/__init__.py b/sudoku/__init__.py deleted file mode 100644 index 3cb0572..0000000 --- a/sudoku/__init__.py +++ /dev/null @@ -1,230 +0,0 @@ -#!env python3 -# Eryn Wells -''' -A Sudoku puzzle solver. -''' - -import itertools -import math - -BOLD_SEQUENCE = '\x1B[1m' -UNBOLD_SEQUENCE = '\x1B[0m' - -class Sudoku: - def __init__(self, size=3, board=None): - self._size = size - sz4 = size ** 4 - if board: - self._board = bytearray(board)[:sz4] - self._clues = frozenset(i for i in range(len(self._board)) if self._board[i] != 0) - else: - self._board = bytearray(sz4) - self._clues = frozenset() - self._possible_values = None - - @property - def size(self): - ''' - The size of the board. This dictates the length of one side of the boxes that the board is subdivided into. - ''' - return self._size - - @property - def row_size(self): - ''' - The length of a row or column, or the area of a box in the grid. - ''' - return self.size ** 2 - - @property - def grid_size(self): - ''' - The total number of squares in the grid. - ''' - return self.size ** 4 - - @property - def all_squares(self): - ''' - Iterator of xy-coordinates for every square in the grid. - ''' - return itertools.product(range(self.row_size), repeat=2) - - @property - def all_boxes(self): - ''' - Iterator of xy-coordinates for every box in the grid. - ''' - return itertools.product(range(self.size), repeat=2) - - @property - def possible_values(self): - ''' - The set of valid values for any grid square. This method does not account for values made invalid by already - being present in a peer of a given square. - ''' - if not self._possible_values: - self._possible_values = set(range(1, self.row_size + 1)) - return self._possible_values - - def possible_values_for_square(self, x, y): - value = self.get(x, y) - if value: - return {value} - else: - peers = self.peers(x, y) - return self.possible_values - peers - - @property - def rows(self): - return self._apply_index_ranges(self.index_rows) - - @property - def columns(self): - return self._apply_index_ranges(self.index_columns) - - @property - def boxes(self): - return self._apply_index_ranges(self.index_boxes) - - def peers(self, x, y): - ''' - Return a set of values of the peers for a given square. - ''' - return {self._board[i] for i in self.index_peers(x, y) if self._board[i] != 0} - - @property - def index_rows(self): - ''' - Return an iterable of ranges of indexes into the board, each defining a row. - ''' - return (self._row(i) for i in range(self.row_size)) - - @property - def index_columns(self): - ''' - Return an iterable of ranges of indexes into the board, each defining a column. - ''' - return (self._column(i) for i in range(self.row_size)) - - @property - def index_boxes(self): - ''' - Return an iterable of ranges of indexes into the board, each defining a box. - ''' - return (self._box(x, y) for (x,y) in self.all_boxes) - - def index_peers(self, x, y): - ''' - Return a set of the peers, indexes into the board, for a given square. - ''' - idx = self._xy_to_idx(x, y) - box = int(x / self.size), int(y / self.size) - return (set(self._row(y)) | set(self._column(x)) | set(self._box(*box))) - {idx} - - def _row(self, r): - row_size = self.row_size - return range(r * row_size, r * row_size + row_size) - - def _column(self, c): - return range(c, self.grid_size, self.row_size) - - def _box(self, x, y): - size = self.size - row_size = self.row_size - offx, offy = (x * size, y * size * row_size) - - def _range(i): - start = (offy + i * row_size) + offx - return range(start, start + size) - - ranges = itertools.chain(*[_range(i) for i in range(size)]) - return ranges - - @property - def solved(self): - expected = self.possible_values - all_groups = itertools.chain(self.rows, self.columns, self.boxes) - return all(expected == set(g) for g in all_groups) - - def solve(self, solver): - return solver(self) - - def get(self, x, y): - idx = self._xy_to_idx(x, y) - value = self._board[idx] - return None if value == 0 else value - - def set(self, x, y, value): - if value not in self.possible_values: - raise ValueError('{} not in set of possible values {}'.format(value, self.possible_values)) - - peers = self.peers(x, y) - if peers == self.possible_values: - raise NoPossibleValues('Peer set for ({},{}) contains all possible values'.format(x, y)) - if value in peers: - raise ValueExistsInPeers('{} already exists in the peer set for ({},{})'.format(value, x, y)) - - self._set(x, y, value) - - def unset(self, x, y): - self._set(x, y, 0) - - def _set(self, x, y, value): - idx = self._xy_to_idx(x, y) - if idx in self._clues: - raise SquareIsClue('Cannot set clue square ({},{})'.format(x, y)) - self._board[idx] = value - - def _xy_to_idx(self, x, y): - return y * self.row_size + x - - def _apply_index_ranges(self, ranges): - return ((self._board[i] for i in r) for r in ranges) - - def __repr__(self): - return "{}(size={}, board='{}')".format(self.__class__.__name__, - self.size, - ''.join(str(i) for i in self._board)) - - def __str__(self): - field_width = len(str(max(self.possible_values))) - spacer = '{0}{1}{0}'.format('+', '+'.join(['-' * (field_width * self.size) for _ in range(self.size)])) - - fmt = '' - for (y,x) in self.all_squares: - if x == 0: - if y % self.size == 0: - if y != 0: - fmt += '\n' - fmt += '{spacer}' - fmt += '\n' - - if x % self.size == 0: - fmt += '|' - - idx = self._xy_to_idx(x,y) - if idx in self._clues: - bold = BOLD_SEQUENCE - unbold = UNBOLD_SEQUENCE - else: - bold = unbold = '' - fmt += '{bold}{{board[{i}]:^{{width}}}}{unbold}'.format(i=idx, bold=bold, unbold=unbold) - - if x == (self.row_size - 1): - fmt += '|' - fmt += '\n{spacer}' - - return fmt.format(board=[str(i) if i != 0 else ' ' for i in self._board], spacer=spacer, width=field_width) - -class SudokuError(Exception): - pass - -class SquareIsClue(SudokuError): - pass - -class NoPossibleValues(SudokuError): - pass - -class ValueExistsInPeers(SudokuError): - pass diff --git a/sudoku/solvers/__init__.py b/sudoku/solvers/__init__.py deleted file mode 100644 index 2f2ffed..0000000 --- a/sudoku/solvers/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -# Eryn Wells -''' -A library of Sudoku solvers. -''' - -from . import backtracker, dlx diff --git a/sudoku/solvers/backtracker.py b/sudoku/solvers/backtracker.py deleted file mode 100644 index d58fad2..0000000 --- a/sudoku/solvers/backtracker.py +++ /dev/null @@ -1,61 +0,0 @@ -# Eryn Wells - -from .. import SquareIsClue, ValueExistsInPeers, NoPossibleValues - -class Backtrack(Exception): - pass - -def solve(sudoku): - ''' - Implements a recursive backtracking Sudoku solver. - ''' - return _solve_square(sudoku, 0, 0) - -def _solve_square(sudoku, x, y): - should_backtrack = False - for value in sudoku.possible_values: - should_backtrack = False - try: - sudoku.set(x, y, value) - except SquareIsClue: - # Do nothing with this square; continue on to the next square below. - pass - except ValueExistsInPeers: - # Try next value. - should_backtrack = True - continue - except NoPossibleValues: - # Need to backtrack. - should_backtrack = True - break - - print('\r{!r}'.format(sudoku), end='', flush=True) - - next_coord = _next_coord(sudoku, x, y) - if not next_coord: - break - - try: - return _solve_square(sudoku, *next_coord) - except Backtrack: - should_backtrack = True - continue - - if should_backtrack: - try: - sudoku.unset(x, y) - except SquareIsClue: - pass - raise Backtrack() - - print() - - return sudoku - -def _next_coord(sudoku, x, y): - x += 1 - if x >= sudoku.row_size: - (x, y) = (0, y + 1) - if y >= sudoku.row_size: - return None - return (x, y) diff --git a/sudoku/solvers/dlx.py b/sudoku/solvers/dlx.py deleted file mode 100644 index 39e4b98..0000000 --- a/sudoku/solvers/dlx.py +++ /dev/null @@ -1,137 +0,0 @@ -# Eryn Wells - -from collections import namedtuple -from enum import Enum -from .. import SquareIsClue, ValueExistsInPeers, NoPossibleValues - -class Backtrack(Exception): - pass - -class ConstraintKind(Enum): - CELL = 1 - ROW = 2 - COL = 3 - BOX = 4 - -Constraint = namedtuple('CellConstraint', ['kind', 'index', 'value']) -Possibility= namedtuple('Possibility', ['row', 'col', 'value']) - -class Node: - ''' - A doubly-linked list node that is a member of two distinct lists. One list is the row it is a member of. The other - list is the column it is a member of. - ''' - def __init__(self): - # Horizontal linked list. West is "previous", east is "next". - self.west = self.east = self - # Vertical linked list. North is "previous", south is "next". - self.north = self.south = self - - def insert_after_in_row(self, node): - self._insert(node, 'west', 'east') - - def insert_before_in_col(self, node): - self._insert(node, 'south', 'north') - - def iterate_row(self): - return self._iterate('east') - - def iterate_col(self): - return self._iterate('south') - - def _insert(self, node, prev_attr, next_attr): - self_east = getattr(self, next_attr) # Save my old next node - setattr(self, next_attr, node) # My next points to the new node - setattr(node, next_attr, self_east) # New node's next points to the old next - setattr(node, prev_attr, self) # New node's prev points to me - if self_east: - setattr(self_east, prev_attr, node) # Old next's prev points to the new node - - def _iterate(self, next_attr): - cur = self - while cur: - yield cur - cur = getattr(cur, next_attr) - if cur == self: - break - -class Header(Node): - ''' - A column header, including a count of the number of rows in this column. - ''' - def __init__(self, constraint): - Node.__init__(self) - self.constraint = constraint - self.number_of_rows = 0 - - def append(self, node): - self.insert_before_in_col(node) - self.number_of_rows += 1 - node.header = self - -class Cell(Node): - ''' - A cell in the DLX matrix. - ''' - def __init__(self, possibility): - super(Cell, self).__init__() - self.header = None - self.possibility = possibility - - def __repr__(self): - return '{}({})'.format(self.__class__.__name__, self.possibility) - -def solve(sudoku): - ''' - Implements DLX, Don Knuth's Dancing Links version of Algorithm X, for solving exact cover problems. - ''' - # TODO: Construct the matrix based on the provided sudoku. - # TODO: Perform the algorithm on the matrix. - # TODO: With the solution from running the algorithm above, fill in the sudoku. - return sudoku - -def _build_matrix(sudoku): - # 1. Create headers for all columns. - headers = _build_headers(sudoku) - _build_rows(sudoku, headers) - return headers - -def _build_headers(sudoku): - head = None - cur = None - - def _insert_header(data): - header = Header(data) - if cur: - cur.insert_after_in_row(header) - return header - - # Cell constraints - for i in range(sudoku.grid_size): - cur = _insert_header(Constraint(ConstraintKind.CELL, i, None)) - - # Row, Col, and Box constraints - for kind in (ConstraintKind.ROW, ConstraintKind.COL, ConstraintKind.BOX): - for i in range(sudoku.row_size): - for value in sudoku.possible_values: - cur = _insert_header(Constraint(kind, i, value)) - - # Head points to the first column header - head = cur.east - - return head - -def _build_rows(sudoku, headers): - for (index, coords) in enumerate(sudoku.all_squares): - board_value = sudoku.get(*coords) - possibilities = sudoku.possible_values_for_square(*coords) - for value in possibilities: - cur = None - for col in headers.iterate_row(): - if col.constraint.index != index: - continue - cell = Cell(Possibility(*coords, value)) - col.append(cell) - if cur: - cur.insert_after_in_row(cell) - cur = cell diff --git a/sudoku/test.py b/sudoku/test.py deleted file mode 100644 index b8fccd2..0000000 --- a/sudoku/test.py +++ /dev/null @@ -1,89 +0,0 @@ -#!env python3 -# Eryn Wells -''' -Unit tests for the Sudoku module. -''' - -import unittest -import sudoku - -class Sudoku4TestCase(unittest.TestCase): - def setUp(self): - self.board = sudoku.Sudoku(size=2) - -class Sudoku4BasicTests(Sudoku4TestCase): - def test_that_board_is_sane(self): - self.assertEqual(self.board.size, 2) - self.assertEqual(len(self.board._board), 2**4) - - def test_rows(self): - expected_rows = [ - [ 0, 1, 2, 3], - [ 4, 5, 6, 7], - [ 8, 9, 10, 11], - [12, 13, 14, 15] - ] - for (row, exrow) in zip(self.board.index_rows, expected_rows): - row_list = list(row) - with self.subTest(row=row_list, ex=exrow): - self.assertEqual(row_list, exrow) - - def test_columns(self): - expected_columns = [ - [0, 4, 8, 12], - [1, 5, 9, 13], - [2, 6, 10, 14], - [3, 7, 11, 15] - ] - for (col, excol) in zip(self.board.index_columns, expected_columns): - col_list = list(col) - with self.subTest(col=col_list, ex=excol): - self.assertEqual(col_list, excol) - - def test_boxes(self): - expected_boxes = { - (0,0): set([ 0, 1, 4, 5]), - (1,0): set([ 2, 3, 6, 7]), - (0,1): set([ 8, 9, 12, 13]), - (1,1): set([10, 11, 14, 15]), - } - for (coord, exbox) in expected_boxes.items(): - with self.subTest(sq=coord, ex=exbox): - sq = set(self.board._box(*coord)) - self.assertEqual(sq, exbox) - - def test_peers(self): - expected_peers = { - (0,0): set([1, 2, 3, 4, 8, 12, 5]), - (1,0): set([0, 2, 3, 5, 9, 13, 4]), - (2,0): set([0, 1, 3, 6, 10, 14, 7]), - (3,0): set([0, 1, 2, 7, 11, 15, 6]), - - (0,1): set([5, 6, 7, 0, 8, 12, 1]), - (1,1): set([4, 6, 7, 1, 9, 13, 0]), - (2,1): set([4, 5, 7, 2, 10, 14, 3]), - (3,1): set([4, 5, 6, 3, 11, 15, 2]), - - (0,2): set([9, 10, 11, 0, 4, 12, 13]), - (1,2): set([8, 10, 11, 1, 5, 13, 12]), - (2,2): set([8, 9, 11, 2, 6, 14, 15]), - (3,2): set([8, 9, 10, 3, 7, 15, 14]), - - (0,3): set([13, 14, 15, 0, 4, 8, 9]), - (1,3): set([12, 14, 15, 1, 5, 9, 8]), - (2,3): set([12, 13, 15, 2, 6, 10, 11]), - (3,3): set([12, 13, 14, 3, 7, 11, 10]), - } - for (coords, expeers) in expected_peers.items(): - with self.subTest(coord=coords, ex=expeers): - peers = self.board.index_peers(*coords) - self.assertEqual(peers, expeers) - -class Sudoku4SolvedTests(Sudoku4TestCase): - def test_that_an_empty_board_is_not_solved(self): - self.assertFalse(self.board.solved) - - def test_simple_solution_is_solved(self): - board = (int(i) for i in '1234341221434321') - self.board = sudoku.Sudoku(2, board) - self.assertTrue(self.board.solved) diff --git a/tests.py b/tests.py new file mode 100644 index 0000000..32dabd1 --- /dev/null +++ b/tests.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +# Eryn Wells + +''' +Tests for sudoku. +''' + +import nose +import sudoku +import unittest + + +def test_9x9_dimensions(): + ''' + Test dimenions of a 9x9 Sudoku board. + ''' + b = sudoku.Board() + assert len(b.keys()) == 81 + assert b.size == 9 + assert b.box_size == 3 + for square, values in b.items(): + assert len(values) == 9 + + +def test_9x9_units(): + ''' + Test units of a 9x9 Sudoku board. + ''' + b = sudoku.Board() + for r in range(b.size): + row = b.row(r) + assert len(row) == 9 + for sq in row: + assert sq[1] == r + + for c in range(b.size): + col = b.col(c) + assert len(col) == 9 + for sq in col: + assert sq[0] == c + + for x in range(0, b.size, b.box_size): + for y in range(0, b.size, b.box_size): + box = b.box(x, y) + assert len(box) == 9 + # TODO: Finish this test + +def test_9x9_row(): + ''' + A few tests on rows of a 9x9 Sudoku board. + ''' + b = sudoku.Board() + row = b.row(1) + expected_keys = ((x, 1) for x in range(b.size)) + for ekey in expected_keys: + assert ekey in row + # No negative numbers + assert (-1, 1) not in row + # Only squares with the right y-coordinate + assert (0, 0) not in row + # No keys above the size of the board + assert (b.size, 1) not in row + + +def test_9x9_col(): + ''' + A few tests on rows of a 9x9 Sudoku board. + ''' + b = sudoku.Board() + col = b.col(3) + expected_keys = ((3, y) for y in range(b.size)) + for ekey in expected_keys: + assert ekey in col + # No negative numbers + assert (3, -1) not in col + # Only squares with the right x-coordinate + assert (0, 0) not in col + # No keys above the size of the board + assert (3, b.size) not in col + + +def test_9x9_box(): + ''' + A few tests on boxes of a 9x9 Sudoku board. + ''' + b = sudoku.Board() + assert True + + +def test_9x9_peers(): + ''' + Test peers. + ''' + b = sudoku.Board() + peers = b.peers(3, 3) + expected_peers = set( [(x, 3) for x in range(b.size)] + + [(3, y) for y in range(b.size)] + + [(3, 3), (3, 4), (3, 5), (4, 3), (4, 4), (4, 5), (5, 3), (5, 4), (5, 5)]) + expected_peers.remove((3, 3)) + for epeer in expected_peers: + assert epeer in peers, '{} not in peers of (3, 3)'.format(epeer) + + +def test_9x9_str(): + ''' + Test string generation/printing. + ''' + b = sudoku.Board() + expected_str = '\n'.join(['. . . | . . . | . . .', + '. . . | . . . | . . .', + '. . . | . . . | . . .', + '------+-------+------', + '. . . | . . . | . . .', + '. . . | . . . | . . .', + '. . . | . . . | . . .', + '------+-------+------', + '. . . | . . . | . . .', + '. . . | . . . | . . .', + '. . . | . . . | . . .']) + assert str(b) == expected_str + + +def main(): + nose.main() + + +if __name__ == '__main__': + main()