casebench
test cases straight onto the board.
git clone https://owenrusk.dev/casebench.git
commit 578e4ea04fdffc20cf4401bad7aa09da682ac011 parent 809597f65802b761bf8e106f0a2338b9121b4359 author Owen Rusk <owen@papermothgames.com> date 2025-07-16 15:44:10 -0500
memstore and the first tests python -m unittest. no database needed.
| casebench/memstore.py | +93 | -0 |
| tests/__init__.py | +0 | -0 |
| tests/helpers.py | +15 | -0 |
| tests/test_casefile.py | +54 | -0 |
| tests/test_commands.py | +60 | -0 |
diff --git a/casebench/memstore.py b/casebench/memstore.py new file mode 100644 index 0000000..c9d0d25 --- /dev/null +++ b/casebench/memstore.py @@ -0,0 +1,93 @@ +import hashlib +from dataclasses import dataclass, field +from datetime import UTC, datetime + +from .casefile import Case, Image +from .lock import Lock +from .store import BENCH, Placed, Player + + +@dataclass +class _Item: + id: str + title: str + body: str + images: list[str] + + +@dataclass +class _Case: + title: str + source: str + placed_at: datetime + items: list[_Item] = field(default_factory=list) + notes: list[tuple[str | None, str]] = field(default_factory=list) + lock: Lock | None = None + + +class MemStore: + # the board in dicts, for tests. keeps the same rules as PgStore. + + def __init__(self, handles: tuple[str, ...] = ()) -> None: + self._players = [Player(f"p{n}", handle) for n, handle in enumerate(handles, 1)] + self.cases: dict[tuple[str, str], _Case] = {} + self.media: dict[str, Image] = {} + + def close(self) -> None: + pass + + def runtime_case(self, player: Player, case_id: str, title: str, items: tuple[str, ...] = ()) -> None: + # a case the runtime put there, for testing what the bench won't touch. + self.cases[(player.id, case_id)] = _Case( + title, "runtime", datetime.now(UTC), [_Item(item, item, "", []) for item in items] + ) + + def players(self) -> list[Player]: + return sorted(self._players, key=lambda p: p.handle) + + def player(self, handle: str) -> Player | None: + return next((p for p in self._players if p.handle == handle), None) + + def placed(self, player: Player, case_id: str) -> Placed | None: + case = self.cases.get((player.id, case_id)) + return None if case is None else Placed(case_id, case.title, case.source, case.placed_at) + + def board(self, player: Player) -> list[Placed]: + found = [self.placed(player, case_id) for pid, case_id in self.cases if pid == player.id] + return sorted(found, key=lambda placed: placed.placed_at) + + def items(self, player: Player, case_id: str) -> list[str]: + return [item.id for item in self.cases[(player.id, case_id)].items] + + def put(self, player: Player, case: Case) -> None: + items = [] + for item in case.items: + shas = [] + for image in item.images: + sha = hashlib.sha256(image.data).hexdigest() + self.media.setdefault(sha, image) + shas.append(sha) + items.append(_Item(item.id, item.title, item.text, shas)) + self.cases[(player.id, case.id)] = _Case(case.title, BENCH, datetime.now(UTC), items) + + def pull(self, player: Player, case_id: str) -> None: + del self.cases[(player.id, case_id)] + + def note(self, player: Player, case_id: str, item_id: str | None, text: str) -> None: + self.cases[(player.id, case_id)].notes.append((item_id, text)) + + def notes(self, player: Player, case_id: str) -> list[tuple[str | None, str]]: + return list(self.cases[(player.id, case_id)].notes) + + def lock(self, player: Player, case_id: str, new: Lock) -> None: + self.cases[(player.id, case_id)].lock = new + + def get_lock(self, player: Player, case_id: str) -> Lock | None: + return self.cases[(player.id, case_id)].lock + + def unlock(self, player: Player, case_id: str) -> bool: + case = self.cases.get((player.id, case_id)) + if case is None or case.lock is None: + return False + case.lock = None + return True diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..2fc5d5e --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,15 @@ +import io +from contextlib import redirect_stderr, redirect_stdout +from pathlib import Path + +from casebench.cli import main +from casebench.memstore import MemStore + +UMBRELLA = Path(__file__).parents[1] / "examples" / "lost-umbrella" + + +def run(store: MemStore, *argv: str) -> tuple[int, str, str]: + out, err = io.StringIO(), io.StringIO() + with redirect_stdout(out), redirect_stderr(err): + code = main(list(argv), store=store) + return code, out.getvalue(), err.getvalue() diff --git a/tests/test_casefile.py b/tests/test_casefile.py new file mode 100644 index 0000000..dedb0d9 --- /dev/null +++ b/tests/test_casefile.py @@ -0,0 +1,54 @@ +import tempfile +import unittest +from pathlib import Path + +from casebench.casefile import CaseError, load + +from .helpers import UMBRELLA + + +class CaseFileTest(unittest.TestCase): + def folder(self, case_yaml: str, files: dict[str, str] | None = None) -> Path: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + (root / "case.yaml").write_text(case_yaml, encoding="utf-8") + for name, text in (files or {}).items(): + (root / name).write_text(text, encoding="utf-8") + return root + + def test_example(self): + case = load(UMBRELLA) + self.assertEqual(case.id, "test-lost-umbrella") + self.assertEqual([item.id for item in case.items], ["ticket", "log", "umbrella"]) + self.assertIn("No. 114", case.items[0].text) + + def test_no_case_yaml(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + with self.assertRaisesRegex(CaseError, "no case.yaml"): + load(Path(tmp.name)) + + def test_missing_text_file(self): + root = self.folder("id: x\ntitle: X\nitems:\n - id: a\n title: A\n text: a.md\n") + with self.assertRaisesRegex(CaseError, r"items\[0\]: no file a.md"): + load(root) + + def test_bad_id(self): + root = self.folder("id: Not An Id\ntitle: X\nitems:\n - id: a\n title: A\n text: a.md\n", {"a.md": "a"}) + with self.assertRaisesRegex(CaseError, "bad id"): + load(root) + + def test_item_twice(self): + item = " - id: a\n title: A\n text: a.md\n" + root = self.folder("id: x\ntitle: X\nitems:\n" + item + item, {"a.md": "a"}) + with self.assertRaisesRegex(CaseError, "a twice"): + load(root) + + def test_no_items(self): + with self.assertRaisesRegex(CaseError, "no items"): + load(self.folder("id: x\ntitle: X\n")) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_commands.py b/tests/test_commands.py new file mode 100644 index 0000000..6236cd3 --- /dev/null +++ b/tests/test_commands.py @@ -0,0 +1,60 @@ +import unittest + +from casebench.memstore import MemStore + +from .helpers import UMBRELLA, run + + +class PutTest(unittest.TestCase): + def setUp(self): + self.store = MemStore(("owen", "tess")) + self.owen = self.store.player("owen") + + def test_put(self): + code, out, _ = run(self.store, "put", str(UMBRELLA), "--player", "owen") + self.assertEqual(code, 0) + self.assertEqual(out, "owen: put test-lost-umbrella\n") + self.assertEqual(self.store.items(self.owen, "test-lost-umbrella"), ["ticket", "log", "umbrella"]) + self.assertIsNone(self.store.placed(self.store.player("tess"), "test-lost-umbrella")) + + def test_no_such_player(self): + code, _, err = run(self.store, "put", str(UMBRELLA), "--player", "nobody") + self.assertEqual(code, 1) + self.assertIn("no player nobody", err) + + def test_leaves_the_runtime_alone(self): + self.store.runtime_case(self.owen, "test-lost-umbrella", "Something real") + code, _, err = run(self.store, "put", str(UMBRELLA), "--player", "owen") + self.assertEqual(code, 1) + self.assertIn("from the runtime", err) + self.assertEqual(self.store.placed(self.owen, "test-lost-umbrella").title, "Something real") + + +class NoteTest(unittest.TestCase): + def setUp(self): + self.store = MemStore(("owen",)) + self.owen = self.store.player("owen") + run(self.store, "put", str(UMBRELLA), "--player", "owen") + + def test_under_the_title(self): + code, _, _ = run(self.store, "note", "test-lost-umbrella", "check the coat rack", "--player", "owen") + self.assertEqual(code, 0) + self.assertEqual(self.store.notes(self.owen, "test-lost-umbrella"), [(None, "check the coat rack")]) + + def test_on_an_item(self): + run(self.store, "note", "test-lost-umbrella", "same number", "--item", "ticket", "--player", "owen") + self.assertEqual(self.store.notes(self.owen, "test-lost-umbrella"), [("ticket", "same number")]) + + def test_no_such_item(self): + code, _, err = run(self.store, "note", "test-lost-umbrella", "x", "--item", "hat", "--player", "owen") + self.assertEqual(code, 1) + self.assertIn("no item hat", err) + + def test_case_not_on_board(self): + code, _, err = run(self.store, "note", "test-other", "x", "--player", "owen") + self.assertEqual(code, 1) + self.assertIn("doesn't have test-other", err) + + +if __name__ == "__main__": + unittest.main()