"""Tests for talk command and conversation system.""" import tempfile from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest from mudlib.commands.talk import cmd_reply, cmd_talk, dialogue_trees from mudlib.conversation import ( active_conversations, advance_conversation, end_conversation, get_conversation, start_conversation, ) from mudlib.dialogue import load_dialogue from mudlib.entity import Mob from mudlib.player import Player, players from mudlib.zone import Zone @pytest.fixture(autouse=True) def clear_state(): """Clear global state before and after each test.""" players.clear() active_conversations.clear() dialogue_trees.clear() yield players.clear() active_conversations.clear() dialogue_trees.clear() @pytest.fixture def test_zone(): """Create a test zone for players and mobs.""" terrain = [["." for _ in range(256)] for _ in range(256)] zone = Zone( name="testzone", width=256, height=256, toroidal=True, terrain=terrain, impassable=set(), ) return zone @pytest.fixture def mock_writer(): writer = MagicMock() writer.write = MagicMock() writer.drain = AsyncMock() return writer @pytest.fixture def mock_reader(): return MagicMock() @pytest.fixture def player(mock_reader, mock_writer, test_zone): p = Player(name="Hero", x=10, y=10, reader=mock_reader, writer=mock_writer) p.location = test_zone test_zone._contents.append(p) players[p.name] = p return p @pytest.fixture def librarian_tree(): """Create a simple dialogue tree for testing.""" toml_content = """ npc_name = "librarian" root_node = "greeting" [nodes.greeting] text = "Welcome to the library. How can I help you today?" [[nodes.greeting.choices]] text = "I'm looking for a book." next_node = "books" [[nodes.greeting.choices]] text = "Tell me about this place." next_node = "about" [[nodes.greeting.choices]] text = "Goodbye." next_node = "farewell" [nodes.books] text = "We have many books. What genre interests you?" [[nodes.books.choices]] text = "History." next_node = "history" [[nodes.books.choices]] text = "Never mind." next_node = "farewell" [nodes.history] text = "The history section is on the second floor." [nodes.about] text = "This library has been here for 200 years." [nodes.farewell] text = "Happy reading. Come back anytime." """ with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f: f.write(toml_content) f.flush() path = Path(f.name) try: tree = load_dialogue(path) return tree finally: path.unlink() @pytest.fixture def librarian_mob(test_zone, librarian_tree): """Create a librarian NPC with dialogue.""" mob = Mob(name="librarian", x=10, y=10, npc_name="librarian") mob.location = test_zone test_zone._contents.append(mob) return mob # Conversation state management tests @pytest.mark.asyncio async def test_start_conversation_creates_state(player, librarian_mob, librarian_tree): """start_conversation creates ConversationState and transitions mob.""" from mudlib.conversation import active_conversations node = start_conversation(player, librarian_mob, librarian_tree) assert node.id == "greeting" assert player.name in active_conversations conv = active_conversations[player.name] assert conv.tree == librarian_tree assert conv.current_node == "greeting" assert conv.npc == librarian_mob assert librarian_mob.behavior_state == "converse" @pytest.mark.asyncio async def test_advance_conversation_follows_choice( player, librarian_mob, librarian_tree ): """advance_conversation moves to next node based on choice.""" start_conversation(player, librarian_mob, librarian_tree) # Choose option 1: "I'm looking for a book." node = advance_conversation(player, 1) assert node is not None assert node.id == "books" assert node.text == "We have many books. What genre interests you?" @pytest.mark.asyncio async def test_advance_conversation_terminal_node_ends( player, librarian_mob, librarian_tree ): """advance_conversation returns node but doesn't auto-end conversation.""" from mudlib.conversation import active_conversations start_conversation(player, librarian_mob, librarian_tree) # Choose "Goodbye." (option 3) node = advance_conversation(player, 3) # Should return the terminal node assert node is not None assert len(node.choices) == 0 # Conversation state is updated but still active # (command layer is responsible for ending it) assert player.name in active_conversations @pytest.mark.asyncio async def test_end_conversation_cleans_up(player, librarian_mob, librarian_tree): """end_conversation removes state and transitions mob back.""" from mudlib.conversation import active_conversations from mudlib.npc_behavior import transition_state # Set mob to idle first transition_state(librarian_mob, "idle") start_conversation(player, librarian_mob, librarian_tree) end_conversation(player) assert player.name not in active_conversations assert librarian_mob.behavior_state == "idle" @pytest.mark.asyncio async def test_get_conversation_returns_state(player, librarian_mob, librarian_tree): """get_conversation returns active conversation or None.""" assert get_conversation(player) is None start_conversation(player, librarian_mob, librarian_tree) conv = get_conversation(player) assert conv is not None assert conv.tree == librarian_tree assert conv.npc == librarian_mob # Talk command tests @pytest.mark.asyncio async def test_talk_starts_conversation(player, librarian_mob, librarian_tree): """talk starts conversation and shows root node.""" from mudlib.commands.talk import dialogue_trees dialogue_trees["librarian"] = librarian_tree await cmd_talk(player, "librarian") messages = [call[0][0] for call in player.writer.write.call_args_list] full_output = "".join(messages) assert "Welcome to the library" in full_output assert "1." in full_output assert "I'm looking for a book" in full_output assert "2." in full_output assert "Tell me about this place" in full_output assert "3." in full_output assert "Goodbye" in full_output @pytest.mark.asyncio async def test_talk_no_npc_nearby(player): """talk with no matching NPC shows error.""" await cmd_talk(player, "librarian") player.writer.write.assert_called_once() output = player.writer.write.call_args[0][0] assert "don't see" in output.lower() or "not here" in output.lower() @pytest.mark.asyncio async def test_talk_non_npc_mob(player, test_zone, mock_reader, mock_writer): """talk with non-NPC mob (no npc_name) shows error.""" # Create a mob without npc_name mob = Mob(name="rat", x=10, y=10) mob.location = test_zone test_zone._contents.append(mob) await cmd_talk(player, "rat") output = "".join([call[0][0] for call in player.writer.write.call_args_list]) assert "can't talk" in output.lower() or "no dialogue" in output.lower() @pytest.mark.asyncio async def test_talk_no_dialogue_tree(player, test_zone): """talk with NPC that has no dialogue tree shows error.""" from mudlib.commands.talk import dialogue_trees dialogue_trees.clear() mob = Mob(name="guard", x=10, y=10, npc_name="guard") mob.location = test_zone test_zone._contents.append(mob) await cmd_talk(player, "guard") output = "".join([call[0][0] for call in player.writer.write.call_args_list]) assert "nothing to say" in output.lower() or "don't have anything" in output.lower() @pytest.mark.asyncio async def test_talk_already_in_conversation_shows_current( player, librarian_mob, librarian_tree ): """talk when already in conversation shows current node.""" from mudlib.commands.talk import dialogue_trees from mudlib.conversation import advance_conversation, start_conversation dialogue_trees["librarian"] = librarian_tree # Start conversation start_conversation(player, librarian_mob, librarian_tree) # Advance to a different node advance_conversation(player, 1) # books node # Clear writer calls player.writer.write.reset_mock() # Talk again await cmd_talk(player, "librarian") output = "".join([call[0][0] for call in player.writer.write.call_args_list]) assert "We have many books" in output # Reply command tests @pytest.mark.asyncio async def test_reply_advances_conversation(player, librarian_mob, librarian_tree): """reply advances to next node.""" from mudlib.commands.talk import dialogue_trees dialogue_trees["librarian"] = librarian_tree # Start conversation await cmd_talk(player, "librarian") player.writer.write.reset_mock() # Reply with choice 1 await cmd_reply(player, "1") output = "".join([call[0][0] for call in player.writer.write.call_args_list]) assert "We have many books" in output assert "1." in output assert "History" in output @pytest.mark.asyncio async def test_reply_terminal_node_ends_conversation( player, librarian_mob, librarian_tree ): """reply that reaches terminal node ends conversation.""" from mudlib.commands.talk import dialogue_trees from mudlib.conversation import active_conversations dialogue_trees["librarian"] = librarian_tree # Start conversation await cmd_talk(player, "librarian") # Reset mock to only capture reply output player.writer.write.reset_mock() # Choose "Goodbye." (option 3) await cmd_reply(player, "3") output = "".join([call[0][0] for call in player.writer.write.call_args_list]) assert "Happy reading" in output # No numbered choices should be shown for terminal node lines = output.split("\n") choice_lines = [line for line in lines if line.strip().startswith("1.")] assert len(choice_lines) == 0 # Conversation should be ended assert player.name not in active_conversations @pytest.mark.asyncio async def test_reply_no_conversation(player): """reply when not in conversation shows error.""" await cmd_reply(player, "1") output = player.writer.write.call_args[0][0] assert ( "not in a conversation" in output.lower() or "no conversation" in output.lower() ) @pytest.mark.asyncio async def test_reply_invalid_choice_number(player, librarian_mob, librarian_tree): """reply with invalid choice number shows error.""" from mudlib.commands.talk import dialogue_trees dialogue_trees["librarian"] = librarian_tree await cmd_talk(player, "librarian") player.writer.write.reset_mock() # Reply with out-of-range choice await cmd_reply(player, "99") output = player.writer.write.call_args[0][0] assert "invalid" in output.lower() or "choose" in output.lower() @pytest.mark.asyncio async def test_reply_non_numeric(player, librarian_mob, librarian_tree): """reply with non-numeric argument shows error.""" from mudlib.commands.talk import dialogue_trees dialogue_trees["librarian"] = librarian_tree await cmd_talk(player, "librarian") player.writer.write.reset_mock() # Reply with non-number await cmd_reply(player, "abc") output = player.writer.write.call_args[0][0] assert "number" in output.lower() or "invalid" in output.lower() # Display format tests @pytest.mark.asyncio async def test_dialogue_display_format(player, librarian_mob, librarian_tree): """Dialogue is displayed with correct format.""" from mudlib.commands.talk import dialogue_trees dialogue_trees["librarian"] = librarian_tree await cmd_talk(player, "librarian") output = "".join([call[0][0] for call in player.writer.write.call_args_list]) # Check for NPC name and "says" assert "librarian says" in output.lower() # Check for numbered list assert "1." in output assert "2." in output assert "3." in output @pytest.mark.asyncio async def test_terminal_node_no_choices(player, librarian_mob, librarian_tree): """Terminal nodes show text without choices.""" from mudlib.commands.talk import dialogue_trees dialogue_trees["librarian"] = librarian_tree await cmd_talk(player, "librarian") # Reset mock to only capture reply output player.writer.write.reset_mock() # Advance to farewell (terminal node) await cmd_reply(player, "3") output = "".join([call[0][0] for call in player.writer.write.call_args_list]) assert "Happy reading" in output # Should not have numbered choices lines = output.split("\n") numbered = [ line for line in lines if line.strip() and line.strip()[0].isdigit() and "." in line.split()[0] ] assert len(numbered) == 0