Skip to content

Commit a2f49f7

Browse files
test(all): add comprehensive test suite
Add tests for all components: - Unit tests for board, phrases, and win pattern logic - Integration tests for UI components and synchronization - End-to-end tests for application functionality - Test utilities and fixtures 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 283ef1e commit a2f49f7

File tree

7 files changed

+579
-0
lines changed

7 files changed

+579
-0
lines changed

tests/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Test package initialization

tests/test_board.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import unittest
2+
import tempfile
3+
import os
4+
from unittest.mock import patch, MagicMock
5+
from src.core.board import generate_board, toggle_tile, reset_board, clicked_tiles
6+
7+
class TestBoard(unittest.TestCase):
8+
def setUp(self):
9+
"""Set up mock phrases for testing."""
10+
# Create a temporary phrases.txt file
11+
self.temp_file = tempfile.NamedTemporaryFile(delete=False)
12+
with open(self.temp_file.name, 'w') as f:
13+
f.write("PHRASE 1\nPHRASE 2\nPHRASE 3\nPHRASE 4\nPHRASE 5\n")
14+
f.write("PHRASE 6\nPHRASE 7\nPHRASE 8\nPHRASE 9\nPHRASE 10\n")
15+
f.write("PHRASE 11\nPHRASE 12\nPHRASE 13\nPHRASE 14\nPHRASE 15\n")
16+
f.write("PHRASE 16\nPHRASE 17\nPHRASE 18\nPHRASE 19\nPHRASE 20\n")
17+
f.write("PHRASE 21\nPHRASE 22\nPHRASE 23\nPHRASE 24\nPHRASE 25\n")
18+
19+
# Mock the phrases module
20+
self.phrases_patcher = patch('src.core.phrases.get_phrases')
21+
self.mock_get_phrases = self.phrases_patcher.start()
22+
self.mock_get_phrases.return_value = [
23+
f"PHRASE {i}" for i in range(1, 26)
24+
]
25+
26+
# Reset clicked tiles before each test
27+
clicked_tiles.clear()
28+
29+
def tearDown(self):
30+
"""Clean up after tests."""
31+
self.phrases_patcher.stop()
32+
os.unlink(self.temp_file.name)
33+
34+
def test_generate_board(self):
35+
"""Test that a board is generated with the correct structure."""
36+
# Directly using the returned board rather than the global variable
37+
# This makes the test more reliable
38+
board = generate_board(42)
39+
40+
# Board should be a 5x5 grid
41+
self.assertEqual(len(board), 5)
42+
for row in board:
43+
self.assertEqual(len(row), 5)
44+
45+
# The middle cell should be FREE MEAT
46+
from src.config.constants import FREE_SPACE_TEXT
47+
self.assertEqual(board[2][2].upper(), FREE_SPACE_TEXT)
48+
49+
# FREE SPACE should be the only clicked tile initially
50+
self.assertEqual(len(clicked_tiles), 1)
51+
self.assertIn((2, 2), clicked_tiles)
52+
53+
def test_toggle_tile(self):
54+
"""Test that toggling a tile works correctly."""
55+
# Initially, only FREE SPACE should be clicked
56+
from src.core.board import board
57+
generate_board(42)
58+
initial_count = len(clicked_tiles)
59+
60+
# Toggle a tile that isn't FREE SPACE
61+
toggle_tile(0, 0)
62+
self.assertEqual(len(clicked_tiles), initial_count + 1)
63+
self.assertIn((0, 0), clicked_tiles)
64+
65+
# Toggle the same tile again (should remove it)
66+
toggle_tile(0, 0)
67+
self.assertEqual(len(clicked_tiles), initial_count)
68+
self.assertNotIn((0, 0), clicked_tiles)
69+
70+
# Toggle FREE SPACE (should do nothing)
71+
toggle_tile(2, 2)
72+
self.assertEqual(len(clicked_tiles), initial_count)
73+
self.assertIn((2, 2), clicked_tiles)
74+
75+
def test_reset_board(self):
76+
"""Test that resetting the board works correctly."""
77+
# Set up a board with some clicked tiles
78+
from src.core.board import board
79+
generate_board(42)
80+
toggle_tile(0, 0)
81+
toggle_tile(1, 1)
82+
self.assertEqual(len(clicked_tiles), 3) # FREE SPACE + 2 others
83+
84+
# Reset the board
85+
reset_board()
86+
87+
# Only FREE SPACE should remain clicked
88+
self.assertEqual(len(clicked_tiles), 1)
89+
self.assertIn((2, 2), clicked_tiles)
90+
self.assertNotIn((0, 0), clicked_tiles)
91+
self.assertNotIn((1, 1), clicked_tiles)
92+
93+
if __name__ == '__main__':
94+
unittest.main()

