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:
Jared Miller 2026-02-10 17:03:19 -05:00
parent 6d29ec00fb
commit 6d7b404365
Signed by: shmup
GPG key ID: 22B5C6D66A38B06C

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