mud/tests/test_talk.py
Jared Miller 9c480f8d47
Remove duplicate mock_writer/mock_reader fixtures
Removed identical local copies from 45 test files. These fixtures
are already defined in conftest.py.
2026-02-16 15:29:21 -05:00

452 lines
12 KiB
Python

"""Tests for talk command and conversation system."""
import tempfile
from pathlib import Path
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 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 <npc> 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 <number> 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