owenrusk.dev

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()