"""Real-behavior unit tests for ``DuelConsumer`` (Django Channels async consumer). These tests instantiate ``DuelConsumer`` directly and drive its async methods, mocking only the I/O boundary (``channel_layer``, ``send``, ``accept``, ``close``) and the data/service layer (``DuelRoom`` ORM, the DI container's catalog/game services, and the user's profile reward path). No real DB / redis / network. They complement the API-level tests in ``test_duel.py`` (which exercise the REST create/join/matchmaking endpoints) by covering the websocket game logic: connection gating, group membership, guess routing, win/score reconciliation and the exact payloads broadcast to the room. """ import json from unittest.mock import AsyncMock, MagicMock import pytest from animetix.consumers import duel as duel_module from animetix.consumers.duel import DuelConsumer from core.domain.exceptions import GameLogicError pytestmark = pytest.mark.asyncio # --------------------------------------------------------------------------- # # Helpers / fixtures # --------------------------------------------------------------------------- # def _make_user(username="player1", anonymous=False): """A stand-in for a Django ``User`` (identity by object, has .username/.profile).""" user = MagicMock() user.username = username user.is_anonymous = anonymous # profile.add_win is the reward hook invoked on a win. user.profile = MagicMock() user.profile.add_win = MagicMock() return user def _make_duel( room_code="ABCDE", media_type="Anime", secret="Naruto", player1=None, player2=None, is_finished=False, ): """A stand-in for a ``DuelRoom`` instance with the attributes the consumer reads.""" duel = MagicMock() duel.room_code = room_code duel.media_type = media_type duel.secret_title = secret duel.is_finished = is_finished duel.winner = None duel.player1 = player1 duel.player2 = player2 duel.save = MagicMock() return duel def _patch_duelroom(mocker, get_return=None, get_side_effect=None): """Patch ``DuelRoom`` in the models namespace (the consumer imports it lazily). Returns the mock class so the test can assert / inspect ``objects.get`` calls. """ import animetix.models as models duelroom = MagicMock() # Preserve a real-ish DoesNotExist exception class for the except clause. duelroom.DoesNotExist = models.DuelRoom.DoesNotExist if get_side_effect is not None: duelroom.objects.get.side_effect = get_side_effect else: duelroom.objects.get.return_value = get_return mocker.patch.object(models, "DuelRoom", duelroom) return duelroom def _patch_container(mocker, is_correct=False, rank=None, report=None): """Patch ``get_container`` in the duel module to a structured mock. Mirrors the real access path: ``container.core.catalog_service.load_data``, ``container.core.game_service.check_title_match`` and ``container.core.proximity_service.{rank, report}`` -- the same percentile scoring the classic game uses, instead of the raw (meaningless) cosine. These are sync callables (the consumer wraps them in ``sync_to_async``). """ container = MagicMock() catalogue_titles = ["Naruto"] + list( rank if rank is not None else ["Bleach", "One Piece"] ) container.core.catalog_service.return_value.load_data.return_value = { "title_to_full_data": {"Naruto": {"id": 1, "title": "Naruto"}}, # Le duel valide la proposition contre le catalogue avant de la scorer, # exactement comme le mode Classique (classic.py) : sans ça, une trame # websocket portant un titre inventé diffusait « 100 % » à toute la salle. "title_to_index": {t: i for i, t in enumerate(catalogue_titles)}, } container.core.game_service.return_value.check_title_match.return_value = is_correct container.core.proximity_service.return_value.rank.return_value = ( rank if rank is not None else ["Bleach", "One Piece"] ) container.core.proximity_service.return_value.report.return_value = report or { "percent": 5.0, "rank": 2, "total": 2, "reasons": [], } mocker.patch.object(duel_module, "get_container", return_value=container) return container def _make_consumer(user, room_code="ABCDE"): """Instantiate a consumer wired with mocked async I/O and a populated scope.""" consumer = DuelConsumer() consumer.scope = { "url_route": {"kwargs": {"lobby_id": room_code}}, "user": user, } consumer.channel_name = "test.channel.1" # ``room_code`` / ``room_group_name`` / ``user`` are normally assigned in # connect(); set them for methods driven directly (handle_guess / # broadcast_state) which read them for the ORM lookup and group_send target. consumer.room_code = room_code consumer.room_group_name = f"duel_{room_code}" consumer.user = user consumer.channel_layer = AsyncMock() consumer.send = AsyncMock() consumer.accept = AsyncMock() consumer.close = AsyncMock() return consumer def _last_group_send_message(channel_layer): """Extract the inner ``message`` dict from the most recent group_send call.""" args, kwargs = channel_layer.group_send.call_args group, event = args return group, event["message"] # --------------------------------------------------------------------------- # # connect() # --------------------------------------------------------------------------- # async def test_connect_rejects_anonymous_user(mocker): """Anonymous users are closed out before any DB lookup or group join.""" duelroom = _patch_duelroom(mocker, get_return=_make_duel()) user = _make_user(anonymous=True) consumer = _make_consumer(user) await consumer.connect() consumer.close.assert_awaited_once() consumer.accept.assert_not_awaited() consumer.channel_layer.group_add.assert_not_awaited() duelroom.objects.get.assert_not_called() # never touched the DB async def test_connect_closes_when_room_missing(mocker): """A non-existent room code closes the socket (DoesNotExist branch).""" import animetix.models as models duelroom = _patch_duelroom(mocker, get_side_effect=models.DuelRoom.DoesNotExist()) user = _make_user(anonymous=False) consumer = _make_consumer(user) await consumer.connect() consumer.close.assert_awaited_once() consumer.accept.assert_not_awaited() consumer.channel_layer.group_add.assert_not_awaited() assert duelroom.objects.get.called async def test_connect_rejects_non_participant(mocker): """A user who is neither player1 nor player2 is closed out.""" p1, p2 = _make_user("p1"), _make_user("p2") intruder = _make_user("intruder") duel = _make_duel(player1=p1, player2=p2) _patch_duelroom(mocker, get_return=duel) consumer = _make_consumer(intruder) await consumer.connect() consumer.close.assert_awaited_once() consumer.accept.assert_not_awaited() consumer.channel_layer.group_add.assert_not_awaited() async def test_connect_accepts_participant_joins_group_and_broadcasts_state(mocker): """A valid participant: joins the group, accepts, then broadcasts duel_state.""" p1, p2 = _make_user("p1"), _make_user("p2") duel = _make_duel( room_code="ROOM1", media_type="Manga", player1=p1, player2=p2, is_finished=False ) _patch_duelroom(mocker, get_return=duel) consumer = _make_consumer(p1, room_code="ROOM1") await consumer.connect() # Group derived from room code, joined with this channel, then accepted. assert consumer.room_group_name == "duel_ROOM1" consumer.channel_layer.group_add.assert_awaited_once_with( "duel_ROOM1", "test.channel.1" ) consumer.accept.assert_awaited_once() consumer.close.assert_not_awaited() # connect() ends by broadcasting the initial state to the room. group, message = _last_group_send_message(consumer.channel_layer) assert group == "duel_ROOM1" assert message == { "type": "duel_state", "player1": "p1", "player2": "p2", "media_type": "Manga", "is_finished": False, } # --------------------------------------------------------------------------- # # disconnect() # --------------------------------------------------------------------------- # async def test_disconnect_leaves_group(mocker): """disconnect() removes this channel from the room group.""" consumer = _make_consumer(_make_user()) consumer.room_group_name = "duel_ROOM1" await consumer.disconnect(close_code=1000) consumer.channel_layer.group_discard.assert_awaited_once_with( "duel_ROOM1", "test.channel.1" ) # --------------------------------------------------------------------------- # # receive() routing # --------------------------------------------------------------------------- # async def test_receive_routes_guess_action_to_handle_guess(mocker): """A ``{"type": "guess", "guess": ...}`` frame is dispatched to handle_guess.""" consumer = _make_consumer(_make_user()) consumer.handle_guess = AsyncMock() await consumer.receive(text_data=json.dumps({"type": "guess", "guess": "Bleach"})) consumer.handle_guess.assert_awaited_once_with("Bleach") async def test_receive_ignores_unknown_action(mocker): """An unrecognised action type is a silent no-op (no handler invoked).""" consumer = _make_consumer(_make_user()) consumer.handle_guess = AsyncMock() await consumer.receive(text_data=json.dumps({"type": "chat", "message": "hi"})) consumer.handle_guess.assert_not_awaited() consumer.channel_layer.group_send.assert_not_awaited() # --------------------------------------------------------------------------- # # handle_guess() # --------------------------------------------------------------------------- # async def test_handle_guess_noop_when_duel_already_finished(mocker): """No scoring or broadcast happens once the duel is finished.""" duel = _make_duel(is_finished=True) _patch_duelroom(mocker, get_return=duel) container = _patch_container(mocker) consumer = _make_consumer(_make_user()) await consumer.handle_guess("Naruto") consumer.channel_layer.group_send.assert_not_awaited() duel.save.assert_not_called() container.core.game_service.return_value.check_title_match.assert_not_called() async def test_handle_guess_correct_finishes_duel_and_broadcasts_winner(mocker): """A correct guess: marks finished, records winner, rewards, broadcasts result.""" winner = _make_user("p1") duel = _make_duel(secret="Naruto", media_type="Anime", is_finished=False) _patch_duelroom(mocker, get_return=duel) container = _patch_container(mocker, is_correct=True) consumer = _make_consumer(winner) await consumer.handle_guess("Naruto") # Real state transition persisted. assert duel.is_finished is True assert duel.winner is winner duel.save.assert_called_once() # check_title_match was given the secret's full data, not the bare title. container.core.game_service.return_value.check_title_match.assert_called_once_with( "Naruto", {"id": 1, "title": "Naruto"} ) # Reward path fired with the right game mode / media type. winner.profile.add_win.assert_called_once_with(game_mode="duel", media_type="Anime") # No proximity scoring on a win. container.core.proximity_service.return_value.rank.assert_not_called() container.core.proximity_service.return_value.report.assert_not_called() # Broadcast announces the winner and reveals the secret. group, message = _last_group_send_message(consumer.channel_layer) assert group == consumer.room_group_name assert message == { "type": "duel_finished", "winner": "p1", "secret": "Naruto", } async def test_handle_guess_wrong_broadcasts_proximity_percent_without_finishing( mocker, ): """A wrong guess: duel stays open, no reward, broadcast carries the ProximityService percentile (NOT the raw cosine -- that's the whole point of this fix).""" player = _make_user("p2") duel = _make_duel(secret="Naruto", media_type="Anime", is_finished=False) _patch_duelroom(mocker, get_return=duel) container = _patch_container( mocker, is_correct=False, rank=["Bleach", "One Piece"], report={"percent": 81.2, "rank": 1, "total": 2, "reasons": []}, ) consumer = _make_consumer(player) await consumer.handle_guess("Bleach") # State untouched: not finished, no winner, not saved, no reward. assert duel.is_finished is False assert duel.winner is None duel.save.assert_not_called() player.profile.add_win.assert_not_called() # Ranking computed against the secret, report computed for this guess. container.core.proximity_service.return_value.rank.assert_called_once_with( "Anime", "Naruto" ) container.core.proximity_service.return_value.report.assert_called_once_with( "Anime", "Naruto", "Bleach", ranking=["Bleach", "One Piece"] ) group, message = _last_group_send_message(consumer.channel_layer) assert group == consumer.room_group_name assert message == { "type": "opponent_guess", "player": "p2", "score": 81.2, } async def test_handle_guess_with_no_relationship_scores_low_not_the_noise_floor(mocker): """The regression this fix closes: with the 0.7 cosine factor gone, an unrelated guess used to render at the raw-cosine noise floor (~58 on the real catalogue -- the average for two works with NO relationship). The percentile from ProximityService must instead put a no-signal guess near the BOTTOM of the ranking, never in the high 50s reading as "getting warm".""" player = _make_user("p2") duel = _make_duel(secret="Naruto", media_type="Anime", is_finished=False) _patch_duelroom(mocker, get_return=duel) # An unrelated title ranked dead last out of 100 -> percentile near 0, not ~58. _patch_container( mocker, is_correct=False, rank=["Unrelated Title"] + [f"Title {i}" for i in range(99)], report={"percent": 1.0, "rank": 100, "total": 100, "reasons": []}, ) consumer = _make_consumer(player) await consumer.handle_guess("Unrelated Title") _, message = _last_group_send_message(consumer.channel_layer) assert message["type"] == "opponent_guess" assert message["score"] < 50.0 # nowhere near the old ~58 noise floor assert message["score"] == 1.0 async def test_handle_guess_rejects_a_title_that_is_not_in_the_catalogue(mocker): """Une proposition hors catalogue n'est PAS scorée et n'est PAS diffusée. Le mode Classique valide la proposition contre le catalogue avant tout scoring (classic.py) ; le duel ne le faisait pas. Comme report() rendait 100 % pour un titre absent du classement, n'importe quelle trame websocket portant un titre inventé diffusait un « 100 % » à toute la salle -- strictement pire que le chemin qu'elle remplace, qui rendait 0.0. """ player = _make_user("p2") duel = _make_duel(secret="Naruto", media_type="Anime", is_finished=False) _patch_duelroom(mocker, get_return=duel) container = _patch_container(mocker, is_correct=False) consumer = _make_consumer(player) await consumer.handle_guess("Titre Inventé