animetix-web / tests /api /test_codemanga_consumer.py
MissawB's picture
Upload folder using huggingface_hub (part 4)
8f5abd4 verified
Raw
History Blame Contribute Delete
19.1 kB
"""Real-behavior tests for the CodeMangaConsumer (Codenames-style game).
Unit-style: the consumer is driven directly with the I/O boundaries mocked
(Channels layer, ``accept``) and persistence patched (``state_adapter`` + the
catalog ``get_container``). Players are keyed by a stable client id (cid) passed
as a ``?cid=`` query param. Asserts game logic + protocol, not bare mocks:
* grid generation shape (25 cards) and role distribution (9/8/7/1),
* clue gating: only the current team's spymaster may clue,
* click rules require an active clue + a current-team operative,
* turn switching, assassin loss, score-based wins, guess-limit end of turn,
* spymaster vs operative grid visibility + per-player public shape.
"""
import json
from unittest.mock import AsyncMock, MagicMock
import pytest
from animetix.consumers import codemanga as cm
pytestmark = pytest.mark.asyncio
# --------------------------------------------------------------------------- #
# Helpers / fixtures
# --------------------------------------------------------------------------- #
def _make_consumer(room_code="abc", cid="cid-host", channel_name="chan-host"):
c = cm.CodeMangaConsumer()
c.room_code = room_code.upper()
c.room_group_name = f"codemanga_{room_code.upper()}"
c.cid = cid
c.channel_name = channel_name
c.scope = {
"url_route": {"kwargs": {"room_code": room_code}},
"query_string": f"cid={cid}".encode(),
}
c.send = AsyncMock()
c.accept = AsyncMock()
c.close = AsyncMock()
c.channel_layer = AsyncMock()
return c
def _fresh_room(host="cid-host"):
return {
"host": host,
"players": {},
"state": "lobby",
"categories": ["Anime"],
"difficulty": "Normal",
"grid": [],
"turn": "blue",
"blue_score": 0,
"red_score": 0,
"winner": None,
"clue": None,
"messages": [],
}
def _playing_room_with_grid(host="cid-host"):
"""Mid-game room: indices 0-8 blue, 9-16 red, 17-23 neutral, 24 assassin.
Blue's turn with an active blue clue granting plenty of guesses.
"""
grid = []
for i in range(25):
role = (
"blue"
if i < 9
else "red" if i < 17 else "neutral" if i < 24 else "assassin"
)
grid.append(
{"title": f"T{i}", "image": f"i{i}.png", "role": role, "revealed": False}
)
room = _fresh_room(host)
room.update(
{
"state": "playing",
"grid": grid,
"turn": "blue",
"clue": {"team": "blue", "word": "ninja", "number": 5, "guesses_left": 6},
}
)
room["players"] = {
"cid-op": {
"name": "Op",
"team": "blue",
"role": "operative",
"channel": "chan-op",
}
}
return room
def _blue_operative():
return {"name": "Op", "team": "blue", "role": "operative", "channel": "chan-op"}
@pytest.fixture
def patched_state(mocker):
store = {}
async def _get(key):
return store.get(key)
async def _set(key, value, timeout=None):
store[key] = value
async def _del(key):
store.pop(key, None)
mocker.patch.object(cm.state_adapter, "get_state", side_effect=_get)
mocker.patch.object(cm.state_adapter, "set_state", side_effect=_set)
mocker.patch.object(cm.state_adapter, "delete_state", side_effect=_del)
return store
@pytest.fixture
def patched_catalog(mocker):
db = [{"title": f"Anime {i}", "image": f"img{i}.png"} for i in range(40)]
catalog = MagicMock()
catalog.load_data = MagicMock(return_value={"db": db})
container = MagicMock()
container.core.catalog_service.return_value = catalog
mocker.patch.object(cm, "get_container", return_value=container)
return catalog
# --------------------------------------------------------------------------- #
# connect (cid identity)
# --------------------------------------------------------------------------- #
async def test_connect_bootstraps_lobby_and_registers_player(patched_state, mocker):
c = _make_consumer(cid="cid-host")
mocker.patch.object(c, "broadcast_state", AsyncMock())
await c.connect()
assert c.room_code == "ABC"
c.channel_layer.group_add.assert_awaited_once_with("codemanga_ABC", "chan-host")
c.accept.assert_awaited_once()
room = patched_state["codemanga_room_ABC"]
assert room["state"] == "lobby"
assert room["host"] == "cid-host"
assert "cid-host" in room["players"]
assert room["players"]["cid-host"]["name"].startswith("Fan #")
async def test_reconnect_same_cid_reuses_slot_and_keeps_host(patched_state, mocker):
room = _fresh_room(host="cid-host")
room["players"]["cid-host"] = {
"name": "Kept",
"team": "blue",
"role": "spymaster",
"channel": "old-chan",
}
patched_state["codemanga_room_ABC"] = room
c = _make_consumer(cid="cid-host", channel_name="new-chan")
mocker.patch.object(c, "broadcast_state", AsyncMock())
await c.connect()
saved = patched_state["codemanga_room_ABC"]
assert set(saved["players"]) == {"cid-host"} # no duplicate
assert saved["players"]["cid-host"]["role"] == "spymaster" # preserved
assert saved["players"]["cid-host"]["channel"] == "new-chan" # channel updated
assert saved["host"] == "cid-host"
# --------------------------------------------------------------------------- #
# receive routing
# --------------------------------------------------------------------------- #
async def test_receive_bails_when_not_a_member(patched_state, mocker):
patched_state["codemanga_room_ABC"] = _fresh_room()
c = _make_consumer(cid="stranger")
save = mocker.patch.object(c, "save_room", AsyncMock())
await c.receive(json.dumps({"action": "chat", "message": "hi"}))
save.assert_not_awaited()
async def test_set_player_updates_team_and_role(patched_state, mocker):
room = _fresh_room()
room["players"]["cid-host"] = _blue_operative()
patched_state["codemanga_room_ABC"] = room
c = _make_consumer(cid="cid-host")
mocker.patch.object(c, "broadcast_state", AsyncMock())
await c.receive(
json.dumps(
{
"action": "set_player",
"name": "Naruto",
"team": "red",
"role": "spymaster",
}
)
)
me = patched_state["codemanga_room_ABC"]["players"]["cid-host"]
assert me["name"] == "Naruto" and me["team"] == "red" and me["role"] == "spymaster"
async def test_set_categories_keeps_only_known(patched_state, mocker):
room = _fresh_room(host="cid-host")
room["players"]["cid-host"] = {
"name": "h",
"team": "blue",
"role": "spymaster",
"channel": "chan-host",
}
patched_state["codemanga_room_ABC"] = room
c = _make_consumer(cid="cid-host")
mocker.patch.object(c, "broadcast_state", AsyncMock())
await c.receive(
json.dumps(
{"action": "set_categories", "categories": ["Anime", "Game", "BOGUS"]}
)
)
assert patched_state["codemanga_room_ABC"]["categories"] == ["Anime", "Game"]
async def test_collect_pools_splits_popular_and_otaku(patched_catalog):
"""Cards split into a top-N 'popular' pool and a deeper 'otaku' pool."""
c = _make_consumer()
popular, otaku = c._collect_pools(["Anime"], 450, popular_limit=10)
assert len(popular) == 10 # first 10 by popularity
assert len(otaku) == 30 # remaining of the 40 fake items
assert all("title" in x and "image" in x for x in popular + otaku)
async def test_collect_pools_respects_difficulty_limit(patched_catalog):
"""The difficulty cap trims how deep the otaku band reaches."""
c = _make_consumer()
popular, otaku = c._collect_pools(["Anime"], 15, popular_limit=10)
assert len(popular) == 10 and len(otaku) == 5 # only ranks 0..14 kept
async def test_compose_cards_keeps_otaku_majority():
"""25 cards with obscure ('otaku') cards a >=70% randomised majority."""
c = _make_consumer()
popular = [{"title": f"p{i}", "image": "x"} for i in range(100)]
otaku = [{"title": f"o{i}", "image": "x"} for i in range(100)]
for _ in range(20): # randomised — check the guarantee holds every time
chosen = c._compose_cards(popular, otaku)
assert len(chosen) == 25
n_otaku = sum(1 for c2 in chosen if c2["title"].startswith("o"))
assert n_otaku >= 18 # >= 72% obscure
async def test_compose_cards_empty_when_too_few():
c = _make_consumer()
assert c._compose_cards([{"title": "a", "image": "x"}], []) == []
async def test_set_difficulty_validates(patched_state, mocker):
room = _fresh_room(host="cid-host")
room["players"]["cid-host"] = {
"name": "h",
"team": "blue",
"role": "spymaster",
"channel": "chan-host",
}
patched_state["codemanga_room_ABC"] = room
c = _make_consumer(cid="cid-host")
mocker.patch.object(c, "broadcast_state", AsyncMock())
await c.receive(json.dumps({"action": "set_difficulty", "difficulty": "Hard"}))
assert patched_state["codemanga_room_ABC"]["difficulty"] == "Hard"
await c.receive(json.dumps({"action": "set_difficulty", "difficulty": "BOGUS"}))
assert patched_state["codemanga_room_ABC"]["difficulty"] == "Normal"
async def test_start_game_builds_grid_no_redirect(
patched_state, patched_catalog, mocker
):
room = _fresh_room()
room["players"]["cid-host"] = {
"name": "h",
"team": "blue",
"role": "spymaster",
"channel": "chan-host",
}
patched_state["codemanga_room_ABC"] = room
c = _make_consumer(cid="cid-host")
mocker.patch.object(c, "broadcast_state", AsyncMock())
await c.receive(json.dumps({"action": "start_game"}))
saved = patched_state["codemanga_room_ABC"]
assert saved["state"] == "playing"
assert len(saved["grid"]) == 25
# No redirect group_send is emitted (single-page room).
c.channel_layer.group_send.assert_not_awaited()
async def test_start_game_ignored_for_non_host(patched_state, patched_catalog, mocker):
room = _fresh_room(host="cid-host")
room["players"]["cid-host"] = {**_blue_operative(), "channel": "chan-host"}
room["players"]["cid-guest"] = {
"name": "g",
"team": "red",
"role": "spymaster",
"channel": "chan-guest",
}
patched_state["codemanga_room_ABC"] = room
c = _make_consumer(cid="cid-guest", channel_name="chan-guest")
mocker.patch.object(c, "broadcast_state", AsyncMock())
await c.receive(json.dumps({"action": "start_game"}))
assert patched_state["codemanga_room_ABC"]["state"] == "lobby"
async def test_chat_appends_message(patched_state, mocker):
room = _fresh_room()
room["players"]["cid-host"] = _blue_operative()
patched_state["codemanga_room_ABC"] = room
c = _make_consumer(cid="cid-host")
mocker.patch.object(c, "broadcast_state", AsyncMock())
await c.receive(json.dumps({"action": "chat", "message": "gg"}))
msgs = patched_state["codemanga_room_ABC"]["messages"]
assert msgs[-1] == {"user": "Op", "text": "gg"}
async def test_back_to_lobby_resets_but_keeps_players(patched_state, mocker):
room = _playing_room_with_grid()
room["host"] = "cid-host"
room["players"]["cid-host"] = {
"name": "h",
"team": "blue",
"role": "spymaster",
"channel": "chan-host",
}
room["winner"] = "blue"
patched_state["codemanga_room_ABC"] = room
c = _make_consumer(cid="cid-host")
mocker.patch.object(c, "broadcast_state", AsyncMock())
await c.receive(json.dumps({"action": "back_to_lobby"}))
saved = patched_state["codemanga_room_ABC"]
assert saved["state"] == "lobby" and saved["grid"] == [] and saved["winner"] is None
assert set(saved["players"]) == {"cid-op", "cid-host"} # players kept
# --------------------------------------------------------------------------- #
# generate_grid
# --------------------------------------------------------------------------- #
async def test_generate_grid_shape_and_roles(patched_catalog):
c = _make_consumer()
room = _fresh_room()
await c.generate_grid(room)
roles = [card["role"] for card in room["grid"]]
assert len(room["grid"]) == 25
assert roles.count("blue") == 9
assert roles.count("red") == 8
assert roles.count("neutral") == 7
assert roles.count("assassin") == 1
assert all(card["revealed"] is False for card in room["grid"])
async def test_generate_grid_noop_when_catalog_too_small(mocker):
catalog = MagicMock()
catalog.load_data = MagicMock(return_value={"db": [{"title": "x"}]})
container = MagicMock()
container.core.catalog_service.return_value = catalog
mocker.patch.object(cm, "get_container", return_value=container)
c = _make_consumer()
room = _fresh_room()
await c.generate_grid(room)
assert room["grid"] == []
# --------------------------------------------------------------------------- #
# clue gating
# --------------------------------------------------------------------------- #
async def test_clue_set_by_current_team_spymaster():
c = _make_consumer()
room = _playing_room_with_grid()
room["clue"] = None
spy = {"name": "S", "team": "blue", "role": "spymaster", "channel": "c"}
c.handle_clue(room, spy, {"word": "sword", "number": 3})
assert room["clue"] == {
"team": "blue",
"word": "sword",
"number": 3,
"guesses_left": 4,
}
async def test_clue_rejected_from_wrong_team_or_operative():
c = _make_consumer()
room = _playing_room_with_grid()
room["clue"] = None
red_spy = {"name": "R", "team": "red", "role": "spymaster", "channel": "c"}
c.handle_clue(room, red_spy, {"word": "x", "number": 1})
assert room["clue"] is None # not their turn
blue_op = _blue_operative()
c.handle_clue(room, blue_op, {"word": "x", "number": 1})
assert room["clue"] is None # operatives can't clue
# --------------------------------------------------------------------------- #
# click gating + rules
# --------------------------------------------------------------------------- #
async def test_click_requires_active_clue():
c = _make_consumer()
room = _playing_room_with_grid()
room["clue"] = None
c.handle_card_click(room, _blue_operative(), 0)
assert room["grid"][0]["revealed"] is False
async def test_click_rejected_for_wrong_team_operative():
c = _make_consumer()
room = _playing_room_with_grid()
red_op = {"name": "R", "team": "red", "role": "operative", "channel": "c"}
c.handle_card_click(room, red_op, 0)
assert room["grid"][0]["revealed"] is False
async def test_click_rejected_for_spymaster():
c = _make_consumer()
room = _playing_room_with_grid()
spy = {"name": "S", "team": "blue", "role": "spymaster", "channel": "c"}
c.handle_card_click(room, spy, 0)
assert room["grid"][0]["revealed"] is False
async def test_correct_guess_keeps_turn_decrements_guesses():
c = _make_consumer()
room = _playing_room_with_grid() # guesses_left 6
c.handle_card_click(room, _blue_operative(), 0) # blue card
assert room["grid"][0]["revealed"] is True
assert room["turn"] == "blue"
assert room["blue_score"] == 1
assert room["clue"]["guesses_left"] == 5
async def test_wrong_team_card_switches_turn_and_clears_clue():
c = _make_consumer()
room = _playing_room_with_grid()
c.handle_card_click(room, _blue_operative(), 9) # red card
assert room["turn"] == "red"
assert room["red_score"] == 1
assert room["clue"] is None
async def test_neutral_card_ends_turn():
c = _make_consumer()
room = _playing_room_with_grid()
c.handle_card_click(room, _blue_operative(), 17) # neutral
assert room["turn"] == "red"
assert room["clue"] is None
async def test_exhausting_guesses_ends_turn():
c = _make_consumer()
room = _playing_room_with_grid()
room["clue"]["guesses_left"] = 1 # last allowed guess
c.handle_card_click(room, _blue_operative(), 0) # correct blue
assert room["grid"][0]["revealed"] is True
assert room["turn"] == "red" # ran out → turn ends
assert room["clue"] is None
async def test_assassin_makes_opponent_win():
c = _make_consumer()
room = _playing_room_with_grid()
c.handle_card_click(room, _blue_operative(), 24)
assert room["winner"] == "red"
assert room["clue"] is None
async def test_revealing_all_blue_wins():
c = _make_consumer()
room = _playing_room_with_grid()
for i in range(8):
room["grid"][i]["revealed"] = True
c.handle_card_click(room, _blue_operative(), 8) # 9th blue
assert room["blue_score"] == 9
assert room["winner"] == "blue"
async def test_click_ignored_when_not_playing_or_won():
c = _make_consumer()
lobby = _playing_room_with_grid()
lobby["state"] = "lobby"
c.handle_card_click(lobby, _blue_operative(), 0)
assert lobby["grid"][0]["revealed"] is False
won = _playing_room_with_grid()
won["winner"] = "blue"
c.handle_card_click(won, _blue_operative(), 1)
assert won["grid"][1]["revealed"] is False
# --------------------------------------------------------------------------- #
# broadcast_state
# --------------------------------------------------------------------------- #
async def test_broadcast_masks_for_operative_reveals_for_spymaster(patched_state):
room = _playing_room_with_grid()
room["grid"][0]["revealed"] = True
room["players"] = {
"cid-spy": {
"name": "Spy",
"team": "blue",
"role": "spymaster",
"channel": "chan-spy",
},
"cid-op": {
"name": "Op",
"team": "red",
"role": "operative",
"channel": "chan-op",
},
}
room["host"] = "cid-spy"
patched_state["codemanga_room_ABC"] = room
c = _make_consumer()
await c.broadcast_state()
by_channel = {
call.args[0]: call.args[1] for call in c.channel_layer.send.call_args_list
}
assert set(by_channel) == {"chan-spy", "chan-op"}
spy_grid = by_channel["chan-spy"]["message"]["room"]["grid"]
op_grid = by_channel["chan-op"]["message"]["room"]["grid"]
assert spy_grid[24]["role"] == "assassin" # spymaster sees truth
assert op_grid[0]["role"] == "blue" # revealed → real
assert op_grid[24]["role"] == "unknown" # hidden → masked
# Public player shape carries id + is_host.
players = by_channel["chan-op"]["message"]["room"]["players"]
assert {p["id"] for p in players} == {"cid-spy", "cid-op"}
assert any(p["is_host"] for p in players)
assert by_channel["chan-op"]["message"]["my_team"] == "red"
async def test_broadcast_noop_when_room_missing(patched_state):
c = _make_consumer()
await c.broadcast_state()
c.channel_layer.send.assert_not_called()