tests: cover the roster and the command line

The roster tests pin down the .old backup rotation and the five minute
periodic save, with a fake clock in place of datetime.

The command line tests call main() in-process with the save and data
paths in a temporary directory, so coverage counts them. They cover
listing, loading, importing and exporting saves, dumping and loading
word lists, and the first-run character creation. The curses UI is
swapped for a stub there so a stray code path fails instead of taking
over the terminal.

--load-save 0 falls through to the menu and --load-save -1 plays the
last character instead of being rejected; both are marked xfail.

A shared fixture restores pytest's SIGINT handler after the UIs
install their own.
This commit is contained in:
Dash committed 2026-10-17 19:46:05 +02:00
1 parent 92d4466f6d
commit 083dbe01c2
4 files changed
+561

No files matched your search

+131
View File
@@ -0,0 +1,131 @@
import pickle
from datetime import datetime, timedelta
from pathlib import Path
import pytest
from saves import hero
from pqcli import roster as roster_module
from pqcli.roster import Roster
START = datetime(2024, 1, 1, 12, 0, 0)
class Clock:
"""Stands in for datetime in pqcli.roster; only now() is used."""
current = START
@classmethod
def now(cls) -> datetime:
return cls.current
@classmethod
def advance(cls, seconds: float) -> None:
cls.current += timedelta(seconds=seconds)
@pytest.fixture
def clock(monkeypatch: pytest.MonkeyPatch) -> type[Clock]:
monkeypatch.setattr(Clock, "current", START)
monkeypatch.setattr(roster_module, "datetime", Clock)
return Clock
def names(path: Path) -> list[str]:
return [player.name for player in pickle.loads(path.read_bytes())]
def test_load_missing_file_gives_empty_roster(tmp_path: Path) -> None:
path = tmp_path / "nowhere" / "save.dat"
roster = Roster.load(path)
assert roster.players == []
assert roster.path == path
assert not path.parent.exists()
def test_load_accepts_a_string_path(tmp_path: Path) -> None:
path = tmp_path / "save.dat"
Roster(path, [hero("Ann")]).save()
assert Roster.load(str(path)).path == path
def test_save_then_load_round_trip(tmp_path: Path) -> None:
path = tmp_path / "deep" / "dir" / "save.dat"
player = hero("Ann", ticks=1000)
Roster(path, [player, hero("Bob")]).save()
loaded = Roster.load(path).players
assert [p.name for p in loaded] == ["Ann", "Bob"]
assert loaded[0].level == player.level
assert loaded[0].inventory.gold == player.inventory.gold
assert loaded[0].quest_book.quests == player.quest_book.quests
def test_save_keeps_the_previous_save_as_old(tmp_path: Path) -> None:
path = tmp_path / "save.dat"
old = tmp_path / "save.dat.old"
roster = Roster(path, [hero("Ann")])
roster.save()
assert names(path) == ["Ann"]
assert not old.exists()
roster.players.append(hero("Bob"))
roster.save()
assert names(path) == ["Ann", "Bob"]
assert names(old) == ["Ann"]
roster.players.pop(0)
roster.save()
assert names(path) == ["Bob"]
assert names(old) == ["Ann", "Bob"]
assert sorted(p.name for p in tmp_path.iterdir()) == [
"save.dat",
"save.dat.old",
]
def test_save_periodically_waits_five_minutes(
tmp_path: Path, clock: type[Clock]
) -> None:
path = tmp_path / "save.dat"
roster = Roster(path, [hero("Ann")])
clock.advance(299)
roster.save_periodically()
assert not path.exists()
clock.advance(1)
roster.save_periodically()
assert names(path) == ["Ann"]
# the timer starts over after a save
roster.players.append(hero("Bob"))
clock.advance(299)
roster.save_periodically()
assert names(path) == ["Ann"]
clock.advance(1)
roster.save_periodically()
assert names(path) == ["Ann", "Bob"]
def test_manual_save_restarts_the_periodic_timer(
tmp_path: Path, clock: type[Clock]
) -> None:
path = tmp_path / "save.dat"
roster = Roster(path, [hero("Ann")])
clock.advance(200)
roster.save()
roster.players.append(hero("Bob"))
clock.advance(200)
roster.save_periodically()
assert names(path) == ["Ann"]
clock.advance(100)
roster.save_periodically()
assert names(path) == ["Ann", "Bob"]