mirror of
https://github.com/rr-/pq-cli.git
synced 2026-11-12 22:40:36 -07:00
Python 3.10 is the minimum, so annotations use `X | None`, `list[X]` and friends, enforced by ruff's pyupgrade rules. The remaining typing names are imported directly, with abstract types coming from collections.abc. first() is now generic over the item and default.
183 lines
6.1 KiB
Python
183 lines
6.1 KiB
Python
import datetime
|
|
import json
|
|
import pickle
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from pqcli import random
|
|
from pqcli.config import Class, Monster, Race, StatType
|
|
from pqcli.game_data import (
|
|
DATA_FILES,
|
|
DataError,
|
|
GameData,
|
|
dump_builtin_data,
|
|
load_game_data,
|
|
)
|
|
from pqcli.mechanic import Player, Stats, monster_task
|
|
|
|
|
|
def write(directory: Path, name: str, value: Any) -> Path:
|
|
path = directory / f"{name}.json"
|
|
path.write_text(json.dumps(value), encoding="utf-8")
|
|
return path
|
|
|
|
|
|
def test_builtin_data_loads() -> None:
|
|
data = load_game_data()
|
|
assert data.spells[0] == "Slime Finger"
|
|
assert data.monsters[0] == Monster("Anhkheg", 6, "chitin")
|
|
assert data.races[0] == Race("Half Orc", [StatType.hp_max])
|
|
assert data.classes[-1] == Class("Vermineer", [StatType.intelligence])
|
|
assert data.combat_verbs == ("Executing {target}",)
|
|
assert all(getattr(data, name) for name in DATA_FILES)
|
|
|
|
|
|
def test_builtin_data_keeps_quirks() -> None:
|
|
data = load_game_data()
|
|
names = [monster.name for monster in data.monsters]
|
|
assert names.count("Rat") == 2
|
|
assert Monster("Neo-Otyugh", 11, "organ ") in data.monsters
|
|
assert any(monster.item is None for monster in data.monsters)
|
|
|
|
|
|
def test_missing_override_dir_uses_builtin(tmp_path: Path) -> None:
|
|
assert load_game_data(tmp_path / "nope") == load_game_data()
|
|
|
|
|
|
def test_override_replaces_only_that_list(tmp_path: Path) -> None:
|
|
write(tmp_path, "spells", ["Zap", "Bonk"])
|
|
write(
|
|
tmp_path,
|
|
"races",
|
|
[{"name": "Robot", "attr": ["STR", "hp_max", "Wisdom"]}],
|
|
)
|
|
builtin = load_game_data()
|
|
data = load_game_data(tmp_path)
|
|
assert data.spells == ("Zap", "Bonk")
|
|
assert data.races == (
|
|
Race("Robot", [StatType.strength, StatType.hp_max, StatType.wisdom]),
|
|
)
|
|
assert data.monsters == builtin.monsters
|
|
assert data.classes == builtin.classes
|
|
|
|
|
|
def test_override_accepts_all_shapes(tmp_path: Path) -> None:
|
|
write(tmp_path, "monsters", [{"name": "Bob", "level": 0, "item": None}])
|
|
write(tmp_path, "weapons", [{"name": "Spoon", "quality": -1}])
|
|
write(tmp_path, "offense_bad", [{"name": "Soggy", "quality": -2}])
|
|
data = load_game_data(tmp_path)
|
|
assert data.monsters == (Monster("Bob", 0, None),)
|
|
assert data.weapons[0].name == "Spoon"
|
|
assert data.offense_bad[0].quality == -2
|
|
|
|
|
|
def test_unknown_file_warns(
|
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
write(tmp_path, "monster", [])
|
|
load_game_data(tmp_path)
|
|
assert "monster.json" in capsys.readouterr().err
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"name,value,message",
|
|
[
|
|
("spells", [], "must not be empty"),
|
|
("spells", {"a": 1}, "must be a list"),
|
|
("spells", ["ok", 3], "[1]"),
|
|
("monsters", [{"name": "Imp", "level": "2", "item": "x"}], "level"),
|
|
("monsters", [{"name": "Imp", "level": True, "item": "x"}], "level"),
|
|
("monsters", [{"name": "Imp", "level": 2}], "item"),
|
|
("monsters", [{"name": "Imp", "level": 2, "item": 1}], "item"),
|
|
("weapons", [{"name": "Spoon", "quality": 1, "x": 1}], "x"),
|
|
("races", [{"name": "Elf", "attr": ["LUCK"]}], "LUCK"),
|
|
("races", [{"name": "Elf", "attr": "STR"}], "attr"),
|
|
("combat_verbs", ["Hitting"], "{target}"),
|
|
],
|
|
)
|
|
def test_invalid_override_raises(
|
|
tmp_path: Path, name: str, value: Any, message: str
|
|
) -> None:
|
|
write(tmp_path, name, value)
|
|
with pytest.raises(DataError) as excinfo:
|
|
load_game_data(tmp_path)
|
|
assert f"{name}.json" in str(excinfo.value)
|
|
assert message in str(excinfo.value)
|
|
|
|
|
|
def test_malformed_json_raises(tmp_path: Path) -> None:
|
|
(tmp_path / "spells.json").write_text("[oops", encoding="utf-8")
|
|
with pytest.raises(DataError, match="spells.json"):
|
|
load_game_data(tmp_path)
|
|
|
|
|
|
def test_non_utf8_raises(tmp_path: Path) -> None:
|
|
(tmp_path / "spells.json").write_bytes(b'["\xff"]')
|
|
with pytest.raises(DataError, match="spells.json"):
|
|
load_game_data(tmp_path)
|
|
|
|
|
|
def test_override_dir_must_be_directory(tmp_path: Path) -> None:
|
|
path = write(tmp_path, "spells", ["Zap"])
|
|
with pytest.raises(DataError, match="not a directory"):
|
|
load_game_data(path)
|
|
|
|
|
|
def test_combat_verbs_are_used(tmp_path: Path) -> None:
|
|
verbs = [
|
|
"Fighting bravely against {target}",
|
|
"Kicking the butt of {target}",
|
|
]
|
|
write(tmp_path, "combat_verbs", verbs)
|
|
data = load_game_data(tmp_path)
|
|
random.seed("verbs")
|
|
seen = set()
|
|
for _ in range(200):
|
|
description = monster_task(data, 5, None).description
|
|
prefix = description.split(" ")[0]
|
|
assert prefix in {"Fighting", "Kicking"}
|
|
assert "{target}" not in description
|
|
seen.add(prefix)
|
|
assert seen == {"Fighting", "Kicking"}
|
|
|
|
|
|
def test_dump_builtin_data_round_trips(tmp_path: Path) -> None:
|
|
written = dump_builtin_data(tmp_path / "out")
|
|
assert sorted(path.stem for path in written) == sorted(DATA_FILES)
|
|
assert load_game_data(tmp_path / "out") == load_game_data()
|
|
|
|
|
|
def test_dump_builtin_data_refuses_to_overwrite(tmp_path: Path) -> None:
|
|
existing = write(tmp_path, "spells", ["Mine"])
|
|
with pytest.raises(DataError, match="spells.json"):
|
|
dump_builtin_data(tmp_path)
|
|
assert json.loads(existing.read_text()) == ["Mine"]
|
|
assert list(tmp_path.iterdir()) == [existing]
|
|
|
|
|
|
def test_saved_player_survives_removed_race(tmp_path: Path) -> None:
|
|
data = load_game_data()
|
|
player = Player(
|
|
name="Saved",
|
|
birthday=datetime.datetime(2000, 1, 1),
|
|
race=data.races[3],
|
|
class_=data.classes[2],
|
|
stats=Stats({stat: 10 for stat in StatType}),
|
|
)
|
|
blob = pickle.dumps(player)
|
|
|
|
write(tmp_path, "races", [{"name": "Robot", "attr": ["STR"]}])
|
|
load_game_data(tmp_path)
|
|
restored = pickle.loads(blob)
|
|
assert restored.race == Race("Double Hobbit", [StatType.strength])
|
|
assert type(restored.race).__module__ == "pqcli.config"
|
|
|
|
|
|
def test_game_data_is_immutable() -> None:
|
|
data = load_game_data()
|
|
assert isinstance(data, GameData)
|
|
with pytest.raises(AttributeError):
|
|
data.spells = () # type: ignore[misc]
|