Add pytest regression harness for z-machine game compatibility
Implements Phase 4 of the z-machine compatibility plan. Creates automated regression tests that smoke-test all supported games (V3, V5, V8) by loading each story, executing basic commands, and verifying the interpreter doesn't crash. Key features: - Parametrized test covering 7 games (zork1, curses, photopia, Tangle, shade, LostPig, anchor) - QuietScreen class that disables [MORE] prompts for unattended testing - AutoInputStream that auto-feeds commands then exits cleanly - Tests verify: no crashes, unimplemented opcodes, and minimum instruction count - All tests pass in ~2 seconds Tests skip gracefully if story files aren't present, making this safe to run in CI or on systems without all game files.
This commit is contained in:
parent
6d29ec00fb
commit
6d7b404365
1 changed files with 118 additions and 0 deletions
118
tests/test_game_compatibility.py
Normal file
118
tests/test_game_compatibility.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
"""Regression tests for z-machine game compatibility.
|
||||
|
||||
Smoke tests a suite of games to ensure the interpreter can load them,
|
||||
execute basic commands, and reach the input prompt without crashing.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from mudlib.zmachine import ZMachine, zscreen, zstream, zui
|
||||
from mudlib.zmachine.trivialzui import (
|
||||
TrivialAudio,
|
||||
TrivialFilesystem,
|
||||
TrivialScreen,
|
||||
)
|
||||
from mudlib.zmachine.zcpu import ZCpuQuit, ZCpuRestart
|
||||
|
||||
STORIES_DIR = Path(__file__).parent.parent / "content" / "stories"
|
||||
|
||||
# Game test suite: (filename, commands to feed)
|
||||
GAMES = [
|
||||
("zork1.z3", ["look", "open mailbox", "read leaflet"]),
|
||||
("curses.z5", ["look", "inventory", "north"]),
|
||||
("photopia.z5", ["look", "yes", "look"]),
|
||||
("Tangle.z5", ["look", "inventory", "north"]),
|
||||
("shade.z5", ["look", "inventory", "look"]),
|
||||
("LostPig.z8", ["look", "inventory", "north"]),
|
||||
("anchor.z8", ["look", "inventory", "north"]),
|
||||
]
|
||||
|
||||
|
||||
class QuietScreen(TrivialScreen):
|
||||
"""Screen for testing that never shows [MORE] prompts."""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
# Set infinite rows to prevent [MORE] prompts
|
||||
self._rows = zscreen.INFINITE_ROWS
|
||||
|
||||
|
||||
class AutoInputStream(zstream.ZInputStream):
|
||||
"""Input stream that auto-feeds commands."""
|
||||
|
||||
def __init__(self, commands=None):
|
||||
super().__init__()
|
||||
self._commands = commands or []
|
||||
self._input_count = 0
|
||||
|
||||
def read_line(self, *args, **kwargs):
|
||||
if self._input_count >= len(self._commands):
|
||||
raise ZCpuQuit
|
||||
cmd = self._commands[self._input_count]
|
||||
self._input_count += 1
|
||||
return cmd
|
||||
|
||||
def read_char(self, *args, **kwargs):
|
||||
if self._input_count >= len(self._commands):
|
||||
raise ZCpuQuit
|
||||
cmd = self._commands[self._input_count]
|
||||
self._input_count += 1
|
||||
# Return first character as ord
|
||||
return ord(cmd[0]) if cmd else ord(" ")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("game,commands", GAMES, ids=[g[0] for g in GAMES])
|
||||
def test_game_smoke(game, commands):
|
||||
"""Run game through interpreter, feed commands, verify no crash."""
|
||||
story_path = STORIES_DIR / game
|
||||
if not story_path.exists():
|
||||
pytest.skip(f"{game} not found in {STORIES_DIR}")
|
||||
|
||||
# Load story
|
||||
story_bytes = story_path.read_bytes()
|
||||
|
||||
# Create test UI components
|
||||
audio = TrivialAudio()
|
||||
screen = QuietScreen()
|
||||
keyboard = AutoInputStream(commands)
|
||||
filesystem = TrivialFilesystem()
|
||||
ui = zui.ZUI(audio, screen, keyboard, filesystem)
|
||||
|
||||
# Create interpreter
|
||||
zm = ZMachine(story_bytes, ui)
|
||||
|
||||
# Step through instructions
|
||||
step_count = 0
|
||||
max_steps = 500_000 # Enough to get through several commands
|
||||
|
||||
try:
|
||||
while step_count < max_steps:
|
||||
(opcode_class, opcode_number, operands) = (
|
||||
zm._cpu._opdecoder.get_next_instruction()
|
||||
)
|
||||
|
||||
implemented, func = zm._cpu._get_handler(opcode_class, opcode_number)
|
||||
|
||||
# If unimplemented, that's a test failure
|
||||
assert implemented, (
|
||||
f"Unimplemented opcode {opcode_class}:{opcode_number:02x}"
|
||||
)
|
||||
|
||||
# Execute instruction
|
||||
func(zm._cpu, *operands)
|
||||
|
||||
step_count += 1
|
||||
|
||||
except ZCpuQuit:
|
||||
# Normal exit (ran out of commands)
|
||||
pass
|
||||
except ZCpuRestart:
|
||||
# Some games restart - this is fine for smoke test
|
||||
pass
|
||||
|
||||
# Sanity check: at least some instructions executed
|
||||
assert step_count >= 100, (
|
||||
f"Only {step_count} instructions executed, expected at least 100"
|
||||
)
|
||||
Loading…
Reference in a new issue