"""Shared test fixtures. The synchronous tool-invocation helper ``call_tool`` lives in ``storied.testing`` so it's importable by test modules without needing ``tests/`` to be a Python package — pytest's conftest loading isn't a regular import. ## Path isolation Storied's path configuration lives in module globals on ``storied.paths``. The test suite MUST NEVER touch the user's real ``~/.storied/`` directory. We enforce this with a single autouse function-level fixture that uses ``monkeypatch`` to rebind the globals to a fresh ``tmp_path`` for every test. ``tmp_path`` is pytest's per-function temp dir, so every test gets its own clean directory and pytest cleans them up automatically. ``monkeypatch`` restores the previous value when the fixture tears down, but since every subsequent test re-runs the fixture before any storied code touches paths, there's no window where a stale default matters. """ import asyncio import hashlib from collections.abc import Callable, Iterator from pathlib import Path from typing import Any import pytest from fastmcp import Client from storied import paths from storied.initiative import Combatant from storied.log import CampaignLog from storied.mcp_server import _compose_server from storied.search import VectorIndex from storied.tools import EntityIndex, ToolContext, init_ctx, reset_ctx from storied.tools.combat import _flip_into_combat, _flip_out_of_combat @pytest.fixture(autouse=True) def _isolate_storied_paths( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> Iterator[Path]: """Point storied's data home and user rules at ``tmp_path``. Runs for every test (autouse, function scope). Uses the public ``paths.using_data_home`` context manager — same code path as the CLI's startup configuration, with automatic restoration on teardown. Library code that reads ``data_home()`` / ``worlds_path()`` / ``player_path()`` / etc. during the test resolves under ``tmp_path``, so writes land in pytest's per-function temp dir instead of the user's real ``~/.storied/``. ``STORIED_HOME`` is also exported so subprocesses inherit the isolation if any test ever spawns one. """ monkeypatch.setenv("STORIED_HOME", str(tmp_path)) with paths.using_data_home(tmp_path): yield tmp_path EMBED_DIM = 384 def _fake_embed(texts: list[str]) -> list[list[float]]: """Deterministic hash-based embeddings for testing.""" results = [] for text in texts: h = hashlib.sha256(text.encode()).digest() floats: list[float] = [] seed = h while len(floats) < EMBED_DIM: seed = hashlib.sha256(seed).digest() for byte in seed: if len(floats) >= EMBED_DIM: break floats.append((byte - 128) / 128.0) norm = sum(x * x for x in floats) ** 0.5 results.append([x / norm for x in floats]) return results @pytest.fixture def ctx(tmp_path: Path) -> Iterator[ToolContext]: """Process-global ToolContext with a fake embedder. Depends on the autouse ``_isolate_storied_paths`` fixture having already rebound ``storied.paths._data_home`` to ``tmp_path``, so library reads of ``data_home()`` resolve here. """ world_dir = tmp_path / "worlds" / "test-world" world_dir.mkdir(parents=True) vi = VectorIndex(tmp_path / "search.db") vi._embed_fn = _fake_embed context = init_ctx( world_id="test-world", player_id="default", campaign_log=CampaignLog("test-world"), entity_index=EntityIndex(world_dir), vector_index=vi, ) try: yield context finally: reset_ctx() # --- MCP in-memory client helpers ------------------------------------------- # # Tests that exercise the FastMCP tool surface drive the same call path the # production server uses (claude → MCP → tool function), but in-process via # fastmcp.Client. These fixtures hide the asyncio.run + Client(server) # scaffolding so individual tests stay flat. McpCall = Callable[[str, dict[str, Any] | None], str] McpCallInCombat = Callable[[str, dict[str, Any], list[Combatant] | None], str] @pytest.fixture def mcp_call() -> McpCall: """Sync helper: call a tool on the composed dm server, return its text.""" def _call(tool_name: str, args: dict[str, Any] | None = None) -> str: async def _run() -> str: server = await _compose_server("dm") async with Client(server) as client: result = await client.call_tool(tool_name, args or {}) return result.data if result.data is not None else "" return asyncio.run(_run()) return _call @pytest.fixture def mcp_call_in_combat(ctx: ToolContext) -> McpCallInCombat: """Sync helper: begin initiative, flip combat tools visible, call a tool. Pass ``combatants`` to start a fresh initiative; pass ``None`` when the test has already begun initiative (or wired up state) on its own. """ def _call( tool_name: str, args: dict[str, Any], combatants: list[Combatant] | None = None, ) -> str: if combatants is not None: ctx.initiative.begin(combatants) async def _run() -> str: server = await _compose_server("dm") _flip_into_combat() try: async with Client(server) as client: result = await client.call_tool(tool_name, args) return result.data if result.data is not None else "" finally: _flip_out_of_combat() return asyncio.run(_run()) return _call