Skip to content

Commit 8a05a48

Browse files
test: add test tagging utility and improved hot reload test
- Add scripts/tag_tests.py for bulk test marker application - Create improved hot reload integration test with visual state validation - Remove temporary debugging files and images 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 1f5c5af commit 8a05a48

File tree

2 files changed

+374
-0
lines changed

2 files changed

+374
-0
lines changed

scripts/tag_tests.py

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Script to automatically add pytest marks to test files based on their characteristics.
4+
"""
5+
6+
import re
7+
from pathlib import Path
8+
9+
# Test file categorization
10+
TEST_CATEGORIES = {
11+
# Pure unit tests
12+
"unit": [
13+
"test_state_manager.py",
14+
"test_file_operations.py",
15+
"test_helpers.py",
16+
],
17+
18+
# Integration tests
19+
"integration": [
20+
"test_board_builder.py",
21+
"test_ui_functions.py",
22+
"test_state_persistence.py",
23+
"test_integration.py",
24+
"test_game_logic.py", # Has some integration aspects
25+
],
26+
27+
# E2E tests
28+
"e2e": [
29+
"test_hot_reload_integration.py",
30+
"test_hot_reload_integration_improved.py",
31+
"test_multi_session_simple.py",
32+
"test_multi_session_responsiveness.py",
33+
"test_multi_session_bdd.py",
34+
"test_state_persistence_bdd.py",
35+
],
36+
}
37+
38+
# Component-specific markers
39+
COMPONENT_MARKERS = {
40+
"state": ["test_state_manager.py", "test_state_persistence.py", "test_state_persistence_bugs.py", "test_state_persistence_issues.py"],
41+
"game_logic": ["test_game_logic.py"],
42+
"ui": ["test_board_builder.py", "test_ui_functions.py"],
43+
"persistence": ["test_state_persistence.py", "test_state_persistence_bugs.py", "test_hot_reload_integration.py"],
44+
"sync": ["test_multi_session_simple.py", "test_multi_session_responsiveness.py"],
45+
}
46+
47+
# Characteristic markers
48+
CHARACTERISTIC_MARKERS = {
49+
"playwright": ["test_hot_reload", "test_multi_session"],
50+
"slow": ["test_hot_reload", "test_multi_session", "_bdd.py"],
51+
"requires_app": ["test_hot_reload", "test_multi_session", "test_integration.py"],
52+
}
53+
54+
# Special markers
55+
SPECIAL_MARKERS = {
56+
"smoke": ["test_state_manager.py::test_initialization", "test_game_logic.py::test_generate_board"],
57+
"regression": ["test_state_persistence_bugs.py", "test_state_persistence_issues.py"],
58+
"performance": ["test_multi_session_responsiveness.py"],
59+
}
60+
61+
62+
def get_marks_for_file(filename):
63+
"""Determine which marks should be applied to a test file."""
64+
marks = set()
65+
66+
# Check speed/scope categories
67+
for category, files in TEST_CATEGORIES.items():
68+
if filename in files:
69+
marks.add(category)
70+
71+
# Check component markers
72+
for marker, files in COMPONENT_MARKERS.items():
73+
if filename in files:
74+
marks.add(marker)
75+
76+
# Check characteristic markers
77+
for marker, patterns in CHARACTERISTIC_MARKERS.items():
78+
if any(pattern in filename for pattern in patterns):
79+
marks.add(marker)
80+
81+
# Check special markers
82+
for marker, patterns in SPECIAL_MARKERS.items():
83+
if any(pattern in filename or filename in pattern for pattern in patterns):
84+
marks.add(marker)
85+
86+
return marks
87+
88+
89+
def add_marks_to_file(filepath):
90+
"""Add pytest marks to a test file."""
91+
content = filepath.read_text()
92+
filename = filepath.name
93+
94+
# Skip if already has marks (check for @pytest.mark at class level)
95+
if re.search(r'^@pytest\.mark\.\w+\s*\nclass\s+Test', content, re.MULTILINE):
96+
print(f"Skipping {filename} - already has marks")
97+
return
98+
99+
marks = get_marks_for_file(filename)
100+
if not marks:
101+
print(f"No marks for {filename}")
102+
return
103+
104+
# Sort marks for consistency
105+
marks = sorted(marks)
106+
mark_lines = [f"@pytest.mark.{mark}" for mark in marks]
107+
108+
# Add import if not present
109+
if "import pytest" not in content:
110+
# Find where to add import
111+
import_match = re.search(r'^(import .*?)\n\n', content, re.MULTILINE | re.DOTALL)
112+
if import_match:
113+
import_section = import_match.group(1)
114+
content = content.replace(import_section, f"{import_section}\nimport pytest")
115+
else:
116+
content = f"import pytest\n\n{content}"
117+
118+
# Add marks to test classes
119+
def add_marks_to_class(match):
120+
indent = match.group(1) if match.group(1) else ""
121+
class_line = match.group(2)
122+
marks_str = "\n".join(f"{indent}{mark}" for mark in mark_lines)
123+
return f"{marks_str}\n{indent}{class_line}"
124+
125+
# Match class definitions
126+
content = re.sub(
127+
r'^(\s*)class\s+(Test\w+.*?:)$',
128+
add_marks_to_class,
129+
content,
130+
flags=re.MULTILINE
131+
)
132+
133+
# For files without classes, add marks to individual test functions
134+
if "class Test" not in content:
135+
def add_marks_to_function(match):
136+
indent = match.group(1) if match.group(1) else ""
137+
async_marker = match.group(2) if match.group(2) else ""
138+
func_line = match.group(3)
139+
140+
# Build marks including async if needed
141+
all_marks = mark_lines.copy()
142+
if async_marker:
143+
# Already has async marker, just add our marks before it
144+
marks_str = "\n".join(f"{indent}{mark}" for mark in all_marks)
145+
return f"{marks_str}\n{indent}{async_marker}\n{indent}{func_line}"
146+
else:
147+
marks_str = "\n".join(f"{indent}{mark}" for mark in all_marks)
148+
return f"{marks_str}\n{indent}{func_line}"
149+
150+
# Match test functions (with or without @pytest.mark.asyncio)
151+
content = re.sub(
152+
r'^(\s*)(@pytest\.mark\.asyncio\s*\n)?(\s*def\s+test_\w+.*?:)$',
153+
add_marks_to_function,
154+
content,
155+
flags=re.MULTILINE
156+
)
157+
158+
filepath.write_text(content)
159+
print(f"Added marks to {filename}: {', '.join(marks)}")
160+
161+
162+
def main():
163+
"""Main function to process all test files."""
164+
tests_dir = Path(__file__).parent.parent / "tests"
165+
166+
for test_file in tests_dir.glob("test_*.py"):
167+
if test_file.name == "test_hot_reload_manual.py":
168+
# Skip manual test file
169+
continue
170+
add_marks_to_file(test_file)
171+
172+
173+
if __name__ == "__main__":
174+
main()
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
"""
2+
Improved integration test for hot reload persistence.
3+
This test focuses on validating the core state persistence mechanism
4+
without depending on specific board content.
5+
"""
6+
7+
import asyncio
8+
import json
9+
from pathlib import Path
10+
11+
import pytest
12+
from playwright.async_api import async_playwright, expect
13+
14+
15+
class TestHotReloadIntegrationImproved:
16+
"""Integration tests for hot reload state persistence."""
17+
18+
@pytest.mark.asyncio
19+
async def test_state_persistence_mechanism(self):
20+
"""Test that the state persistence mechanism works correctly."""
21+
async with async_playwright() as p:
22+
browser = await p.chromium.launch(headless=True)
23+
page = await browser.new_page()
24+
25+
try:
26+
# Navigate to the app
27+
await page.goto("http://localhost:8080")
28+
await page.wait_for_load_state("networkidle")
29+
30+
# Verify board is loaded
31+
tiles = await page.locator("[style*='cursor: pointer']").all()
32+
assert len(tiles) == 25, f"Should have exactly 25 tiles, got {len(tiles)}"
33+
34+
# Map tile indices to board positions
35+
# Board is 5x5, so index = row * 5 + col
36+
tiles_to_click = [
37+
(0, 0), # Top-left
38+
(0, 4), # Top-right
39+
(1, 1), # Second row, second column
40+
(3, 2), # Fourth row, middle
41+
]
42+
43+
# Click tiles by their board position
44+
for row, col in tiles_to_click:
45+
index = row * 5 + col
46+
await tiles[index].click()
47+
await asyncio.sleep(0.1) # Small delay for state save
48+
49+
# Wait a bit longer to ensure all saves complete
50+
await asyncio.sleep(0.5)
51+
52+
# Verify state file exists and contains our clicks
53+
state_file = Path("game_state.json")
54+
assert state_file.exists(), "State file should exist"
55+
56+
with open(state_file, 'r') as f:
57+
state_before = json.load(f)
58+
59+
# Convert to set of tuples for easier comparison
60+
clicked_before = {tuple(pos) for pos in state_before['clicked_tiles']}
61+
62+
# Should have our 4 clicks + FREE MEAT at (2,2)
63+
assert len(clicked_before) == 5, f"Should have 5 clicked tiles, got {len(clicked_before)}"
64+
65+
# Verify our clicked positions are in the state
66+
for pos in tiles_to_click:
67+
assert pos in clicked_before, f"Position {pos} should be in clicked tiles"
68+
69+
# Verify FREE MEAT is clicked
70+
assert (2, 2) in clicked_before, "FREE MEAT at (2,2) should be clicked"
71+
72+
# Take screenshot before reload for debugging
73+
await page.screenshot(path="test_before_reload.png")
74+
75+
# Reload the page
76+
await page.reload()
77+
await page.wait_for_load_state("networkidle")
78+
79+
# Take screenshot after reload for debugging
80+
await page.screenshot(path="test_after_reload.png")
81+
82+
# Verify state file still exists and has same data
83+
with open(state_file, 'r') as f:
84+
state_after = json.load(f)
85+
86+
clicked_after = {tuple(pos) for pos in state_after['clicked_tiles']}
87+
88+
# State should be identical
89+
assert clicked_before == clicked_after, "Clicked tiles should be preserved after reload"
90+
91+
# Verify board hasn't changed
92+
assert state_before['board'] == state_after['board'], "Board should remain the same"
93+
assert state_before['board_iteration'] == state_after['board_iteration'], "Board iteration should remain the same"
94+
assert state_before['today_seed'] == state_after['today_seed'], "Seed should remain the same"
95+
96+
finally:
97+
await browser.close()
98+
99+
@pytest.mark.asyncio
100+
async def test_visual_state_restoration(self):
101+
"""Test that clicked tiles visually appear clicked after reload."""
102+
async with async_playwright() as p:
103+
browser = await p.chromium.launch(headless=False) # Set to True for CI
104+
page = await browser.new_page()
105+
106+
try:
107+
# Navigate to the app
108+
await page.goto("http://localhost:8080")
109+
await page.wait_for_load_state("networkidle")
110+
111+
# Click a specific tile and get its visual state
112+
first_tile = page.locator("[style*='cursor: pointer']").first
113+
114+
# Get background color before clicking
115+
bg_before = await first_tile.evaluate("el => window.getComputedStyle(el).backgroundColor")
116+
117+
# Click the tile
118+
await first_tile.click()
119+
await asyncio.sleep(0.5)
120+
121+
# Get background color after clicking
122+
bg_after_click = await first_tile.evaluate("el => window.getComputedStyle(el).backgroundColor")
123+
124+
# Colors should be different (tile is now clicked)
125+
assert bg_before != bg_after_click, "Tile background should change when clicked"
126+
127+
# Reload the page
128+
await page.reload()
129+
await page.wait_for_load_state("networkidle")
130+
131+
# Get the same tile after reload
132+
first_tile_after_reload = page.locator("[style*='cursor: pointer']").first
133+
134+
# Get background color after reload
135+
bg_after_reload = await first_tile_after_reload.evaluate("el => window.getComputedStyle(el).backgroundColor")
136+
137+
# Color should match the clicked state
138+
assert bg_after_click == bg_after_reload, "Tile should maintain clicked appearance after reload"
139+
140+
finally:
141+
await browser.close()
142+
143+
@pytest.mark.asyncio
144+
async def test_multiple_sessions_share_state(self):
145+
"""Test that multiple browser sessions see the same state."""
146+
async with async_playwright() as p:
147+
browser1 = await p.chromium.launch(headless=True)
148+
browser2 = await p.chromium.launch(headless=True)
149+
150+
try:
151+
# User 1 connects
152+
page1 = await browser1.new_page()
153+
await page1.goto("http://localhost:8080")
154+
await page1.wait_for_load_state("networkidle")
155+
156+
# User 1 clicks a tile
157+
tiles1 = await page1.locator("[style*='cursor: pointer']").all()
158+
await tiles1[0].click() # Click first tile
159+
await asyncio.sleep(0.5)
160+
161+
# User 2 connects
162+
page2 = await browser2.new_page()
163+
await page2.goto("http://localhost:8080")
164+
await page2.wait_for_load_state("networkidle")
165+
166+
# Both users should see the same state
167+
state_file = Path("game_state.json")
168+
with open(state_file, 'r') as f:
169+
shared_state = json.load(f)
170+
171+
clicked_tiles = {tuple(pos) for pos in shared_state['clicked_tiles']}
172+
173+
# Should have tile at (0,0) and FREE MEAT at (2,2)
174+
assert (0, 0) in clicked_tiles, "First tile should be clicked"
175+
assert (2, 2) in clicked_tiles, "FREE MEAT should be clicked"
176+
assert len(clicked_tiles) == 2, "Should have exactly 2 clicked tiles"
177+
178+
# User 2 clicks another tile
179+
tiles2 = await page2.locator("[style*='cursor: pointer']").all()
180+
await tiles2[6].click() # Click a different tile
181+
await asyncio.sleep(0.5)
182+
183+
# User 1 reloads to see User 2's changes
184+
await page1.reload()
185+
await page1.wait_for_load_state("networkidle")
186+
187+
# Check final state
188+
with open(state_file, 'r') as f:
189+
final_state = json.load(f)
190+
191+
final_clicked = {tuple(pos) for pos in final_state['clicked_tiles']}
192+
assert len(final_clicked) == 3, "Should have 3 clicked tiles after both users clicked"
193+
194+
finally:
195+
await browser1.close()
196+
await browser2.close()
197+
198+
199+
if __name__ == "__main__":
200+
pytest.main([__file__, "-v", "-s"])

0 commit comments

Comments
 (0)