From 6d7b40436548f0ee5620b55dd729117b3311697e Mon Sep 17 00:00:00 2001 From: Jared Miller Date: Tue, 10 Feb 2026 17:03:19 -0500 Subject: [PATCH] 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. --- tests/test_game_compatibility.py | 118 +++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 tests/test_game_compatibility.py diff --git a/tests/test_game_compatibility.py b/tests/test_game_compatibility.py new file mode 100644 index 0000000..2304f93 --- /dev/null +++ b/tests/test_game_compatibility.py @@ -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" + )