When starting an IF game, check for existing save file and restore if present. Shows 'restoring saved game...' message and broadcasts restored game state to spectators. Also cleaned up redundant tests that didn't properly mock the auto-save functionality now present in ::quit and stop().
561 lines
16 KiB
Python
561 lines
16 KiB
Python
"""Tests for IF session management."""
|
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from mudlib.if_session import IFResponse, IFSession
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_if_response_dataclass():
|
|
"""IFResponse dataclass can be created."""
|
|
response = IFResponse(output="test output", done=False)
|
|
assert response.output == "test output"
|
|
assert response.done is False
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_if_response_done():
|
|
"""IFResponse can signal completion."""
|
|
response = IFResponse(output="", done=True)
|
|
assert response.done is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_if_session_init():
|
|
"""IFSession can be initialized."""
|
|
player = MagicMock()
|
|
session = IFSession(player, "/path/to/story.z5", "story")
|
|
assert session.player == player
|
|
assert session.story_path == "/path/to/story.z5"
|
|
assert session.game_name == "story"
|
|
assert session.process is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_if_session_init_infers_game_name():
|
|
"""IFSession infers game_name from story_path if not provided."""
|
|
player = MagicMock()
|
|
session = IFSession(player, "/path/to/zork.z5")
|
|
assert session.game_name == "zork"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_start_spawns_subprocess_and_returns_intro():
|
|
"""start() spawns dfrotz subprocess and returns intro text."""
|
|
player = MagicMock()
|
|
session = IFSession(player, "/path/to/story.z5")
|
|
|
|
# Mock the subprocess
|
|
mock_process = AsyncMock()
|
|
mock_process.stdout = AsyncMock()
|
|
mock_process.stdin = AsyncMock()
|
|
|
|
# Simulate dfrotz output: intro text followed by ">" prompt
|
|
intro_bytes = b"Welcome to the story!\nYou are in a room.\n>"
|
|
|
|
async def read_side_effect(n):
|
|
nonlocal intro_bytes
|
|
if intro_bytes:
|
|
byte = intro_bytes[:1]
|
|
intro_bytes = intro_bytes[1:]
|
|
return byte
|
|
return b""
|
|
|
|
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
|
|
|
|
with patch("asyncio.create_subprocess_exec", return_value=mock_process):
|
|
intro = await session.start()
|
|
|
|
assert session.process == mock_process
|
|
assert "Welcome to the story!" in intro
|
|
assert "You are in a room." in intro
|
|
# The prompt should be stripped from the output
|
|
assert intro.strip().endswith("room.")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_input_sends_to_dfrotz():
|
|
"""handle_input() sends regular input to dfrotz and returns response."""
|
|
player = MagicMock()
|
|
session = IFSession(player, "/path/to/story.z5")
|
|
|
|
# Mock process
|
|
mock_process = MagicMock()
|
|
mock_process.stdin = MagicMock()
|
|
mock_process.stdin.write = MagicMock()
|
|
mock_process.stdin.drain = AsyncMock()
|
|
mock_process.stdout = AsyncMock()
|
|
session.process = mock_process
|
|
|
|
# Simulate dfrotz response
|
|
response_bytes = b"You move north.\n>"
|
|
|
|
async def read_side_effect(n):
|
|
nonlocal response_bytes
|
|
if response_bytes:
|
|
byte = response_bytes[:1]
|
|
response_bytes = response_bytes[1:]
|
|
return byte
|
|
return b""
|
|
|
|
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
|
|
|
|
response = await session.handle_input("north")
|
|
|
|
assert response.output == "You move north."
|
|
assert response.done is False
|
|
# Verify stdin.write was called
|
|
mock_process.stdin.write.assert_called()
|
|
mock_process.stdin.drain.assert_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_input_help_returns_help_text():
|
|
"""handle_input('::help') returns help text listing escape commands."""
|
|
player = MagicMock()
|
|
session = IFSession(player, "/path/to/story.z5")
|
|
session.process = AsyncMock()
|
|
|
|
response = await session.handle_input("::help")
|
|
|
|
assert response.done is False
|
|
assert "::quit" in response.output
|
|
assert "::help" in response.output
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_when_no_process():
|
|
"""stop() does nothing if process is None."""
|
|
player = MagicMock()
|
|
session = IFSession(player, "/path/to/story.z5")
|
|
session.process = None
|
|
|
|
# Should not raise
|
|
await session.stop()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_when_already_terminated():
|
|
"""stop() handles already-terminated process gracefully."""
|
|
player = MagicMock()
|
|
session = IFSession(player, "/path/to/story.z5")
|
|
|
|
# Mock process that's already done
|
|
mock_process = AsyncMock()
|
|
mock_process.returncode = 0
|
|
session.process = mock_process
|
|
|
|
await session.stop()
|
|
|
|
# Should not call terminate on already-finished process
|
|
mock_process.terminate.assert_not_called()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_read_response_detects_prompt():
|
|
"""_read_response() reads until '>' prompt appears."""
|
|
player = MagicMock()
|
|
session = IFSession(player, "/path/to/story.z5")
|
|
|
|
# Mock process
|
|
mock_process = AsyncMock()
|
|
mock_process.stdout = AsyncMock()
|
|
session.process = mock_process
|
|
|
|
# Simulate multi-line output with prompt
|
|
output_bytes = b"Line 1\nLine 2\nLine 3\n>"
|
|
|
|
async def read_side_effect(n):
|
|
nonlocal output_bytes
|
|
if output_bytes:
|
|
byte = output_bytes[:1]
|
|
output_bytes = output_bytes[1:]
|
|
return byte
|
|
return b""
|
|
|
|
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
|
|
|
|
result = await session._read_response()
|
|
|
|
assert "Line 1" in result
|
|
assert "Line 2" in result
|
|
assert "Line 3" in result
|
|
# Prompt should be stripped
|
|
assert not result.strip().endswith(">")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_input_strips_whitespace():
|
|
"""handle_input() strips input before sending to dfrotz."""
|
|
player = MagicMock()
|
|
session = IFSession(player, "/path/to/story.z5")
|
|
|
|
# Mock process
|
|
mock_process = MagicMock()
|
|
mock_process.stdin = MagicMock()
|
|
mock_process.stdin.write = MagicMock()
|
|
mock_process.stdin.drain = AsyncMock()
|
|
mock_process.stdout = AsyncMock()
|
|
session.process = mock_process
|
|
|
|
# Simulate response
|
|
response_bytes = b"ok\n>"
|
|
|
|
async def read_side_effect(n):
|
|
nonlocal response_bytes
|
|
if response_bytes:
|
|
byte = response_bytes[:1]
|
|
response_bytes = response_bytes[1:]
|
|
return byte
|
|
return b""
|
|
|
|
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
|
|
|
|
await session.handle_input(" look ")
|
|
|
|
# Check that write was called with stripped input + newline
|
|
calls = mock_process.stdin.write.call_args_list
|
|
assert len(calls) == 1
|
|
written = calls[0][0][0]
|
|
assert written == b"look\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_input_empty_string():
|
|
"""handle_input() with empty string sends newline to dfrotz."""
|
|
player = MagicMock()
|
|
session = IFSession(player, "/path/to/story.z5")
|
|
|
|
# Mock process
|
|
mock_process = MagicMock()
|
|
mock_process.stdin = MagicMock()
|
|
mock_process.stdin.write = MagicMock()
|
|
mock_process.stdin.drain = AsyncMock()
|
|
mock_process.stdout = AsyncMock()
|
|
session.process = mock_process
|
|
|
|
# Simulate response
|
|
response_bytes = b"ok\n>"
|
|
|
|
async def read_side_effect(n):
|
|
nonlocal response_bytes
|
|
if response_bytes:
|
|
byte = response_bytes[:1]
|
|
response_bytes = response_bytes[1:]
|
|
return byte
|
|
return b""
|
|
|
|
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
|
|
|
|
await session.handle_input("")
|
|
|
|
# Should still write a newline
|
|
mock_process.stdin.write.assert_called()
|
|
|
|
|
|
def test_save_path_property(tmp_path):
|
|
"""save_path returns correct path for player/game combo."""
|
|
player = MagicMock()
|
|
player.name = "tester"
|
|
session = IFSession(player, "/path/to/zork.z5", "zork")
|
|
|
|
# Override data_dir for testing
|
|
session._data_dir = tmp_path
|
|
|
|
save_path = session.save_path
|
|
assert save_path == tmp_path / "if_saves" / "tester" / "zork.qzl"
|
|
|
|
|
|
def test_ensure_save_dir_creates_directories(tmp_path):
|
|
"""_ensure_save_dir() creates parent directories."""
|
|
player = MagicMock()
|
|
player.name = "alice"
|
|
session = IFSession(player, "/path/to/story.z5", "story")
|
|
|
|
# Override data_dir for testing
|
|
session._data_dir = tmp_path
|
|
|
|
# Directory shouldn't exist yet
|
|
expected_dir = tmp_path / "if_saves" / "alice"
|
|
assert not expected_dir.exists()
|
|
|
|
# Call _ensure_save_dir
|
|
session._ensure_save_dir()
|
|
|
|
# Now it should exist
|
|
assert expected_dir.exists()
|
|
assert expected_dir.is_dir()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_do_save_sends_save_command(tmp_path):
|
|
"""_do_save() sends save command and filepath to dfrotz."""
|
|
player = MagicMock()
|
|
player.name = "tester"
|
|
session = IFSession(player, "/path/to/zork.z5", "zork")
|
|
session._data_dir = tmp_path
|
|
|
|
# Mock process
|
|
mock_process = MagicMock()
|
|
mock_process.stdin = MagicMock()
|
|
mock_process.stdin.write = MagicMock()
|
|
mock_process.stdin.drain = AsyncMock()
|
|
mock_process.stdout = AsyncMock()
|
|
session.process = mock_process
|
|
|
|
# Simulate dfrotz responses: first the filename prompt, then confirmation
|
|
responses = [
|
|
b"Enter saved game to store: \n>",
|
|
b"Ok.\n>",
|
|
]
|
|
response_data = b"".join(responses)
|
|
|
|
async def read_side_effect(n):
|
|
nonlocal response_data
|
|
if response_data:
|
|
byte = response_data[:1]
|
|
response_data = response_data[1:]
|
|
return byte
|
|
return b""
|
|
|
|
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
|
|
|
|
result = await session._do_save()
|
|
|
|
# Should have written "save\n" then the filepath
|
|
calls = mock_process.stdin.write.call_args_list
|
|
assert len(calls) == 2
|
|
assert calls[0][0][0] == b"save\n"
|
|
assert str(session.save_path) in calls[1][0][0].decode()
|
|
assert b"\n" in calls[1][0][0]
|
|
|
|
# Result should contain confirmation
|
|
assert "Ok" in result or "ok" in result.lower()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_do_save_creates_save_directory(tmp_path):
|
|
"""_do_save() ensures save directory exists."""
|
|
player = MagicMock()
|
|
player.name = "alice"
|
|
session = IFSession(player, "/path/to/story.z5", "story")
|
|
session._data_dir = tmp_path
|
|
|
|
# Mock process
|
|
mock_process = MagicMock()
|
|
mock_process.stdin = MagicMock()
|
|
mock_process.stdin.write = MagicMock()
|
|
mock_process.stdin.drain = AsyncMock()
|
|
mock_process.stdout = AsyncMock()
|
|
session.process = mock_process
|
|
|
|
# Simulate dfrotz responses
|
|
responses = [b"Enter saved game: \n>", b"Ok.\n>"]
|
|
response_data = b"".join(responses)
|
|
|
|
async def read_side_effect(n):
|
|
nonlocal response_data
|
|
if response_data:
|
|
byte = response_data[:1]
|
|
response_data = response_data[1:]
|
|
return byte
|
|
return b""
|
|
|
|
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
|
|
|
|
# Directory shouldn't exist yet
|
|
expected_dir = tmp_path / "if_saves" / "alice"
|
|
assert not expected_dir.exists()
|
|
|
|
await session._do_save()
|
|
|
|
# Now it should exist
|
|
assert expected_dir.exists()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_do_restore_returns_empty_if_no_save(tmp_path):
|
|
"""_do_restore() returns empty string if no save file exists."""
|
|
player = MagicMock()
|
|
player.name = "tester"
|
|
session = IFSession(player, "/path/to/zork.z5", "zork")
|
|
session._data_dir = tmp_path
|
|
|
|
# Mock process (even though we won't use it)
|
|
session.process = MagicMock()
|
|
|
|
# No save file exists
|
|
assert not session.save_path.exists()
|
|
|
|
result = await session._do_restore()
|
|
|
|
assert result == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_do_restore_sends_restore_command(tmp_path):
|
|
"""_do_restore() sends restore command and filepath to dfrotz."""
|
|
player = MagicMock()
|
|
player.name = "tester"
|
|
session = IFSession(player, "/path/to/zork.z5", "zork")
|
|
session._data_dir = tmp_path
|
|
|
|
# Create a fake save file
|
|
session._ensure_save_dir()
|
|
session.save_path.write_text("fake save data")
|
|
|
|
# Mock process
|
|
mock_process = MagicMock()
|
|
mock_process.stdin = MagicMock()
|
|
mock_process.stdin.write = MagicMock()
|
|
mock_process.stdin.drain = AsyncMock()
|
|
mock_process.stdout = AsyncMock()
|
|
session.process = mock_process
|
|
|
|
# Simulate dfrotz responses: filename prompt, then game text
|
|
responses = [
|
|
b"Enter saved game to load: \n>",
|
|
b"West of House\nYou are standing in an open field.\n>",
|
|
]
|
|
response_data = b"".join(responses)
|
|
|
|
async def read_side_effect(n):
|
|
nonlocal response_data
|
|
if response_data:
|
|
byte = response_data[:1]
|
|
response_data = response_data[1:]
|
|
return byte
|
|
return b""
|
|
|
|
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
|
|
|
|
result = await session._do_restore()
|
|
|
|
# Should have written "restore\n" then the filepath
|
|
calls = mock_process.stdin.write.call_args_list
|
|
assert len(calls) == 2
|
|
assert calls[0][0][0] == b"restore\n"
|
|
assert str(session.save_path) in calls[1][0][0].decode()
|
|
|
|
# Result should contain game text
|
|
assert "West of House" in result
|
|
assert "open field" in result
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_input_save_triggers_save(tmp_path):
|
|
"""handle_input('::save') triggers save and returns confirmation."""
|
|
player = MagicMock()
|
|
player.name = "tester"
|
|
session = IFSession(player, "/path/to/zork.z5", "zork")
|
|
session._data_dir = tmp_path
|
|
|
|
# Mock process
|
|
mock_process = MagicMock()
|
|
mock_process.stdin = MagicMock()
|
|
mock_process.stdin.write = MagicMock()
|
|
mock_process.stdin.drain = AsyncMock()
|
|
mock_process.stdout = AsyncMock()
|
|
session.process = mock_process
|
|
|
|
# Simulate dfrotz save responses
|
|
responses = [b"Enter saved game: \n>", b"Ok.\n>"]
|
|
response_data = b"".join(responses)
|
|
|
|
async def read_side_effect(n):
|
|
nonlocal response_data
|
|
if response_data:
|
|
byte = response_data[:1]
|
|
response_data = response_data[1:]
|
|
return byte
|
|
return b""
|
|
|
|
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
|
|
|
|
response = await session.handle_input("::save")
|
|
|
|
assert response.done is False
|
|
assert "Ok" in response.output or "ok" in response.output.lower()
|
|
# Verify save command was sent
|
|
assert mock_process.stdin.write.call_args_list[0][0][0] == b"save\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_input_quit_saves_before_exit(tmp_path):
|
|
"""handle_input('::quit') saves game before returning done=True."""
|
|
player = MagicMock()
|
|
player.name = "tester"
|
|
session = IFSession(player, "/path/to/zork.z5", "zork")
|
|
session._data_dir = tmp_path
|
|
|
|
# Mock process
|
|
mock_process = MagicMock()
|
|
mock_process.stdin = MagicMock()
|
|
mock_process.stdin.write = MagicMock()
|
|
mock_process.stdin.drain = AsyncMock()
|
|
mock_process.stdout = AsyncMock()
|
|
session.process = mock_process
|
|
|
|
# Simulate dfrotz save responses
|
|
responses = [b"Enter saved game: \n>", b"Ok.\n>"]
|
|
response_data = b"".join(responses)
|
|
|
|
async def read_side_effect(n):
|
|
nonlocal response_data
|
|
if response_data:
|
|
byte = response_data[:1]
|
|
response_data = response_data[1:]
|
|
return byte
|
|
return b""
|
|
|
|
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
|
|
|
|
response = await session.handle_input("::quit")
|
|
|
|
assert response.done is True
|
|
assert "saved" in response.output.lower()
|
|
# Verify save command was sent
|
|
assert mock_process.stdin.write.call_args_list[0][0][0] == b"save\n"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_stop_saves_before_terminating(tmp_path):
|
|
"""stop() saves game before terminating process."""
|
|
player = MagicMock()
|
|
player.name = "tester"
|
|
session = IFSession(player, "/path/to/zork.z5", "zork")
|
|
session._data_dir = tmp_path
|
|
|
|
# Mock process
|
|
mock_process = MagicMock()
|
|
mock_process.returncode = None
|
|
mock_process.stdin = MagicMock()
|
|
mock_process.stdin.write = MagicMock()
|
|
mock_process.stdin.drain = AsyncMock()
|
|
mock_process.stdout = AsyncMock()
|
|
mock_process.terminate = MagicMock()
|
|
mock_process.wait = AsyncMock()
|
|
session.process = mock_process
|
|
|
|
# Simulate dfrotz save responses
|
|
responses = [b"Enter saved game: \n>", b"Ok.\n>"]
|
|
response_data = b"".join(responses)
|
|
|
|
async def read_side_effect(n):
|
|
nonlocal response_data
|
|
if response_data:
|
|
byte = response_data[:1]
|
|
response_data = response_data[1:]
|
|
return byte
|
|
return b""
|
|
|
|
mock_process.stdout.read = AsyncMock(side_effect=read_side_effect)
|
|
|
|
await session.stop()
|
|
|
|
# Verify save was called before terminate
|
|
assert mock_process.stdin.write.call_args_list[0][0][0] == b"save\n"
|
|
mock_process.terminate.assert_called_once()
|
|
mock_process.wait.assert_called_once()
|