mud/tests/test_look_command.py

441 lines
13 KiB
Python

"""Tests for the look command with structured room display."""
import time
from unittest.mock import AsyncMock, MagicMock
import pytest
from mudlib.commands import look # noqa: F401
from mudlib.commands.look import cmd_look
from mudlib.entity import Mob
from mudlib.gametime import init_game_time
from mudlib.player import Player
from mudlib.portal import Portal
from mudlib.thing import Thing
from mudlib.weather import WeatherCondition, init_weather
from mudlib.zone import Zone
@pytest.fixture(autouse=True)
def _reset_globals():
yield
import mudlib.gametime
import mudlib.weather
mudlib.gametime._game_time = None
mudlib.weather._current_weather = None
@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 test_zone():
"""Create a test zone with simple terrain."""
# 50x50 zone with grass everywhere
terrain = [["." for _ in range(50)] for _ in range(50)]
# Add some mountains
terrain[10][10] = "^"
terrain[10][11] = "^"
return Zone(
name="test_zone",
description="The Test Zone",
width=50,
height=50,
terrain=terrain,
impassable={"^", "~"},
)
@pytest.fixture
def player(mock_reader, mock_writer, test_zone):
"""Create a test player in the test zone."""
p = Player(
name="TestPlayer",
x=25,
y=25,
reader=mock_reader,
writer=mock_writer,
)
p.location = test_zone
test_zone._contents.append(p)
return p
def get_output(player):
"""Get all output written to player's writer."""
return "".join([call[0][0] for call in player.writer.write.call_args_list])
@pytest.mark.asyncio
async def test_look_includes_where_header(player):
"""Look output should include 'Where: {zone description}' line."""
await cmd_look(player, "")
output = get_output(player)
assert "Where: The Test Zone" in output
@pytest.mark.asyncio
async def test_look_includes_location_line(player):
"""Look output should include 'Location:' line with quadrant and coords."""
await cmd_look(player, "")
output = get_output(player)
assert "Location: center 25, 25" in output
@pytest.mark.asyncio
async def test_look_includes_exits_line(player):
"""Look output should include 'Exits:' line."""
await cmd_look(player, "")
output = get_output(player)
assert "Exits: north south east west up" in output
@pytest.mark.asyncio
async def test_look_shows_nearby_entities(player, test_zone):
"""Look should show nearby entities (not on player's tile) in viewport."""
# Add some mobs in viewport range (location param auto-adds to zone)
Mob(name="Goku", x=26, y=25, location=test_zone)
Mob(name="Vegeta", x=27, y=26, location=test_zone)
await cmd_look(player, "")
output = get_output(player)
# Should see nearby line with count
assert "Nearby: (2)" in output
assert "Goku" in output
assert "Vegeta" in output
@pytest.mark.asyncio
async def test_look_shows_entities_here(player, test_zone):
"""Look should show entities on player's tile with posture."""
# Add entities on player's tile (location param auto-adds to zone)
Mob(name="Krillin", x=25, y=25, resting=True, location=test_zone)
Mob(name="Piccolo", x=25, y=25, location=test_zone)
await cmd_look(player, "")
output = get_output(player)
# Should see individual lines with postures
assert "Krillin is resting here." in output
assert "Piccolo is standing here." in output
@pytest.mark.asyncio
async def test_look_shows_ground_items(player, test_zone):
"""Look should show items on the ground."""
# Add an item on player's tile (location param auto-adds to zone)
Thing(name="rusty sword", x=25, y=25, location=test_zone)
await cmd_look(player, "")
output = get_output(player)
# Should see ground items
assert "rusty sword" in output
@pytest.mark.asyncio
async def test_look_shows_portals(player, test_zone):
"""Look should show portals on player's tile."""
# Add a portal on player's tile (location param auto-adds to zone)
Portal(
name="wide dirt path",
x=25,
y=25,
location=test_zone,
target_zone="other",
target_x=0,
target_y=0,
)
await cmd_look(player, "")
output = get_output(player)
# Should see portal with new format
assert "You see wide dirt path." in output
@pytest.mark.asyncio
async def test_look_with_args_routes_to_examine(player, test_zone):
"""look <thing> should route to examine command logic."""
called = {}
async def fake_examine(p, args, *, prefer_inventory=True):
called["args"] = args
called["prefer_inventory"] = prefer_inventory
await p.send("examined\r\n")
import mudlib.commands.examine
original = mudlib.commands.examine.examine_target
mudlib.commands.examine.examine_target = fake_examine # type: ignore[invalid-assignment]
try:
await cmd_look(player, "sword")
finally:
mudlib.commands.examine.examine_target = original
output = get_output(player)
assert called["args"] == "sword"
assert called["prefer_inventory"] is False
assert "examined" in output
@pytest.mark.asyncio
async def test_look_flying_shows_down_exit(player):
"""Flying players should see down as a vertical exit."""
player.flying = True
await cmd_look(player, "")
output = get_output(player)
assert "Exits: north south east west down" in output
@pytest.mark.asyncio
async def test_look_structure_order(player, test_zone):
"""Look output should have sections in correct order."""
# Add entities and items (location param auto-adds to zone)
Mob(name="Goku", x=25, y=25, location=test_zone)
await cmd_look(player, "")
output = get_output(player)
# Find positions of key sections
where_pos = output.find("Where:")
location_pos = output.find("Location:")
exits_pos = output.find("Exits:")
# Verify order: Where comes first, then viewport (with terrain),
# then Location, then Exits
assert where_pos < location_pos
assert location_pos < exits_pos
@pytest.mark.asyncio
async def test_look_nowhere(mock_reader, mock_writer):
"""Look should show 'You are nowhere.' when player has no location."""
# Create player without a location
player = Player(
name="NowherePlayer",
x=0,
y=0,
reader=mock_reader,
writer=mock_writer,
)
player.location = None
await cmd_look(player, "")
output = get_output(player)
assert "You are nowhere." in output
@pytest.mark.asyncio
async def test_look_includes_atmosphere_clear():
"""Look should include atmosphere line with clear weather."""
# Initialize game time and weather
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
init_weather(condition=WeatherCondition.clear, intensity=0.5)
# Create player
mock_reader = MagicMock()
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
terrain = [["." for _ in range(50)] for _ in range(50)]
zone = Zone(
name="test_zone",
description="The Test Zone",
width=50,
height=50,
terrain=terrain,
impassable={"^", "~"},
)
player = Player(
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
)
player.location = zone
zone._contents.append(player)
await cmd_look(player, "")
output = get_output(player)
# Should see atmosphere line with period and season
assert "[" in output and "]" in output
# Should not have weather description for clear weather
# (the format is: sky. [period, season])
seasons = ["spring]", "summer]", "autumn]", "winter]"]
assert any(season in output for season in seasons)
@pytest.mark.asyncio
async def test_look_includes_atmosphere_with_rain():
"""Look should include atmosphere line with rain."""
# Initialize game time and weather
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
init_weather(condition=WeatherCondition.rain, intensity=0.5)
# Create player
mock_reader = MagicMock()
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
terrain = [["." for _ in range(50)] for _ in range(50)]
zone = Zone(
name="test_zone",
description="The Test Zone",
width=50,
height=50,
terrain=terrain,
impassable={"^", "~"},
)
player = Player(
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
)
player.location = zone
zone._contents.append(player)
await cmd_look(player, "")
output = get_output(player)
# Should see atmosphere line with rain description
assert "rain" in output.lower()
# Should see season tag
assert "[" in output and "]" in output
@pytest.mark.asyncio
async def test_look_atmosphere_between_where_and_viewport():
"""Atmosphere line should appear between Where header and viewport."""
# Initialize game time and weather
init_game_time(epoch=time.time(), real_minutes_per_game_hour=1.0)
init_weather(condition=WeatherCondition.clear, intensity=0.5)
# Create player
mock_reader = MagicMock()
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
terrain = [["." for _ in range(50)] for _ in range(50)]
zone = Zone(
name="test_zone",
description="The Test Zone",
width=50,
height=50,
terrain=terrain,
impassable={"^", "~"},
)
player = Player(
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
)
player.location = zone
zone._contents.append(player)
await cmd_look(player, "")
output = get_output(player)
# Find positions of key sections
where_pos = output.find("Where:")
location_pos = output.find("Location:")
# Atmosphere should be between Where and Location
# Look for a bracket tag (season indicator)
bracket_pos = output.find("[")
assert where_pos < bracket_pos < location_pos
@pytest.mark.asyncio
async def test_look_night_reduces_viewport():
"""Look at night should show smaller viewport than day."""
# Set time to noon (hour 12) - epoch was 12 hours ago
# elapsed_game_hours = (time.time() - epoch) / 60 / 1.0 = 12
# so epoch = time.time() - 12 * 60
init_game_time(epoch=time.time() - 12 * 60, real_minutes_per_game_hour=1.0)
init_weather(condition=WeatherCondition.clear, intensity=0.5)
mock_reader = MagicMock()
mock_writer = MagicMock()
mock_writer.write = MagicMock()
mock_writer.drain = AsyncMock()
terrain = [["." for _ in range(50)] for _ in range(50)]
zone = Zone(
name="test_zone",
description="The Test Zone",
width=50,
height=50,
terrain=terrain,
impassable={"^", "~"},
)
player = Player(
name="TestPlayer", x=25, y=25, reader=mock_reader, writer=mock_writer
)
player.location = zone
zone._contents.append(player)
await cmd_look(player, "")
day_output = get_output(player)
# Find viewport section (between atmosphere and Location:)
day_lines = day_output.split("\r\n")
# Viewport is after atmosphere (containing '[') and before Location:
viewport_start_idx = None
viewport_end_idx = None
for i, line in enumerate(day_lines):
if "[" in line and viewport_start_idx is None:
viewport_start_idx = i + 1
if line.startswith("Location:"):
viewport_end_idx = i
break
assert viewport_start_idx is not None
assert viewport_end_idx is not None
day_viewport_height = viewport_end_idx - viewport_start_idx
# Reset writer
mock_writer.reset_mock()
# Set time to night (hour 22) - epoch was 22 hours ago
init_game_time(epoch=time.time() - 22 * 60, real_minutes_per_game_hour=1.0)
await cmd_look(player, "")
night_output = get_output(player)
# Find viewport section
night_lines = night_output.split("\r\n")
viewport_start_idx = None
viewport_end_idx = None
for i, line in enumerate(night_lines):
if "[" in line and viewport_start_idx is None:
viewport_start_idx = i + 1
if line.startswith("Location:"):
viewport_end_idx = i
break
assert viewport_start_idx is not None
assert viewport_end_idx is not None
night_viewport_height = viewport_end_idx - viewport_start_idx
# Night should have fewer viewport lines (9 vs 11)
assert night_viewport_height < day_viewport_height
assert day_viewport_height == 11 # full viewport at noon
assert night_viewport_height == 9 # reduced viewport at night