├── .gitignore ├── LICENSE ├── README.md ├── fizzbuzz ├── fizzbuzz.py └── test_fizzbuzz.py ├── life ├── life.py ├── life_gui.py ├── pattern1.txt ├── pattern2.txt ├── pattern3.txt ├── pattern4.txt ├── patterns │ ├── acorn.txt │ ├── beacon.txt │ ├── beehive.txt │ ├── blinker.txt │ ├── block.txt │ ├── boat.txt │ ├── diehard.txt │ ├── glider.txt │ ├── gosper-glider-gun.txt │ ├── hwss.txt │ ├── loaf.txt │ ├── lwss.txt │ ├── mwss.txt │ ├── pentadecathlon.txt │ ├── pulsar.txt │ ├── r-pentomino.txt │ ├── simkin-glider-gun.txt │ ├── toad.txt │ └── tub.txt └── test_life.py └── requirements.txt /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Miguel Grinberg 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # python-testing 2 | 3 | This repositoru contains the code from my Python unit testing blog articles: 4 | 5 | - [How to Write Unit Tests in Python, Part 1: Fizz Buzz](https://blog.miguelgrinberg.com/post/how-to-write-unit-tests-in-python-part-1-fizz-buzz) 6 | - [How to Write Unit Tests in Python, Part 2: Game of Life](https://blog.miguelgrinberg.com/post/how-to-write-unit-tests-in-python-part-2-game-of-life) 7 | - [How to Write Unit Tests in Python, Part 3: Web Applications](https://blog.miguelgrinberg.com/post/how-to-write-unit-tests-in-python-part-3-web-applications) 8 | 9 | See the articles for a details. 10 | -------------------------------------------------------------------------------- /fizzbuzz/fizzbuzz.py: -------------------------------------------------------------------------------- 1 | def fizzbuzz(i): 2 | if i % 15 == 0: 3 | return "FizzBuzz" 4 | elif i % 3 == 0: 5 | return "Fizz" 6 | elif i % 5 == 0: 7 | return "Buzz" 8 | else: 9 | return i 10 | 11 | 12 | def main(): 13 | for i in range(1, 101): 14 | print(fizzbuzz(i)) 15 | 16 | 17 | if __name__ == '__main__': # pragma: no cover 18 | main() 19 | -------------------------------------------------------------------------------- /fizzbuzz/test_fizzbuzz.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | from fizzbuzz import fizzbuzz 3 | 4 | 5 | class TestFizzBuzz(unittest.TestCase): 6 | def test_fizz(self): 7 | for i in [3, 6, 9, 18]: 8 | print('testing', i) 9 | assert fizzbuzz(i) == 'Fizz' 10 | 11 | def test_buzz(self): 12 | for i in [5, 10, 50]: 13 | print('testing', i) 14 | assert fizzbuzz(i) == 'Buzz' 15 | 16 | def test_fizzbuzz(self): 17 | for i in [15, 30, 75]: 18 | print('testing', i) 19 | assert fizzbuzz(i) == 'FizzBuzz' 20 | 21 | def test_number(self): 22 | for i in [2, 4, 88]: 23 | print('testing', i) 24 | assert fizzbuzz(i) == i 25 | -------------------------------------------------------------------------------- /life/life.py: -------------------------------------------------------------------------------- 1 | class CellList: 2 | """Maintain a list of (x, y) cells.""" 3 | 4 | def __init__(self): 5 | self.cells = {} 6 | 7 | def has(self, x, y): 8 | """Check if a cell exists in this list.""" 9 | return x in self.cells.get(y, []) 10 | 11 | def set(self, x, y, value=None): 12 | """Add, remove or toggle a cell in this list.""" 13 | if value is None: 14 | value = not self.has(x, y) 15 | if value: 16 | row = self.cells.setdefault(y, set()) 17 | if x not in row: 18 | row.add(x) 19 | else: 20 | try: 21 | self.cells[y].remove(x) 22 | except KeyError: 23 | pass 24 | else: 25 | if not self.cells[y]: 26 | del self.cells[y] 27 | 28 | def __iter__(self): 29 | """Iterator over the cells in this list.""" 30 | for y in self.cells: 31 | for x in self.cells[y]: 32 | yield (x, y) 33 | 34 | 35 | class Life: 36 | """Game of Life simulation.""" 37 | 38 | def __init__(self, survival=[2, 3], birth=[3]): 39 | self.survival = survival 40 | self.birth = birth 41 | self.alive = CellList() 42 | 43 | def rules_str(self): 44 | """Return the rules of the game as a printable string.""" 45 | survival_rule = "".join([str(n) for n in self.survival]) 46 | birth_rule = "".join([str(n) for n in self.birth]) 47 | return f'{survival_rule}/{birth_rule}' 48 | 49 | def load(self, filename): 50 | """Load a pattern from a file into the game grid.""" 51 | with open(filename, "rt") as f: 52 | header = f.readline() 53 | if header == '#Life 1.05\n': 54 | x = y = 0 55 | for line in f.readlines(): 56 | if line.startswith('#D'): 57 | continue 58 | elif line.startswith('#N'): 59 | self.survival = [2, 3] 60 | self.birth = [3] 61 | elif line.startswith('#R'): 62 | self.survival, self.birth = [ 63 | [int(n) for n in i] 64 | for i in line[2:].strip().split('/', 1)] 65 | elif line.startswith('#P'): 66 | x, y = [int(i) for i in line[2:].strip().split(' ', 1)] 67 | else: 68 | i = line.find('*') 69 | while i != -1: 70 | self.alive.set(x + i, y, True) 71 | i = line.find('*', i + 1) 72 | y += 1 73 | elif header == '#Life 1.06\n': 74 | for line in f.readlines(): 75 | if not line.startswith('#'): 76 | x, y = [int(i) for i in line.strip().split(' ', 1)] 77 | self.alive.set(x, y, True) 78 | else: 79 | raise RuntimeError('Unknown file format') 80 | 81 | def toggle(self, x, y): 82 | """Toggle a cell in the grid.""" 83 | self.alive.set(x, y) 84 | 85 | def living_cells(self): 86 | """Iterate over the living cells.""" 87 | return self.alive.__iter__() 88 | 89 | def bounding_box(self): 90 | """Return the bounding box that includes all living cells.""" 91 | minx = miny = maxx = maxy = None 92 | for cell in self.living_cells(): 93 | x = cell[0] 94 | y = cell[1] 95 | if minx is None or x < minx: 96 | minx = x 97 | if miny is None or y < miny: 98 | miny = y 99 | if maxx is None or x > maxx: 100 | maxx = x 101 | if maxy is None or y > maxy: 102 | maxy = y 103 | return (minx or 0, miny or 0, maxx or 0, maxy or 0) 104 | 105 | def advance(self): 106 | """Advance the simulation by one time unit.""" 107 | processed = CellList() 108 | new_alive = CellList() 109 | for cell in self.living_cells(): 110 | x = cell[0] 111 | y = cell[1] 112 | for i in range(-1, 2): 113 | for j in range(-1, 2): 114 | if (x + i, y + j) in processed: 115 | continue 116 | processed.set(x + i, y + j, True) 117 | if self._advance_cell(x + i, y + j): 118 | new_alive.set(x + i, y + j, True) 119 | self.alive = new_alive 120 | 121 | def _advance_cell(self, x, y): 122 | """Calculate the new state of a cell.""" 123 | neighbors = 0 124 | for i in range(-1, 2): 125 | for j in range(-1, 2): 126 | if i != 0 or j != 0: 127 | neighbors += 1 if self.alive.has(x + i, y + j) else 0 128 | 129 | if self.alive.has(x, y): 130 | return neighbors in self.survival 131 | else: 132 | return neighbors in self.birth 133 | -------------------------------------------------------------------------------- /life/life_gui.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import pygame 3 | from life import Life 4 | 5 | SCREEN_SIZE = 500 6 | FPS = 5 7 | 8 | life = Life() 9 | 10 | 11 | def initialize_game(pattern_file=None): 12 | pygame.init() 13 | screen = pygame.display.set_mode([SCREEN_SIZE, SCREEN_SIZE]) 14 | 15 | if pattern_file: 16 | life.load(pattern_file) 17 | 18 | pygame.display.set_caption(f'Game of Life [{life.rules_str()}]') 19 | return screen 20 | 21 | 22 | def center(scale): 23 | cell_count = SCREEN_SIZE // scale 24 | minx, miny, maxx, maxy = life.bounding_box() 25 | 26 | basex = minx - (cell_count - (maxx - minx + 1)) // 2 27 | basey = miny - (cell_count - (maxy - miny + 1)) // 2 28 | return basex, basey 29 | 30 | 31 | def game_loop(screen): 32 | running = True 33 | paused = False 34 | scale = 20 35 | basex, basey = center(scale) 36 | interval = 1000 // FPS 37 | 38 | while running: 39 | start_time = pygame.time.get_ticks() 40 | 41 | screen.fill((255, 255, 255)) 42 | for i in range(0, SCREEN_SIZE, scale): 43 | pygame.draw.line(screen, (0, 0, 0), (i, 0), (i, SCREEN_SIZE)) 44 | pygame.draw.line(screen, (0, 0, 0), (0, i), (SCREEN_SIZE, i)) 45 | for cell in life.alive: 46 | x = cell[0] 47 | y = cell[1] 48 | pygame.draw.rect(screen, (80, 80, 192), 49 | ((x - basex) * scale + 2, (y - basey) * scale + 2, 50 | scale - 3, scale - 3)) 51 | 52 | pygame.display.flip() 53 | if not paused: 54 | life.advance() 55 | 56 | wait_time = 1 57 | while wait_time > 0: 58 | event = pygame.event.wait(timeout=wait_time) 59 | while event: 60 | if event.type == pygame.QUIT: 61 | running = False 62 | break 63 | elif event.type == pygame.KEYDOWN: 64 | if event.key == pygame.K_ESCAPE: 65 | running = False 66 | elif event.key == pygame.K_LEFT: 67 | basex -= 2 68 | elif event.key == pygame.K_RIGHT: 69 | basex += 2 70 | elif event.key == pygame.K_UP: 71 | basey -= 2 72 | elif event.key == pygame.K_DOWN: 73 | basey += 2 74 | elif event.unicode == ' ': 75 | paused = not paused 76 | if paused: 77 | pygame.display.set_caption('Game of Life (paused)') 78 | else: 79 | pygame.display.set_caption( 80 | f'Game of Life [{life.rules_str()}]') 81 | elif event.unicode == '+': 82 | if scale < 50: 83 | scale += 5 84 | elif event.unicode == '-': 85 | if scale > 10: 86 | scale -= 5 87 | elif event.unicode == 'c': 88 | basex, basey = center(scale) 89 | break 90 | elif event.type == pygame.MOUSEBUTTONUP: 91 | mx, my = pygame.mouse.get_pos() 92 | x = mx // scale + basex 93 | y = my // scale + basey 94 | life.toggle(x, y) 95 | break 96 | event = pygame.event.poll() 97 | if event: 98 | break 99 | 100 | current_time = pygame.time.get_ticks() 101 | wait_time = interval - (current_time - start_time) 102 | 103 | 104 | if __name__ == '__main__': 105 | pattern_file = sys.argv[1] if len(sys.argv) > 1 else None 106 | screen = initialize_game(pattern_file) 107 | print(''' 108 | Press: Arrows to scroll 109 | Space to pause/resume the simulation 110 | +/- to zoom in/out 111 | c to re-center 112 | mouse click to toggle the state of a cell 113 | Esc to exit''') 114 | game_loop(screen) 115 | pygame.quit() 116 | -------------------------------------------------------------------------------- /life/pattern1.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #D test pattern 1 3 | #N 4 | #P 10 10 5 | *. 6 | .* 7 | #P 15 10 8 | *.* 9 | -------------------------------------------------------------------------------- /life/pattern2.txt: -------------------------------------------------------------------------------- 1 | #Life 1.06 2 | # test pattern 2 3 | 10 10 4 | 11 11 5 | 15 10 6 | 17 10 7 | -------------------------------------------------------------------------------- /life/pattern3.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #D test pattern 3 3 | #R 34/45 4 | #P 10 10 5 | *. 6 | .* 7 | *. 8 | -------------------------------------------------------------------------------- /life/pattern4.txt: -------------------------------------------------------------------------------- 1 | this is not a life file 2 | -------------------------------------------------------------------------------- /life/patterns/acorn.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | .*..... 4 | ...*... 5 | **..*** 6 | -------------------------------------------------------------------------------- /life/patterns/beacon.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | **.. 4 | **.. 5 | ..** 6 | ..** 7 | -------------------------------------------------------------------------------- /life/patterns/beehive.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | .**. 4 | *..* 5 | .**. 6 | -------------------------------------------------------------------------------- /life/patterns/blinker.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | * 4 | * 5 | * 6 | -------------------------------------------------------------------------------- /life/patterns/block.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | ** 4 | ** 5 | -------------------------------------------------------------------------------- /life/patterns/boat.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | **. 4 | *.* 5 | .*. 6 | -------------------------------------------------------------------------------- /life/patterns/diehard.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | ......*. 4 | **...... 5 | .*...*** 6 | -------------------------------------------------------------------------------- /life/patterns/glider.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #D This is a glider. 3 | #N 4 | #P -1 -1 5 | .*. 6 | ..* 7 | *** 8 | -------------------------------------------------------------------------------- /life/patterns/gosper-glider-gun.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | ........................*........... 4 | ......................*.*........... 5 | ............**......**............** 6 | ...........*...*....**............** 7 | **........*.....*...**.............. 8 | **........*...*.**....*.*........... 9 | ..........*.....*.......*........... 10 | ...........*...*.................... 11 | ............**...................... 12 | -------------------------------------------------------------------------------- /life/patterns/hwss.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | .****** 4 | *.....* 5 | ......* 6 | *....*. 7 | ..**... 8 | -------------------------------------------------------------------------------- /life/patterns/loaf.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | .**. 4 | *..* 5 | .*.* 6 | ..*. 7 | -------------------------------------------------------------------------------- /life/patterns/lwss.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | *..*. 4 | ....* 5 | *...* 6 | .**** 7 | -------------------------------------------------------------------------------- /life/patterns/mwss.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | .***** 4 | *....* 5 | .....* 6 | *...*. 7 | ..*... 8 | -------------------------------------------------------------------------------- /life/patterns/pentadecathlon.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | .***. 4 | ..... 5 | *...* 6 | *...* 7 | ..... 8 | .***. 9 | ..... 10 | ..... 11 | .***. 12 | ..... 13 | *...* 14 | *...* 15 | ..... 16 | .***. 17 | -------------------------------------------------------------------------------- /life/patterns/pulsar.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | ..***...***.. 4 | ............. 5 | *....*.*....* 6 | *....*.*....* 7 | *....*.*....* 8 | ..***...***.. 9 | ............. 10 | ..***...***.. 11 | *....*.*....* 12 | *....*.*....* 13 | *....*.*....* 14 | ............. 15 | ..***...***.. 16 | -------------------------------------------------------------------------------- /life/patterns/r-pentomino.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | .** 4 | **. 5 | .*. 6 | -------------------------------------------------------------------------------- /life/patterns/simkin-glider-gun.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | #P 0 0 4 | **.....** 5 | **.....** 6 | ......... 7 | ....**... 8 | ....**... 9 | #P 21 9 10 | .**.**...... 11 | *.....*..... 12 | *......*..** 13 | ***...*...** 14 | .....*...... 15 | -------------------------------------------------------------------------------- /life/patterns/toad.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | .*** 4 | ***. 5 | -------------------------------------------------------------------------------- /life/patterns/tub.txt: -------------------------------------------------------------------------------- 1 | #Life 1.05 2 | #N 3 | .*. 4 | *.* 5 | .*. 6 | -------------------------------------------------------------------------------- /life/test_life.py: -------------------------------------------------------------------------------- 1 | from io import StringIO 2 | import itertools 3 | import random 4 | import unittest 5 | from unittest import mock 6 | import pytest 7 | from life import CellList, Life 8 | from parameterized import parameterized 9 | 10 | 11 | class TestCellList(unittest.TestCase): 12 | def test_empty(self): 13 | c = CellList() 14 | assert list(c) == [] 15 | 16 | def test_set_true(self): 17 | c = CellList() 18 | c.set(1, 2, True) 19 | assert c.has(1, 2) 20 | assert list(c) == [(1, 2)] 21 | c.set(500, 600, True) 22 | assert c.has(1, 2) and c.has(500, 600) 23 | assert list(c) == [(1, 2), (500, 600)] 24 | c.set(1, 2, True) 25 | assert c.has(1, 2) and c.has(500, 600) 26 | assert list(c) == [(1, 2), (500, 600)] 27 | 28 | def test_set_false(self): 29 | c = CellList() 30 | c.set(1, 2, False) 31 | assert not c.has(1, 2) 32 | assert list(c) == [] 33 | c.set(1, 2, True) 34 | c.set(1, 2, False) 35 | assert not c.has(1, 2) 36 | assert list(c) == [] 37 | c.set(1, 2, True) 38 | c.set(3, 2, True) 39 | c.set(1, 2, False) 40 | assert not c.has(1, 2) 41 | assert c.has(3, 2) 42 | assert list(c) == [(3, 2)] 43 | 44 | def test_set_default(self): 45 | c = CellList() 46 | c.set(1, 2) 47 | assert c.has(1, 2) 48 | assert list(c) == [(1, 2)] 49 | c.set(1, 2) 50 | assert not c.has(1, 2) 51 | assert list(c) == [] 52 | 53 | 54 | class TestLife(unittest.TestCase): 55 | def test_new(self): 56 | life = Life() 57 | assert life.survival == [2, 3] 58 | assert life.birth == [3] 59 | assert list(life.living_cells()) == [] 60 | assert life.rules_str() == '23/3' 61 | 62 | def test_new_custom(self): 63 | life = Life([3, 4], [4, 7, 8]) 64 | assert life.survival == [3, 4] 65 | assert life.birth == [4, 7, 8] 66 | assert list(life.living_cells()) == [] 67 | assert life.rules_str() == '34/478' 68 | 69 | @parameterized.expand([('pattern1.txt',), ('pattern2.txt',)]) 70 | def test_load(self, pattern): 71 | life = Life() 72 | life.load(pattern) 73 | assert life.survival == [2, 3] 74 | assert life.birth == [3] 75 | assert set(life.living_cells()) == { 76 | (10, 10), (11, 11), (15, 10), (17, 10)} 77 | assert life.bounding_box() == (10, 10, 17, 11) 78 | 79 | def test_load_life_custom_rules(self): 80 | life = Life() 81 | life.load('pattern3.txt') 82 | assert life.survival == [3, 4] 83 | assert life.birth == [4, 5] 84 | assert list(life.living_cells()) == [(10, 10), (11, 11), (10, 12)] 85 | assert life.bounding_box() == (10, 10, 11, 12) 86 | 87 | def test_load_invalid(self): 88 | life = Life() 89 | with pytest.raises(RuntimeError): 90 | life.load('pattern4.txt') 91 | 92 | def test_toggle(self): 93 | life = Life() 94 | life.toggle(5, 5) 95 | assert list(life.living_cells()) == [(5, 5)] 96 | life.toggle(5, 6) 97 | life.toggle(5, 5) 98 | assert list(life.living_cells()) == [(5, 6)] 99 | 100 | @parameterized.expand(itertools.product( 101 | [[2, 3], [4]], # two different survival rules 102 | [[3], [3, 4]], # two different birth rules 103 | [True, False], # two possible states for the cell 104 | range(0, 9), # nine number of possible neighbors 105 | )) 106 | def test_advance_cell(self, survival, birth, alive, num_neighbors): 107 | life = Life(survival, birth) 108 | if alive: 109 | life.toggle(0, 0) 110 | neighbors = [(-1, -1), (0, -1), (1, -1), 111 | (-1, 0), (1, 0), 112 | (-1, 1), (0, 1), (1, 1)] 113 | for i in range(num_neighbors): 114 | n = random.choice(neighbors) 115 | neighbors.remove(n) 116 | life.toggle(*n) 117 | 118 | new_state = life._advance_cell(0, 0) 119 | if alive: 120 | # survival rule 121 | if num_neighbors in survival: 122 | assert new_state is True 123 | else: 124 | assert new_state is False 125 | else: 126 | # birth rule 127 | if num_neighbors in birth: 128 | assert new_state is True 129 | else: 130 | assert new_state is False 131 | 132 | @mock.patch.object(Life, '_advance_cell') 133 | def test_advance_false(self, mock_advance_cell): 134 | mock_advance_cell.return_value = False 135 | life = Life() 136 | life.toggle(10, 10) 137 | life.toggle(12, 10) 138 | life.toggle(20, 20) 139 | life.advance() 140 | 141 | # there should be exactly 24 calls to _advance_cell: 142 | # - 9 around the (10, 10) cell 143 | # - 6 around the (12, 10) cell (3 were already processed by (10, 10)) 144 | # - 9 around the (20, 20) cell 145 | assert mock_advance_cell.call_count == 24 146 | assert list(life.living_cells()) == [] 147 | 148 | @mock.patch.object(Life, '_advance_cell') 149 | def test_advance_true(self, mock_advance_cell): 150 | mock_advance_cell.return_value = True 151 | life = Life() 152 | life.toggle(10, 10) 153 | life.toggle(11, 10) 154 | life.toggle(20, 20) 155 | life.advance() 156 | 157 | # there should be exactly 24 calls to _advance_cell: 158 | # - 9 around the (10, 10) cell 159 | # - 3 around the (11, 10) cell (3 were already processed by (10, 10)) 160 | # - 9 around the (20, 20) cell 161 | assert mock_advance_cell.call_count == 21 162 | 163 | # since the mocked advance_cell returns True in all cases, all 24 164 | # cells must be alive 165 | assert set(life.living_cells()) == { 166 | (9, 9), (10, 9), (11, 9), (12, 9), 167 | (9, 10), (10, 10), (11, 10), (12, 10), 168 | (9, 11), (10, 11), (11, 11), (12, 11), 169 | (19, 19), (20, 19), (21, 19), 170 | (19, 20), (20, 20), (21, 20), 171 | (19, 21), (20, 21), (21, 21), 172 | } 173 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==20.3.0 2 | coverage==5.5 3 | iniconfig==1.1.1 4 | packaging==20.9 5 | parameterized==0.8.1 6 | pluggy==0.13.1 7 | py==1.10.0 8 | pygame==2.0.1 9 | pyparsing==2.4.7 10 | pytest==6.2.2 11 | pytest-cov==2.11.1 12 | toml==0.10.2 13 | --------------------------------------------------------------------------------