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()