mirror of
https://github.com/rr-/pq-cli.git
synced 2026-11-12 22:40:36 -07:00
tests: cover the basic UI
Drive the line-based UI in-process: input() answers come from a list and the output is captured. The tests walk the main menu through creating, playing, querying, deleting, importing and exporting characters, plus bad menu input and the Ctrl+C handler. The play loop runs on a fake clock that advances a minute per call and stops when time.sleep has been called a set number of times, so it levels up and saves on schedule without waiting. The test helpers move to tests/helpers.py, which now also holds the input() feeder.
This commit is contained in:
4 files changed
+501
-18
No files matched your search
@@ -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)
|
||||
@@ -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
|
||||
+10
-16
@@ -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"])
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user