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

+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