From 083dbe01c2788f28ad1d62d5ef1fc22c939295e0 Mon Sep 17 00:00:00 2001 From: Dash Date: Sat, 17 Oct 2026 19:20:53 +0200 Subject: [PATCH] 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. --- tests/conftest.py | 12 ++ tests/saves.py | 43 +++++ tests/test_main.py | 375 +++++++++++++++++++++++++++++++++++++++++++ tests/test_roster.py | 131 +++++++++++++++ 4 files changed, 561 insertions(+) create mode 100644 tests/saves.py create mode 100644 tests/test_main.py create mode 100644 tests/test_roster.py diff --git a/tests/conftest.py b/tests/conftest.py index 1da1dcf..236b156 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) diff --git a/tests/saves.py b/tests/saves.py new file mode 100644 index 0000000..26f7a2b --- /dev/null +++ b/tests/saves.py @@ -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 diff --git a/tests/test_main.py b/tests/test_main.py new file mode 100644 index 0000000..3d5a1c4 --- /dev/null +++ b/tests/test_main.py @@ -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")) diff --git a/tests/test_roster.py b/tests/test_roster.py new file mode 100644 index 0000000..16a0d77 --- /dev/null +++ b/tests/test_roster.py @@ -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"]