"""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()