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

+12
View File
@@ -1,4 +1,16 @@
import signal
import sys
from collections.abc import Iterator
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).parent))
@pytest.fixture(autouse=True)
def _restore_sigint() -> Iterator[None]:
"""The UIs install their own SIGINT handler; keep pytest's."""
handler = signal.getsignal(signal.SIGINT)
yield
signal.signal(signal.SIGINT, handler)
+43
View File
@@ -0,0 +1,43 @@
"""Characters, rosters and .pqw files for the UI and CLI tests."""
from pathlib import Path
from test_pqw import played
from pqcli import pqw
from pqcli.game_data import load_game_data
from pqcli.mechanic import Player
from pqcli.roster import Roster
DATA = load_game_data()
def hero(name: str = "Exportée", ticks: int = 300) -> Player:
"""A character that has played for a while."""
player = played(ticks).player
player.name = name
return player
def save_path(config: Path) -> Path:
"""Where pqcli keeps its roster with XDG_CONFIG_HOME set to config."""
return config / "pqcli" / "save.dat"
def write_roster(config: Path, *players: Player) -> Path:
path = save_path(config)
Roster(path, list(players)).save()
return path
def read_names(config: Path) -> list[str]:
return [player.name for player in Roster.load(save_path(config)).players]
def write_pqw(path: Path, player: Player, *, online: bool = False) -> Path:
"""Write player as a .pqw; online adds fields the import warns about."""
game = pqw.export_game(player, DATA)
if online:
game["online"] = {"realm": "Alpaquil", "passkey": 1}
path.write_text(pqw.encode(game), encoding="ascii")
return path
+375
View File
@@ -0,0 +1,375 @@
"""pqcli's command line, run in-process so coverage sees it."""
import json
from collections.abc import Callable, Iterable
from pathlib import Path
import pytest
from saves import DATA, hero, read_names, save_path, write_pqw, write_roster
import pqcli.__main__ as cli
from pqcli import pqw
from pqcli.game_data import DATA_FILES
from pqcli.mechanic import Player
from pqcli.roster import Roster
from pqcli.ui.basic import BasicUserInterface
from pqcli.ui.curses import CursesUserInterface
Run = Callable[..., int]
@pytest.fixture
def config(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
"""Point the save and data paths at tmp_path/config."""
config = tmp_path / "config"
monkeypatch.setattr(cli, "SAVE_PATH", save_path(config))
monkeypatch.setattr(cli, "DATA_PATH", config / "pqcli" / "data")
return config
class NoCurses:
"""Fails the test instead of taking over the terminal with curses."""
def __init__(self, *args: object) -> None:
raise AssertionError("the curses UI was started")
@pytest.fixture(autouse=True)
def _no_curses(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(cli, "CursesUserInterface", NoCurses)
@pytest.fixture
def run(config: Path, monkeypatch: pytest.MonkeyPatch) -> Run:
"""Call main() with the given arguments and return its exit code."""
def run(*args: str) -> int:
monkeypatch.setattr("sys.argv", ["pqcli", *args])
try:
cli.main()
except SystemExit as ex:
assert isinstance(ex.code, int | None)
return ex.code or 0
return 0
return run
def feed(monkeypatch: pytest.MonkeyPatch, answers: Iterable[str]) -> None:
"""Answer input() prompts in order; running out is a test failure."""
it = iter(answers)
def fake_input(prompt: str = "") -> str:
print(prompt)
try:
return next(it)
except StopIteration:
raise AssertionError(f"unexpected prompt {prompt!r}") from None
monkeypatch.setattr("builtins.input", fake_input)
def test_defaults_to_the_curses_ui(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.undo()
monkeypatch.setattr("sys.argv", ["pqcli"])
args = cli.parse_args()
assert args.ui is CursesUserInterface
assert args.use_saves
assert args.colors
assert not args.cheats
def test_list_saves(
run: Run, config: Path, capsys: pytest.CaptureFixture[str]
) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
assert run("--list-saves") == 0
assert capsys.readouterr().out == "1. Ann\n2. Bob\n"
def test_list_saves_when_there_are_none(
run: Run, config: Path, capsys: pytest.CaptureFixture[str]
) -> None:
assert run("--list-saves") == 0
assert capsys.readouterr().out == ""
# no first-run character creation, and nothing written
assert not config.exists()
BAD_LOAD_SAVE = "--load-save 0 and negative numbers are not rejected"
@pytest.mark.parametrize(
"num",
[
"3",
pytest.param(
"0", marks=pytest.mark.xfail(strict=True, reason=BAD_LOAD_SAVE)
),
pytest.param(
"-1", marks=pytest.mark.xfail(strict=True, reason=BAD_LOAD_SAVE)
),
],
)
def test_load_save_rejects_unknown_character(
run: Run,
config: Path,
capsys: pytest.CaptureFixture[str],
monkeypatch: pytest.MonkeyPatch,
num: str,
) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
# 0 falls through to the menu and -1 plays Bob; neither should
feed(monkeypatch, ["7"])
monkeypatch.setattr(BasicUserInterface, "play", lambda self, p: None)
assert run("--basic", "--load-save", num) == 1
err = capsys.readouterr().err
assert err == "Invalid player. Available players:\n1. Ann\n2. Bob\n"
def test_load_save_plays_the_character_and_saves(
run: Run, config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
played: list[Player] = []
def play(self: BasicUserInterface, player: Player) -> None:
played.append(player)
player.name = "Bobby"
monkeypatch.setattr(BasicUserInterface, "play", play)
assert run("--basic", "--load-save", "2") == 0
assert [p.name for p in played] == ["Bobby"]
assert read_names(config) == ["Ann", "Bobby"]
def test_no_save_leaves_the_roster_alone(
run: Run, config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
write_roster(config, hero("Ann"))
def play(self: BasicUserInterface, player: Player) -> None:
player.name = "Changed"
monkeypatch.setattr(BasicUserInterface, "play", play)
assert run("--basic", "--no-save", "--load-save", "1") == 0
assert read_names(config) == ["Ann"]
def test_saves_even_when_the_ui_crashes(
run: Run, config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
write_roster(config, hero("Ann"))
def play(self: BasicUserInterface, player: Player) -> None:
player.name = "Crashed"
raise RuntimeError("boom")
monkeypatch.setattr(BasicUserInterface, "play", play)
with pytest.raises(RuntimeError, match="boom"):
run("--basic", "--load-save", "1")
assert read_names(config) == ["Crashed"]
def test_dump_data(
run: Run, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
target = tmp_path / "words"
assert run("--dump-data", str(target)) == 0
assert capsys.readouterr().out == (
f"Wrote {len(DATA_FILES)} files to {target}\n"
)
assert sorted(p.stem for p in target.iterdir()) == sorted(DATA_FILES)
def test_dump_data_refuses_to_overwrite(
run: Run, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
target = tmp_path / "words"
target.mkdir()
existing = target / f"{next(iter(DATA_FILES))}.json"
existing.write_text("mine")
assert run("--dump-data", str(target)) == 1
assert capsys.readouterr().err.startswith("pqcli: refusing to overwrite")
assert existing.read_text() == "mine"
def test_data_dir_must_be_a_directory(
run: Run, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
not_dir = tmp_path / "file"
not_dir.write_text("")
assert run("--data-dir", str(not_dir), "--list-saves") == 1
assert capsys.readouterr().err == f"pqcli: {not_dir}: not a directory\n"
def test_data_dir_with_broken_list(
run: Run, tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
(tmp_path / "races.json").write_text("{not json")
assert run("--data-dir", str(tmp_path), "--list-saves") == 1
err = capsys.readouterr().err
assert err.startswith("pqcli: ")
assert "races.json" in err
def test_default_data_dir_is_used(
run: Run, config: Path, capsys: pytest.CaptureFixture[str]
) -> None:
data_dir = config / "pqcli" / "data"
data_dir.mkdir(parents=True)
(data_dir / "races.json").write_text("[]")
assert run("--list-saves") == 1
assert "races.json" in capsys.readouterr().err
def test_import_saves(
run: Run,
config: Path,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
write_roster(config, hero("Ann"))
first = write_pqw(tmp_path / "a.pqw", hero("Bob"))
second = write_pqw(tmp_path / "b.pqw", hero("Cid"), online=True)
assert run("--import-save", str(first), "--import-save", str(second)) == 0
out, err = capsys.readouterr()
assert out == "Imported Bob\nImported Cid\n"
assert (
err == f"pqcli: {second}: dropped 'online': pqcli plays offline only\n"
)
assert read_names(config) == ["Ann", "Bob", "Cid"]
def test_import_saves_without_saving(
run: Run, config: Path, tmp_path: Path
) -> None:
source = write_pqw(tmp_path / "a.pqw", hero("Bob"))
assert run("--no-save", "--import-save", str(source)) == 0
assert not save_path(config).exists()
def test_import_stops_at_the_first_bad_file(
run: Run,
config: Path,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
good = write_pqw(tmp_path / "a.pqw", hero("Bob"))
missing = tmp_path / "missing.pqw"
assert run("--import-save", str(good), "--import-save", str(missing)) == 1
assert capsys.readouterr().err.startswith(f"pqcli: cannot read {missing}")
assert not save_path(config).exists()
def test_export_save(
run: Run,
config: Path,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
target = tmp_path / "bob.pqw"
assert run("--export-save", "2", str(target)) == 0
assert capsys.readouterr().out == f"Exported Bob to {target}\n"
assert pqw.read_file(target, DATA)[0].name == "Bob"
@pytest.mark.parametrize("num", ["0", "3", "x", "-1"])
def test_export_save_rejects_unknown_character(
run: Run,
config: Path,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
num: str,
) -> None:
write_roster(config, hero("Ann"), hero("Bob"))
target = tmp_path / "x.pqw"
assert run("--export-save", num, str(target)) == 1
assert capsys.readouterr().err == (
"Invalid player. Available players:\n1. Ann\n2. Bob\n"
)
assert not target.exists()
def test_export_save_never_overwrites(
run: Run,
config: Path,
tmp_path: Path,
capsys: pytest.CaptureFixture[str],
) -> None:
write_roster(config, hero("Ann"))
target = tmp_path / "x.pqw"
target.write_text("mine")
assert run("--export-save", "1", str(target)) == 1
assert capsys.readouterr().err == f"pqcli: {target} already exists\n"
assert target.read_text() == "mine"
# first run ------------------------------------------------------------
def test_first_run_creates_a_character_then_shows_the_menu(
run: Run,
config: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
race, class_ = DATA.races[2], DATA.classes[3]
# name, race, class, accept the stats, then quit from the main menu
feed(monkeypatch, ["Newbie", "3", "4", "y", "7"])
assert run("--basic") == 0
out = capsys.readouterr().out
assert out.startswith(
"No saved characters found. Starting first-run character creation."
)
# no "play now?" question on the first run
assert "Do you want to play" not in out
assert "1) Create a new character" in out
[player] = Roster.load(save_path(config)).players
assert player.name == "Newbie"
assert player.race == race
assert player.class_ == class_
def test_first_run_can_be_retried(
run: Run,
config: Path,
monkeypatch: pytest.MonkeyPatch,
) -> None:
feed(monkeypatch, ["", "maybe", "y", "Second", "1", "1", "y", "7"])
assert run("--basic") == 0
assert read_names(config) == ["Second"]
def test_first_run_gives_up_without_a_character(
run: Run,
config: Path,
monkeypatch: pytest.MonkeyPatch,
capsys: pytest.CaptureFixture[str],
) -> None:
feed(monkeypatch, ["", "n"])
assert run("--basic") == 1
out, err = capsys.readouterr()
assert "Cancelled." in out
assert err == "A character is required for first run.\n"
assert not save_path(config).exists()
def test_first_run_is_skipped_with_characters(
run: Run, config: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
write_roster(config, hero("Ann"))
feed(monkeypatch, ["7"])
assert run("--basic") == 0
assert read_names(config) == ["Ann"]
def test_dump_data_writes_valid_json(run: Run, tmp_path: Path) -> None:
target = tmp_path / "words"
run("--dump-data", str(target))
for path in target.iterdir():
json.loads(path.read_text(encoding="utf-8"))
+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"]