diff --git a/.gitignore b/.gitignore deleted file mode 100644 index d646835..0000000 --- a/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -*.pyc -__pycache__/ diff --git a/puzzles.py b/puzzles.py old mode 100644 new mode 100755 index 62f5199..1d7dc00 --- a/puzzles.py +++ b/puzzles.py @@ -1,58 +1,67 @@ -#!/usr/bin/env python3 +#!env python3 # Eryn Wells - ''' Parser for puzzles in the ./puzzles directory. ''' -import sudoku +import argparse +import os.path +import sys +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 parse_euler(filename='./puzzles/euler.txt'): +def _get_puzzles(filename, quiet): with open(filename, 'r') as f: - puzzle_lines = f.readlines() + puzzles = f.readlines() + return (_parse_puzzle(p, quiet) for p in puzzles if p) - 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_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 +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 parse_norvig(filename='./puzzles/norvig.txt'): - with open(filename, 'r') as f: - puzzle_lines = f.readlines() +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 - 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)) +if __name__ == '__main__': + sys.exit(main()) diff --git a/puzzles/norvig.txt b/puzzles/norvig.txt index c8429cc..a68d542 100644 --- a/puzzles/norvig.txt +++ b/puzzles/norvig.txt @@ -93,4 +93,3 @@ .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 deleted file mode 100644 index 964e6be..0000000 --- a/sudoku.py +++ /dev/null @@ -1,237 +0,0 @@ -#!/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 new file mode 100644 index 0000000..3cb0572 --- /dev/null +++ b/sudoku/__init__.py @@ -0,0 +1,230 @@ +#!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 new file mode 100644 index 0000000..2f2ffed --- /dev/null +++ b/sudoku/solvers/__init__.py @@ -0,0 +1,6 @@ +# Eryn Wells +''' +A library of Sudoku solvers. +''' + +from . import backtracker, dlx diff --git a/sudoku/solvers/backtracker.py b/sudoku/solvers/backtracker.py new file mode 100644 index 0000000..d58fad2 --- /dev/null +++ b/sudoku/solvers/backtracker.py @@ -0,0 +1,61 @@ +# 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 new file mode 100644 index 0000000..39e4b98 --- /dev/null +++ b/sudoku/solvers/dlx.py @@ -0,0 +1,137 @@ +# 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 new file mode 100644 index 0000000..b8fccd2 --- /dev/null +++ b/sudoku/test.py @@ -0,0 +1,89 @@ +#!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 deleted file mode 100644 index 32dabd1..0000000 --- a/tests.py +++ /dev/null @@ -1,128 +0,0 @@ -#!/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()