diff --git a/README.md b/README.md index 97672e2..d78e070 100644 --- a/README.md +++ b/README.md @@ -28,21 +28,21 @@ Players guess a secret word by submitting words in chat. The bot uses word embed cp .env.example .env ``` - | Variable | Description | Default | - |------------------------|----------------------------------------------------------------|---------| - | `TWITCH_TOKEN` | Manual OAuth token (`oauth:…`) — optional if using OAuth flow | — | - | `TWITCH_CHANNEL` | Twitch channel name to join — **required** | — | - | `TWITCH_CLIENT_ID` | Twitch app client ID (OAuth flow) | — | - | `TWITCH_CLIENT_SECRET` | Twitch app client secret (OAuth flow) | — | - | `TWITCH_REDIRECT_URI` | OAuth redirect URI | `http://localhost:4343/callback` | - | `TWITCH_SCOPES` | Space-separated OAuth scopes | `chat:read chat:edit` | - | `TWITCH_TOKEN_PATH` | Path to the JSON token storage file | `.secrets/twitch_tokens.json` | - | `COMMAND_PREFIX` | Prefix for bot commands | `!sx` | - | `COOLDOWN` | Cooldown between guesses (seconds) | `5` | - | `DIFFICULTY` | Game difficulty (`easy`=facile, `hard`=difficile) | `easy` | - | `MODEL_PATH` | Path to the Word2Vec binary model file | `models/frWac_no_postag_no_phrase_700_skip_cut50.bin` | - | `OVERLAY_ENABLED` | Start the web overlay server | `false` | - | `OVERLAY_PORT` | TCP port for the overlay server | `8080` | + | Variable | Description | Default | + |------------------------|---------------------------------------------------------------|---------| + | `TWITCH_TOKEN` | Manual OAuth token (`oauth:…`) — optional if using OAuth flow | — | + | `TWITCH_CHANNEL` | Twitch channel name to join — **required** | — | + | `TWITCH_CLIENT_ID` | Twitch app client ID (OAuth flow) | — | + | `TWITCH_CLIENT_SECRET` | Twitch app client secret (OAuth flow) | — | + | `TWITCH_REDIRECT_URI` | OAuth redirect URI | `http://localhost:4343/callback` | + | `TWITCH_SCOPES` | Space-separated OAuth scopes | `chat:read chat:edit` | + | `TWITCH_TOKEN_PATH` | Path to the JSON token storage file | `.secrets/twitch_tokens.json` | + | `COMMAND_PREFIX` | Prefix for bot commands | `!sx` | + | `COOLDOWN` | Cooldown between guesses (seconds) | `5` | + | `DIFFICULTY` | Game difficulty (`easy`=facile, `hard`=difficile) | `easy` | + | `MODEL_PATH` | Path to the Word2Vec binary model file | `models/frWac_no_postag_no_phrase_700_skip_cut50.bin` | + | `OVERLAY_ENABLED` | Start the web overlay server | `false` | + | `OVERLAY_PORT` | TCP port for the overlay server | `8080` | ## Twitch Authentication @@ -125,13 +125,13 @@ The `.secrets/` directory is gitignored to prevent accidentally committing token ### Model details - | Property | Value | - |----------------|-------| - | Filename | `frWac_no_postag_no_phrase_700_skip_cut50.bin` | - | Source | | - | Format | Binary Word2Vec (gensim `KeyedVectors`) | - | Approx. size | ~1 GB | - | Licence | [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) — please attribute *ATILF / CNRS & Université de Lorraine* | + | Property | Value | + |--------------|----------------------------------------------------------------------------------| + | Filename | `frWac_no_postag_no_phrase_700_skip_cut50.bin` | + | Source | | + | Format | Binary Word2Vec (gensim `KeyedVectors`) | + | Approx. size | ~1 GB | + | Licence | [CC BY 3.0](https://creativecommons.org/licenses/by/3.0/) — please attribute *ATILF / CNRS & Université de Lorraine* | To use a different model, set the `MODEL_PATH` environment variable before starting the bot: @@ -185,12 +185,34 @@ All commands are prefixed with the configured `COMMAND_PREFIX` (default: `!sx`). ## Testing +The test suite is split into two layers: + +| Layer | Location | What it covers | +|-------|----------|----------------| +| Unit | `tests/unit/` | Individual classes and functions in isolation | +| Functional | `tests/functional/` | End-to-end scenarios: bot commands → game state → WebSocket payload received by an overlay client | + +Shared fixtures and helpers live in `tests/conftest.py` (no Word2Vec model or Twitch +connection required — all external dependencies are replaced by lightweight fakes). + Run the full test suite: ```bash poetry run pytest ``` +Run only unit tests: + +```bash +poetry run pytest tests/unit/ +``` + +Run only functional tests: + +```bash +poetry run pytest tests/functional/ +``` + Run with coverage report: ```bash @@ -266,10 +288,10 @@ It displays live game information: best guess, last guess, attempt count, top-10 ### Environment variables -| Variable | Description | Default | -|-------------------|--------------------------------------------------|----------| -| `OVERLAY_ENABLED` | Set to `true` to start the overlay server | `false` | -| `OVERLAY_PORT` | TCP port for the overlay HTTP/WebSocket server | `8080` | +| Variable | Description | Default | +| ----------------- | ---------------------------------------------- | ------- | +| `OVERLAY_ENABLED` | Set to `true` to start the overlay server | `false` | +| `OVERLAY_PORT` | TCP port for the overlay HTTP/WebSocket server | `8080` | ### Network tips diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..d5e417e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,66 @@ +"""Shared pytest fixtures and helpers for all Streamantix tests.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock + +import pytest + +from bot.bot import StreamantixBot +from bot.cooldown import CooldownManager +from game.state import Difficulty, GameState + + +class FakeScorer: + """Deterministic scorer: returns min(1.0, len(guess)/len(target)).""" + + def score_guess(self, guess: str, target: str) -> float | None: + if not target: + return None + return min(1.0, len(guess) / len(target)) + + +def make_ctx( + *, + is_mod: bool = False, + is_broadcaster: bool = False, + author_name: str = "viewer", +) -> MagicMock: + """Return a minimal mock of a TwitchIO Context.""" + ctx = MagicMock() + ctx.send = AsyncMock() + ctx.author.is_mod = is_mod + ctx.author.is_broadcaster = is_broadcaster + ctx.author.name = author_name + return ctx + + +def make_bot( + prefix: str = "!sx", + cooldown: int = 5, + scorer=None, + on_state_change=None, +) -> StreamantixBot: + """Return a StreamantixBot instance without connecting to Twitch.""" + bot = object.__new__(StreamantixBot) + bot._command_prefix = prefix + bot._cooldown = CooldownManager(cooldown) + bot._game_state = GameState(scorer=scorer) + bot._next_difficulty = Difficulty.EASY + bot._on_state_change = on_state_change + return bot + + +@pytest.fixture() +def fake_scorer() -> FakeScorer: + return FakeScorer() + + +@pytest.fixture() +def game_state(fake_scorer: FakeScorer) -> GameState: + return GameState(scorer=fake_scorer) + + +@pytest.fixture() +def game_state_no_scorer() -> GameState: + return GameState() diff --git a/tests/functional/__init__.py b/tests/functional/__init__.py new file mode 100644 index 0000000..6f6f491 --- /dev/null +++ b/tests/functional/__init__.py @@ -0,0 +1 @@ +"""tests.functional package.""" diff --git a/tests/functional/test_bot_overlay.py b/tests/functional/test_bot_overlay.py new file mode 100644 index 0000000..af1277a --- /dev/null +++ b/tests/functional/test_bot_overlay.py @@ -0,0 +1,120 @@ +"""Functional tests: bot ↔ overlay WebSocket integration.""" + +from __future__ import annotations + +import asyncio +import json +from unittest.mock import patch + +from starlette.testclient import TestClient + +from bot.bot import StreamantixBot +from game.state import Difficulty +from overlay.server import OverlayServer +from tests.conftest import FakeScorer, make_bot, make_ctx + +_start_fn = StreamantixBot.start_game._callback +_guess_fn = StreamantixBot.guess._callback +_solution_fn = StreamantixBot.solution._callback + + +# --------------------------------------------------------------------------- +# TestBotOverlayIntegration — verify what a WS client receives after bot commands +# --------------------------------------------------------------------------- + + +class TestBotOverlayIntegration: + def test_start_game_broadcasts_running_state(self): + overlay = OverlayServer() + bot = make_bot(cooldown=0, on_state_change=overlay.broadcast) + client = TestClient(overlay.app) + + ctx = make_ctx(is_broadcaster=True) + with client.websocket_connect("/ws") as ws: + with patch("bot.bot.load_word_list", return_value=["chat"]): + asyncio.run(_start_fn(bot, ctx)) + data = json.loads(ws.receive_text()) + + assert data["status"] == "running" + assert data["difficulty"] == "easy" + + def test_guess_broadcasts_updated_attempt_count(self): + overlay = OverlayServer() + bot = make_bot(cooldown=0, scorer=FakeScorer(), on_state_change=overlay.broadcast) + bot._game_state.start_new_game("chat", Difficulty.EASY) + client = TestClient(overlay.app) + + ctx = make_ctx(author_name="alice") + with client.websocket_connect("/ws") as ws: + asyncio.run(_guess_fn(bot, ctx, "chien")) + data = json.loads(ws.receive_text()) + + assert data["attempt_count"] == 1 + assert data["last_guess"]["word"] == "chien" + assert data["last_guess"]["user"] == "alice" + + def test_win_broadcasts_found_state(self): + overlay = OverlayServer() + bot = make_bot(cooldown=0, on_state_change=overlay.broadcast) + bot._game_state.start_new_game("chat", Difficulty.EASY) + client = TestClient(overlay.app) + + ctx = make_ctx(author_name="alice") + with client.websocket_connect("/ws") as ws: + asyncio.run(_guess_fn(bot, ctx, "chat")) + data = json.loads(ws.receive_text()) + + assert data["status"] == "found" + assert data["target_word"] == "chat" + + def test_solution_broadcasts_found_state(self): + overlay = OverlayServer() + bot = make_bot(cooldown=0, on_state_change=overlay.broadcast) + bot._game_state.start_new_game("chat", Difficulty.EASY) + client = TestClient(overlay.app) + + ctx = make_ctx(is_broadcaster=True, author_name="broadcaster") + with client.websocket_connect("/ws") as ws: + asyncio.run(_solution_fn(bot, ctx)) + data = json.loads(ws.receive_text()) + + assert data["status"] == "found" + + +# --------------------------------------------------------------------------- +# TestOverlayWebSocketMultiClient — multiple simultaneous WS connections +# --------------------------------------------------------------------------- + + +class TestOverlayWebSocketMultiClient: + def test_broadcast_reaches_all_connected_clients(self): + """All connected WebSocket clients receive the same broadcast payload.""" + overlay = OverlayServer() + bot = make_bot(cooldown=0, on_state_change=overlay.broadcast) + bot._game_state.start_new_game("chat", Difficulty.EASY) + client = TestClient(overlay.app) + + ctx = make_ctx(author_name="alice") + with client.websocket_connect("/ws") as ws1: + with client.websocket_connect("/ws") as ws2: + asyncio.run(_guess_fn(bot, ctx, "chat")) + data1 = json.loads(ws1.receive_text()) + data2 = json.loads(ws2.receive_text()) + + assert data1["status"] == "found" + assert data2["status"] == "found" + assert data1["target_word"] == data2["target_word"] == "chat" + + def test_late_client_receives_cached_state(self): + overlay = OverlayServer() + bot = make_bot(cooldown=0, on_state_change=overlay.broadcast) + # Trigger a broadcast before any WS client connects + bot._game_state.start_new_game("chat", Difficulty.EASY) + asyncio.run(bot._notify_overlay()) + assert overlay._last_state is not None + + client = TestClient(overlay.app) + with client.websocket_connect("/ws") as ws: + data = json.loads(ws.receive_text()) + + assert data["status"] == "running" diff --git a/tests/functional/test_game_flow.py b/tests/functional/test_game_flow.py new file mode 100644 index 0000000..f592ff5 --- /dev/null +++ b/tests/functional/test_game_flow.py @@ -0,0 +1,227 @@ +"""Functional tests: complete multi-step game scenarios (game layer only, no overlay, no Twitch network).""" + +from __future__ import annotations + +from unittest.mock import patch + +from bot.bot import StreamantixBot +from game.state import Difficulty, GameState +from tests.conftest import FakeScorer, make_bot, make_ctx + +_start_fn = StreamantixBot.start_game._callback +_guess_fn = StreamantixBot.guess._callback +_hint_fn = StreamantixBot.hint._callback +_status_fn = StreamantixBot.status._callback +_solution_fn = StreamantixBot.solution._callback +_setdifficulty_fn = StreamantixBot.setdifficulty._callback + + +# --------------------------------------------------------------------------- +# TestCompleteGameScenario +# --------------------------------------------------------------------------- + + +class TestCompleteGameScenario: + async def test_broadcaster_starts_game_and_player_wins(self): + bot = make_bot(cooldown=0) + ctx_broadcaster = make_ctx(is_broadcaster=True) + with patch("bot.bot.load_word_list", return_value=["chat"]): + await _start_fn(bot, ctx_broadcaster) + assert bot._game_state.target_word == "chat" + + ctx_player = make_ctx(author_name="alice") + await _guess_fn(bot, ctx_player, "chat") + + assert bot._game_state.is_found + message = ctx_player.send.call_args[0][0] + assert "alice" in message.lower() + + async def test_multiple_players_compete_only_first_wins(self): + bot = make_bot(cooldown=0) + bot._game_state.start_new_game("chat", Difficulty.EASY) + + ctx_alice = make_ctx(author_name="alice") + await _guess_fn(bot, ctx_alice, "chat") + assert bot._game_state.is_found + assert bot._game_state.found_by == "alice" + + ctx_bob = make_ctx(author_name="bob") + await _guess_fn(bot, ctx_bob, "chat") + message = ctx_bob.send.call_args[0][0] + assert "alice" in message.lower() + assert "chat" in message.lower() + + async def test_guess_count_increments_across_players(self): + bot = make_bot(cooldown=0, scorer=FakeScorer()) + bot._game_state.start_new_game("chat", Difficulty.EASY) + + for name, word in [("alice", "chien"), ("bob", "maison"), ("carol", "arbre")]: + ctx = make_ctx(author_name=name) + await _guess_fn(bot, ctx, word) + + assert bot._game_state.attempt_count == 3 + + +# --------------------------------------------------------------------------- +# TestDifficultyFlow +# --------------------------------------------------------------------------- + + +class TestDifficultyFlow: + async def test_setdifficulty_affects_next_game(self): + bot = make_bot() + ctx_mod = make_ctx(is_mod=True) + await _setdifficulty_fn(bot, ctx_mod, "hard") + assert bot._next_difficulty == Difficulty.HARD + + ctx_broadcaster = make_ctx(is_broadcaster=True) + with patch("bot.bot.load_word_list", return_value=["ambiguïté"]): + await _start_fn(bot, ctx_broadcaster) + assert bot._game_state.difficulty == Difficulty.HARD + + async def test_start_with_explicit_difficulty_overrides_next_difficulty(self): + bot = make_bot() + ctx_mod = make_ctx(is_mod=True) + await _setdifficulty_fn(bot, ctx_mod, "hard") + assert bot._next_difficulty == Difficulty.HARD + + ctx_broadcaster = make_ctx(is_broadcaster=True) + with patch("bot.bot.load_word_list", return_value=["chat"]): + await _start_fn(bot, ctx_broadcaster, "easy") + assert bot._game_state.difficulty == Difficulty.EASY + # _next_difficulty should remain hard (not overwritten by explicit start arg) + assert bot._next_difficulty == Difficulty.HARD + + async def test_invalid_difficulty_sends_error(self): + bot = make_bot() + ctx_broadcaster = make_ctx(is_broadcaster=True) + await _start_fn(bot, ctx_broadcaster, "invalid") + message = ctx_broadcaster.send.call_args[0][0] + assert "invalid" in message.lower() + assert bot._game_state.target_word is None + + +# --------------------------------------------------------------------------- +# TestHintAndStatus +# --------------------------------------------------------------------------- + + +class TestHintAndStatus: + async def test_hint_shows_sorted_leaderboard(self): + bot = make_bot(scorer=FakeScorer()) + bot._game_state.start_new_game("chat", Difficulty.EASY) + # Scores: c=1/4=0.25, ch=2/4=0.5, cha=3/4=0.75 (FakeScorer length-ratio) + for user, word in [("alice", "c"), ("bob", "ch"), ("carol", "cha")]: + bot._game_state.submit_guess(user, word) + + ctx = make_ctx() + await _hint_fn(bot, ctx) + message = ctx.send.call_args[0][0] + # Verify rank markers and each word appears as a distinct entry + # Expected format: "1. cha (75%) | 2. ch (50%) | 3. c (25%)" + assert "1. cha" in message + assert "2. ch" in message + assert "3. c " in message + + async def test_status_shows_attempt_count(self): + bot = make_bot(cooldown=0, scorer=FakeScorer()) + bot._game_state.start_new_game("chat", Difficulty.EASY) + for name, word in [("alice", "chien"), ("bob", "maison")]: + ctx_g = make_ctx(author_name=name) + await _guess_fn(bot, ctx_g, word) + + ctx = make_ctx() + await _status_fn(bot, ctx) + message = ctx.send.call_args[0][0] + assert "2 attempt" in message.lower() + + async def test_status_reports_no_game_when_inactive(self): + bot = make_bot() + ctx = make_ctx() + await _status_fn(bot, ctx) + message = ctx.send.call_args[0][0] + assert "no game" in message.lower() + + async def test_hint_reports_no_guesses_when_empty(self): + bot = make_bot() + bot._game_state.start_new_game("chat", Difficulty.EASY) + ctx = make_ctx() + await _hint_fn(bot, ctx) + message = ctx.send.call_args[0][0] + assert "no scored" in message.lower() + + +# --------------------------------------------------------------------------- +# TestSolutionCommand +# --------------------------------------------------------------------------- + + +class TestSolutionCommand: + async def test_solution_reveals_word_and_marks_found(self): + bot = make_bot(cooldown=0) + bot._game_state.start_new_game("chat", Difficulty.EASY) + ctx = make_ctx(is_broadcaster=True, author_name="broadcaster") + await _solution_fn(bot, ctx) + assert bot._game_state.is_found + message = ctx.send.call_args[0][0] + assert "chat" in message.lower() + + async def test_solution_requires_broadcaster(self): + bot = make_bot() + bot._game_state.start_new_game("chat", Difficulty.EASY) + ctx = make_ctx(author_name="viewer") + await _solution_fn(bot, ctx) + assert not bot._game_state.is_found + message = ctx.send.call_args[0][0] + assert "broadcaster" in message.lower() + + async def test_solution_fails_without_active_game(self): + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) + await _solution_fn(bot, ctx) + message = ctx.send.call_args[0][0] + assert "no game" in message.lower() + + +# --------------------------------------------------------------------------- +# TestGuessEdgeCases +# --------------------------------------------------------------------------- + + +class TestGuessEdgeCases: + async def test_duplicate_guess_gets_already_cited_response(self): + bot = make_bot(cooldown=0) + bot._game_state.start_new_game("chat", Difficulty.EASY) + ctx1 = make_ctx(author_name="alice") + await _guess_fn(bot, ctx1, "chien") + ctx2 = make_ctx(author_name="bob") + await _guess_fn(bot, ctx2, "chien") + message = ctx2.send.call_args[0][0] + assert "already" in message.lower() + + async def test_invalid_word_characters_rejected(self): + bot = make_bot(cooldown=0) + bot._game_state.start_new_game("chat", Difficulty.EASY) + ctx = make_ctx(author_name="alice") + await _guess_fn(bot, ctx, "mot123") + message = ctx.send.call_args[0][0] + assert "letter" in message.lower() + + async def test_word_too_long_rejected(self): + bot = make_bot(cooldown=0) + bot._game_state.start_new_game("chat", Difficulty.EASY) + ctx = make_ctx(author_name="alice") + await _guess_fn(bot, ctx, "a" * 51) + message = ctx.send.call_args[0][0] + assert "long" in message.lower() or "max" in message.lower() + + async def test_cooldown_blocks_rapid_fire_guesses(self): + bot = make_bot(cooldown=30, scorer=FakeScorer()) + bot._game_state.start_new_game("chat", Difficulty.EASY) + ctx1 = make_ctx(author_name="alice") + await _guess_fn(bot, ctx1, "chien") + # Second guess from the same user while on cooldown + ctx2 = make_ctx(author_name="alice") + await _guess_fn(bot, ctx2, "maison") + message = ctx2.send.call_args[0][0] + assert "wait" in message.lower() diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..6a88c3c --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +"""tests.unit package.""" diff --git a/tests/test_commands.py b/tests/unit/test_commands.py similarity index 82% rename from tests/test_commands.py rename to tests/unit/test_commands.py index ed2bf94..8b1e4f5 100644 --- a/tests/test_commands.py +++ b/tests/unit/test_commands.py @@ -4,31 +4,8 @@ from unittest.mock import AsyncMock, MagicMock, patch from bot.bot import StreamantixBot, _validate_prefix, _validate_cooldown, _validate_difficulty, _VALID_GUESS_RE -from bot.cooldown import CooldownManager from game.state import Difficulty, GameState - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -def _make_bot(prefix: str = "!sx", cooldown: int = 5) -> StreamantixBot: - """Return a StreamantixBot instance without connecting to Twitch.""" - bot = object.__new__(StreamantixBot) - bot._command_prefix = prefix - bot._cooldown = CooldownManager(cooldown) - bot._game_state = GameState() - bot._next_difficulty = Difficulty.EASY - return bot - - -def _make_ctx(*, is_mod: bool = False, is_broadcaster: bool = False) -> MagicMock: - """Return a minimal mock of a twitchio Context.""" - ctx = MagicMock() - ctx.send = AsyncMock() - ctx.author.is_mod = is_mod - ctx.author.is_broadcaster = is_broadcaster - return ctx +from tests.conftest import make_bot, make_ctx # Underlying async function behind the @commands.command() decorator @@ -72,12 +49,12 @@ def test_too_long_prefix_is_invalid(self): class TestCommandParsing: def test_default_prefix_stored_on_bot(self): """Bot stores the prefix passed at construction time.""" - bot = _make_bot("!sx") + bot = make_bot("!sx") assert bot._command_prefix == "!sx" def test_custom_prefix_stored_on_bot(self): """Bot stores a custom prefix passed at construction time.""" - bot = _make_bot("?") + bot = make_bot("?") assert bot._command_prefix == "?" def test_guess_command_ignores_extra_args(self): @@ -95,22 +72,22 @@ def test_unknown_command_is_ignored(self): class TestPermissionChecks: async def test_moderator_can_change_prefix(self): - bot = _make_bot("!sx") - ctx = _make_ctx(is_mod=True) + bot = make_bot("!sx") + ctx = make_ctx(is_mod=True) await _setprefix_fn(bot, ctx, "?") assert bot._command_prefix == "?" ctx.send.assert_called_once() async def test_broadcaster_can_change_prefix(self): - bot = _make_bot("!sx") - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot("!sx") + ctx = make_ctx(is_broadcaster=True) await _setprefix_fn(bot, ctx, "?") assert bot._command_prefix == "?" ctx.send.assert_called_once() async def test_regular_user_cannot_change_prefix(self): - bot = _make_bot("!sx") - ctx = _make_ctx() # neither mod nor broadcaster + bot = make_bot("!sx") + ctx = make_ctx() # neither mod nor broadcaster await _setprefix_fn(bot, ctx, "?") assert bot._command_prefix == "!sx" # unchanged ctx.send.assert_called_once() @@ -127,33 +104,33 @@ async def test_any_user_can_guess(self): class TestSetprefixValidation: async def test_empty_prefix_rejected(self): - bot = _make_bot("!sx") - ctx = _make_ctx(is_mod=True) + bot = make_bot("!sx") + ctx = make_ctx(is_mod=True) await _setprefix_fn(bot, ctx, "") assert bot._command_prefix == "!sx" async def test_no_prefix_argument_rejected(self): - bot = _make_bot("!sx") - ctx = _make_ctx(is_mod=True) + bot = make_bot("!sx") + ctx = make_ctx(is_mod=True) # default value for new_prefix is "" await _setprefix_fn(bot, ctx) assert bot._command_prefix == "!sx" async def test_prefix_with_spaces_rejected(self): - bot = _make_bot("!sx") - ctx = _make_ctx(is_mod=True) + bot = make_bot("!sx") + ctx = make_ctx(is_mod=True) await _setprefix_fn(bot, ctx, "! sx") assert bot._command_prefix == "!sx" async def test_too_long_prefix_rejected(self): - bot = _make_bot("!sx") - ctx = _make_ctx(is_mod=True) + bot = make_bot("!sx") + ctx = make_ctx(is_mod=True) await _setprefix_fn(bot, ctx, "a" * 11) assert bot._command_prefix == "!sx" async def test_invalid_prefix_sends_error_message(self): - bot = _make_bot("!sx") - ctx = _make_ctx(is_mod=True) + bot = make_bot("!sx") + ctx = make_ctx(is_mod=True) await _setprefix_fn(bot, ctx, "bad prefix") ctx.send.assert_called_once() assert "invalid" in ctx.send.call_args[0][0].lower() @@ -167,18 +144,18 @@ class TestCooldownEnforcement: """Prefix changes take effect immediately for all subsequent commands.""" async def test_prefix_change_persists_in_session(self): - bot = _make_bot("!sx") - ctx = _make_ctx(is_mod=True) + bot = make_bot("!sx") + ctx = make_ctx(is_mod=True) await _setprefix_fn(bot, ctx, "?") assert bot._command_prefix == "?" # A second change uses the updated prefix as "old" - ctx2 = _make_ctx(is_mod=True) + ctx2 = make_ctx(is_mod=True) await _setprefix_fn(bot, ctx2, "!new") assert bot._command_prefix == "!new" async def test_prefix_change_confirmation_message_contains_both_prefixes(self): - bot = _make_bot("!sx") - ctx = _make_ctx(is_mod=True) + bot = make_bot("!sx") + ctx = make_ctx(is_mod=True) await _setprefix_fn(bot, ctx, "?") message = ctx.send.call_args[0][0] assert "!sx" in message @@ -218,22 +195,22 @@ def test_empty_string_is_invalid(self): class TestSetcooldownPermissions: async def test_moderator_can_set_cooldown(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setcooldown_fn(bot, ctx, "10") assert bot._cooldown.duration == 10 ctx.send.assert_called_once() async def test_broadcaster_can_set_cooldown(self): - bot = _make_bot() - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) await _setcooldown_fn(bot, ctx, "10") assert bot._cooldown.duration == 10 ctx.send.assert_called_once() async def test_regular_user_cannot_set_cooldown(self): - bot = _make_bot() - ctx = _make_ctx() + bot = make_bot() + ctx = make_ctx() await _setcooldown_fn(bot, ctx, "10") assert bot._cooldown.duration == 5 # unchanged ctx.send.assert_called_once() @@ -242,34 +219,34 @@ async def test_regular_user_cannot_set_cooldown(self): class TestSetcooldownValidation: async def test_valid_cooldown_is_applied(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setcooldown_fn(bot, ctx, "20") assert bot._cooldown.duration == 20 async def test_zero_cooldown_is_accepted(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setcooldown_fn(bot, ctx, "0") assert bot._cooldown.duration == 0 async def test_invalid_cooldown_sends_error(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setcooldown_fn(bot, ctx, "bad") assert bot._cooldown.duration == 5 # unchanged ctx.send.assert_called_once() assert "invalid" in ctx.send.call_args[0][0].lower() async def test_negative_cooldown_rejected(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setcooldown_fn(bot, ctx, "-1") assert bot._cooldown.duration == 5 # unchanged async def test_confirmation_message_contains_value(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setcooldown_fn(bot, ctx, "7") assert "7" in ctx.send.call_args[0][0] @@ -283,8 +260,8 @@ async def test_confirmation_message_contains_value(self): class TestGuessCooldownEnforcement: async def test_guess_blocked_during_cooldown(self): - bot = _make_bot(cooldown=30) - ctx = _make_ctx() + bot = make_bot(cooldown=30) + ctx = make_ctx() ctx.author.name = "alice" bot._cooldown.record("alice") # simulate a recent guess by this user await _guess_fn(bot, ctx) @@ -292,24 +269,24 @@ async def test_guess_blocked_during_cooldown(self): assert "wait" in message.lower() async def test_guess_allowed_when_not_on_cooldown(self): - bot = _make_bot(cooldown=0) - ctx = _make_ctx() + bot = make_bot(cooldown=0) + ctx = make_ctx() ctx.author.name = "alice" await _guess_fn(bot, ctx) message = ctx.send.call_args[0][0] assert "wait" not in message.lower() async def test_guess_records_cooldown(self): - bot = _make_bot(cooldown=30) - ctx = _make_ctx() + bot = make_bot(cooldown=30) + ctx = make_ctx() ctx.author.name = "alice" assert not bot._cooldown.is_on_cooldown("alice") await _guess_fn(bot, ctx) assert bot._cooldown.is_on_cooldown("alice") async def test_blocked_guess_message_mentions_seconds(self): - bot = _make_bot(cooldown=30) - ctx = _make_ctx() + bot = make_bot(cooldown=30) + ctx = make_ctx() ctx.author.name = "alice" bot._cooldown.record("alice") await _guess_fn(bot, ctx) @@ -318,10 +295,10 @@ async def test_blocked_guess_message_mentions_seconds(self): async def test_different_users_have_independent_cooldowns(self): """One user being on cooldown must not block another user.""" - bot = _make_bot(cooldown=30) - ctx_alice = _make_ctx() + bot = make_bot(cooldown=30) + ctx_alice = make_ctx() ctx_alice.author.name = "alice" - ctx_bob = _make_ctx() + ctx_bob = make_ctx() ctx_bob.author.name = "bob" bot._cooldown.record("alice") # only alice is on cooldown assert bot._cooldown.is_on_cooldown("alice") @@ -337,7 +314,7 @@ async def test_different_users_have_independent_cooldowns(self): # --------------------------------------------------------------------------- -class _FakeScorer: +class _FixedScorer: """Deterministic scorer: returns 0.5 for any non-exact guess.""" def score_guess(self, guess: str, target: str) -> float | None: @@ -349,24 +326,24 @@ def score_guess(self, guess: str, target: str) -> float | None: class TestGuessRouting: async def test_guess_without_word_sends_usage_hint(self): - bot = _make_bot(cooldown=0) - ctx = _make_ctx() + bot = make_bot(cooldown=0) + ctx = make_ctx() await _guess_fn(bot, ctx) message = ctx.send.call_args[0][0] assert "provide" in message.lower() or "usage" in message.lower() async def test_guess_without_active_game_sends_error(self): - bot = _make_bot(cooldown=0) - ctx = _make_ctx() + bot = make_bot(cooldown=0) + ctx = make_ctx() ctx.author.name = "alice" await _guess_fn(bot, ctx, "chat") message = ctx.send.call_args[0][0] assert "no game" in message.lower() async def test_guess_exact_match_announces_winner(self): - bot = _make_bot(cooldown=0) + bot = make_bot(cooldown=0) bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() + ctx = make_ctx() ctx.author.name = "alice" await _guess_fn(bot, ctx, "chat") message = ctx.send.call_args[0][0] @@ -374,10 +351,10 @@ async def test_guess_exact_match_announces_winner(self): assert "chat" in message.lower() async def test_guess_near_match_shows_similarity(self): - bot = _make_bot(cooldown=0) - bot._game_state = GameState(scorer=_FakeScorer()) + bot = make_bot(cooldown=0) + bot._game_state = GameState(scorer=_FixedScorer()) bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() + ctx = make_ctx() ctx.author.name = "alice" await _guess_fn(bot, ctx, "chien") message = ctx.send.call_args[0][0] @@ -399,10 +376,10 @@ def score_guess(self, guess: str, target: str) -> float | None: return 1.0 return 0.998 - bot = _make_bot(cooldown=0) + bot = make_bot(cooldown=0) bot._game_state = GameState(scorer=_NearHundredScorer()) bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() + ctx = make_ctx() ctx.author.name = "alice" await _guess_fn(bot, ctx, "chien") message = ctx.send.call_args[0][0] @@ -421,19 +398,19 @@ def score_guess(self, guess: str, target: str) -> float | None: return 1.0 return 0.475 - bot = _make_bot(cooldown=0) + bot = make_bot(cooldown=0) bot._game_state = GameState(scorer=_FloorScorer()) bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() + ctx = make_ctx() ctx.author.name = "alice" await _guess_fn(bot, ctx, "chien") message = ctx.send.call_args[0][0] assert "47%" in message, f"Expected 47% (floor), got: {message}" async def test_guess_unknown_word_reports_vocabulary_miss(self): - bot = _make_bot(cooldown=0) + bot = make_bot(cooldown=0) bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() + ctx = make_ctx() ctx.author.name = "alice" # No scorer, so non-exact guess produces score=None await _guess_fn(bot, ctx, "unknownword") @@ -441,15 +418,15 @@ async def test_guess_unknown_word_reports_vocabulary_miss(self): assert "vocabulary" in message.lower() async def test_guess_already_cited_word_with_score_sends_distinct_message(self): - bot = _make_bot(cooldown=0) - bot._game_state = GameState(scorer=_FakeScorer()) + bot = make_bot(cooldown=0) + bot._game_state = GameState(scorer=_FixedScorer()) bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() + ctx = make_ctx() ctx.author.name = "alice" # First submission of "chien" await _guess_fn(bot, ctx, "chien") # Second submission of the same word - ctx2 = _make_ctx() + ctx2 = make_ctx() ctx2.author.name = "bob" await _guess_fn(bot, ctx2, "chien") message = ctx2.send.call_args[0][0] @@ -457,13 +434,13 @@ async def test_guess_already_cited_word_with_score_sends_distinct_message(self): assert "%" in message async def test_guess_already_cited_word_without_score_sends_distinct_message(self): - bot = _make_bot(cooldown=0) + bot = make_bot(cooldown=0) bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() + ctx = make_ctx() ctx.author.name = "alice" # No scorer, so non-exact guess produces score=None await _guess_fn(bot, ctx, "unknownword") - ctx2 = _make_ctx() + ctx2 = make_ctx() ctx2.author.name = "bob" await _guess_fn(bot, ctx2, "unknownword") message = ctx2.send.call_args[0][0] @@ -471,15 +448,15 @@ async def test_guess_already_cited_word_without_score_sends_distinct_message(sel async def test_guess_exact_match_not_reported_as_already_cited(self): """Re-guessing the winning word after game is won should show an already-found message.""" - bot = _make_bot(cooldown=0) - bot._game_state = GameState(scorer=_FakeScorer()) + bot = make_bot(cooldown=0) + bot._game_state = GameState(scorer=_FixedScorer()) bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() + ctx = make_ctx() ctx.author.name = "alice" # Someone submits the winning word first (game won) await _guess_fn(bot, ctx, "chat") # Another user submits the same winning word again - ctx2 = _make_ctx() + ctx2 = make_ctx() ctx2.author.name = "bob" await _guess_fn(bot, ctx2, "chat") message = ctx2.send.call_args[0][0] @@ -498,11 +475,11 @@ async def test_guess_exact_match_not_reported_as_already_cited(self): class TestEventError: async def test_event_error_does_not_raise(self): """event_error should swallow exceptions without crashing.""" - bot = _make_bot() + bot = make_bot() await bot.event_error(RuntimeError("connection lost")) async def test_event_error_with_data_does_not_raise(self): - bot = _make_bot() + bot = make_bot() await bot.event_error(RuntimeError("reconnecting"), data="some data") @@ -515,14 +492,14 @@ async def test_event_error_with_data_does_not_raise(self): class TestHelpCommand: async def test_help_sends_message(self): - bot = _make_bot() - ctx = _make_ctx() + bot = make_bot() + ctx = make_ctx() await _help_fn(bot, ctx) ctx.send.assert_called_once() async def test_help_message_mentions_commands(self): - bot = _make_bot() - ctx = _make_ctx() + bot = make_bot() + ctx = make_ctx() await _help_fn(bot, ctx) message = ctx.send.call_args[0][0] for keyword in ("help", "start", "guess", "setprefix", "setcooldown", "hint", "status", "setdifficulty"): @@ -593,26 +570,26 @@ class TestGuessWordValidationCommand: """Integration tests: invalid chars should be rejected before submit_guess.""" async def test_digit_in_word_rejected(self): - bot = _make_bot(cooldown=0) + bot = make_bot(cooldown=0) bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() + ctx = make_ctx() ctx.author.name = "alice" await _guess_fn(bot, ctx, "mot1") message = ctx.send.call_args[0][0] assert "letter" in message.lower() async def test_special_char_in_word_rejected(self): - bot = _make_bot(cooldown=0) + bot = make_bot(cooldown=0) bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() + ctx = make_ctx() ctx.author.name = "alice" await _guess_fn(bot, ctx, "chat!") message = ctx.send.call_args[0][0] assert "letter" in message.lower() async def test_compound_word_accepted(self): - bot = _make_bot(cooldown=0) - ctx = _make_ctx() + bot = make_bot(cooldown=0) + ctx = make_ctx() ctx.author.name = "alice" # No active game — validation passes and reaches "no game" error await _guess_fn(bot, ctx, "arc-en-ciel") @@ -620,8 +597,8 @@ async def test_compound_word_accepted(self): assert "letter" not in message.lower() async def test_accented_word_accepted(self): - bot = _make_bot(cooldown=0) - ctx = _make_ctx() + bot = make_bot(cooldown=0) + ctx = make_ctx() ctx.author.name = "alice" await _guess_fn(bot, ctx, "étoile") message = ctx.send.call_args[0][0] @@ -629,8 +606,8 @@ async def test_accented_word_accepted(self): async def test_help_available_to_any_user(self): """Any user (no mod/broadcaster role) can call help.""" - bot = _make_bot() - ctx = _make_ctx() # no special role + bot = make_bot() + ctx = make_ctx() # no special role await _help_fn(bot, ctx) ctx.send.assert_called_once() @@ -644,24 +621,24 @@ async def test_help_available_to_any_user(self): class TestStartPermissions: async def test_non_broadcaster_cannot_start(self): - bot = _make_bot() - ctx = _make_ctx() # no special role + bot = make_bot() + ctx = make_ctx() # no special role await _start_fn(bot, ctx) ctx.send.assert_called_once() assert "broadcaster" in ctx.send.call_args[0][0].lower() assert bot._game_state.target_word is None async def test_moderator_cannot_start(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _start_fn(bot, ctx) ctx.send.assert_called_once() assert "broadcaster" in ctx.send.call_args[0][0].lower() assert bot._game_state.target_word is None async def test_broadcaster_can_start(self): - bot = _make_bot() - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) with patch("random.choice", return_value="chat"): await _start_fn(bot, ctx) ctx.send.assert_called_once() @@ -670,44 +647,44 @@ async def test_broadcaster_can_start(self): class TestStartDifficulty: async def test_no_difficulty_defaults_to_easy(self): - bot = _make_bot() - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) with patch("random.choice", return_value="chat"): await _start_fn(bot, ctx) assert bot._game_state.difficulty == Difficulty.EASY async def test_easy_difficulty_accepted(self): - bot = _make_bot() - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) with patch("random.choice", return_value="chat"): await _start_fn(bot, ctx, "easy") assert bot._game_state.difficulty == Difficulty.EASY async def test_hard_difficulty_accepted(self): - bot = _make_bot() - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) with patch("random.choice", return_value="ambiguïté"): await _start_fn(bot, ctx, "hard") assert bot._game_state.difficulty == Difficulty.HARD async def test_medium_difficulty_accepted(self): - bot = _make_bot() - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) with patch("random.choice", return_value="ambiguïté"): await _start_fn(bot, ctx, "medium") assert bot._game_state.difficulty == Difficulty.MEDIUM async def test_invalid_difficulty_sends_error(self): - bot = _make_bot() - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) await _start_fn(bot, ctx, "impossible") ctx.send.assert_called_once() assert "invalid" in ctx.send.call_args[0][0].lower() assert bot._game_state.target_word is None async def test_difficulty_is_case_insensitive(self): - bot = _make_bot() - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) with patch("random.choice", return_value="chat"): await _start_fn(bot, ctx, "EASY") assert bot._game_state.difficulty == Difficulty.EASY @@ -715,33 +692,33 @@ async def test_difficulty_is_case_insensitive(self): class TestStartGameState: async def test_start_sets_target_word(self): - bot = _make_bot() - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) with patch("random.choice", return_value="bateau"): await _start_fn(bot, ctx) assert bot._game_state.target_word == "bateau" async def test_start_resets_previous_game(self): - bot = _make_bot() + bot = make_bot() bot._game_state.start_new_game("old_word", Difficulty.EASY) bot._game_state.submit_guess("alice", "arbre") - ctx = _make_ctx(is_broadcaster=True) + ctx = make_ctx(is_broadcaster=True) with patch("random.choice", return_value="chat"): await _start_fn(bot, ctx) assert bot._game_state.target_word == "chat" assert bot._game_state.attempt_count == 0 async def test_start_confirmation_message_contains_difficulty(self): - bot = _make_bot() - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) with patch("random.choice", return_value="chat"): await _start_fn(bot, ctx, "easy") message = ctx.send.call_args[0][0] assert "easy" in message.lower() async def test_start_confirmation_message_contains_prefix(self): - bot = _make_bot("!sx") - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot("!sx") + ctx = make_ctx(is_broadcaster=True) with patch("random.choice", return_value="chat"): await _start_fn(bot, ctx) message = ctx.send.call_args[0][0] @@ -757,46 +734,46 @@ async def test_start_confirmation_message_contains_prefix(self): class TestHintCommand: async def test_hint_no_game_sends_error(self): - bot = _make_bot() - ctx = _make_ctx() + bot = make_bot() + ctx = make_ctx() await _hint_fn(bot, ctx) message = ctx.send.call_args[0][0] assert "no game" in message.lower() async def test_hint_no_guesses_sends_message(self): - bot = _make_bot() + bot = make_bot() bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() + ctx = make_ctx() await _hint_fn(bot, ctx) message = ctx.send.call_args[0][0] assert "no scored" in message.lower() async def test_hint_with_guesses_shows_leaderboard(self): - bot = _make_bot(cooldown=0) - bot._game_state = GameState(scorer=_FakeScorer()) + bot = make_bot(cooldown=0) + bot._game_state = GameState(scorer=_FixedScorer()) bot._game_state.start_new_game("chat", Difficulty.EASY) bot._game_state.submit_guess("alice", "chien") bot._game_state.submit_guess("bob", "maison") - ctx = _make_ctx() + ctx = make_ctx() await _hint_fn(bot, ctx) message = ctx.send.call_args[0][0] assert "1." in message assert "%" in message async def test_hint_available_to_any_user(self): - bot = _make_bot() + bot = make_bot() bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() # no special role + ctx = make_ctx() # no special role await _hint_fn(bot, ctx) ctx.send.assert_called_once() async def test_hint_shows_at_most_ten_entries(self): - bot = _make_bot(cooldown=0) - bot._game_state = GameState(scorer=_FakeScorer()) + bot = make_bot(cooldown=0) + bot._game_state = GameState(scorer=_FixedScorer()) bot._game_state.start_new_game("chat", Difficulty.EASY) for i in range(15): bot._game_state.submit_guess("alice", f"mot{i}") - ctx = _make_ctx() + ctx = make_ctx() await _hint_fn(bot, ctx) message = ctx.send.call_args[0][0] # At most 10 entries means rank 11 should not appear @@ -812,44 +789,44 @@ async def test_hint_shows_at_most_ten_entries(self): class TestStatusCommand: async def test_status_no_game_sends_error(self): - bot = _make_bot() - ctx = _make_ctx() + bot = make_bot() + ctx = make_ctx() await _status_fn(bot, ctx) message = ctx.send.call_args[0][0] assert "no game" in message.lower() async def test_status_game_in_progress_no_guesses(self): - bot = _make_bot() + bot = make_bot() bot._game_state.start_new_game("chat", Difficulty.EASY) - ctx = _make_ctx() + ctx = make_ctx() await _status_fn(bot, ctx) message = ctx.send.call_args[0][0] assert "in progress" in message.lower() assert "0" in message async def test_status_game_in_progress_with_scored_guess(self): - bot = _make_bot(cooldown=0) - bot._game_state = GameState(scorer=_FakeScorer()) + bot = make_bot(cooldown=0) + bot._game_state = GameState(scorer=_FixedScorer()) bot._game_state.start_new_game("chat", Difficulty.EASY) bot._game_state.submit_guess("alice", "chien") - ctx = _make_ctx() + ctx = make_ctx() await _status_fn(bot, ctx) message = ctx.send.call_args[0][0] assert "in progress" in message.lower() assert "%" in message async def test_status_game_found_shows_winner(self): - bot = _make_bot(cooldown=0) + bot = make_bot(cooldown=0) bot._game_state.start_new_game("chat", Difficulty.EASY) bot._game_state.submit_guess("alice", "chat") - ctx = _make_ctx() + ctx = make_ctx() await _status_fn(bot, ctx) message = ctx.send.call_args[0][0] assert "alice" in message.lower() async def test_status_available_to_any_user(self): - bot = _make_bot() - ctx = _make_ctx() # no special role + bot = make_bot() + ctx = make_ctx() # no special role await _status_fn(bot, ctx) ctx.send.assert_called_once() @@ -888,22 +865,22 @@ def test_unknown_value_is_invalid(self): class TestSetdifficultyPermissions: async def test_moderator_can_set_difficulty(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setdifficulty_fn(bot, ctx, "hard") assert bot._next_difficulty == Difficulty.HARD ctx.send.assert_called_once() async def test_broadcaster_can_set_difficulty(self): - bot = _make_bot() - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) await _setdifficulty_fn(bot, ctx, "hard") assert bot._next_difficulty == Difficulty.HARD ctx.send.assert_called_once() async def test_regular_user_cannot_set_difficulty(self): - bot = _make_bot() - ctx = _make_ctx() # no special role + bot = make_bot() + ctx = make_ctx() # no special role await _setdifficulty_fn(bot, ctx, "hard") assert bot._next_difficulty == Difficulty.EASY # unchanged ctx.send.assert_called_once() @@ -912,43 +889,43 @@ async def test_regular_user_cannot_set_difficulty(self): class TestSetdifficultyValidation: async def test_invalid_difficulty_rejected(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setdifficulty_fn(bot, ctx, "medium") assert bot._next_difficulty == Difficulty.EASY # unchanged ctx.send.assert_called_once() assert "invalid" in ctx.send.call_args[0][0].lower() async def test_empty_difficulty_rejected(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setdifficulty_fn(bot, ctx, "") assert bot._next_difficulty == Difficulty.EASY # unchanged async def test_valid_easy_accepted(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setdifficulty_fn(bot, ctx, "easy") assert bot._next_difficulty == Difficulty.EASY ctx.send.assert_called_once() async def test_valid_hard_accepted(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setdifficulty_fn(bot, ctx, "hard") assert bot._next_difficulty == Difficulty.HARD ctx.send.assert_called_once() async def test_confirmation_message_contains_difficulty(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setdifficulty_fn(bot, ctx, "hard") message = ctx.send.call_args[0][0] assert "hard" in message.lower() async def test_case_insensitive(self): - bot = _make_bot() - ctx = _make_ctx(is_mod=True) + bot = make_bot() + ctx = make_ctx(is_mod=True) await _setdifficulty_fn(bot, ctx, "HARD") assert bot._next_difficulty == Difficulty.HARD @@ -960,20 +937,20 @@ async def test_case_insensitive(self): class TestSetdifficultyIntegration: async def test_start_uses_next_difficulty_as_default(self): - bot = _make_bot() - ctx_mod = _make_ctx(is_mod=True) + bot = make_bot() + ctx_mod = make_ctx(is_mod=True) await _setdifficulty_fn(bot, ctx_mod, "hard") assert bot._next_difficulty == Difficulty.HARD - ctx_broadcaster = _make_ctx(is_broadcaster=True) + ctx_broadcaster = make_ctx(is_broadcaster=True) with patch("random.choice", return_value="ambiguïté"): await _start_fn(bot, ctx_broadcaster) assert bot._game_state.difficulty == Difficulty.HARD async def test_setdifficulty_does_not_reset_current_game(self): - bot = _make_bot() + bot = make_bot() bot._game_state.start_new_game("chat", Difficulty.EASY) bot._game_state.submit_guess("alice", "arbre") - ctx = _make_ctx(is_mod=True) + ctx = make_ctx(is_mod=True) await _setdifficulty_fn(bot, ctx, "hard") # Current game is unaffected assert bot._game_state.target_word == "chat" @@ -989,27 +966,27 @@ async def test_setdifficulty_does_not_reset_current_game(self): class TestSolutionPermissions: async def test_non_broadcaster_cannot_reveal(self): - bot = _make_bot() + bot = make_bot() bot._game_state.start_new_game("secret", Difficulty.EASY) - ctx = _make_ctx() # no special role + ctx = make_ctx() # no special role await _solution_fn(bot, ctx) ctx.send.assert_called_once() assert "broadcaster" in ctx.send.call_args[0][0].lower() assert not bot._game_state.is_found async def test_moderator_cannot_reveal(self): - bot = _make_bot() + bot = make_bot() bot._game_state.start_new_game("secret", Difficulty.EASY) - ctx = _make_ctx(is_mod=True) + ctx = make_ctx(is_mod=True) await _solution_fn(bot, ctx) ctx.send.assert_called_once() assert "broadcaster" in ctx.send.call_args[0][0].lower() assert not bot._game_state.is_found async def test_broadcaster_can_reveal(self): - bot = _make_bot() + bot = make_bot() bot._game_state.start_new_game("secret", Difficulty.EASY) - ctx = _make_ctx(is_broadcaster=True) + ctx = make_ctx(is_broadcaster=True) ctx.author.name = "streamer123" await _solution_fn(bot, ctx) assert bot._game_state.is_found @@ -1017,33 +994,33 @@ async def test_broadcaster_can_reveal(self): class TestSolutionGameState: async def test_reveal_requires_active_game(self): - bot = _make_bot() - ctx = _make_ctx(is_broadcaster=True) + bot = make_bot() + ctx = make_ctx(is_broadcaster=True) await _solution_fn(bot, ctx) ctx.send.assert_called_once() assert "no game" in ctx.send.call_args[0][0].lower() async def test_reveal_marks_game_as_found(self): - bot = _make_bot() + bot = make_bot() bot._game_state.start_new_game("mystère", Difficulty.EASY) assert not bot._game_state.is_found - ctx = _make_ctx(is_broadcaster=True) + ctx = make_ctx(is_broadcaster=True) ctx.author.name = "broadcaster" await _solution_fn(bot, ctx) assert bot._game_state.is_found async def test_reveal_sets_found_by_to_broadcaster(self): - bot = _make_bot() + bot = make_bot() bot._game_state.start_new_game("énigme", Difficulty.EASY) - ctx = _make_ctx(is_broadcaster=True) + ctx = make_ctx(is_broadcaster=True) ctx.author.name = "streamer_pro" await _solution_fn(bot, ctx) assert bot._game_state.found_by == "streamer_pro" async def test_reveal_message_contains_target_word(self): - bot = _make_bot() + bot = make_bot() bot._game_state.start_new_game("papillon", Difficulty.EASY) - ctx = _make_ctx(is_broadcaster=True) + ctx = make_ctx(is_broadcaster=True) ctx.author.name = "streamer" await _solution_fn(bot, ctx) message = ctx.send.call_args[0][0] @@ -1051,10 +1028,10 @@ async def test_reveal_message_contains_target_word(self): assert "revealed" in message.lower() or "solution" in message.lower() async def test_reveal_adds_to_history(self): - bot = _make_bot() + bot = make_bot() bot._game_state.start_new_game("cascade", Difficulty.EASY) bot._game_state.submit_guess("alice", "rivière") - ctx = _make_ctx(is_broadcaster=True) + ctx = make_ctx(is_broadcaster=True) ctx.author.name = "broadcaster" await _solution_fn(bot, ctx) assert bot._game_state.attempt_count == 2 @@ -1064,10 +1041,10 @@ async def test_reveal_adds_to_history(self): class TestSolutionOverlayNotification: async def test_reveal_notifies_overlay(self): mock_callback = AsyncMock() - bot = _make_bot() + bot = make_bot() bot._on_state_change = mock_callback bot._game_state.start_new_game("solution", Difficulty.EASY) - ctx = _make_ctx(is_broadcaster=True) + ctx = make_ctx(is_broadcaster=True) ctx.author.name = "broadcaster" await _solution_fn(bot, ctx) mock_callback.assert_called_once() diff --git a/tests/test_cooldown.py b/tests/unit/test_cooldown.py similarity index 100% rename from tests/test_cooldown.py rename to tests/unit/test_cooldown.py diff --git a/tests/test_engine.py b/tests/unit/test_engine.py similarity index 100% rename from tests/test_engine.py rename to tests/unit/test_engine.py diff --git a/tests/test_game_state.py b/tests/unit/test_game_state.py similarity index 87% rename from tests/test_game_state.py rename to tests/unit/test_game_state.py index 6070d30..2889c74 100644 --- a/tests/test_game_state.py +++ b/tests/unit/test_game_state.py @@ -3,25 +3,7 @@ import pytest from game.state import Difficulty, GameState, GuessEntry, GuessResult - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -class _FakeScorer: - """Deterministic scorer for testing: returns the length-ratio as score.""" - - def score_guess(self, guess: str, target: str) -> float | None: - if not target: - return None - return min(1.0, len(guess) / len(target)) - - -def _make_state(with_scorer: bool = False) -> GameState: - scorer = _FakeScorer() if with_scorer else None - return GameState(scorer=scorer) +from tests.conftest import FakeScorer # --------------------------------------------------------------------------- @@ -32,7 +14,7 @@ def _make_state(with_scorer: bool = False) -> GameState: class TestGameStateReset: def test_reset_clears_guesses(self): """Resetting the game should clear all recorded guesses.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") assert gs.attempt_count == 1 @@ -43,7 +25,7 @@ def test_reset_clears_guesses(self): def test_reset_changes_target_word(self): """Resetting the game should allow setting a new target word.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) assert gs.target_word == "chat" @@ -53,7 +35,7 @@ def test_reset_changes_target_word(self): def test_reset_clears_is_found(self): """Resetting after a win should clear the found flag.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chat") assert gs.is_found @@ -70,7 +52,7 @@ def test_reset_clears_is_found(self): class TestGameStateWinCondition: def test_exact_guess_triggers_win(self): """A guess that exactly matches the target word should trigger a win.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) result = gs.submit_guess("alice", "chat") assert result.is_found @@ -78,7 +60,7 @@ def test_exact_guess_triggers_win(self): def test_wrong_guess_does_not_trigger_win(self): """A non-matching guess should not trigger a win.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) result = gs.submit_guess("alice", "chien") assert not result.is_found @@ -86,7 +68,7 @@ def test_wrong_guess_does_not_trigger_win(self): def test_normalized_guess_triggers_win(self): """A guess that matches after normalisation should trigger a win.""" - gs = _make_state() + gs = GameState() gs.start_new_game("Chat", Difficulty.EASY) result = gs.submit_guess("alice", "CHAT") assert result.is_found @@ -94,14 +76,14 @@ def test_normalized_guess_triggers_win(self): def test_found_word_returns_raw_winning_word(self): """found_word should return the exact string the winner typed.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "CHAT") assert gs.found_word == "CHAT" def test_found_word_is_none_before_win(self): """found_word should be None until a correct guess is submitted.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) assert gs.found_word is None @@ -114,7 +96,7 @@ def test_found_word_is_none_before_win(self): class TestGameStateHistory: def test_guess_history_appended(self): """Each guess should be appended to the history in order.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") gs.submit_guess("bob", "maison") @@ -124,7 +106,7 @@ def test_guess_history_appended(self): def test_history_stores_raw_and_normalized_word(self): """GuessEntry should carry both the raw and the normalised word.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "CHAT!") entry = gs.history[0] @@ -133,7 +115,7 @@ def test_history_stores_raw_and_normalized_word(self): def test_attempt_count_increments(self): """attempt_count should increase by one for each submitted guess.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) assert gs.attempt_count == 0 gs.submit_guess("alice", "chien") @@ -143,13 +125,13 @@ def test_attempt_count_increments(self): def test_submit_guess_raises_without_active_game(self): """Submitting a guess before start_new_game should raise RuntimeError.""" - gs = _make_state() + gs = GameState() with pytest.raises(RuntimeError, match="No game in progress"): gs.submit_guess("alice", "chat") def test_guess_result_contains_entry(self): """GuessResult should carry the GuessEntry that was recorded.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) result = gs.submit_guess("alice", "chien") assert isinstance(result, GuessResult) @@ -165,14 +147,14 @@ def test_guess_result_contains_entry(self): class TestAlreadyCited: def test_first_guess_is_not_already_cited(self): """The first submission of a word should not be marked as already cited.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) result = gs.submit_guess("alice", "chien") assert not result.already_cited def test_repeated_word_is_already_cited(self): """A word submitted a second time by any player should be already cited.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") result = gs.submit_guess("bob", "chien") @@ -180,7 +162,7 @@ def test_repeated_word_is_already_cited(self): def test_same_user_repeated_word_is_already_cited(self): """A word submitted twice by the same user should be already cited.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") result = gs.submit_guess("alice", "chien") @@ -188,7 +170,7 @@ def test_same_user_repeated_word_is_already_cited(self): def test_different_word_is_not_already_cited(self): """A word not yet in history should not be marked as already cited.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") result = gs.submit_guess("bob", "maison") @@ -196,7 +178,7 @@ def test_different_word_is_not_already_cited(self): def test_already_cited_normalised_match(self): """Words that normalise to the same form should be detected as already cited.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "Chien") result = gs.submit_guess("bob", "CHIEN") @@ -204,7 +186,7 @@ def test_already_cited_normalised_match(self): def test_already_cited_word_not_appended_to_history(self): """Already-cited guesses should not be recorded again in history.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") gs.submit_guess("bob", "chien") @@ -212,7 +194,7 @@ def test_already_cited_word_not_appended_to_history(self): def test_already_cited_resets_on_new_game(self): """After starting a new game, previously cited words are no longer cited.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") gs.start_new_game("maison", Difficulty.EASY) @@ -228,7 +210,7 @@ def test_already_cited_resets_on_new_game(self): class TestGameStateLeaderboard: def test_top_guesses_returned_in_order(self): """The leaderboard should return guesses sorted by descending score.""" - gs = _make_state(with_scorer=True) + gs = GameState(scorer=FakeScorer()) gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "ch") # score = 2/4 = 0.5 gs.submit_guess("bob", "chat") # score = 1.0 (exact match / found) @@ -239,13 +221,13 @@ def test_top_guesses_returned_in_order(self): def test_leaderboard_empty_at_start(self): """The leaderboard should be empty when no guesses have been made.""" - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) assert gs.top_guesses() == [] def test_top_guesses_respects_n(self): """top_guesses(n) should return at most n entries.""" - gs = _make_state(with_scorer=True) + gs = GameState(scorer=FakeScorer()) gs.start_new_game("chat", Difficulty.EASY) for word in ["a", "ab", "abc", "abcd", "abcde"]: gs.submit_guess("alice", word) @@ -253,7 +235,7 @@ def test_top_guesses_respects_n(self): def test_already_cited_word_excluded_from_top_guesses(self): """A word submitted a second time should not appear twice in the leaderboard.""" - gs = _make_state(with_scorer=True) + gs = GameState(scorer=FakeScorer()) gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") gs.submit_guess("bob", "chien") @@ -263,21 +245,21 @@ def test_already_cited_word_excluded_from_top_guesses(self): def test_score_stored_in_entry_with_scorer(self): """When a scorer is provided, GuessEntry.score should be set.""" - gs = _make_state(with_scorer=True) + gs = GameState(scorer=FakeScorer()) gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") assert gs.history[0].score is not None def test_score_none_without_scorer_for_non_exact(self): """Without a scorer, non-exact guesses should have score=None.""" - gs = _make_state(with_scorer=False) + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") assert gs.history[0].score is None def test_exact_guess_score_is_one_even_without_scorer(self): """Exact guesses always score 1.0 regardless of scorer.""" - gs = _make_state(with_scorer=False) + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chat") assert gs.history[0].score == 1.0 diff --git a/tests/test_overlay.py b/tests/unit/test_overlay.py similarity index 88% rename from tests/test_overlay.py rename to tests/unit/test_overlay.py index ba5010d..7b07317 100644 --- a/tests/test_overlay.py +++ b/tests/unit/test_overlay.py @@ -11,25 +11,7 @@ from game.state import Difficulty, GameState from overlay.server import OverlayServer from overlay.state import serialize_game_state - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -class _FakeScorer: - """Deterministic scorer for tests: returns the length-ratio as score.""" - - def score_guess(self, guess: str, target: str) -> float | None: - if not target: - return None - return min(1.0, len(guess) / len(target)) - - -def _make_state(with_scorer: bool = False) -> GameState: - scorer = _FakeScorer() if with_scorer else None - return GameState(scorer=scorer) +from tests.conftest import FakeScorer # --------------------------------------------------------------------------- @@ -39,60 +21,60 @@ def _make_state(with_scorer: bool = False) -> GameState: class TestSerializeIdle: def test_status_is_idle_when_no_game_started(self): - gs = _make_state() + gs = GameState() result = serialize_game_state(gs) assert result["status"] == "idle" def test_all_fields_present_when_idle(self): - gs = _make_state() + gs = GameState() result = serialize_game_state(gs) for key in ("status", "difficulty", "attempt_count", "best_guess", "last_guess", "top_guesses", "target_word"): assert key in result def test_idle_has_none_target_word(self): - gs = _make_state() + gs = GameState() result = serialize_game_state(gs) assert result["target_word"] is None def test_idle_has_zero_attempt_count(self): - gs = _make_state() + gs = GameState() result = serialize_game_state(gs) assert result["attempt_count"] == 0 def test_idle_has_empty_top_guesses(self): - gs = _make_state() + gs = GameState() result = serialize_game_state(gs) assert result["top_guesses"] == [] class TestSerializeRunning: def test_status_running_after_start(self): - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) result = serialize_game_state(gs) assert result["status"] == "running" def test_difficulty_present(self): - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.HARD) result = serialize_game_state(gs) assert result["difficulty"] == "hard" def test_target_word_hidden_while_running(self): - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) result = serialize_game_state(gs) assert result["target_word"] is None def test_attempt_count_increments(self): - gs = _make_state(with_scorer=True) + gs = GameState(scorer=FakeScorer()) gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") result = serialize_game_state(gs) assert result["attempt_count"] == 1 def test_last_guess_populated_after_guess(self): - gs = _make_state(with_scorer=True) + gs = GameState(scorer=FakeScorer()) gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") result = serialize_game_state(gs) @@ -101,13 +83,13 @@ def test_last_guess_populated_after_guess(self): assert result["last_guess"]["user"] == "alice" def test_last_guess_is_none_before_any_guess(self): - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) result = serialize_game_state(gs) assert result["last_guess"] is None def test_best_guess_is_highest_scored(self): - gs = _make_state(with_scorer=True) + gs = GameState(scorer=FakeScorer()) gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "ch") # score = 2/4 = 0.5 gs.submit_guess("bob", "chateau") # score = 6/4 -> clamped to 1.0, but this is exact? No, clean("chateau") != clean("chat") @@ -116,7 +98,7 @@ def test_best_guess_is_highest_scored(self): assert result["best_guess"]["score"] >= 0.5 def test_top_guesses_max_ten(self): - gs = _make_state(with_scorer=True) + gs = GameState(scorer=FakeScorer()) gs.start_new_game("chat", Difficulty.EASY) for i in range(15): gs.submit_guess(f"user{i}", "ch") @@ -124,7 +106,7 @@ def test_top_guesses_max_ten(self): assert len(result["top_guesses"]) <= 10 def test_top_guesses_ordered_by_descending_score(self): - gs = _make_state(with_scorer=True) + gs = GameState(scorer=FakeScorer()) gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "c") # score = 1/4 gs.submit_guess("bob", "cha") # score = 3/4 @@ -134,7 +116,7 @@ def test_top_guesses_ordered_by_descending_score(self): assert scores == sorted(scores, reverse=True) def test_top_guesses_contain_required_keys(self): - gs = _make_state(with_scorer=True) + gs = GameState(scorer=FakeScorer()) gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "ch") result = serialize_game_state(gs) @@ -146,21 +128,21 @@ def test_top_guesses_contain_required_keys(self): class TestSerializeFound: def test_status_found_after_correct_guess(self): - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chat") result = serialize_game_state(gs) assert result["status"] == "found" def test_target_word_revealed_when_found(self): - gs = _make_state() + gs = GameState() gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chat") result = serialize_game_state(gs) assert result["target_word"] == "chat" def test_serialisation_is_json_serialisable(self): - gs = _make_state(with_scorer=True) + gs = GameState(scorer=FakeScorer()) gs.start_new_game("chat", Difficulty.EASY) gs.submit_guess("alice", "chien") gs.submit_guess("bob", "chat") diff --git a/tests/test_twitch_auth.py b/tests/unit/test_twitch_auth.py similarity index 100% rename from tests/test_twitch_auth.py rename to tests/unit/test_twitch_auth.py diff --git a/tests/test_word_utils.py b/tests/unit/test_word_utils.py similarity index 100% rename from tests/test_word_utils.py rename to tests/unit/test_word_utils.py