Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 48 additions & 26 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 | <https://embeddings.net/embeddings/frWac_no_postag_no_phrase_700_skip_cut50.bin> |
| 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 | <https://embeddings.net/embeddings/frWac_no_postag_no_phrase_700_skip_cut50.bin> |
| 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:
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
66 changes: 66 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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()
1 change: 1 addition & 0 deletions tests/functional/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""tests.functional package."""
120 changes: 120 additions & 0 deletions tests/functional/test_bot_overlay.py
Original file line number Diff line number Diff line change
@@ -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"
Loading
Loading