tests/test_e2e.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import unittest
2+
import threading
3+
import time
4+
import multiprocessing
5+
import requests
6+
from unittest.mock import patch
7+
import sys
8+
import os
9+
import signal
10+
from contextlib import contextmanager
11+
12+
# Create a context manager to run the server in another process for e2e tests
13+
@contextmanager
14+
def run_app_in_process(timeout=10):
15+
"""Run the app in a separate process and yield a client session."""
16+
# Define a function to run the app
17+
def run_app():
18+
# Add the parent directory to path so we can import the app
19+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
20+
try:
21+
# Import and run the app
22+
from app import main
23+
main()
24+
except Exception as e:
25+
print(f"Error in app process: {e}")
26+
27+
# Start the process
28+
process = multiprocessing.Process(target=run_app)
29+
process.start()
30+
31+
try:
32+
# Wait for the app to start up
33+
start_time = time.time()
34+
while time.time() - start_time < timeout:
35+
try:
36+
# Check if the server is responding
37+
response = requests.get("http://localhost:8080", timeout=0.1)
38+
if response.status_code == 200:
39+
# Server is up
40+
break
41+
except requests.exceptions.RequestException:
42+
# Server not yet started, wait a bit
43+
time.sleep(0.1)
44+
else:
45+
raise TimeoutError("Server did not start within the timeout period")
46+
47+
# Yield control back to the test
48+
yield
49+
finally:
50+
# Clean up: terminate the process
51+
process.terminate()
52+
process.join(timeout=5)
53+
if process.is_alive():
54+
os.kill(process.pid, signal.SIGKILL)
55+
56+
57+
class TestEndToEnd(unittest.TestCase):
58+
"""End-to-end tests for the app. These tests require the app to be running."""
59+
60+
@unittest.skip("Skip E2E test that requires running a server - only run manually")
61+
def test_home_page_loads(self):
62+
"""Test that the home page loads and contains the necessary elements."""
63+
with run_app_in_process():
64+
# Make a request to the home page
65+
response = requests.get("http://localhost:8080")
66+
self.assertEqual(response.status_code, 200)
67+
68+
# Check for the presence of key elements in the HTML
69+
self.assertIn("COMMIT !BINGO", response.text)
70+
self.assertIn("FREE MEAT", response.text)
71+
72+
# The board should have 5x5 = 25 cells
73+
# Look for cards or grid elements
74+
self.assertIn("board-container", response.text)
75+
76+
@unittest.skip("Skip E2E test that requires running a server - only run manually")
77+
def test_stream_page_loads(self):
78+
"""Test that the stream page loads and contains the necessary elements."""
79+
with run_app_in_process():
80+
# Make a request to the stream page
81+
response = requests.get("http://localhost:8080/stream")
82+
self.assertEqual(response.status_code, 200)
83+
84+
# Check for the presence of key elements in the HTML
85+
self.assertIn("COMMIT !BINGO", response.text)
86+
self.assertIn("FREE MEAT", response.text)
87+
88+
# The board should be present
89+
self.assertIn("stream-board-container", response.text)
90+
91+
# The stream page should have a different background color
92+
self.assertIn("#00FF00", response.text) # STREAM_BG_COLOR
93+
94+
if __name__ == '__main__':
95+
unittest.main()

