mud/tests/test_embedded_if.py
Jared Miller ee0dc839d8
Offer GMCP/MSDP during connection and guard tick sends
The server never proactively offered GMCP or MSDP to clients, so
telnetlib3 logged "cannot send MSDP without negotiation" every second.
Now the server sends WILL GMCP and WILL MSDP on connection, and
send_msdp_vitals checks negotiation state before attempting to send.
2026-02-12 15:58:54 -05:00

369 lines
11 KiB
Python

"""Tests for embedded z-machine MUD integration.
Tests the MUD UI components and EmbeddedIFSession integration with real zork1.z3.
"""
import threading
from dataclasses import dataclass
from pathlib import Path
import pytest
from mudlib.zmachine.mud_ui import MudFilesystem, MudInputStream, MudScreen
ZORK_PATH = Path(__file__).parent.parent / "content" / "stories" / "zork1.z3"
requires_zork = pytest.mark.skipif(not ZORK_PATH.exists(), reason="zork1.z3 not found")
@dataclass
class MockWriter:
def __post_init__(self):
# Mock option negotiation state
from unittest.mock import MagicMock
self.local_option = MagicMock()
self.local_option.enabled = MagicMock(return_value=False)
self.remote_option = MagicMock()
self.remote_option.enabled = MagicMock(return_value=False)
def write(self, data):
pass
async def drain(self):
pass
# Unit tests for MUD UI components
def test_mud_screen_captures_output():
"""MudScreen captures written text and flush returns it."""
screen = MudScreen()
screen.write("Hello ")
screen.write("world!")
output = screen.flush()
assert output == "Hello world!"
def test_mud_screen_flush_clears_buffer():
"""MudScreen flush clears buffer, second flush returns empty."""
screen = MudScreen()
screen.write("test")
first = screen.flush()
assert first == "test"
second = screen.flush()
assert second == ""
def test_mud_screen_suppresses_upper_window_writes():
"""MudScreen discards writes to upper window (status line)."""
screen = MudScreen()
screen.write("before ")
screen.select_window(1) # upper window
screen.write("STATUS LINE")
screen.select_window(0) # back to lower window
screen.write("after")
output = screen.flush()
assert output == "before after"
def test_mud_screen_lower_window_is_default():
"""MudScreen starts with lower window active, writes are captured."""
screen = MudScreen()
screen.write("hello")
assert screen.flush() == "hello"
def test_mud_screen_upper_window_multiple_writes():
"""MudScreen discards all writes while upper window is active."""
screen = MudScreen()
screen.select_window(1)
screen.write("Room Name")
screen.write(" | Score: 0")
screen.select_window(0)
screen.write("You are in a dark room.\n")
output = screen.flush()
assert output == "You are in a dark room.\n"
def test_mud_input_stream_feed_and_read():
"""MudInputStream feed and read_line work with threading."""
stream = MudInputStream()
result = []
def reader():
result.append(stream.read_line())
t = threading.Thread(target=reader)
t.start()
# Wait for stream to signal it's waiting
stream._waiting.wait(timeout=2)
stream.feed("hello")
t.join(timeout=2)
assert result == ["hello"]
def test_mud_filesystem_save_restore(tmp_path):
"""MudFilesystem save and restore bytes correctly."""
save_path = tmp_path / "test.qzl"
filesystem = MudFilesystem(save_path)
test_data = b"\x01\x02\x03\x04\x05"
success = filesystem.save_game(test_data)
assert success
assert save_path.exists()
restored = filesystem.restore_game()
assert restored == test_data
# Integration tests with real zork1.z3
@requires_zork
@pytest.mark.asyncio
async def test_embedded_session_start():
"""EmbeddedIFSession starts and returns intro containing game info."""
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.player import Player
mock_writer = MockWriter()
player = Player(name="tester", x=5, y=5, writer=mock_writer)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
# Clean up any existing save to get a fresh start
if session.save_path.exists():
session.save_path.unlink()
intro = await session.start()
assert intro is not None
assert len(intro) > 0
# Intro should contain game title or location
assert "ZORK" in intro or "West of House" in intro
@requires_zork
@pytest.mark.asyncio
async def test_embedded_session_handle_input():
"""EmbeddedIFSession handles input and returns response."""
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.player import Player
mock_writer = MockWriter()
player = Player(name="tester", x=5, y=5, writer=mock_writer)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
# Clean up any existing save to get a fresh start
if session.save_path.exists():
session.save_path.unlink()
await session.start()
response = await session.handle_input("look")
assert response is not None
assert response.done is False
assert len(response.output) > 0
# Looking should describe the starting location
assert "West of House" in response.output or "house" in response.output
@requires_zork
@pytest.mark.asyncio
async def test_embedded_session_escape_help():
"""EmbeddedIFSession ::help returns help text."""
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.player import Player
mock_writer = MockWriter()
player = Player(name="tester", x=5, y=5, writer=mock_writer)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
await session.start()
response = await session.handle_input("::help")
assert response.done is False
assert "::quit" in response.output
assert "::save" in response.output
assert "::help" in response.output
@requires_zork
@pytest.mark.asyncio
async def test_embedded_session_escape_quit():
"""EmbeddedIFSession ::quit returns done=True."""
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.player import Player
mock_writer = MockWriter()
player = Player(name="tester", x=5, y=5, writer=mock_writer)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
await session.start()
response = await session.handle_input("::quit")
assert response.done is True
assert "saved" in response.output.lower()
@requires_zork
@pytest.mark.asyncio
async def test_embedded_session_location_name():
"""EmbeddedIFSession get_location_name returns location after input."""
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.player import Player
mock_writer = MockWriter()
player = Player(name="tester", x=5, y=5, writer=mock_writer)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
await session.start()
# Send a command to advance game state
await session.handle_input("look")
location = session.get_location_name()
# Location may be None or a string depending on game state
assert location is None or isinstance(location, str)
@requires_zork
@pytest.mark.asyncio
async def test_embedded_session_room_objects():
"""EmbeddedIFSession get_room_objects returns a list after start."""
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.player import Player
mock_writer = MockWriter()
player = Player(name="tester", x=5, y=5, writer=mock_writer)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
await session.start()
objects = session.get_room_objects()
assert isinstance(objects, list)
# Zork1 starting location usually has some objects
assert len(objects) >= 0 # May or may not have visible objects initially
@requires_zork
@pytest.mark.asyncio
async def test_embedded_session_try_restore_before_thread():
"""_try_restore() is called synchronously before interpreter thread starts."""
from unittest.mock import patch
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.player import Player
mock_writer = MockWriter()
player = Player(name="tester", x=5, y=5, writer=mock_writer)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
# Create a save file
if session.save_path.exists():
session.save_path.unlink()
session.save_path.parent.mkdir(parents=True, exist_ok=True)
# Write a minimal valid save (header only, won't actually restore correctly)
session.save_path.write_bytes(b"FORM\x00\x00\x00\x08IFZSQUTZ\x00\x00\x00\x00")
call_order = []
original_try_restore = session._try_restore
original_run_interpreter = session._run_interpreter
def track_try_restore():
call_order.append("try_restore")
return original_try_restore()
def track_run_interpreter():
call_order.append("run_interpreter")
original_run_interpreter()
with (
patch.object(session, "_try_restore", side_effect=track_try_restore),
patch.object(session, "_run_interpreter", side_effect=track_run_interpreter),
):
await session.start()
# Verify _try_restore was called before _run_interpreter
assert call_order[0] == "try_restore"
assert call_order[1] == "run_interpreter"
await session.stop()
if session.save_path.exists():
session.save_path.unlink()
@requires_zork
@pytest.mark.asyncio
async def test_embedded_session_no_restore_without_save():
"""start() does not restore when no save file exists."""
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.player import Player
mock_writer = MockWriter()
player = Player(name="nosaveplayer", writer=mock_writer, x=0, y=0)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
# Ensure no save file exists
if session.save_path.exists():
session.save_path.unlink()
intro = await session.start()
# Should NOT contain restore message
assert "restoring" not in intro.lower()
# Should contain normal game intro
assert "ZORK" in intro or "West of House" in intro
await session.stop()
@requires_zork
@pytest.mark.asyncio
async def test_embedded_session_save_and_restore():
"""Save a game, create new session, restore it via start()."""
from mudlib.embedded_if_session import EmbeddedIFSession
from mudlib.player import Player
mock_writer = MockWriter()
# Start first session
player = Player(name="testplayer", writer=mock_writer, x=0, y=0)
session = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
# Clean up any existing save to get a fresh start
if session.save_path.exists():
session.save_path.unlink()
await session.start()
# Do something to change state
await session.handle_input("open mailbox")
# Save
save_result = await session.handle_input("::save")
assert "saved" in save_result.output.lower()
await session.stop()
# Start new session - should auto-restore via start()
# start() calls _try_restore() BEFORE launching the interpreter thread
session2 = EmbeddedIFSession(player, str(ZORK_PATH), "zork1")
intro = await session2.start()
# Should contain restore message prefixed to output
assert "restoring saved game" in intro.lower()
assert "restored" in intro.lower()
# The game state should reflect the restored state
# (location may differ after restore, just verify it works)
response = await session2.handle_input("look")
assert response.output # Should get some output
await session2.stop()
# Clean up save file
if session2.save_path.exists():
session2.save_path.unlink()