Spaces:
Sleeping
Sleeping
| """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"} | |
| 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 | |
| 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() | |