diff --git a/pyproject.toml b/pyproject.toml index 2beda2f..19bbbc5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,4 +69,4 @@ branch = true omit = ["src/storied/cli.py", "src/storied/srd/*"] [tool.coverage.report] -fail_under = 80 +fail_under = 85 diff --git a/src/storied/search.py b/src/storied/search.py index c4f0bb9..2508a01 100644 --- a/src/storied/search.py +++ b/src/storied/search.py @@ -25,6 +25,7 @@ _SPLIT_PATTERNS = [ r"\n(?=### )", # ### headings r"\n(?=#### )", # #### headings (class features, combat sub-topics) r"\n(?=\*\*[A-Z])", # bold definitions (glossary entries, level features) + r"\n\n", # paragraph breaks (table rows, final resort) ] diff --git a/tests/test_engine.py b/tests/test_engine.py new file mode 100644 index 0000000..150fa66 --- /dev/null +++ b/tests/test_engine.py @@ -0,0 +1,145 @@ +"""Tests for engine helper functions and context building.""" + +from pathlib import Path +from unittest.mock import patch + +import pytest + +from storied.engine import ( + _extract_roll_reason, + _tool_notification, + load_prompt, +) + + +class TestLoadPrompt: + + def test_loads_dm_system(self): + result = load_prompt("dm-system") + assert "Dungeon Master" in result + + def test_custom_prompts_path(self, tmp_path: Path): + prompt_file = tmp_path / "test-prompt.md" + prompt_file.write_text("You are a test prompt.") + result = load_prompt("test-prompt", prompts_path=tmp_path) + assert result == "You are a test prompt." + + def test_missing_prompt_raises(self, tmp_path: Path): + with pytest.raises(FileNotFoundError): + load_prompt("nonexistent", prompts_path=tmp_path) + + +class TestExtractRollReason: + + def test_extracts_reason(self): + assert _extract_roll_reason('{"reason": "Athletics"}') == "Athletics" + + def test_no_reason_field(self): + assert _extract_roll_reason('{"notation": "1d20"}') is None + + def test_invalid_json(self): + assert _extract_roll_reason("not json") is None + + def test_empty_string(self): + assert _extract_roll_reason("") is None + + +class TestToolNotification: + + def test_known_tool(self): + assert "Rolling" in _tool_notification("roll") + + def test_mcp_prefixed_tool(self): + assert "Establishing" in _tool_notification("mcp__storied__establish") + + def test_unknown_tool(self): + result = _tool_notification("something_new") + assert "something_new" in result + + def test_tune_tool(self): + assert "Tuning" in _tool_notification("tune") + + +class TestDMEngineContext: + """Tests for DMEngine context building (mocks MCP server startup).""" + + @pytest.fixture(autouse=True) + def _mock_terminal(self): + import os + size = os.terminal_size((120, 40)) + with patch("storied.engine.os.get_terminal_size", return_value=size): + yield + + @pytest.fixture + def engine(self, tmp_path: Path): + from storied.engine import DMEngine + + world_dir = tmp_path / "worlds" / "test" + world_dir.mkdir(parents=True) + + prompts_dir = tmp_path / "prompts" + prompts_dir.mkdir() + (prompts_dir / "dm-system.md").write_text("You are a DM.") + + with patch("storied.engine.start_mcp_server") as mock_mcp: + from storied.tools import EntityIndex + + mock_mcp.return_value = type("Handle", (), { + "url": "http://localhost:0/sse", + "ctx": type("Ctx", (), { + "entity_index": EntityIndex(world_dir), + "vector_index": None, + })(), + })() + return DMEngine( + world_id="test", + player_id="default", + base_path=tmp_path, + prompt_name="dm-system", + ) + + def test_build_context_no_style(self, engine): + context = engine._build_context() + assert "Style" not in engine._context_parts + + def test_build_context_with_style(self, engine): + style_path = engine.base_path / "worlds" / "test" / "style.md" + style_path.write_text("# Style\n\nMore intrigue, less combat.\n") + + context = engine._build_context() + + assert "Style" in engine._context_parts + assert "intrigue" in engine._context_parts["Style"] + + def test_style_is_first_context_part(self, engine): + style_path = engine.base_path / "worlds" / "test" / "style.md" + style_path.write_text("# Style\n\nDark tone.\n") + + context = engine._build_context() + parts = list(engine._context_parts.keys()) + + assert parts[0] == "Style" + + def test_estimate_tokens(self): + from storied.engine import DMEngine + assert DMEngine._estimate_tokens("a" * 400) == 100 + + def test_format_entity(self, engine): + result = engine._format_entity("Npc", { + "name": "Vera", "body": "Tavern owner.", + }) + assert "## Npc: Vera" in result + assert "Tavern owner." in result + + def test_parse_knowledge_file_no_frontmatter(self, engine, tmp_path: Path): + f = tmp_path / "note.md" + f.write_text("Just some text.") + result = engine._parse_knowledge_file(f) + assert result["body"] == "Just some text." + + def test_parse_knowledge_file_with_frontmatter(self, engine, tmp_path: Path): + f = tmp_path / "note.md" + f.write_text("---\ntype: npc\nname: Vera\n---\n\nTavern owner.") + result = engine._parse_knowledge_file(f) + assert result["name"] == "Vera" + assert result["body"] == "Tavern owner." diff --git a/tests/test_search.py b/tests/test_search.py index c1f62e7..2233d02 100644 --- a/tests/test_search.py +++ b/tests/test_search.py @@ -124,11 +124,13 @@ class TestChunkDocument: assert any("Second Wind" in t for t in texts) assert any("Action Surge" in t for t in texts) - def test_oversized_with_no_splittable_headings(self): - content = "# Blob\n\n" + "word " * 1_000 + def test_oversized_with_no_headings_splits_on_paragraphs(self): + content = "# Blob\n\n" + "\n\n".join( + "word " * 200 for _ in range(5) + ) chunks = chunk_document(Path("blob.md"), content) - assert len(chunks) >= 1 - assert "word" in chunks[0][1] + assert len(chunks) > 1 + assert any("word" in text for _, text in chunks) def test_chunks_get_title_context(self): content = "# Rogue\n\n" + "\n".join( diff --git a/tests/test_srd_recall.py b/tests/test_srd_recall.py index 4ac0eee..249a862 100644 --- a/tests/test_srd_recall.py +++ b/tests/test_srd_recall.py @@ -119,3 +119,17 @@ class TestCoreMechanicsRecall: _assert_any_hit_contains( srd_index, "concentration spell", "Concentration", ) + + +# --- Equipment --- + +class TestEquipmentRecall: + + def test_rapier(self, srd_index: VectorIndex): + _assert_any_hit_contains(srd_index, "rapier", "Rapier") + + def test_chain_mail(self, srd_index: VectorIndex): + _assert_any_hit_contains(srd_index, "chain mail armor", "Chain Mail") + + def test_shield(self, srd_index: VectorIndex): + _assert_any_hit_contains(srd_index, "shield", "Shield")