mud/tests/test_embedded_if.py
Jared Miller 15e1d807aa
Move z-machine restore before interpreter thread start
Replaces the async _do_restore() (called after thread launch) with a
synchronous _try_restore() called before the thread starts. This
eliminates the race condition where restore mutates z-machine state
while the interpreter thread is running.

The restore prefix message is now part of start()'s return value
instead of being sent separately in play.py.
2026-02-10 11:51:45 -05:00

329 lines
9.8 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 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_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()