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