Skip to content

Commit 49b8fdf

Browse files
test: add comprehensive unit tests for current functionality
- Added unit tests for game logic functions (board generation, win detection, etc.) - Added unit tests for UI and styling functions - Added tests for file operations (reading phrases.txt) - Added integration test for full game flow - Current test coverage: 69% for main.py, 80% overall
1 parent 6a8d682 commit 49b8fdf

File tree

8 files changed

+629
-0
lines changed

8 files changed

+629
-0
lines changed

pytest.ini

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[pytest]
2+
testpaths = tests
3+
python_files = test_*.py
4+
python_classes = Test*
5+
python_functions = test_*
6+
addopts = --verbose

tests/__init__.py

Whitespace-only changes.

tests/test_file_operations.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import unittest
2+
import sys
3+
import os
4+
import tempfile
5+
from unittest.mock import patch, MagicMock, mock_open
6+
7+
# Add the parent directory to sys.path to import from main.py
8+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
9+
10+
# We need to mock the NiceGUI imports and other dependencies before importing main
11+
sys.modules['nicegui'] = MagicMock()
12+
sys.modules['nicegui.ui'] = MagicMock()
13+
sys.modules['fastapi.staticfiles'] = MagicMock()
14+
15+
# Import the function we want to test
16+
from main import check_phrases_file_change
17+
18+
class TestFileOperations(unittest.TestCase):
19+
def setUp(self):
20+
# Create mocks and patches
21+
self.patches = [
22+
patch('main.last_phrases_mtime', 123456),
23+
patch('main.phrases', []),
24+
patch('main.board', []),
25+
patch('main.board_views', {}),
26+
patch('main.board_iteration', 1),
27+
patch('main.generate_board'),
28+
patch('main.build_board'),
29+
patch('main.ui.run_javascript')
30+
]
31+
32+
for p in self.patches:
33+
p.start()
34+
35+
def tearDown(self):
36+
# Clean up patches
37+
for p in self.patches:
38+
p.stop()
39+
40+
@patch('os.path.getmtime')
41+
@patch('builtins.open', new_callable=mock_open, read_data="PHRASE1\nPHRASE2\nPHRASE3")
42+
def test_check_phrases_file_change_no_change(self, mock_file, mock_getmtime):
43+
"""Test when phrases.txt has not changed"""
44+
import main
45+
46+
# Mock that the file's mtime is the same as last check
47+
mock_getmtime.return_value = main.last_phrases_mtime
48+
49+
# Run the function
50+
check_phrases_file_change()
51+
52+
# The file should not have been opened
53+
mock_file.assert_not_called()
54+
55+
# generate_board should not have been called
56+
main.generate_board.assert_not_called()
57+
58+
@patch('os.path.getmtime')
59+
@patch('builtins.open', new_callable=mock_open, read_data="PHRASE1\nPHRASE2\nPHRASE3")
60+
def test_check_phrases_file_change_with_change(self, mock_file, mock_getmtime):
61+
"""Test when phrases.txt has changed"""
62+
import main
63+
64+
# Mock that the file's mtime is newer
65+
mock_getmtime.return_value = main.last_phrases_mtime + 1
66+
67+
# Setup a mock board_views dictionary
68+
container_mock = MagicMock()
69+
tile_buttons_mock = {}
70+
main.board_views = {"home": (container_mock, tile_buttons_mock)}
71+
72+
# Run the function
73+
check_phrases_file_change()
74+
75+
# The file should have been opened
76+
mock_file.assert_called_once_with("phrases.txt", "r")
77+
78+
# last_phrases_mtime should be updated
79+
self.assertEqual(main.last_phrases_mtime, mock_getmtime.return_value)
80+
81+
# generate_board should have been called with board_iteration
82+
main.generate_board.assert_called_once_with(main.board_iteration)
83+
84+
# Container should have been cleared
85+
container_mock.clear.assert_called_once()
86+
87+
# build_board should have been called
88+
main.build_board.assert_called_once()
89+
90+
# Container should have been updated
91+
container_mock.update.assert_called_once()
92+
93+
# JavaScript should have been executed to resize text
94+
main.ui.run_javascript.assert_called_once()
95+
96+
@patch('os.path.getmtime')
97+
def test_check_phrases_file_change_with_error(self, mock_getmtime):
98+
"""Test when there's an error checking the file"""
99+
import main
100+
101+
# Mock that checking the file raises an exception
102+
mock_getmtime.side_effect = FileNotFoundError("File not found")
103+
104+
# Run the function - it should not raise an exception
105+
check_phrases_file_change()
106+
107+
# No other function should have been called
108+
main.generate_board.assert_not_called()
109+
110+
111+
if __name__ == '__main__':
112+
unittest.main()

