diff --git a/tests/saves.py b/tests/helpers.py similarity index 63% rename from tests/saves.py rename to tests/helpers.py index 26f7a2b..ce24dd4 100644 --- a/tests/saves.py +++ b/tests/helpers.py @@ -1,7 +1,9 @@ -"""Characters, rosters and .pqw files for the UI and CLI tests.""" +"""Characters, saves and prompts shared by the UI and CLI tests.""" +from collections.abc import Iterable from pathlib import Path +import pytest from test_pqw import played from pqcli import pqw @@ -41,3 +43,21 @@ def write_pqw(path: Path, player: Player, *, online: bool = False) -> Path: game["online"] = {"realm": "Alpaquil", "passkey": 1} path.write_text(pqw.encode(game), encoding="ascii") return path + + +def feed(monkeypatch: pytest.MonkeyPatch, answers: Iterable[str]) -> None: + """Answer input() prompts in order; running out is a test failure. + + Prompts are echoed so captured output reads like a session. + """ + it = iter(answers) + + def fake_input(prompt: str = "") -> str: + try: + answer = next(it) + except StopIteration: + raise AssertionError(f"unexpected prompt {prompt!r}") from None + print(f"{prompt}{answer}") + return answer + + monkeypatch.setattr("builtins.input", fake_input) diff --git a/tests/test_basic_ui.py b/tests/test_basic_ui.py new file mode 100644 index 0000000..00bd3be --- /dev/null +++ b/tests/test_basic_ui.py @@ -0,0 +1,469 @@ +"""The basic line-based UI, driven in-process through input().""" + +import argparse +import signal +from datetime import datetime, timedelta +from pathlib import Path + +import pytest +from helpers import DATA, feed, hero, read_names, save_path, write_pqw +from test_pqw import played + +from pqcli import pqw +from pqcli import roster as roster_module +from pqcli.config import PRIME_STATS +from pqcli.lingo import to_roman +from pqcli.mechanic import Player +from pqcli.roster import Roster +from pqcli.ui import basic +from pqcli.ui.basic import BasicUserInterface + + +def make_ui( + config: Path, *players: Player, use_saves: bool = True +) -> BasicUserInterface: + args = argparse.Namespace(use_saves=use_saves, cheats=False, colors=True) + roster = Roster(save_path(config), list(players)) + return BasicUserInterface(roster, None, args, DATA) + + +@pytest.fixture +def no_play(monkeypatch: pytest.MonkeyPatch) -> list[Player]: + """Make play() record the character instead of looping forever.""" + played: list[Player] = [] + monkeypatch.setattr( + BasicUserInterface, "play", lambda self, player: played.append(player) + ) + return played + + +# main menu ------------------------------------------------------------ + + +def test_quit_saves_the_roster( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + ui = make_ui(tmp_path, hero("Ann")) + feed(monkeypatch, ["7"]) + ui.run() + out = capsys.readouterr().out + assert "| __/| | | (_) | (_| |" in out # the logo + for line in [ + "1) Create a new character", + "2) Play as character", + "3) Query character info", + "4) Delete a character", + "5) Import a character (.pqw)", + "6) Export a character (.pqw)", + "7) Quit", + ]: + assert line in out + assert read_names(tmp_path) == ["Ann"] + + +def test_quit_without_saving( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + ui = make_ui(tmp_path, hero("Ann"), use_saves=False) + feed(monkeypatch, ["7"]) + ui.run() + assert not save_path(tmp_path).exists() + + +def test_run_with_a_character_goes_straight_to_play( + tmp_path: Path, no_play: list[Player] +) -> None: + player = hero("Ann") + ui = make_ui(tmp_path, player) + ui.player = player + ui.run() # no prompts: feed() is not set up, input() would fail + assert no_play == [player] + + +def test_menu_rejects_bad_input( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + ui = make_ui(tmp_path) + feed(monkeypatch, ["", "two", "0", "8", "-1", "7"]) + ui.main_menu() + out = capsys.readouterr().out + assert out.count("Not a number") == 2 + assert out.count("Expected a number between 1..7") == 3 + + +def test_confirm_accepts_several_spellings( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + ui = make_ui(tmp_path) + answers = ["Y", "yes", "1", "N", "No", "0"] + feed(monkeypatch, ["?", *answers]) + assert [ui.confirm("Sure?") for _ in answers] == [True] * 3 + [False] * 3 + assert capsys.readouterr().out.count("Sure? [y/n] ") == 7 + + +@pytest.mark.parametrize("choice", ["2", "3", "4", "6"]) +def test_choosing_with_no_characters( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + choice: str, +) -> None: + ui = make_ui(tmp_path) + feed(monkeypatch, [choice, "7"]) + ui.main_menu() + assert "No characters to choose from!" in capsys.readouterr().out + + +# creating ------------------------------------------------------------- + + +def test_create_rerolls_until_accepted( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + no_play: list[Player], +) -> None: + ui = make_ui(tmp_path) + # create, name, race, class, reroll twice, accept, don't play, quit + feed(monkeypatch, ["1", "Rolf", "2", "3", "n", "n", "y", "n", "7"]) + ui.main_menu() + + out = capsys.readouterr().out + assert out.count("Total: ") == 3 + for race in DATA.races: + assert f") {race.name}\n" in out + assert "Do you want to play as your new character? [y/n] n" in out + assert no_play == [] + + [player] = ui.roster.players + assert player.name == "Rolf" + assert player.race == DATA.races[1] + assert player.class_ == DATA.classes[2] + + # the accepted stats are the last ones shown + last_shown = out.split("Is this okay?")[-2].splitlines()[-7:-1] + assert last_shown == [ + f"{stat.value}: {player.stats[stat]}" for stat in PRIME_STATS + ] + assert read_names(tmp_path) == ["Rolf"] # saved on quit + + +def test_create_then_play( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + no_play: list[Player], +) -> None: + ui = make_ui(tmp_path) + feed(monkeypatch, ["Rolf", "1", "1", "y", "y"]) + player = ui.create_player() + assert no_play == [player] + assert ui.roster.players == [player] + + +def test_create_cancelled_by_empty_name( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + ui = make_ui(tmp_path) + feed(monkeypatch, [""]) + assert ui.create_player() is None + assert "Cancelled." in capsys.readouterr().out + assert ui.roster.players == [] + + +# playing -------------------------------------------------------------- + + +def test_play_from_the_menu( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + no_play: list[Player], +) -> None: + ann, bob = hero("Ann"), hero("Bob") + ui = make_ui(tmp_path, ann, bob) + feed(monkeypatch, ["2", "x", "3", "2", "7"]) + ui.main_menu() + assert no_play == [bob] + out = capsys.readouterr().out + assert "Choose your character:\n1) Ann\n2) Bob\n" in out + assert "Expected a number between 1..2" in out + + +class StopPlaying(Exception): + pass + + +class FastClock: + """Stands in for datetime: every now() is a minute after the last.""" + + current = datetime(2024, 1, 1) + + @classmethod + def now(cls) -> datetime: + cls.current += timedelta(minutes=1) + return cls.current + + +@pytest.fixture +def game_loop(monkeypatch: pytest.MonkeyPatch) -> list[float]: + """Run play() on a fast clock, stopping it after 800 rounds. + + Returns the requested sleeps. + """ + sleeps: list[float] = [] + + def sleep(seconds: float) -> None: + sleeps.append(seconds) + if len(sleeps) == 800: + raise StopPlaying + + monkeypatch.setattr(FastClock, "current", datetime(2024, 1, 1)) + monkeypatch.setattr(basic, "datetime", FastClock) + monkeypatch.setattr(roster_module, "datetime", FastClock) + monkeypatch.setattr(basic.time, "sleep", sleep) + return sleeps + + +def test_play_prints_the_sheet_on_level_up( + tmp_path: Path, + game_loop: list[float], + capsys: pytest.CaptureFixture[str], +) -> None: + # played(0) reaches level 2 within 800 minute-long ticks + player = played(0).player + ui = make_ui(tmp_path, player, use_saves=False) + with pytest.raises(StopPlaying): + ui.play(player) + + out = capsys.readouterr().out + assert out.startswith("Playing as Exportée\n") + sheets = out.split("--- Character Sheet ---")[1:] + assert len(sheets) == 2 + assert "Level: 1\n" in sheets[0] + assert "Level: 2\n" in sheets[1] + assert player.level == 2 + # ticks are a minute late, so play() never waits + assert set(game_loop) == {0} + assert not save_path(tmp_path).exists() + + +def test_play_saves_every_five_minutes( + tmp_path: Path, game_loop: list[float], monkeypatch: pytest.MonkeyPatch +) -> None: + player = played(0).player + ui = make_ui(tmp_path, player) + saves: list[datetime] = [] + real_save = ui.roster.save + + def save() -> None: + saves.append(FastClock.current) + real_save() + + monkeypatch.setattr(ui.roster, "save", save) + with pytest.raises(StopPlaying): + ui.play(player) + + assert len(saves) > 100 + gaps = {b - a for a, b in zip(saves, saves[1:], strict=False)} + assert min(gaps) >= timedelta(minutes=5) + assert read_names(tmp_path) == ["Exportée"] + + +def test_play_waits_out_the_rest_of_a_tick( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """With no time passing, each round sleeps a full 100 ms.""" + sleeps: list[float] = [] + + class FrozenClock: + @staticmethod + def now() -> datetime: + return datetime(2024, 1, 1) + + def sleep(seconds: float) -> None: + sleeps.append(seconds) + if len(sleeps) == 3: + raise StopPlaying + + monkeypatch.setattr(basic, "datetime", FrozenClock) + monkeypatch.setattr(basic.time, "sleep", sleep) + player = played(0).player + with pytest.raises(StopPlaying): + make_ui(tmp_path, use_saves=False).play(player) + assert sleeps == [0.1, 0.1, 0.1] + + +# info ----------------------------------------------------------------- + + +def test_info_for_a_seasoned_character( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + player = hero("Ann", ticks=20000) + assert player.spell_book.best is not None + ui = make_ui(tmp_path, player) + feed(monkeypatch, ["3", "1", "7"]) + ui.main_menu() + + out = capsys.readouterr().out + assert "Name: Ann\n" in out + assert f"Race: {player.race.name}\n" in out + assert f"Class: {player.class_.name}\n" in out + assert f"Level: {player.level}\n" in out + assert "No spells memorized yet." not in out + for spell in player.spell_book: + assert f"\n{spell.name} " in out + assert f"Gold: {player.inventory.gold}\n" in out + for item in player.inventory: + assert f"{item.name}: {item.quantity}\n" in out + assert f"Current quest: {player.quest_book.current_quest}\n" in out + assert player.task is not None + assert f"Current task: {player.task.description}\n" in out + + +def test_info_for_a_new_character( + tmp_path: Path, capsys: pytest.CaptureFixture[str] +) -> None: + player = played(0, seed="new").player + player.task = None + make_ui(tmp_path).print_player_info(player) + out = capsys.readouterr().out + assert "No spells memorized yet." in out + assert f"Current act: {to_roman(player.quest_book.act)}\n" in out + assert "Current task: ?\n" in out + + +# deleting ------------------------------------------------------------- + + +def test_delete_after_confirming( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + ann, bob = hero("Ann"), hero("Bob") + ui = make_ui(tmp_path, ann, bob) + feed(monkeypatch, ["4", "1", "n", "4", "1", "y", "7"]) + ui.main_menu() + assert ui.roster.players == [bob] + assert capsys.readouterr().out.count("Ann") >= 2 + assert read_names(tmp_path) == ["Bob"] + + +# importing and exporting ---------------------------------------------- + + +def test_import( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + write_pqw(tmp_path / "cid.pqw", hero("Cid"), online=True) + ui = make_ui(tmp_path / "config", hero("Ann")) + feed(monkeypatch, ["5", " ~/cid.pqw ", "7"]) + ui.main_menu() + + out = capsys.readouterr().out + assert "Warning: dropped 'online': pqcli plays offline only\n" in out + assert "Imported Cid.\n" in out + assert [p.name for p in ui.roster.players] == ["Ann", "Cid"] + assert read_names(tmp_path / "config") == ["Ann", "Cid"] + + +def test_import_without_saving( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + source = write_pqw(tmp_path / "cid.pqw", hero("Cid")) + ui = make_ui(tmp_path, use_saves=False) + feed(monkeypatch, [str(source)]) + ui.import_player() + assert [p.name for p in ui.roster.players] == ["Cid"] + assert not save_path(tmp_path).exists() + + +@pytest.mark.parametrize( + ("answer", "message"), + [("", "Cancelled."), ("missing.pqw", "Cannot import: cannot read")], +) +def test_import_failures( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], + answer: str, + message: str, +) -> None: + monkeypatch.chdir(tmp_path) + ui = make_ui(tmp_path) + feed(monkeypatch, [answer]) + ui.import_player() + assert message in capsys.readouterr().out + assert ui.roster.players == [] + assert not save_path(tmp_path).exists() + + +def test_export_to_the_default_file( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.chdir(tmp_path) + ui = make_ui(tmp_path, hero("Ann"), hero("Bob")) + feed(monkeypatch, ["6", "2", "", "7"]) + ui.main_menu() + out = capsys.readouterr().out + assert "Export to .pqw file [Bob.pqw]: " in out + assert "Exported Bob to Bob.pqw.\n" in out + assert pqw.read_file(tmp_path / "Bob.pqw", DATA)[0].name == "Bob" + + +def test_export_to_a_chosen_file( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setenv("HOME", str(tmp_path)) + ui = make_ui(tmp_path, hero("Ann")) + feed(monkeypatch, [" ~/out.pqw "]) + ui.export_player(ui.roster.players[0]) + assert "Exported Ann to ~/out.pqw.\n" in capsys.readouterr().out + assert pqw.read_file(tmp_path / "out.pqw", DATA)[0].name == "Ann" + + +def test_export_never_overwrites( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + target = tmp_path / "out.pqw" + target.write_text("mine") + ui = make_ui(tmp_path, hero("Ann")) + feed(monkeypatch, [str(target)]) + ui.export_player(ui.roster.players[0]) + assert f"Cannot export: {target} already exists" in capsys.readouterr().out + assert target.read_text() == "mine" + + +# Ctrl+C --------------------------------------------------------------- + + +@pytest.mark.parametrize("use_saves", [True, False]) +def test_ctrl_c_saves_and_exits(tmp_path: Path, use_saves: bool) -> None: + ui = make_ui(tmp_path, hero("Ann"), use_saves=use_saves) + handler = signal.getsignal(signal.SIGINT) + assert callable(handler) + with pytest.raises(SystemExit) as ex: + handler(signal.SIGINT, None) + assert ex.value.code == 0 + assert save_path(tmp_path).exists() == use_saves + assert ui.roster.players # still there diff --git a/tests/test_main.py b/tests/test_main.py index 3d5a1c4..11b74b9 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,11 +1,19 @@ """pqcli's command line, run in-process so coverage sees it.""" import json -from collections.abc import Callable, Iterable +from collections.abc import Callable from pathlib import Path import pytest -from saves import DATA, hero, read_names, save_path, write_pqw, write_roster +from helpers import ( + DATA, + feed, + hero, + read_names, + save_path, + write_pqw, + write_roster, +) import pqcli.__main__ as cli from pqcli import pqw @@ -55,20 +63,6 @@ def run(config: Path, monkeypatch: pytest.MonkeyPatch) -> Run: 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"]) diff --git a/tests/test_roster.py b/tests/test_roster.py index 16a0d77..bbc6c9a 100644 --- a/tests/test_roster.py +++ b/tests/test_roster.py @@ -3,7 +3,7 @@ from datetime import datetime, timedelta from pathlib import Path import pytest -from saves import hero +from helpers import hero from pqcli import roster as roster_module from pqcli.roster import Roster