Files
Dash c169102c32 pqcli: reject --load-save 0 and negative numbers
`if args.load_save:` treated 0 as unset, so the menu opened instead,
and a negative number indexed from the end of the roster, so -1
played the last character. Both now list the characters and exit
like any other number out of range.
2026-10-17 19:51:19 +02:00

362 lines
11 KiB
Python

"""pqcli's command line, run in-process so coverage sees it."""
import json
from collections.abc import Callable
from pathlib import Path
import pytest
from helpers import (
DATA,
feed,
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 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()
@pytest.mark.parametrize(
"num",
[
"3",
"0",
"-1",
],
)
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"))
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"))