diff --git a/CLAUDE.md b/CLAUDE.md
index bb2b3e3..da24fd7 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -161,4 +161,93 @@ The project utilizes GitHub Actions for continuous integration and deployment:
- Determines new version based on commit messages
- Updates CHANGELOG.md
- Creates Git tag for the release
- - Publishes release on GitHub
\ No newline at end of file
+ - Publishes release on GitHub
+
+## Pre-Push Checklist
+
+Before pushing changes to the repository, run these checks locally to ensure the CI pipeline will pass:
+
+```bash
+# 1. Run linters to ensure code quality and style
+poetry run flake8 main.py src/ tests/
+poetry run black --check .
+poetry run isort --check .
+
+# 2. Run tests to ensure functionality works
+poetry run pytest
+
+# 3. Check test coverage to ensure sufficient testing
+poetry run pytest --cov=main --cov-report=term-missing
+
+# 4. Fix any linting issues
+poetry run black .
+poetry run isort .
+
+# 5. Run tests again after fixing linting issues
+poetry run pytest
+
+# 6. Verify application starts without errors
+poetry run python main.py # (Ctrl+C to exit after confirming it starts)
+```
+
+### Common CI Failure Points to Check:
+
+1. **Code Style Issues**:
+ - Inconsistent indentation
+ - Line length exceeding 88 characters
+ - Missing docstrings
+ - Improper import ordering
+
+2. **Test Failures**:
+ - Broken functionality due to recent changes
+ - Missing tests for new features
+ - Incorrectly mocked dependencies in tests
+ - Race conditions in async tests
+
+3. **Coverage Thresholds**:
+ - Insufficient test coverage on new code
+ - Missing edge case tests
+ - Uncovered exception handling paths
+
+### Quick Fix Command Sequence
+
+If you encounter CI failures, this sequence often resolves common issues:
+
+```bash
+# Fix style issues
+poetry run black .
+poetry run isort .
+
+# Run tests with coverage to identify untested code
+poetry run pytest --cov=main --cov-report=term-missing
+
+# Add tests for any uncovered code sections then run again
+poetry run pytest
+```
+
+## Testing Game State Synchronization
+
+Special attention should be paid to testing game state synchronization between the main view and the stream view:
+
+```bash
+# Run specific tests for state synchronization
+poetry run pytest -v tests/test_ui_functions.py::TestUIFunctions::test_header_updates_on_both_paths
+poetry run pytest -v tests/test_ui_functions.py::TestUIFunctions::test_stream_header_update_when_game_closed
+```
+
+When making changes to game state management, especially related to:
+- Game closing/reopening
+- Header text updates
+- Board visibility
+- Broadcast mechanisms
+
+Verify both these scenarios:
+1. Changes made on main view are reflected in stream view
+2. Changes persist across page refreshes
+3. New connections to stream page see the correct state
+
+Common issues:
+- Missing ui.broadcast() calls
+- Not handling header updates across different views
+- Not checking if game is closed in sync_board_state
+- Ignoring exception handling for disconnected clients
\ No newline at end of file
diff --git a/main.py b/main.py
index 5b08be9..6150020 100644
--- a/main.py
+++ b/main.py
@@ -1,20 +1,23 @@
-from nicegui import ui
-import random
+import asyncio
import datetime
import logging
-import asyncio
import os
+import random
+
from fastapi.staticfiles import StaticFiles
-from nicegui import app
+from nicegui import app, ui
# Set up logging
-logging.basicConfig(level=logging.DEBUG, format='%(asctime)s - %(levelname)s - %(message)s')
+logging.basicConfig(
+ level=logging.DEBUG, format="%(asctime)s - %(levelname)s - %(message)s"
+)
# Global variable to track phrases.txt modification time.
last_phrases_mtime = os.path.getmtime("phrases.txt")
HEADER_TEXT = "COMMIT !BINGO"
-HEADER_TEXT_COLOR = "#0CB2B3" # Color for header text
+HEADER_TEXT_COLOR = "#0CB2B3" # Color for header text
+CLOSED_HEADER_TEXT = "Bingo Is Closed" # Text to display when game is closed
FREE_SPACE_TEXT = "FREE MEAT"
FREE_SPACE_TEXT_COLOR = "#FF7f33"
@@ -26,19 +29,23 @@
TILE_UNCLICKED_TEXT_COLOR = "#100079"
-HOME_BG_COLOR = "#100079" # Background for home page
-STREAM_BG_COLOR = "#00FF00" # Background for stream page
+HOME_BG_COLOR = "#100079" # Background for home page
+STREAM_BG_COLOR = "#00FF00" # Background for stream page
HEADER_FONT_FAMILY = "'Super Carnival', sans-serif"
BOARD_TILE_FONT = "Inter" # Set the desired Google Font for board tiles
BOARD_TILE_FONT_WEIGHT = "700" # Default weight for board tiles; adjust as needed.
-BOARD_TILE_FONT_STYLE = "normal" # Default font style for board tiles; for example, "normal" or "italic"
+BOARD_TILE_FONT_STYLE = (
+ "normal" # Default font style for board tiles; for example, "normal" or "italic"
+)
# UI Class Constants
BOARD_CONTAINER_CLASS = "flex justify-center items-center w-full"
HEADER_CONTAINER_CLASS = "w-full"
-CARD_CLASSES = "relative p-2 rounded-xl shadow-8 w-full h-full flex items-center justify-center"
+CARD_CLASSES = (
+ "relative p-2 rounded-xl shadow-8 w-full h-full flex items-center justify-center"
+)
COLUMN_CLASSES = "flex flex-col items-center justify-center gap-0 w-full"
GRID_CONTAINER_CLASS = "w-full aspect-square p-4"
GRID_CLASSES = "gap-2 h-full grid-rows-5"
@@ -56,6 +63,13 @@
# Global set to track winning patterns (rows, columns, & diagonals)
bingo_patterns = set()
+# Global flag to track if the game is closed
+is_game_closed = False
+
+# Global variable to store header label reference
+header_label = None
+
+
def generate_board(seed_val: int):
"""
Generate a new board using the provided seed value.
@@ -66,7 +80,7 @@ def generate_board(seed_val: int):
random.seed(seed_val)
shuffled_phrases = random.sample(phrases, 24)
shuffled_phrases.insert(12, FREE_SPACE_TEXT)
- board = [shuffled_phrases[i:i+5] for i in range(0, 25, 5)]
+ board = [shuffled_phrases[i : i + 5] for i in range(0, 25, 5)]
clicked_tiles.clear()
for r, row in enumerate(board):
for c, phrase in enumerate(row):
@@ -74,6 +88,7 @@ def generate_board(seed_val: int):
clicked_tiles.add((r, c))
today_seed = f"{todays_seed}.{seed_val}"
+
def get_line_style_for_lines(line_count: int, default_text_color: str) -> str:
"""
Return a complete style string with an adjusted line-height based on the number of lines
@@ -85,11 +100,12 @@ def get_line_style_for_lines(line_count: int, default_text_color: str) -> str:
elif line_count == 2:
lh = "1.2em" # Slightly reduced spacing for two lines.
elif line_count == 3:
- lh = "0.9em" # Even tighter spacing for three lines.
+ lh = "0.9em" # Even tighter spacing for three lines.
else:
lh = "0.7em" # For four or more lines.
return f"font-family: '{BOARD_TILE_FONT}', sans-serif; font-weight: {BOARD_TILE_FONT_WEIGHT}; font-style: {BOARD_TILE_FONT_STYLE}; padding: 0; margin: 0; color: {default_text_color}; line-height: {lh};"
+
# Read phrases from a text file and convert them to uppercase.
with open("phrases.txt", "r") as f:
raw_phrases = [line.strip().upper() for line in f if line.strip()]
@@ -102,6 +118,7 @@ def get_line_style_for_lines(line_count: int, default_text_color: str) -> str:
seen.add(p)
unique_phrases.append(p)
+
# Optional: filter out phrases with too many repeated words.
def has_too_many_repeats(phrase, threshold=0.5):
"""
@@ -115,10 +132,13 @@ def has_too_many_repeats(phrase, threshold=0.5):
unique_count = len(set(words))
ratio = unique_count / len(words)
if ratio < threshold:
- logging.debug(f"Discarding phrase '{phrase}' due to repeats: {unique_count}/{len(words)} = {ratio:.2f} < {threshold}")
+ logging.debug(
+ f"Discarding phrase '{phrase}' due to repeats: {unique_count}/{len(words)} = {ratio:.2f} < {threshold}"
+ )
return True
return False
+
phrases = [p for p in unique_phrases if not has_too_many_repeats(p)]
# Track clicked tiles and store chip references
@@ -129,6 +149,7 @@ def has_too_many_repeats(phrase, threshold=0.5):
# Initialize the board using the default iteration value.
generate_board(board_iteration)
+
def split_phrase_into_lines(phrase: str, forced_lines: int = None) -> list:
"""
Splits the phrase into balanced lines.
@@ -151,75 +172,81 @@ def segment_length(segment):
candidates = [] # list of tuples: (number_of_lines, diff, candidate)
# 2-line candidate
- best_diff_2 = float('inf')
+ best_diff_2 = float("inf")
best_seg_2 = None
for i in range(1, n):
- seg1 = words[:i]
- seg2 = words[i:]
- len1 = segment_length(seg1)
- len2 = segment_length(seg2)
- diff = abs(len1 - len2)
- if diff < best_diff_2:
- best_diff_2 = diff
- best_seg_2 = [" ".join(seg1), " ".join(seg2)]
+ seg1 = words[:i]
+ seg2 = words[i:]
+ len1 = segment_length(seg1)
+ len2 = segment_length(seg2)
+ diff = abs(len1 - len2)
+ if diff < best_diff_2:
+ best_diff_2 = diff
+ best_seg_2 = [" ".join(seg1), " ".join(seg2)]
if best_seg_2 is not None:
- candidates.append((2, best_diff_2, best_seg_2))
-
+ candidates.append((2, best_diff_2, best_seg_2))
+
# 3-line candidate (if at least 4 words)
if n >= 4:
- best_diff_3 = float('inf')
- best_seg_3 = None
- for i in range(1, n-1):
- for j in range(i+1, n):
- seg1 = words[:i]
- seg2 = words[i:j]
- seg3 = words[j:]
- len1 = segment_length(seg1)
- len2 = segment_length(seg2)
- len3 = segment_length(seg3)
- current_diff = max(len1, len2, len3) - min(len1, len2, len3)
- if current_diff < best_diff_3:
- best_diff_3 = current_diff
- best_seg_3 = [" ".join(seg1), " ".join(seg2), " ".join(seg3)]
- if best_seg_3 is not None:
- candidates.append((3, best_diff_3, best_seg_3))
+ best_diff_3 = float("inf")
+ best_seg_3 = None
+ for i in range(1, n - 1):
+ for j in range(i + 1, n):
+ seg1 = words[:i]
+ seg2 = words[i:j]
+ seg3 = words[j:]
+ len1 = segment_length(seg1)
+ len2 = segment_length(seg2)
+ len3 = segment_length(seg3)
+ current_diff = max(len1, len2, len3) - min(len1, len2, len3)
+ if current_diff < best_diff_3:
+ best_diff_3 = current_diff
+ best_seg_3 = [" ".join(seg1), " ".join(seg2), " ".join(seg3)]
+ if best_seg_3 is not None:
+ candidates.append((3, best_diff_3, best_seg_3))
# 4-line candidate (if at least 5 words)
if n >= 5:
- best_diff_4 = float('inf')
- best_seg_4 = None
- for i in range(1, n-2):
- for j in range(i+1, n-1):
- for k in range(j+1, n):
- seg1 = words[:i]
- seg2 = words[i:j]
- seg3 = words[j:k]
- seg4 = words[k:]
- len1 = segment_length(seg1)
- len2 = segment_length(seg2)
- len3 = segment_length(seg3)
- len4 = segment_length(seg4)
- diff = max(len1, len2, len3, len4) - min(len1, len2, len3, len4)
- if diff < best_diff_4:
- best_diff_4 = diff
- best_seg_4 = [" ".join(seg1), " ".join(seg2), " ".join(seg3), " ".join(seg4)]
- if best_seg_4 is not None:
- candidates.append((4, best_diff_4, best_seg_4))
+ best_diff_4 = float("inf")
+ best_seg_4 = None
+ for i in range(1, n - 2):
+ for j in range(i + 1, n - 1):
+ for k in range(j + 1, n):
+ seg1 = words[:i]
+ seg2 = words[i:j]
+ seg3 = words[j:k]
+ seg4 = words[k:]
+ len1 = segment_length(seg1)
+ len2 = segment_length(seg2)
+ len3 = segment_length(seg3)
+ len4 = segment_length(seg4)
+ diff = max(len1, len2, len3, len4) - min(len1, len2, len3, len4)
+ if diff < best_diff_4:
+ best_diff_4 = diff
+ best_seg_4 = [
+ " ".join(seg1),
+ " ".join(seg2),
+ " ".join(seg3),
+ " ".join(seg4),
+ ]
+ if best_seg_4 is not None:
+ candidates.append((4, best_diff_4, best_seg_4))
# If a forced number of lines is specified, try to return that candidate first.
if forced_lines is not None:
- forced_candidates = [cand for cand in candidates if cand[0] == forced_lines]
- if forced_candidates:
- _, _, best_candidate = min(forced_candidates, key=lambda x: x[1])
- return best_candidate
+ forced_candidates = [cand for cand in candidates if cand[0] == forced_lines]
+ if forced_candidates:
+ _, _, best_candidate = min(forced_candidates, key=lambda x: x[1])
+ return best_candidate
# Otherwise, choose the candidate with the smallest diff.
if candidates:
- _, best_diff, best_candidate = min(candidates, key=lambda x: x[1])
- return best_candidate
+ _, best_diff, best_candidate = min(candidates, key=lambda x: x[1])
+ return best_candidate
else:
- # fallback (should never happen)
- return [" ".join(words)]
+ # fallback (should never happen)
+ return [" ".join(words)]
+
# Toggle tile click state (for example usage)
def toggle_tile(row, col):
@@ -231,9 +258,9 @@ def toggle_tile(row, col):
clicked_tiles.remove(key)
else:
clicked_tiles.add(key)
-
+
check_winner()
-
+
for view_key, (container, tile_buttons_local) in board_views.items():
for (r, c), tile in tile_buttons_local.items():
phrase = board[r][c]
@@ -243,22 +270,22 @@ def toggle_tile(row, col):
else:
new_card_style = f"background-color: {TILE_UNCLICKED_BG_COLOR}; color: {TILE_UNCLICKED_TEXT_COLOR}; border: none;"
new_label_color = TILE_UNCLICKED_TEXT_COLOR
-
+
tile["card"].style(new_card_style)
lines = split_phrase_into_lines(phrase)
line_count = len(lines)
new_label_style = get_line_style_for_lines(line_count, new_label_color)
-
+
for label_info in tile["labels"]:
lbl = label_info["ref"]
lbl.classes(label_info["base_classes"])
lbl.style(new_label_style)
lbl.update()
-
+
tile["card"].update()
-
+
container.update()
-
+
try:
js_code = """
setTimeout(function() {
@@ -272,6 +299,7 @@ def toggle_tile(row, col):
except Exception as e:
logging.debug(f"JavaScript execution failed: {e}")
+
# Check for Bingo win condition
def check_winner():
global bingo_patterns
@@ -291,7 +319,7 @@ def check_winner():
new_patterns.append("diag_main")
# Check anti-diagonal.
- if all((i, 4-i) in clicked_tiles for i in range(5)):
+ if all((i, 4 - i) in clicked_tiles for i in range(5)):
if "diag_anti" not in bingo_patterns:
new_patterns.append("diag_anti")
@@ -303,7 +331,7 @@ def check_winner():
new_patterns.append("blackout")
# 4 Corners: top-left, top-right, bottom-left, bottom-right.
- if all(pos in clicked_tiles for pos in [(0,0), (0,4), (4,0), (4,4)]):
+ if all(pos in clicked_tiles for pos in [(0, 0), (0, 4), (4, 0), (4, 4)]):
if "four_corners" not in bingo_patterns:
new_patterns.append("four_corners")
@@ -314,12 +342,19 @@ def check_winner():
new_patterns.append("plus")
# X shape: both diagonals complete.
- if all((i, i) in clicked_tiles for i in range(5)) and all((i, 4-i) in clicked_tiles for i in range(5)):
+ if all((i, i) in clicked_tiles for i in range(5)) and all(
+ (i, 4 - i) in clicked_tiles for i in range(5)
+ ):
if "x_shape" not in bingo_patterns:
new_patterns.append("x_shape")
# Outside edges (perimeter): all border cells clicked.
- perimeter_cells = {(0, c) for c in range(5)} | {(4, c) for c in range(5)} | {(r, 0) for r in range(5)} | {(r, 4) for r in range(5)}
+ perimeter_cells = (
+ {(0, c) for c in range(5)}
+ | {(4, c) for c in range(5)}
+ | {(r, 0) for r in range(5)}
+ | {(r, 4) for r in range(5)}
+ )
if all(cell in clicked_tiles for cell in perimeter_cells):
if "perimeter" not in bingo_patterns:
new_patterns.append("perimeter")
@@ -356,15 +391,53 @@ def check_winner():
sp_message = sp.replace("_", " ").title() + " Bingo!"
ui.notify(sp_message, color="blue", duration=5)
+
def sync_board_state():
"""
Update tile styles in every board view (e.g., home and stream).
+ Also handles the game closed state to ensure consistency across views.
"""
try:
+ global is_game_closed, header_label
+
+ # If game is closed, make sure all views reflect that
+ if is_game_closed:
+ # Update header if available
+ if header_label:
+ header_label.set_text(CLOSED_HEADER_TEXT)
+ header_label.update()
+
+ # Hide all board views
+ for view_key, (container, _) in board_views.items():
+ container.style("display: none;")
+ container.update()
+
+ # Make sure controls row is showing only the Start New Game button
+ if "controls_row" in globals():
+ # Check if controls row has been already updated
+ if (
+ controls_row.default_slot
+ and len(controls_row.default_slot.children) != 1
+ ):
+ controls_row.clear()
+ with controls_row:
+ with ui.button(
+ "", icon="autorenew", on_click=reopen_game
+ ).classes("rounded-full w-12 h-12") as new_game_btn:
+ ui.tooltip("Start New Game")
+
+ return
+ else:
+ # Ensure header text is correct when game is open
+ if header_label and header_label.text != HEADER_TEXT:
+ header_label.set_text(HEADER_TEXT)
+ header_label.update()
+
+ # Normal update if game is not closed
# Update tile styles in every board view (e.g., home and stream)
for view_key, (container, tile_buttons_local) in board_views.items():
update_tile_styles(tile_buttons_local)
-
+
# Safely run JavaScript to resize text
try:
# Add a slight delay to ensure DOM updates have propagated
@@ -378,10 +451,13 @@ def sync_board_state():
"""
ui.run_javascript(js_code)
except Exception as e:
- logging.debug(f"JavaScript execution failed (likely disconnected client): {e}")
+ logging.debug(
+ f"JavaScript execution failed (likely disconnected client): {e}"
+ )
except Exception as e:
logging.debug(f"Error in sync_board_state: {e}")
+
def create_board_view(background_color: str, is_global: bool):
"""
Creates a board page view based on the background color and a flag.
@@ -391,18 +467,26 @@ def create_board_view(background_color: str, is_global: bool):
setup_head(background_color)
# Create the board container. For the home view, assign an ID to capture it.
if is_global:
- container = ui.element("div").classes("home-board-container flex justify-center items-center w-full")
+ container = ui.element("div").classes(
+ "home-board-container flex justify-center items-center w-full"
+ )
try:
- ui.run_javascript("document.querySelector('.home-board-container').id = 'board-container'")
+ ui.run_javascript(
+ "document.querySelector('.home-board-container').id = 'board-container'"
+ )
except Exception as e:
logging.debug(f"Setting board container ID failed: {e}")
else:
- container = ui.element("div").classes("stream-board-container flex justify-center items-center w-full")
+ container = ui.element("div").classes(
+ "stream-board-container flex justify-center items-center w-full"
+ )
try:
- ui.run_javascript("document.querySelector('.stream-board-container').id = 'board-container-stream'")
+ ui.run_javascript(
+ "document.querySelector('.stream-board-container').id = 'board-container-stream'"
+ )
except Exception as e:
logging.debug(f"Setting stream container ID failed: {e}")
-
+
if is_global:
global home_board_container, tile_buttons, seed_label
home_board_container = container
@@ -414,21 +498,36 @@ def create_board_view(background_color: str, is_global: bool):
check_timer = ui.timer(1, check_phrases_file_change)
except Exception as e:
logging.warning(f"Error setting up timer: {e}")
-
- global seed_label
- with ui.row().classes("w-full mt-4 items-center justify-center gap-4"):
- with ui.button("", icon="refresh", on_click=reset_board).classes("rounded-full w-12 h-12") as reset_btn:
- ui.tooltip("Reset Board")
- with ui.button("", icon="autorenew", on_click=generate_new_board).classes("rounded-full w-12 h-12") as new_board_btn:
- ui.tooltip("New Board")
- seed_label = ui.label(f"Seed: {today_seed}").classes("text-sm text-center").style(
- f"font-family: '{BOARD_TILE_FONT}', sans-serif; color: {TILE_UNCLICKED_BG_COLOR};"
- )
+
+ global seed_label, controls_row
+ with ui.row().classes(
+ "w-full mt-4 items-center justify-center gap-4"
+ ) as controls_row:
+ with ui.button("", icon="refresh", on_click=reset_board).classes(
+ "rounded-full w-12 h-12"
+ ) as reset_btn:
+ ui.tooltip("Reset Board")
+ with ui.button("", icon="autorenew", on_click=generate_new_board).classes(
+ "rounded-full w-12 h-12"
+ ) as new_board_btn:
+ ui.tooltip("New Board")
+ with ui.button("", icon="close", on_click=close_game).classes(
+ "rounded-full w-12 h-12 bg-red-500"
+ ) as close_btn:
+ ui.tooltip("Close Game")
+ seed_label = (
+ ui.label(f"Seed: {today_seed}")
+ .classes("text-sm text-center")
+ .style(
+ f"font-family: '{BOARD_TILE_FONT}', sans-serif; color: {TILE_UNCLICKED_BG_COLOR};"
+ )
+ )
else:
local_tile_buttons = {}
build_board(container, local_tile_buttons, toggle_tile)
board_views["stream"] = (container, local_tile_buttons)
+
@ui.page("/")
def home_page():
create_board_view(HOME_BG_COLOR, True)
@@ -438,6 +537,7 @@ def home_page():
except Exception as e:
logging.warning(f"Error creating timer: {e}")
+
@ui.page("/stream")
def stream_page():
create_board_view(STREAM_BG_COLOR, False)
@@ -447,11 +547,13 @@ def stream_page():
except Exception as e:
logging.warning(f"Error creating timer: {e}")
+
def setup_head(background_color: str):
"""
Set up common head elements: fonts, fitty JS, and background color.
"""
- ui.add_css("""
+ ui.add_css(
+ """
@font-face {
font-family: 'Super Carnival';
@@ -461,19 +563,29 @@ def setup_head(background_color: str):
src: url('/static/Super%20Carnival.woff') format('woff');
}
- """)
-
- ui.add_head_html(f"""
+ """
+ )
+
+ ui.add_head_html(
+ f"""
- """)
+ """
+ )
# Add CSS class for board tile fonts; you can later reference this class in your CSS.
- ui.add_head_html(get_google_font_css(BOARD_TILE_FONT, BOARD_TILE_FONT_WEIGHT, BOARD_TILE_FONT_STYLE, "board_tile"))
-
- ui.add_head_html('')
+ ui.add_head_html(
+ get_google_font_css(
+ BOARD_TILE_FONT, BOARD_TILE_FONT_WEIGHT, BOARD_TILE_FONT_STYLE, "board_tile"
+ )
+ )
+
+ ui.add_head_html(
+ ''
+ )
# Add html2canvas library and capture function.
- ui.add_head_html("""
+ ui.add_head_html(
+ """
- """)
-
- ui.add_head_html(f'')
-
- ui.add_head_html("""""")
-
+ """
+ )
+
# Use full width with padding so the header spans edge-to-edge
with ui.element("div").classes("w-full"):
- ui.label(f"{HEADER_TEXT}").classes("fit-header text-center").style(f"font-family: {HEADER_FONT_FAMILY}; color: {HEADER_TEXT_COLOR};")
+ global header_label
+ header_label = (
+ ui.label(f"{HEADER_TEXT}")
+ .classes("fit-header text-center")
+ .style(f"font-family: {HEADER_FONT_FAMILY}; color: {HEADER_TEXT_COLOR};")
+ )
-def get_google_font_css(font_name: str, weight: str, style: str, uniquifier: str) -> str:
+
+def get_google_font_css(
+ font_name: str, weight: str, style: str, uniquifier: str
+) -> str:
"""
Returns a CSS style block defining a class for the specified Google font.
'uniquifier' is used as the CSS class name.
@@ -554,6 +677,7 @@ def get_google_font_css(font_name: str, weight: str, style: str, uniquifier: str
"""
+
def build_board(parent, tile_buttons_dict: dict, on_tile_click):
"""
Build the common Bingo board in the given parent element.
@@ -567,28 +691,61 @@ def build_board(parent, tile_buttons_dict: dict, on_tile_click):
card = ui.card().classes(CARD_CLASSES).style("cursor: pointer;")
labels_list = [] # initialize list for storing label metadata
with card:
- with ui.column().classes("flex flex-col items-center justify-center gap-0 w-full"):
- default_text_color = FREE_SPACE_TEXT_COLOR if phrase.upper() == FREE_SPACE_TEXT else TILE_UNCLICKED_TEXT_COLOR
+ with ui.column().classes(
+ "flex flex-col items-center justify-center gap-0 w-full"
+ ):
+ default_text_color = (
+ FREE_SPACE_TEXT_COLOR
+ if phrase.upper() == FREE_SPACE_TEXT
+ else TILE_UNCLICKED_TEXT_COLOR
+ )
lines = split_phrase_into_lines(phrase)
line_count = len(lines)
for line in lines:
- with ui.row().classes("w-full items-center justify-center"):
- base_class = LABEL_SMALL_CLASSES if len(line) <= 3 else LABEL_CLASSES
- lbl = ui.label(line).classes(base_class).style(get_line_style_for_lines(line_count, default_text_color))
- labels_list.append({
- "ref": lbl,
- "base_classes": base_class,
- "base_style": get_line_style_for_lines(line_count, default_text_color)
- })
- tile_buttons_dict[(row_idx, col_idx)] = {"card": card, "labels": labels_list}
+ with ui.row().classes(
+ "w-full items-center justify-center"
+ ):
+ base_class = (
+ LABEL_SMALL_CLASSES
+ if len(line) <= 3
+ else LABEL_CLASSES
+ )
+ lbl = (
+ ui.label(line)
+ .classes(base_class)
+ .style(
+ get_line_style_for_lines(
+ line_count, default_text_color
+ )
+ )
+ )
+ labels_list.append(
+ {
+ "ref": lbl,
+ "base_classes": base_class,
+ "base_style": get_line_style_for_lines(
+ line_count, default_text_color
+ ),
+ }
+ )
+ tile_buttons_dict[(row_idx, col_idx)] = {
+ "card": card,
+ "labels": labels_list,
+ }
if phrase.upper() == FREE_SPACE_TEXT:
clicked_tiles.add((row_idx, col_idx))
- card.style(f"color: {FREE_SPACE_TEXT_COLOR}; border: none; outline: 3px solid {TILE_CLICKED_TEXT_COLOR};")
-
+ card.style(
+ f"color: {FREE_SPACE_TEXT_COLOR}; border: none; outline: 3px solid {TILE_CLICKED_TEXT_COLOR};"
+ )
+
else:
- card.on("click", lambda e, r=row_idx, c=col_idx: on_tile_click(r, c))
+ card.on(
+ "click",
+ lambda e, r=row_idx, c=col_idx: on_tile_click(r, c),
+ )
return tile_buttons_dict
+
def update_tile_styles(tile_buttons_dict: dict):
"""
Update styles for each tile and its text labels based on the global clicked_tiles.
@@ -622,7 +779,7 @@ def update_tile_styles(tile_buttons_dict: dict):
# Update inline style (which may now use a new color due to tile click state).
lbl.style(new_label_style)
lbl.update()
-
+
# Safely run JavaScript
try:
# Add a slight delay to ensure DOM updates have propagated
@@ -638,6 +795,7 @@ def update_tile_styles(tile_buttons_dict: dict):
except Exception as e:
logging.debug(f"JavaScript execution failed (likely disconnected client): {e}")
+
def check_phrases_file_change():
"""
Check if phrases.txt has changed. If so, re-read the file, update the board,
@@ -677,7 +835,9 @@ def has_too_many_repeats(phrase, threshold=0.5):
unique_count = len(set(words))
ratio = unique_count / len(words)
if ratio < threshold:
- logging.debug(f"Discarding phrase '{phrase}' due to repeats: {unique_count}/{len(words)} = {ratio:.2f} < {threshold}")
+ logging.debug(
+ f"Discarding phrase '{phrase}' due to repeats: {unique_count}/{len(words)} = {ratio:.2f} < {threshold}"
+ )
return True
return False
@@ -690,7 +850,7 @@ def has_too_many_repeats(phrase, threshold=0.5):
tile_buttons_local.clear() # Clear local board dictionary.
build_board(container, tile_buttons_local, toggle_tile)
container.update() # Force update so new styles are applied immediately.
-
+
# Safely run JavaScript
try:
# Add a slight delay to ensure DOM updates have propagated
@@ -704,7 +864,10 @@ def has_too_many_repeats(phrase, threshold=0.5):
"""
ui.run_javascript(js_code)
except Exception as e:
- logging.debug(f"JavaScript execution failed (likely disconnected client): {e}")
+ logging.debug(
+ f"JavaScript execution failed (likely disconnected client): {e}"
+ )
+
def reset_board():
"""
@@ -720,6 +883,7 @@ def reset_board():
clicked_tiles.add((r, c))
sync_board_state()
+
def generate_new_board():
"""
Generate a new board with an incremented iteration seed and update all board views.
@@ -729,16 +893,114 @@ def generate_new_board():
generate_board(board_iteration)
# Update all board views (both home and stream)
for view_key, (container, tile_buttons_local) in board_views.items():
- container.clear()
- tile_buttons_local.clear()
- build_board(container, tile_buttons_local, toggle_tile)
- container.update()
+ container.clear()
+ tile_buttons_local.clear()
+ build_board(container, tile_buttons_local, toggle_tile)
+ container.update()
# Update the seed label if available
- if 'seed_label' in globals():
- seed_label.set_text(f"Seed: {today_seed}")
- seed_label.update()
+ if "seed_label" in globals():
+ seed_label.set_text(f"Seed: {today_seed}")
+ seed_label.update()
reset_board()
+
+def close_game():
+ """
+ Close the game - hide the board and update the header text.
+ This function is called when the close button is clicked.
+ """
+ global is_game_closed, header_label
+ is_game_closed = True
+
+ # Update header text on the current view
+ if header_label:
+ header_label.set_text(CLOSED_HEADER_TEXT)
+ header_label.update()
+
+ # Hide all board views (both home and stream)
+ for view_key, (container, tile_buttons_local) in board_views.items():
+ container.style("display: none;")
+ container.update()
+
+ # Modify the controls row to only show the New Board button
+ if "controls_row" in globals():
+ controls_row.clear()
+ with controls_row:
+ with ui.button("", icon="autorenew", on_click=reopen_game).classes(
+ "rounded-full w-12 h-12"
+ ) as new_game_btn:
+ ui.tooltip("Start New Game")
+
+ # Update stream page as well - this will trigger sync_board_state on connected clients
+ ui.broadcast() # Broadcast changes to all connected clients
+
+ # Notify that game has been closed
+ ui.notify("Game has been closed", color="red", duration=3)
+
+
+def reopen_game():
+ """
+ Reopen the game after it has been closed.
+ This regenerates a new board and resets the UI.
+ """
+ global is_game_closed, header_label, board_iteration, controls_row
+
+ # Reset game state
+ is_game_closed = False
+
+ # Update header text back to original for the current view
+ if header_label:
+ header_label.set_text(HEADER_TEXT)
+ header_label.update()
+
+ # Generate a new board
+ board_iteration += 1
+ generate_board(board_iteration)
+
+ # Rebuild the controls row with all buttons
+ if "controls_row" in globals():
+ controls_row.clear()
+ global seed_label
+ with controls_row:
+ with ui.button("", icon="refresh", on_click=reset_board).classes(
+ "rounded-full w-12 h-12"
+ ) as reset_btn:
+ ui.tooltip("Reset Board")
+ with ui.button("", icon="autorenew", on_click=generate_new_board).classes(
+ "rounded-full w-12 h-12"
+ ) as new_board_btn:
+ ui.tooltip("New Board")
+ with ui.button("", icon="close", on_click=close_game).classes(
+ "rounded-full w-12 h-12 bg-red-500"
+ ) as close_btn:
+ ui.tooltip("Close Game")
+ seed_label = (
+ ui.label(f"Seed: {today_seed}")
+ .classes("text-sm text-center")
+ .style(
+ f"font-family: '{BOARD_TILE_FONT}', sans-serif; color: {TILE_UNCLICKED_BG_COLOR};"
+ )
+ )
+
+ # Recreate and show all board views
+ for view_key, (container, tile_buttons_local) in board_views.items():
+ container.style("display: block;")
+ container.clear()
+ tile_buttons_local.clear()
+ build_board(container, tile_buttons_local, toggle_tile)
+ container.update()
+
+ # Reset clicked tiles except for FREE SPACE
+ reset_board()
+
+ # Notify that a new game has started
+ ui.notify("New game started", color="green", duration=3)
+
+ # Update stream page and all other connected clients
+ # This will trigger sync_board_state on all clients including the stream view
+ ui.broadcast()
+
+
# Mount the local 'static' directory so that files like "Super Carnival.woff" can be served
app.mount("/static", StaticFiles(directory="static"), name="static")
diff --git a/tests/test_file_operations.py b/tests/test_file_operations.py
index 0cb75b4..5f5b478 100644
--- a/tests/test_file_operations.py
+++ b/tests/test_file_operations.py
@@ -1,112 +1,117 @@
-import unittest
-import sys
import os
+import sys
import tempfile
-from unittest.mock import patch, MagicMock, mock_open
+import unittest
+from unittest.mock import MagicMock, mock_open, patch
# Add the parent directory to sys.path to import from main.py
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
# We need to mock the NiceGUI imports and other dependencies before importing main
-sys.modules['nicegui'] = MagicMock()
-sys.modules['nicegui.ui'] = MagicMock()
-sys.modules['fastapi.staticfiles'] = MagicMock()
+sys.modules["nicegui"] = MagicMock()
+sys.modules["nicegui.ui"] = MagicMock()
+sys.modules["fastapi.staticfiles"] = MagicMock()
# Import the function we want to test
from main import check_phrases_file_change
+
class TestFileOperations(unittest.TestCase):
def setUp(self):
# Create mocks and patches
self.patches = [
- patch('main.last_phrases_mtime', 123456),
- patch('main.phrases', []),
- patch('main.board', []),
- patch('main.board_views', {}),
- patch('main.board_iteration', 1),
- patch('main.generate_board'),
- patch('main.build_board'),
- patch('main.ui.run_javascript')
+ patch("main.last_phrases_mtime", 123456),
+ patch("main.phrases", []),
+ patch("main.board", []),
+ patch("main.board_views", {}),
+ patch("main.board_iteration", 1),
+ patch("main.generate_board"),
+ patch("main.build_board"),
+ patch("main.ui.run_javascript"),
]
-
+
for p in self.patches:
p.start()
-
+
def tearDown(self):
# Clean up patches
for p in self.patches:
p.stop()
-
- @patch('os.path.getmtime')
- @patch('builtins.open', new_callable=mock_open, read_data="PHRASE1\nPHRASE2\nPHRASE3")
+
+ @patch("os.path.getmtime")
+ @patch(
+ "builtins.open", new_callable=mock_open, read_data="PHRASE1\nPHRASE2\nPHRASE3"
+ )
def test_check_phrases_file_change_no_change(self, mock_file, mock_getmtime):
"""Test when phrases.txt has not changed"""
import main
-
+
# Mock that the file's mtime is the same as last check
mock_getmtime.return_value = main.last_phrases_mtime
-
+
# Run the function
check_phrases_file_change()
-
+
# The file should not have been opened
mock_file.assert_not_called()
-
+
# generate_board should not have been called
main.generate_board.assert_not_called()
-
- @patch('os.path.getmtime')
- @patch('builtins.open', new_callable=mock_open, read_data="PHRASE1\nPHRASE2\nPHRASE3")
+
+ @patch("os.path.getmtime")
+ @patch(
+ "builtins.open", new_callable=mock_open, read_data="PHRASE1\nPHRASE2\nPHRASE3"
+ )
def test_check_phrases_file_change_with_change(self, mock_file, mock_getmtime):
"""Test when phrases.txt has changed"""
import main
-
+
# Mock that the file's mtime is newer
mock_getmtime.return_value = main.last_phrases_mtime + 1
-
+
# Setup a mock board_views dictionary
container_mock = MagicMock()
tile_buttons_mock = {}
main.board_views = {"home": (container_mock, tile_buttons_mock)}
-
+
# Run the function
check_phrases_file_change()
-
+
# The file should have been opened
mock_file.assert_called_once_with("phrases.txt", "r")
-
+
# last_phrases_mtime should be updated
self.assertEqual(main.last_phrases_mtime, mock_getmtime.return_value)
-
+
# generate_board should have been called with board_iteration
main.generate_board.assert_called_once_with(main.board_iteration)
-
+
# Container should have been cleared
container_mock.clear.assert_called_once()
-
+
# build_board should have been called
main.build_board.assert_called_once()
-
+
# Container should have been updated
container_mock.update.assert_called_once()
-
+
# JavaScript should have been executed to resize text
main.ui.run_javascript.assert_called_once()
-
- @patch('os.path.getmtime')
+
+ @patch("os.path.getmtime")
def test_check_phrases_file_change_with_error(self, mock_getmtime):
"""Test when there's an error checking the file"""
import main
-
+
# Mock that checking the file raises an exception
mock_getmtime.side_effect = FileNotFoundError("File not found")
-
+
# Run the function - it should not raise an exception
check_phrases_file_change()
-
+
# No other function should have been called
main.generate_board.assert_not_called()
-if __name__ == '__main__':
- unittest.main()
\ No newline at end of file
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_game_logic.py b/tests/test_game_logic.py
index 30d52be..ed37669 100644
--- a/tests/test_game_logic.py
+++ b/tests/test_game_logic.py
@@ -1,230 +1,238 @@
-import unittest
-import sys
import os
import random
-from unittest.mock import patch, MagicMock
+import sys
+import unittest
+from unittest.mock import MagicMock, patch
# Add the parent directory to sys.path to import from main.py
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
# We need to mock the NiceGUI imports and other dependencies before importing main
-sys.modules['nicegui'] = MagicMock()
-sys.modules['nicegui.ui'] = MagicMock()
-sys.modules['fastapi.staticfiles'] = MagicMock()
+sys.modules["nicegui"] = MagicMock()
+sys.modules["nicegui.ui"] = MagicMock()
+sys.modules["fastapi.staticfiles"] = MagicMock()
# Now import functions from the main module
-from main import generate_board, has_too_many_repeats, check_winner, split_phrase_into_lines
+from main import (
+ check_winner,
+ generate_board,
+ has_too_many_repeats,
+ split_phrase_into_lines,
+)
+
class TestGameLogic(unittest.TestCase):
def setUp(self):
# Setup common test data
# Mock the global variables used in main.py
self.patches = [
- patch('main.board', []),
- patch('main.today_seed', ''),
- patch('main.clicked_tiles', set()),
- patch('main.phrases', [f"PHRASE{i}" for i in range(1, 30)]),
- patch('main.FREE_SPACE_TEXT', 'FREE SPACE'),
- patch('main.bingo_patterns', set())
+ patch("main.board", []),
+ patch("main.today_seed", ""),
+ patch("main.clicked_tiles", set()),
+ patch("main.phrases", [f"PHRASE{i}" for i in range(1, 30)]),
+ patch("main.FREE_SPACE_TEXT", "FREE SPACE"),
+ patch("main.bingo_patterns", set()),
]
-
+
for p in self.patches:
p.start()
-
+
def tearDown(self):
# Clean up patches
for p in self.patches:
p.stop()
-
+
def test_generate_board(self):
"""Test that generate_board creates a 5x5 board with the FREE_SPACE in the middle"""
import main
-
+
# Generate a board with a known seed
generate_board(42)
-
+
# Check if board is created with 5 rows
self.assertEqual(len(main.board), 5)
-
+
# Check if each row has 5 columns
for row in main.board:
self.assertEqual(len(row), 5)
-
+
# Check if FREE_SPACE is in the middle (2,2)
- self.assertEqual(main.board[2][2], 'FREE SPACE')
-
+ self.assertEqual(main.board[2][2], "FREE SPACE")
+
# Check if the clicked_tiles set has (2,2) for FREE_SPACE
self.assertIn((2, 2), main.clicked_tiles)
-
+
# Check if the seed is set correctly
expected_seed = f"{main.datetime.date.today().strftime('%Y%m%d')}.42"
self.assertEqual(main.today_seed, expected_seed)
-
+
def test_has_too_many_repeats(self):
"""Test the function for detecting phrases with too many repeated words"""
# Test with a phrase having no repeats
self.assertFalse(has_too_many_repeats("ONE TWO THREE FOUR"))
-
+
# Test with a phrase having some repeats but below threshold
self.assertFalse(has_too_many_repeats("ONE TWO ONE THREE FOUR"))
-
+
# Test with a phrase having too many repeats (above default 0.5 threshold)
self.assertTrue(has_too_many_repeats("ONE ONE ONE ONE TWO"))
-
+
# Test with a custom threshold
self.assertFalse(has_too_many_repeats("ONE ONE TWO THREE", threshold=0.3))
self.assertTrue(has_too_many_repeats("ONE ONE TWO THREE", threshold=0.8))
-
+
# Test with an empty phrase
self.assertFalse(has_too_many_repeats(""))
-
+
def test_check_winner_row(self):
"""Test detecting a win with a complete row"""
import main
-
+
# Setup a board with no wins initially
main.bingo_patterns = set()
main.clicked_tiles = {(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)}
-
+
# Mock the ui.notify call
- with patch('main.ui.notify') as mock_notify:
+ with patch("main.ui.notify") as mock_notify:
check_winner()
-
+
# Check if the bingo pattern was added
self.assertIn("row0", main.bingo_patterns)
-
+
# Check if the notification was shown
mock_notify.assert_called_once()
self.assertEqual(mock_notify.call_args[0][0], "BINGO!")
-
+
def test_check_winner_column(self):
"""Test detecting a win with a complete column"""
import main
-
+
# Setup a board with no wins initially
main.bingo_patterns = set()
main.clicked_tiles = {(0, 0), (1, 0), (2, 0), (3, 0), (4, 0)}
-
+
# Mock the ui.notify call
- with patch('main.ui.notify') as mock_notify:
+ with patch("main.ui.notify") as mock_notify:
check_winner()
-
+
# Check if the bingo pattern was added
self.assertIn("col0", main.bingo_patterns)
-
+
# Check if the notification was shown
mock_notify.assert_called_once()
self.assertEqual(mock_notify.call_args[0][0], "BINGO!")
-
+
def test_check_winner_diagonal(self):
"""Test detecting a win with a diagonal"""
import main
-
+
# Setup a board with no wins initially
main.bingo_patterns = set()
main.clicked_tiles = {(0, 0), (1, 1), (2, 2), (3, 3), (4, 4)}
-
+
# Mock the ui.notify call
- with patch('main.ui.notify') as mock_notify:
+ with patch("main.ui.notify") as mock_notify:
check_winner()
-
+
# Check if the bingo pattern was added
self.assertIn("diag_main", main.bingo_patterns)
-
+
# Check if the notification was shown
mock_notify.assert_called_once()
self.assertEqual(mock_notify.call_args[0][0], "BINGO!")
-
+
def test_check_winner_anti_diagonal(self):
"""Test detecting a win with an anti-diagonal"""
import main
-
+
# Setup a board with no wins initially
main.bingo_patterns = set()
main.clicked_tiles = {(0, 4), (1, 3), (2, 2), (3, 1), (4, 0)}
-
+
# Mock the ui.notify call
- with patch('main.ui.notify') as mock_notify:
+ with patch("main.ui.notify") as mock_notify:
check_winner()
-
+
# Check if the bingo pattern was added
self.assertIn("diag_anti", main.bingo_patterns)
-
+
# Check if the notification was shown
mock_notify.assert_called_once()
self.assertEqual(mock_notify.call_args[0][0], "BINGO!")
-
+
def test_check_winner_special_patterns(self):
"""Test detecting special win patterns like blackout, four corners, etc."""
import main
-
+
# Test four corners pattern
main.bingo_patterns = set()
main.clicked_tiles = {(0, 0), (0, 4), (4, 0), (4, 4)}
-
- with patch('main.ui.notify') as mock_notify:
+
+ with patch("main.ui.notify") as mock_notify:
check_winner()
self.assertIn("four_corners", main.bingo_patterns)
mock_notify.assert_called_once()
self.assertEqual(mock_notify.call_args[0][0], "Four Corners Bingo!")
-
+
# Test plus pattern
main.bingo_patterns = set()
main.clicked_tiles = set()
for i in range(5):
main.clicked_tiles.add((2, i)) # Middle row
main.clicked_tiles.add((i, 2)) # Middle column
-
- with patch('main.ui.notify') as mock_notify:
+
+ with patch("main.ui.notify") as mock_notify:
check_winner()
self.assertIn("plus", main.bingo_patterns)
# The notify may be called multiple times as the clicks also trigger row/col wins
self.assertIn(mock_notify.call_args_list[-1][0][0], "Plus Bingo!")
-
+
def test_check_winner_multiple_wins(self):
"""Test detecting multiple win patterns in a single check"""
import main
-
+
# Setup a board with two potential wins (a row and a column)
main.bingo_patterns = set()
main.clicked_tiles = set()
-
+
# Add a complete row and a complete column that intersect
for i in range(5):
main.clicked_tiles.add((0, i)) # First row
main.clicked_tiles.add((i, 0)) # First column
-
+
# Mock the ui.notify call
- with patch('main.ui.notify') as mock_notify:
+ with patch("main.ui.notify") as mock_notify:
check_winner()
-
+
# Check if both bingo patterns were added
self.assertIn("row0", main.bingo_patterns)
self.assertIn("col0", main.bingo_patterns)
-
+
# The function should call notify with "DOUBLE BINGO!"
mock_notify.assert_called_once()
self.assertEqual(mock_notify.call_args[0][0], "DOUBLE BINGO!")
-
+
def test_split_phrase_into_lines(self):
"""Test splitting phrases into balanced lines"""
# Test with a short phrase (3 words or fewer)
- self.assertEqual(split_phrase_into_lines("ONE TWO THREE"), ["ONE", "TWO", "THREE"])
-
+ self.assertEqual(
+ split_phrase_into_lines("ONE TWO THREE"), ["ONE", "TWO", "THREE"]
+ )
+
# Test with a longer phrase - the actual implementation may return different line counts
# based on the word lengths and balancing algorithm
result = split_phrase_into_lines("ONE TWO THREE FOUR FIVE")
self.assertLessEqual(len(result), 4) # Should not exceed 4 lines
-
+
# Test forcing a specific number of lines
result = split_phrase_into_lines("ONE TWO THREE FOUR FIVE SIX", forced_lines=3)
self.assertEqual(len(result), 3) # Should be split into 3 lines
-
+
# Test very long phrase
long_phrase = "ONE TWO THREE FOUR FIVE SIX SEVEN EIGHT NINE TEN"
result = split_phrase_into_lines(long_phrase)
self.assertLessEqual(len(result), 4) # Should never return more than 4 lines
-if __name__ == '__main__':
- unittest.main()
\ No newline at end of file
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_helpers.py b/tests/test_helpers.py
index 4a0dda0..0d1cec9 100644
--- a/tests/test_helpers.py
+++ b/tests/test_helpers.py
@@ -1,9 +1,11 @@
"""
Helper module for common test utilities and mocks
"""
+
import sys
from unittest.mock import MagicMock
+
def setup_mocks():
"""
Set up common mocks for testing main.py
@@ -13,10 +15,10 @@ def setup_mocks():
nicegui_mock = MagicMock()
ui_mock = MagicMock()
nicegui_mock.ui = ui_mock
-
+
# Replace the imports in sys.modules
- sys.modules['nicegui'] = nicegui_mock
- sys.modules['nicegui.ui'] = ui_mock
- sys.modules['fastapi.staticfiles'] = MagicMock()
-
- return nicegui_mock, ui_mock
\ No newline at end of file
+ sys.modules["nicegui"] = nicegui_mock
+ sys.modules["nicegui.ui"] = ui_mock
+ sys.modules["fastapi.staticfiles"] = MagicMock()
+
+ return nicegui_mock, ui_mock
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 8710ab1..000a090 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -1,95 +1,102 @@
-import unittest
-import sys
import os
-from unittest.mock import patch, MagicMock
+import sys
+import unittest
+from unittest.mock import MagicMock, patch
# Add the parent directory to sys.path to import from main.py
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
# We need to mock the NiceGUI imports and other dependencies before importing main
-sys.modules['nicegui'] = MagicMock()
-sys.modules['nicegui.ui'] = MagicMock()
-sys.modules['fastapi.staticfiles'] = MagicMock()
+sys.modules["nicegui"] = MagicMock()
+sys.modules["nicegui.ui"] = MagicMock()
+sys.modules["fastapi.staticfiles"] = MagicMock()
# This test doesn't import main.py directly, but rather tests the interactions
# between various functions in an integration manner
+
class TestBingoIntegration(unittest.TestCase):
- @patch('builtins.open', new_callable=unittest.mock.mock_open, read_data="PHRASE1\nPHRASE2\nPHRASE3\nPHRASE4\nPHRASE5\nPHRASE6\nPHRASE7\nPHRASE8\nPHRASE9\nPHRASE10\nPHRASE11\nPHRASE12\nPHRASE13\nPHRASE14\nPHRASE15\nPHRASE16\nPHRASE17\nPHRASE18\nPHRASE19\nPHRASE20\nPHRASE21\nPHRASE22\nPHRASE23\nPHRASE24\nPHRASE25\n")
+ @patch(
+ "builtins.open",
+ new_callable=unittest.mock.mock_open,
+ read_data="PHRASE1\nPHRASE2\nPHRASE3\nPHRASE4\nPHRASE5\nPHRASE6\nPHRASE7\nPHRASE8\nPHRASE9\nPHRASE10\nPHRASE11\nPHRASE12\nPHRASE13\nPHRASE14\nPHRASE15\nPHRASE16\nPHRASE17\nPHRASE18\nPHRASE19\nPHRASE20\nPHRASE21\nPHRASE22\nPHRASE23\nPHRASE24\nPHRASE25\n",
+ )
def test_full_game_flow(self, mock_open):
"""
Test the full game flow from initializing the board to winning the game
"""
# Import main here so we can mock the open() call before it reads phrases.txt
import main
-
+
# Reset global state for testing
main.board = []
main.clicked_tiles = set()
main.bingo_patterns = set()
main.board_iteration = 1
-
+
# Mock ui functions
ui_mock = MagicMock()
main.ui = ui_mock
-
+
# Step 1: Generate a new board with a fixed seed
main.generate_board(42)
-
+
# Check if board was generated correctly
self.assertEqual(len(main.board), 5)
- self.assertEqual(main.board[2][2], main.FREE_SPACE_TEXT) # Use the actual constant value
+ self.assertEqual(
+ main.board[2][2], main.FREE_SPACE_TEXT
+ ) # Use the actual constant value
self.assertIn((2, 2), main.clicked_tiles)
-
+
# Step 2: Set up a win scenario by clicking a row
- with patch('main.ui.notify') as mock_notify:
+ with patch("main.ui.notify") as mock_notify:
# Click the remaining tiles in the first row
for col in range(5):
if (0, col) not in main.clicked_tiles:
main.toggle_tile(0, col)
-
+
# Check if the win was detected
self.assertIn("row0", main.bingo_patterns)
-
+
# Check if notification was shown
mock_notify.assert_called_with("BINGO!", color="green", duration=5)
-
+
# Step 3: Reset the board
main.reset_board()
-
+
# Check if clicked_tiles was reset (except FREE SPACE)
self.assertEqual(len(main.clicked_tiles), 1)
self.assertIn((2, 2), main.clicked_tiles)
-
+
# Check if bingo_patterns was cleared
self.assertEqual(len(main.bingo_patterns), 0)
-
+
# Step 4: Generate a new board
prev_board = [row[:] for row in main.board] # Make a deep copy
-
+
main.generate_new_board()
-
+
# Check if a new board was generated
self.assertEqual(main.board_iteration, 2)
-
+
# Board should have changed in at least some places
different_elements = 0
for r in range(5):
for c in range(5):
if (r, c) != (2, 2) and main.board[r][c] != prev_board[r][c]:
different_elements += 1
-
+
# There should be some different elements, though by random chance
# there could be some that stay the same
self.assertGreater(different_elements, 0)
-
+
# FREE SPACE should still be in the middle
self.assertEqual(main.board[2][2], main.FREE_SPACE_TEXT)
-
+
# Only FREE SPACE should be clicked
self.assertEqual(len(main.clicked_tiles), 1)
self.assertIn((2, 2), main.clicked_tiles)
-if __name__ == '__main__':
- unittest.main()
\ No newline at end of file
+if __name__ == "__main__":
+ unittest.main()
diff --git a/tests/test_ui_functions.py b/tests/test_ui_functions.py
index 5f78b05..ac96205 100644
--- a/tests/test_ui_functions.py
+++ b/tests/test_ui_functions.py
@@ -1,135 +1,549 @@
-import unittest
-import sys
import os
-from unittest.mock import patch, MagicMock
+import sys
+import unittest
+from unittest.mock import MagicMock, patch
# Add the parent directory to sys.path to import from main.py
-sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
+sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
# We need to mock the NiceGUI imports and other dependencies before importing main
-sys.modules['nicegui'] = MagicMock()
-sys.modules['nicegui.ui'] = MagicMock()
-sys.modules['fastapi.staticfiles'] = MagicMock()
+sys.modules["nicegui"] = MagicMock()
+sys.modules["nicegui.ui"] = MagicMock()
+sys.modules["fastapi.staticfiles"] = MagicMock()
# Now import functions from the main module
-from main import get_line_style_for_lines, get_google_font_css, update_tile_styles
+from main import (
+ close_game,
+ create_board_view,
+ get_google_font_css,
+ get_line_style_for_lines,
+ reopen_game,
+ sync_board_state,
+ update_tile_styles,
+)
+
class TestUIFunctions(unittest.TestCase):
def setUp(self):
# Setup common test data and mocks
self.patches = [
- patch('main.BOARD_TILE_FONT', 'Inter'),
- patch('main.BOARD_TILE_FONT_WEIGHT', '700'),
- patch('main.BOARD_TILE_FONT_STYLE', 'normal'),
- patch('main.TILE_CLICKED_BG_COLOR', '#100079'),
- patch('main.TILE_CLICKED_TEXT_COLOR', '#1BEFF5'),
- patch('main.TILE_UNCLICKED_BG_COLOR', '#1BEFF5'),
- patch('main.TILE_UNCLICKED_TEXT_COLOR', '#100079'),
- patch('main.FREE_SPACE_TEXT', 'FREE SPACE'),
- patch('main.FREE_SPACE_TEXT_COLOR', '#FF7f33'),
- patch('main.board', [["PHRASE1", "PHRASE2"], ["PHRASE3", "FREE SPACE"]]),
- patch('main.clicked_tiles', {(1, 1)}) # FREE SPACE is clicked
+ patch("main.BOARD_TILE_FONT", "Inter"),
+ patch("main.BOARD_TILE_FONT_WEIGHT", "700"),
+ patch("main.BOARD_TILE_FONT_STYLE", "normal"),
+ patch("main.TILE_CLICKED_BG_COLOR", "#100079"),
+ patch("main.TILE_CLICKED_TEXT_COLOR", "#1BEFF5"),
+ patch("main.TILE_UNCLICKED_BG_COLOR", "#1BEFF5"),
+ patch("main.TILE_UNCLICKED_TEXT_COLOR", "#100079"),
+ patch("main.FREE_SPACE_TEXT", "FREE SPACE"),
+ patch("main.FREE_SPACE_TEXT_COLOR", "#FF7f33"),
+ patch("main.board", [["PHRASE1", "PHRASE2"], ["PHRASE3", "FREE SPACE"]]),
+ patch("main.clicked_tiles", {(1, 1)}), # FREE SPACE is clicked
]
-
+
for p in self.patches:
p.start()
-
+
def tearDown(self):
# Clean up patches
for p in self.patches:
p.stop()
-
+
def test_get_line_style_for_lines(self):
"""Test generating style strings based on line count"""
import main
+
default_text_color = "#000000"
-
+
# Test style for a single line
style_1 = get_line_style_for_lines(1, default_text_color)
self.assertIn("line-height: 1.5em", style_1)
self.assertIn(f"color: {default_text_color}", style_1)
self.assertIn(f"font-family: '{main.BOARD_TILE_FONT}'", style_1)
-
+
# Test style for two lines
style_2 = get_line_style_for_lines(2, default_text_color)
self.assertIn("line-height: 1.2em", style_2)
-
+
# Test style for three lines
style_3 = get_line_style_for_lines(3, default_text_color)
self.assertIn("line-height: 0.9em", style_3)
-
+
# Test style for four or more lines
style_4 = get_line_style_for_lines(4, default_text_color)
self.assertIn("line-height: 0.7em", style_4)
style_5 = get_line_style_for_lines(5, default_text_color)
self.assertIn("line-height: 0.7em", style_5)
-
+
def test_get_google_font_css(self):
"""Test generating CSS for Google fonts"""
font_name = "Roboto"
weight = "400"
style = "normal"
uniquifier = "test_font"
-
+
css = get_google_font_css(font_name, weight, style, uniquifier)
-
+
# Check if CSS contains the expected elements
- self.assertIn(f"font-family: \"{font_name}\"", css)
+ self.assertIn(f'font-family: "{font_name}"', css)
self.assertIn(f"font-weight: {weight}", css)
self.assertIn(f"font-style: {style}", css)
self.assertIn(f".{uniquifier}", css)
-
- @patch('main.ui.run_javascript')
+
+ @patch("main.ui.run_javascript")
def test_update_tile_styles(self, mock_run_js):
"""Test updating tile styles based on clicked state"""
import main
-
+
# Create mock tiles
tile_buttons_dict = {}
-
+
# Create a mock for labels and cards
for r in range(2):
for c in range(2):
mock_card = MagicMock()
mock_label = MagicMock()
-
+
# Create a label info dictionary with the required structure
label_info = {
"ref": mock_label,
"base_classes": "some-class",
- "base_style": "some-style"
+ "base_style": "some-style",
}
-
- tile_buttons_dict[(r, c)] = {
- "card": mock_card,
- "labels": [label_info]
- }
-
+
+ tile_buttons_dict[(r, c)] = {"card": mock_card, "labels": [label_info]}
+
# Run the update_tile_styles function
update_tile_styles(tile_buttons_dict)
-
+
# Check that styles were applied to all tiles
for (r, c), tile in tile_buttons_dict.items():
# The card's style should have been updated
tile["card"].style.assert_called_once()
tile["card"].update.assert_called_once()
-
+
# Each label should have had its classes and style updated
for label_info in tile["labels"]:
label = label_info["ref"]
label.classes.assert_called_once_with(label_info["base_classes"])
label.style.assert_called_once()
label.update.assert_called_once()
-
+
# Check that clicked tiles have the clicked style
if (r, c) in main.clicked_tiles:
- self.assertIn(main.TILE_CLICKED_BG_COLOR, tile["card"].style.call_args[0][0])
+ self.assertIn(
+ main.TILE_CLICKED_BG_COLOR, tile["card"].style.call_args[0][0]
+ )
else:
- self.assertIn(main.TILE_UNCLICKED_BG_COLOR, tile["card"].style.call_args[0][0])
-
+ self.assertIn(
+ main.TILE_UNCLICKED_BG_COLOR, tile["card"].style.call_args[0][0]
+ )
+
# Check that JavaScript was run to resize text
mock_run_js.assert_called_once()
+ @patch("main.ui")
+ @patch("main.header_label")
+ def test_close_game(self, mock_header_label, mock_ui):
+ """Test closing the game functionality"""
+ import main
+
+ # Mock board views
+ mock_container1 = MagicMock()
+ mock_container2 = MagicMock()
+ mock_buttons1 = {}
+ mock_buttons2 = {}
+
+ # Set up the board_views global
+ main.board_views = {
+ "home": (mock_container1, mock_buttons1),
+ "stream": (mock_container2, mock_buttons2),
+ }
+
+ # Mock controls_row
+ main.controls_row = MagicMock()
+
+ # Ensure is_game_closed is False initially
+ main.is_game_closed = False
+
+ # Call the close_game function
+ main.close_game()
+
+ # Verify game is marked as closed
+ self.assertTrue(main.is_game_closed)
+
+ # Verify header text is updated
+ mock_header_label.set_text.assert_called_once_with(main.CLOSED_HEADER_TEXT)
+ mock_header_label.update.assert_called_once()
+
+ # Verify containers are hidden
+ mock_container1.style.assert_called_once_with("display: none;")
+ mock_container1.update.assert_called_once()
+ mock_container2.style.assert_called_once_with("display: none;")
+ mock_container2.update.assert_called_once()
+
+ # Verify controls_row is modified (cleared and rebuilt)
+ main.controls_row.clear.assert_called_once()
+
+ # Verify broadcast is called to update all clients
+ mock_ui.broadcast.assert_called_once()
+
+ # Verify notification is shown
+ mock_ui.notify.assert_called_once_with(
+ "Game has been closed", color="red", duration=3
+ )
+
+ @patch("main.ui.run_javascript")
+ def test_sync_board_state_when_game_closed(self, mock_run_js):
+ """Test sync_board_state behavior when game is closed"""
+ import main
+
+ # Setup mocks
+ mock_container1 = MagicMock()
+ mock_container2 = MagicMock()
+ mock_buttons1 = {}
+ mock_buttons2 = {}
+
+ # Set up the board_views global
+ main.board_views = {
+ "home": (mock_container1, mock_buttons1),
+ "stream": (mock_container2, mock_buttons2),
+ }
+
+ # Mock the header label
+ main.header_label = MagicMock()
+
+ # Mock controls_row with a default_slot attribute
+ main.controls_row = MagicMock()
+ main.controls_row.default_slot = MagicMock()
+ main.controls_row.default_slot.children = [] # Empty initially
+
+ # Set game as closed
+ main.is_game_closed = True
+
+ # Call sync_board_state
+ with patch("main.ui") as mock_ui:
+ main.sync_board_state()
+
+ # Verify header text is updated
+ main.header_label.set_text.assert_called_once_with(main.CLOSED_HEADER_TEXT)
+ main.header_label.update.assert_called_once()
+
+ # Verify containers are hidden
+ mock_container1.style.assert_called_once_with("display: none;")
+ mock_container1.update.assert_called_once()
+ mock_container2.style.assert_called_once_with("display: none;")
+ mock_container2.update.assert_called_once()
+
+ # Verify controls_row is modified
+ main.controls_row.clear.assert_called_once()
+
+ # Verify JavaScript was NOT called (should return early for closed games)
+ mock_run_js.assert_not_called()
+
+ @patch("main.ui")
+ def test_header_updates_on_both_paths(self, mock_ui):
+ """Test that header gets updated on both root and /stream paths when game state changes generally"""
+ import main
+
+ # Mock setup_head function to intercept header creation
+ home_header_label = MagicMock()
+ stream_header_label = MagicMock()
+
+ # We'll track which path is currently being handled
+ current_path = None
+
+ # Define a side effect for the setup_head function to create different header labels
+ # based on which path is being accessed (home or stream)
+ def mock_setup_head(background_color):
+ nonlocal current_path
+ # Set the global header_label based on which path we're on
+ if current_path == "home":
+ main.header_label = home_header_label
+ else:
+ main.header_label = stream_header_label
+
+ # Create home page board view
+ with (
+ patch("main.setup_head", side_effect=mock_setup_head),
+ patch("main.build_board") as mock_build_board,
+ patch("main.ui.timer") as mock_timer,
+ ):
+
+ # Create the home page
+ current_path = "home"
+ mock_home_container = MagicMock()
+ mock_ui.element.return_value = mock_home_container
+
+ # First, create the home board view
+ create_board_view(main.HOME_BG_COLOR, True)
+
+ # Create the stream page
+ current_path = "stream"
+ mock_stream_container = MagicMock()
+ mock_ui.element.return_value = mock_stream_container
+
+ # Create the stream board view
+ create_board_view(main.STREAM_BG_COLOR, False)
+
+ # Verify the board views are set up correctly
+ self.assertEqual(len(main.board_views), 2)
+ self.assertIn("home", main.board_views)
+ self.assertIn("stream", main.board_views)
+
+ # Reset mocks for the test
+ home_header_label.reset_mock()
+ stream_header_label.reset_mock()
+ mock_home_container.reset_mock()
+ mock_stream_container.reset_mock()
+
+ # Preserve the original state to restore later
+ original_is_game_closed = main.is_game_closed
+
+ try:
+ # 1. Test Game Closing:
+ # Set up for closing the game
+ main.is_game_closed = False
+ main.header_label = home_header_label # Start with home page header
+
+ # Close the game
+ with patch("main.controls_row") as mock_controls_row:
+ close_game()
+
+ # Verify both headers were updated to show the game is closed
+ # First, check the direct update to the current header
+ home_header_label.set_text.assert_called_with(main.CLOSED_HEADER_TEXT)
+ home_header_label.update.assert_called()
+
+ # Reset mocks to test sync
+ home_header_label.reset_mock()
+ stream_header_label.reset_mock()
+
+ # Now, test the sync mechanism ensuring both views reflect the closed state
+
+ # Switch to stream header and run sync
+ main.header_label = stream_header_label
+ sync_board_state()
+
+ # Both headers should show closed text (the current one will be directly updated)
+ stream_header_label.set_text.assert_called_with(main.CLOSED_HEADER_TEXT)
+ stream_header_label.update.assert_called()
+
+ # Reset mocks again
+ home_header_label.reset_mock()
+ stream_header_label.reset_mock()
+
+ # 2. Test Game Reopening:
+ # Setup for reopening
+ with (
+ patch("main.reset_board"),
+ patch("main.generate_board"),
+ patch("main.build_board"),
+ patch("main.controls_row"),
+ ):
+
+ # Start with stream header active
+ main.header_label = stream_header_label
+
+ # Reopen the game
+ reopen_game()
+
+ # Verify stream header was updated to original text
+ stream_header_label.set_text.assert_called_with(main.HEADER_TEXT)
+ stream_header_label.update.assert_called()
+
+ # Reset mocks
+ home_header_label.reset_mock()
+ stream_header_label.reset_mock()
+
+ # Switch to home header and run sync
+ main.header_label = home_header_label
+
+ # Simulate that the header might still have the old text
+ home_header_label.text = main.CLOSED_HEADER_TEXT
+
+ # Since the game is now open, sync should update header text to original
+ sync_board_state()
+
+ # Header text should be updated to the open game text
+ home_header_label.set_text.assert_called_with(main.HEADER_TEXT)
+ home_header_label.update.assert_called()
+
+ finally:
+ # Restore original state
+ main.is_game_closed = original_is_game_closed
+
+ @patch("main.ui")
+ @patch("main.generate_board")
+ def test_reopen_game(self, mock_generate_board, mock_ui):
+ """Test reopening the game after it has been closed"""
+ import main
+
+ # Mock board views
+ mock_container1 = MagicMock()
+ mock_container2 = MagicMock()
+ mock_buttons1 = {}
+ mock_buttons2 = {}
+
+ # Set up the board_views global
+ main.board_views = {
+ "home": (mock_container1, mock_buttons1),
+ "stream": (mock_container2, mock_buttons2),
+ }
+
+ # Mock header_label
+ main.header_label = MagicMock()
+
+ # Mock controls_row
+ main.controls_row = MagicMock()
+
+ # Mock seed_label
+ main.seed_label = MagicMock()
+
+ # Set initial values
+ main.is_game_closed = True
+ main.board_iteration = 1
+ main.today_seed = "test_seed"
+
+ # Call reopen_game
+ with (
+ patch("main.build_board") as mock_build_board,
+ patch("main.reset_board") as mock_reset_board,
+ ):
+ main.reopen_game()
+
+ # Check that the game is no longer closed
+ self.assertFalse(main.is_game_closed)
+
+ # Verify header text is reset
+ main.header_label.set_text.assert_called_once_with(main.HEADER_TEXT)
+ main.header_label.update.assert_called_once()
+
+ # Verify board_iteration was incremented and generate_board was called
+ self.assertEqual(main.board_iteration, 2) # Incremented from 1
+ mock_generate_board.assert_called_once_with(2)
+
+ # Verify controls_row was rebuilt
+ main.controls_row.clear.assert_called_once()
+
+ # Verify containers are shown and rebuilt
+ mock_container1.style.assert_called_once_with("display: block;")
+ mock_container1.clear.assert_called_once()
+ mock_container1.update.assert_called_once()
+ mock_container2.style.assert_called_once_with("display: block;")
+ mock_container2.clear.assert_called_once()
+ mock_container2.update.assert_called_once()
+
+ # Verify clicked tiles were reset
+ mock_reset_board.assert_called_once()
+
+ # Verify notification was shown
+ mock_ui.notify.assert_called_once_with(
+ "New game started", color="green", duration=3
+ )
+
+ # Verify changes were broadcast
+ mock_ui.broadcast.assert_called_once()
+
+ @patch("main.ui.broadcast")
+ def test_stream_header_update_when_game_closed(self, mock_broadcast):
+ """
+ Test that the header on the /stream path is correctly updated when the game is closed
+ This tests the specific use case where closing the game affects all connected views
+ """
+ import main
+
+ # Create the header labels for both paths
+ home_header = MagicMock()
+ stream_header = MagicMock()
+
+ # Create containers for both views
+ home_container = MagicMock()
+ stream_container = MagicMock()
+
+ # Set up board views dictionary
+ main.board_views = {
+ "home": (home_container, {}),
+ "stream": (stream_container, {}),
+ }
+
+ # Save original state to restore later
+ original_is_game_closed = main.is_game_closed
+ original_header_label = main.header_label
+
+ try:
+ # Set game not closed initially
+ main.is_game_closed = False
+
+ # First test: closing the game from home page updates stream header
+ # Set header_label to home view initially
+ main.header_label = home_header
+
+ # Create and assign a mock for controls_row
+ mock_controls_row = MagicMock()
+ main.controls_row = mock_controls_row
+
+ # Close the game from home view
+ main.close_game()
+
+ # Verify the home header was updated directly
+ home_header.set_text.assert_called_with(main.CLOSED_HEADER_TEXT)
+ home_header.update.assert_called()
+
+ # Verify broadcast was called to update all clients
+ mock_broadcast.assert_called()
+
+ # Reset the mocks
+ home_header.reset_mock()
+ stream_header.reset_mock()
+ mock_broadcast.reset_mock()
+
+ # Now, simulate a stream client connecting (with game already closed)
+ # This should update the stream header when sync_board_state is called
+ main.header_label = stream_header
+
+ # Call sync_board_state which should update stream header
+ with patch("main.ui") as mock_ui:
+ main.sync_board_state()
+
+ # Verify stream header was updated to reflect closed game
+ stream_header.set_text.assert_called_with(main.CLOSED_HEADER_TEXT)
+ stream_header.update.assert_called()
+
+ # Reset mocks again
+ home_header.reset_mock()
+ stream_header.reset_mock()
+ mock_broadcast.reset_mock()
+
+ # Now test reopening and ensuring stream header gets updated again
+ # Set the game as closed with stream header active
+ main.is_game_closed = True
+ main.header_label = stream_header
+
+ # Create mocks for the functions called by reopen_game
+ with (
+ patch("main.reset_board") as mock_reset_board,
+ patch("main.generate_board") as mock_generate_board,
+ patch("main.build_board") as mock_build_board,
+ ):
+
+ # Create a fresh mock for controls_row (it may have been modified by close_game)
+ main.controls_row = MagicMock()
+
+ # Reopen the game
+ main.reopen_game()
+
+ # Verify stream header gets updated back to original text
+ stream_header.set_text.assert_called_with(main.HEADER_TEXT)
+ stream_header.update.assert_called()
+
+ # Verify broadcast was called to update all clients again
+ mock_broadcast.assert_called()
+
+ finally:
+ # Restore original state
+ main.is_game_closed = original_is_game_closed
+ main.header_label = original_header_label
+
-if __name__ == '__main__':
- unittest.main()
\ No newline at end of file
+if __name__ == "__main__":
+ unittest.main()