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:
Dash committed 2026-10-17 19:46:05 +02:00
1 parent 083dbe01c2
commit f8c370cc2e
4 files changed
+501 -18

No files matched your search

+21 -1
View File
@@ -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)
+469
View File
@@ -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
View File
@@ -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"])
+1 -1
View File
@@ -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