tests/test_phrases.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
import unittest
2+
import tempfile
3+
import os
4+
import time
5+
from unittest.mock import patch
6+
from src.core.phrases import has_too_many_repeats, load_phrases, check_phrases_file_change
7+
8+
class TestPhrases(unittest.TestCase):
9+
def setUp(self):
10+
# Create a temporary phrases.txt file
11+
self.temp_dir = tempfile.TemporaryDirectory()
12+
self.temp_file_path = os.path.join(self.temp_dir.name, "phrases.txt")
13+
14+
with open(self.temp_file_path, 'w') as f:
15+
f.write("FIRST PHRASE\n")
16+
f.write("SECOND PHRASE\n")
17+
f.write("THIRD PHRASE\n")
18+
f.write("DUPLICATE PHRASE\n")
19+
f.write("DUPLICATE PHRASE\n") # Duplicate to test deduplication
20+
f.write("REPETITIVE WORD WORD WORD\n") # To test repetition filtering
21+
22+
def tearDown(self):
23+
self.temp_dir.cleanup()
24+
25+
def test_has_too_many_repeats(self):
26+
"""Test the function that checks for repetitive words."""
27+
# Should return False for a phrase with no repeats
28+
self.assertFalse(has_too_many_repeats("NO REPEATS HERE"))
29+
30+
# Should return False for a phrase with some repeats (below threshold)
31+
self.assertFalse(has_too_many_repeats("SOME REPEATS SOME"))
32+
33+
# Should return True for a phrase with many repeats
34+
self.assertTrue(has_too_many_repeats("WORD WORD WORD WORD WORD"))
35+
36+
def test_check_phrases_file_change(self):
37+
"""Test that file changes are detected."""
38+
# More complete mocking to ensure test isolation
39+
with patch('src.core.phrases.os.path.getmtime') as mock_getmtime, \
40+
patch('src.core.phrases.load_phrases') as mock_load_phrases, \
41+
patch('src.core.phrases.last_phrases_mtime', 100, create=True):
42+
43+
# First check (file hasn't changed)
44+
mock_getmtime.return_value = 100 # Same as current timestamp
45+
result = check_phrases_file_change()
46+
47+
# Should not detect a change if the timestamp is the same
48+
self.assertFalse(result)
49+
mock_load_phrases.assert_not_called()
50+
51+
# Reset the mock for the next assertion
52+
mock_load_phrases.reset_mock()
53+
54+
# Second check (file has changed)
55+
mock_getmtime.return_value = 200 # Different timestamp
56+
result = check_phrases_file_change()
57+
58+
# Should detect a change and reload phrases
59+
self.assertTrue(result)
60+
mock_load_phrases.assert_called_once()
61+
62+
if __name__ == '__main__':
63+
unittest.main()

tests/test_text_processing.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import unittest
2+
from src.utils.text_processing import split_phrase_into_lines
3+
4+
class TestTextProcessing(unittest.TestCase):
5+
def test_short_phrase_split(self):
6+
"""Test that short phrases (3 words or less) are split into one word per line."""
7+
phrase = "SHORT TEST PHRASE"
8+
result = split_phrase_into_lines(phrase)
9+
self.assertEqual(result, ["SHORT", "TEST", "PHRASE"])
10+
11+
def test_medium_phrase_split(self):
12+
"""Test that medium phrases are split into balanced lines."""
13+
phrase = "THIS IS A LONGER TEST PHRASE"
14+
result = split_phrase_into_lines(phrase)
15+
# Should be split into roughly equal length lines
16+
self.assertTrue(2 <= len(result) <= 3) # Should be 2 or 3 lines for medium phrases
17+
18+
def test_long_phrase_split(self):
19+
"""Test that long phrases can be split into multiple lines."""
20+
phrase = "THIS IS A VERY LONG TEST PHRASE THAT SHOULD BE SPLIT INTO MULTIPLE LINES"
21+
result = split_phrase_into_lines(phrase)
22+
# Should have multiple lines
23+
self.assertTrue(2 <= len(result) <= 4) # Between 2 and 4 lines
24+
25+
def test_forced_line_count(self):
26+
"""Test forcing a specific number of lines."""
27+
phrase = "THIS PHRASE SHOULD BE SPLIT INTO EXACTLY THREE LINES"
28+
result = split_phrase_into_lines(phrase, forced_lines=3)
29+
self.assertEqual(len(result), 3)
30+
31+
if __name__ == '__main__':
32+
unittest.main()

0 commit comments

Comments
 (0)