tests/test_game_logic.py

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import unittest
2+
import sys
3+
import os
4+
import random
5+
from unittest.mock import patch, MagicMock
6+
7+
# Add the parent directory to sys.path to import from main.py
8+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
9+
10+
# We need to mock the NiceGUI imports and other dependencies before importing main
11+
sys.modules['nicegui'] = MagicMock()
12+
sys.modules['nicegui.ui'] = MagicMock()
13+
sys.modules['fastapi.staticfiles'] = MagicMock()
14+
15+
# Now import functions from the main module
16+
from main import generate_board, has_too_many_repeats, check_winner, split_phrase_into_lines
17+
18+
class TestGameLogic(unittest.TestCase):
19+
def setUp(self):
20+
# Setup common test data
21+
# Mock the global variables used in main.py
22+
self.patches = [
23+
patch('main.board', []),
24+
patch('main.today_seed', ''),
25+
patch('main.clicked_tiles', set()),
26+
patch('main.phrases', [f"PHRASE{i}" for i in range(1, 30)]),
27+
patch('main.FREE_SPACE_TEXT', 'FREE SPACE'),
28+
patch('main.bingo_patterns', set())
29+
]
30+
31+
for p in self.patches:
32+
p.start()
33+
34+
def tearDown(self):
35+
# Clean up patches
36+
for p in self.patches:
37+
p.stop()
38+
39+
def test_generate_board(self):
40+
"""Test that generate_board creates a 5x5 board with the FREE_SPACE in the middle"""
41+
import main
42+
43+
# Generate a board with a known seed
44+
generate_board(42)
45+
46+
# Check if board is created with 5 rows
47+
self.assertEqual(len(main.board), 5)
48+
49+
# Check if each row has 5 columns
50+
for row in main.board:
51+
self.assertEqual(len(row), 5)
52+
53+
# Check if FREE_SPACE is in the middle (2,2)
54+
self.assertEqual(main.board[2][2], 'FREE SPACE')
55+
56+
# Check if the clicked_tiles set has (2,2) for FREE_SPACE
57+
self.assertIn((2, 2), main.clicked_tiles)
58+
59+
# Check if the seed is set correctly
60+
expected_seed = f"{main.datetime.date.today().strftime('%Y%m%d')}.42"
61+
self.assertEqual(main.today_seed, expected_seed)
62+
63+
def test_has_too_many_repeats(self):
64+
"""Test the function for detecting phrases with too many repeated words"""
65+
# Test with a phrase having no repeats
66+
self.assertFalse(has_too_many_repeats("ONE TWO THREE FOUR"))
67+
68+
# Test with a phrase having some repeats but below threshold
69+
self.assertFalse(has_too_many_repeats("ONE TWO ONE THREE FOUR"))
70+
71+
# Test with a phrase having too many repeats (above default 0.5 threshold)
72+
self.assertTrue(has_too_many_repeats("ONE ONE ONE ONE TWO"))
73+
74+
# Test with a custom threshold
75+
self.assertFalse(has_too_many_repeats("ONE ONE TWO THREE", threshold=0.3))
76+
self.assertTrue(has_too_many_repeats("ONE ONE TWO THREE", threshold=0.8))
77+
78+
# Test with an empty phrase
79+
self.assertFalse(has_too_many_repeats(""))
80+
81+
def test_check_winner_row(self):
82+
"""Test detecting a win with a complete row"""
83+
import main
84+
85+
# Setup a board with no wins initially
86+
main.bingo_patterns = set()
87+
main.clicked_tiles = {(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)}
88+
89+
# Mock the ui.notify call
90+
with patch('main.ui.notify') as mock_notify:
91+
check_winner()
92+
93+
# Check if the bingo pattern was added
94+
self.assertIn("row0", main.bingo_patterns)
95+
96+
# Check if the notification was shown
97+
mock_notify.assert_called_once()
98+
self.assertEqual(mock_notify.call_args[0][0], "BINGO!")
99+
100+
def test_check_winner_column(self):
101+
"""Test detecting a win with a complete column"""
102+
import main
103+
104+
# Setup a board with no wins initially
105+
main.bingo_patterns = set()
106+
main.clicked_tiles = {(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)}
107+
108+
# Mock the ui.notify call
109+
with patch('main.ui.notify') as mock_notify:
110+
check_winner()
111+
112+
# Check if the bingo pattern was added
113+
self.assertIn("col0", main.bingo_patterns)
114+
115+
# Check if the notification was shown
116+
mock_notify.assert_called_once()
117+
self.assertEqual(mock_notify.call_args[0][0], "BINGO!")
118+
119+
def test_check_winner_diagonal(self):
120+
"""Test detecting a win with a diagonal"""
121+
import main
122+
123+
# Setup a board with no wins initially
124+
main.bingo_patterns = set()
125+
main.clicked_tiles = {(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)}
126+
127+
# Mock the ui.notify call
128+
with patch('main.ui.notify') as mock_notify:
129+
check_winner()
130+
131+
# Check if the bingo pattern was added
132+
self.assertIn("diag_main", main.bingo_patterns)
133+
134+
# Check if the notification was shown
135+
mock_notify.assert_called_once()
136+
self.assertEqual(mock_notify.call_args[0][0], "BINGO!")
137+
138+
def test_check_winner_anti_diagonal(self):
139+
"""Test detecting a win with an anti-diagonal"""
140+
import main
141+
142+
# Setup a board with no wins initially
143+
main.bingo_patterns = set()
144+
main.clicked_tiles = {(0, 4), (1, 3), (2, 2), (3, 1), (4, 0)}
145+
146+
# Mock the ui.notify call
147+
with patch('main.ui.notify') as mock_notify:
148+
check_winner()
149+
150+
# Check if the bingo pattern was added
151+
self.assertIn("diag_anti", main.bingo_patterns)
152+
153+
# Check if the notification was shown
154+
mock_notify.assert_called_once()
155+
self.assertEqual(mock_notify.call_args[0][0], "BINGO!")
156+
157+
def test_check_winner_special_patterns(self):
158+
"""Test detecting special win patterns like blackout, four corners, etc."""
159+
import main
160+
161+
# Test four corners pattern
162+
main.bingo_patterns = set()
163+
main.clicked_tiles = {(0, 0), (0, 4), (4, 0), (4, 4)}
164+
165+
with patch('main.ui.notify') as mock_notify:
166+
check_winner()
167+
self.assertIn("four_corners", main.bingo_patterns)
168+
mock_notify.assert_called_once()
169+
self.assertEqual(mock_notify.call_args[0][0], "Four Corners Bingo!")
170+
171+
# Test plus pattern
172+
main.bingo_patterns = set()
173+
main.clicked_tiles = set()
174+
for i in range(5):
175+
main.clicked_tiles.add((2, i)) # Middle row
176+
main.clicked_tiles.add((i, 2)) # Middle column
177+
178+
with patch('main.ui.notify') as mock_notify:
179+
check_winner()
180+
self.assertIn("plus", main.bingo_patterns)
181+
# The notify may be called multiple times as the clicks also trigger row/col wins
182+
self.assertIn(mock_notify.call_args_list[-1][0][0], "Plus Bingo!")
183+
184+
def test_check_winner_multiple_wins(self):
185+
"""Test detecting multiple win patterns in a single check"""
186+
import main
187+
188+
# Setup a board with two potential wins (a row and a column)
189+
main.bingo_patterns = set()
190+
main.clicked_tiles = set()
191+
192+
# Add a complete row and a complete column that intersect
193+
for i in range(5):
194+
main.clicked_tiles.add((0, i)) # First row
195+
main.clicked_tiles.add((i, 0)) # First column
196+
197+
# Mock the ui.notify call
198+
with patch('main.ui.notify') as mock_notify:
199+
check_winner()
200+
201+
# Check if both bingo patterns were added
202+
self.assertIn("row0", main.bingo_patterns)
203+
self.assertIn("col0", main.bingo_patterns)
204+
205+
# The function should call notify with "DOUBLE BINGO!"
206+
mock_notify.assert_called_once()
207+
self.assertEqual(mock_notify.call_args[0][0], "DOUBLE BINGO!")
208+
209+
def test_split_phrase_into_lines(self):
210+
"""Test splitting phrases into balanced lines"""
211+
# Test with a short phrase (3 words or fewer)
212+
self.assertEqual(split_phrase_into_lines("ONE TWO THREE"), ["ONE", "TWO", "THREE"])
213+
214+
# Test with a longer phrase - the actual implementation may return different line counts
215+
# based on the word lengths and balancing algorithm
216+
result = split_phrase_into_lines("ONE TWO THREE FOUR FIVE")
217+
self.assertLessEqual(len(result), 4) # Should not exceed 4 lines
218+
219+
# Test forcing a specific number of lines
220+
result = split_phrase_into_lines("ONE TWO THREE FOUR FIVE SIX", forced_lines=3)
221+
self.assertEqual(len(result), 3) # Should be split into 3 lines
222+
223+
# Test very long phrase
224+
long_phrase = "ONE TWO THREE FOUR FIVE SIX SEVEN EIGHT NINE TEN"
225+
result = split_phrase_into_lines(long_phrase)
226+
self.assertLessEqual(len(result), 4) # Should never return more than 4 lines
227+
228+
229+
if __name__ == '__main__':
230+
unittest.main()

tests/test_helpers.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
"""
2+
Helper module for common test utilities and mocks
3+
"""
4+
import sys
5+
from unittest.mock import MagicMock
6+
7+
def setup_mocks():
8+
"""
9+
Set up common mocks for testing main.py
10+
This function needs to be called before importing main.py
11+
"""
12+
# Create fake modules for nicegui
13+
nicegui_mock = MagicMock()
14+
ui_mock = MagicMock()
15+
nicegui_mock.ui = ui_mock
16+
17+
# Replace the imports in sys.modules
18+
sys.modules['nicegui'] = nicegui_mock
19+
sys.modules['nicegui.ui'] = ui_mock
20+
sys.modules['fastapi.staticfiles'] = MagicMock()
21+
22+
return nicegui_mock, ui_mock

0 commit comments

Comments
 (0)