Files
h2g2/tests/test_save_load.py
T
seppedl 2e97bb1a29 Add save/load game with named save files
Serialize full game state (objects, flags, containment, clocks) to
JSON files in ~/.h2g2_saves/. Players can name saves, list existing
ones, and restore by name or number.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 09:56:03 +02:00

293 lines
8.1 KiB
Python

"""Tests for save/load game state serialization."""
from __future__ import annotations
import json
import shutil
from pathlib import Path
import pytest
from h2g2.engine.game_object import Flag
from h2g2.engine.save_load import (
SAVE_DIR,
SAVE_EXTENSION,
_serialize_state,
_restore_state,
save_game,
load_game,
list_saves,
)
@pytest.fixture(autouse=True)
def _clean_save_dir():
"""Ensure a clean save directory for every test."""
if SAVE_DIR.exists():
shutil.rmtree(SAVE_DIR)
SAVE_DIR.mkdir(parents=True, exist_ok=True)
yield
if SAVE_DIR.exists():
shutil.rmtree(SAVE_DIR)
# ---------------------------------------------------------------------------
# list_saves
# ---------------------------------------------------------------------------
class TestListSaves:
def test_empty_directory(self):
assert list_saves() == []
def test_returns_save_names(self):
(SAVE_DIR / "alpha.json").write_text("{}")
(SAVE_DIR / "beta.json").write_text("{}")
names = list_saves()
assert "alpha" in names
assert "beta" in names
def test_ignores_non_json_files(self):
(SAVE_DIR / "notes.txt").write_text("not a save")
(SAVE_DIR / "real.json").write_text("{}")
assert list_saves() == ["real"]
# ---------------------------------------------------------------------------
# Round-trip: serialize -> restore
# ---------------------------------------------------------------------------
class TestSerializeRestore:
def test_scalar_state_round_trips(self, game):
state, _, _ = game
state.score = 42
state.moves = 17
state.verbosity = 2
state.lying_down = False
state.dreaming = True
state.flags["headache"] = False
state.flags["beer_counter"] = 3
data = _serialize_state(state)
# Mutate state to prove restore actually changes it
state.score = 0
state.moves = 0
state.verbosity = 0
state.lying_down = True
state.dreaming = False
state.flags["headache"] = True
state.flags["beer_counter"] = 0
_restore_state(state, data)
assert state.score == 42
assert state.moves == 17
assert state.verbosity == 2
assert state.lying_down is False
assert state.dreaming is True
assert state.flags["headache"] is False
assert state.flags["beer_counter"] == 3
def test_room_round_trips(self, game):
state, _, _ = game
bedroom = state.world.get_room("BEDROOM")
state.here = bedroom
data = _serialize_state(state)
state.here = None
_restore_state(state, data)
assert state.here is bedroom
def test_protagonist_round_trips(self, game):
state, _, _ = game
proto = state.protagonist
data = _serialize_state(state)
state.protagonist = None
_restore_state(state, data)
assert state.protagonist is proto
def test_object_flags_round_trip(self, game):
state, _, _ = game
obj = state.world.get("TOWEL")
obj.fset(Flag.WORNBIT)
assert obj.fset_q(Flag.WORNBIT)
data = _serialize_state(state)
obj.fclear(Flag.WORNBIT)
assert not obj.fset_q(Flag.WORNBIT)
_restore_state(state, data)
assert obj.fset_q(Flag.WORNBIT)
def test_containment_round_trip(self, game):
state, _, _ = game
towel = state.world.get("TOWEL")
proto = state.protagonist
# Move towel into inventory
towel.move_to(proto)
assert towel.parent is proto
assert towel in proto.children
data = _serialize_state(state)
# Move towel somewhere else to prove restore works
bedroom = state.world.get_room("BEDROOM")
towel.move_to(bedroom)
assert towel.parent is bedroom
_restore_state(state, data)
assert towel.parent is proto
assert towel in proto.children
def test_last_action_round_trips(self, game):
state, _, _ = game
towel = state.world.get("TOWEL")
state.l_prsa = "take"
state.l_prso = towel
state.l_prsi = None
data = _serialize_state(state)
state.l_prsa = None
state.l_prso = None
_restore_state(state, data)
assert state.l_prsa == "take"
assert state.l_prso is towel
def test_clock_state_round_trips(self, game):
state, _, _ = game
# Find a named clock entry and modify it
named = [e for e in state.clock.entries if e.name]
if not named:
pytest.skip("No named clock entries in test fixture")
entry = named[0]
original_tick = entry.tick
entry.tick = 99
entry.enabled = False
data = _serialize_state(state)
entry.tick = 0
entry.enabled = True
_restore_state(state, data)
assert entry.tick == 99
assert entry.enabled is False
# Restore original for other tests
entry.tick = original_tick
entry.enabled = True
def test_identity_flag_round_trips(self, game):
state, _, _ = game
arthur = state.world.objects.get("ARTHUR")
if arthur is None:
pytest.skip("ARTHUR object not in test fixture")
state.identity_flag = arthur
data = _serialize_state(state)
state.identity_flag = None
_restore_state(state, data)
assert state.identity_flag is arthur
# ---------------------------------------------------------------------------
# File I/O: save_game / load_game
# ---------------------------------------------------------------------------
class TestFileIO:
def test_save_creates_file(self, game):
state, _, _ = game
path = save_game(state, "test1")
assert Path(path).exists()
assert Path(path).suffix == SAVE_EXTENSION
def test_save_file_is_valid_json(self, game):
state, _, _ = game
save_game(state, "test1")
data = json.loads((SAVE_DIR / "test1.json").read_text())
assert data["version"] == 1
assert "objects" in data
assert "flags" in data
def test_load_restores_state(self, game):
state, _, _ = game
state.score = 100
state.moves = 50
save_game(state, "mysave")
state.score = 0
state.moves = 0
load_game(state, "mysave")
assert state.score == 100
assert state.moves == 50
def test_load_nonexistent_raises(self, game):
state, _, _ = game
with pytest.raises(FileNotFoundError):
load_game(state, "doesnotexist")
def test_overwrite_existing_save(self, game):
state, _, _ = game
state.score = 10
save_game(state, "slot")
state.score = 99
save_game(state, "slot")
state.score = 0
load_game(state, "slot")
assert state.score == 99
def test_multiple_independent_saves(self, game):
state, _, _ = game
state.score = 10
save_game(state, "early")
state.score = 200
save_game(state, "late")
load_game(state, "early")
assert state.score == 10
load_game(state, "late")
assert state.score == 200
def test_full_round_trip_with_mutations(self, game):
state, _, _ = game
proto = state.protagonist
towel = state.world.get("TOWEL")
# Mutate the game
state.score = 42
state.moves = 15
state.lying_down = False
state.flags["earth_demolished"] = True
towel.move_to(proto)
towel.fset(Flag.TOUCHBIT)
save_game(state, "full")
# Trash everything
state.score = 0
state.moves = 0
state.lying_down = True
state.flags["earth_demolished"] = False
bedroom = state.world.get_room("BEDROOM")
towel.move_to(bedroom)
towel.fclear(Flag.TOUCHBIT)
load_game(state, "full")
assert state.score == 42
assert state.moves == 15
assert state.lying_down is False
assert state.flags["earth_demolished"] is True
assert towel.parent is proto
assert towel.fset_q(Flag.TOUCHBIT)