A 5e storytelling engine with an LLM DM
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 216 lines 7.6 kB view raw
1# pyright: reportOptionalMemberAccess=false 2# Tests reach into ContentResolver.load() results without null-narrowing. 3"""Tests for three-layer content resolution (world > user > shipped). 4 5The autouse ``_isolate_storied_paths`` fixture in ``conftest.py`` already 6rebinds ``_data_home`` and ``_user_rules_home`` to ``tmp_path``; these 7tests additionally monkeypatch ``shipped_rules_path`` so the "shipped" 8layer can be faked per-test under the same tmp dir. 9""" 10 11from pathlib import Path 12 13import pytest 14 15from storied import paths 16from storied.content import ContentResolver 17 18# --------------------------------------------------------------------------- 19# Fixtures — build a fake three-layer setup under tmp_path. 20# --------------------------------------------------------------------------- 21 22 23@pytest.fixture 24def shipped_root(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: 25 """Redirect the shipped rules layer to a tmp subdir so tests can write 26 fake SRD content without touching the real package rules.""" 27 root = tmp_path / "shipped" 28 root.mkdir() 29 monkeypatch.setattr(paths, "shipped_rules_path", lambda: root) 30 return root 31 32 33@pytest.fixture 34def shipped_goblin(shipped_root: Path) -> Path: 35 """Standard SRD goblin — the bottom layer.""" 36 monsters = shipped_root / "srd-5.2.1" / "sections" / "monsters" 37 monsters.mkdir(parents=True) 38 path = monsters / "goblin.md" 39 path.write_text("# Goblin\n\n_Small Humanoid, Neutral Evil_\n") 40 return path 41 42 43@pytest.fixture 44def shipped_fireball(shipped_root: Path) -> Path: 45 spells = shipped_root / "srd-5.2.1" / "sections" / "spells" 46 spells.mkdir(parents=True) 47 path = spells / "fireball.md" 48 path.write_text("# Fireball\n\n_Level 3 Evocation_\n") 49 return path 50 51 52@pytest.fixture 53def user_goblin(tmp_path: Path) -> Path: 54 """User homebrew goblin — middle layer.""" 55 monsters = tmp_path / "rules" / "monsters" 56 monsters.mkdir(parents=True) 57 path = monsters / "goblin.md" 58 path.write_text("# Homebrew Goblin\n\n_Slightly meaner_\n") 59 return path 60 61 62@pytest.fixture 63def world_goblin(tmp_path: Path) -> Path: 64 """World-specific goblin override — top layer.""" 65 monsters = tmp_path / "worlds" / "test-world" / "monsters" 66 monsters.mkdir(parents=True) 67 path = monsters / "goblin.md" 68 path.write_text("# Island Goblin\n\n_Tougher variant_\n") 69 return path 70 71 72@pytest.fixture 73def world_vera(tmp_path: Path) -> Path: 74 """Narrative content only exists in the world layer.""" 75 npcs = tmp_path / "worlds" / "test-world" / "npcs" 76 npcs.mkdir(parents=True) 77 path = npcs / "vera.md" 78 path.write_text("# Vera\n\nInnkeeper at the Rusty Anchor.\n") 79 return path 80 81 82# --------------------------------------------------------------------------- 83# Layer priority tests. 84# --------------------------------------------------------------------------- 85 86 87class TestLayerPriority: 88 def test_shipped_only(self, shipped_goblin: Path): 89 resolver = ContentResolver(world_id="test-world") 90 hit = resolver.find("goblin", content_type="monsters") 91 assert hit == shipped_goblin 92 93 def test_user_overrides_shipped( 94 self, 95 shipped_goblin: Path, 96 user_goblin: Path, 97 ): 98 resolver = ContentResolver(world_id="test-world") 99 hit = resolver.find("goblin", content_type="monsters") 100 assert hit == user_goblin 101 assert "Homebrew" in hit.read_text() 102 103 def test_world_overrides_user( 104 self, 105 shipped_goblin: Path, 106 user_goblin: Path, 107 world_goblin: Path, 108 ): 109 resolver = ContentResolver(world_id="test-world") 110 hit = resolver.find("goblin", content_type="monsters") 111 assert hit == world_goblin 112 assert "Island Goblin" in hit.read_text() 113 114 def test_world_overrides_shipped_without_user( 115 self, 116 shipped_goblin: Path, 117 world_goblin: Path, 118 ): 119 resolver = ContentResolver(world_id="test-world") 120 hit = resolver.find("goblin", content_type="monsters") 121 assert hit == world_goblin 122 123 def test_user_overrides_shipped_without_world( 124 self, 125 shipped_goblin: Path, 126 user_goblin: Path, 127 ): 128 resolver = ContentResolver(world_id="test-world") 129 hit = resolver.find("goblin", content_type="monsters") 130 assert hit == user_goblin 131 132 133# --------------------------------------------------------------------------- 134# Narrative content (world-only) tests. 135# --------------------------------------------------------------------------- 136 137 138class TestNarrativeContent: 139 def test_find_world_npc(self, world_vera: Path): 140 resolver = ContentResolver(world_id="test-world") 141 hit = resolver.find("vera", content_type="npcs") 142 assert hit == world_vera 143 144 def test_narrative_misses_fall_through_cleanly(self, shipped_root: Path): 145 """Looking up a narrative entity that doesn't exist returns None 146 without error, even though the user/shipped layers have no 147 ``npcs/`` subdir.""" 148 resolver = ContentResolver(world_id="test-world") 149 hit = resolver.find("nonexistent", content_type="npcs") 150 assert hit is None 151 152 def test_no_world_id_still_searches_rule_layers( 153 self, 154 shipped_fireball: Path, 155 ): 156 """A resolver with no world still finds rule content at the 157 user and shipped layers.""" 158 resolver = ContentResolver() 159 hit = resolver.find("fireball", content_type="spells") 160 assert hit == shipped_fireball 161 162 163# --------------------------------------------------------------------------- 164# Loading content (parsed dict). 165# --------------------------------------------------------------------------- 166 167 168class TestLoadContent: 169 def test_load_plain_markdown(self, shipped_goblin: Path): 170 resolver = ContentResolver(world_id="test-world") 171 data = resolver.load("goblin", content_type="monsters") 172 assert data is not None 173 assert "Goblin" in data["body"] 174 175 def test_load_with_frontmatter(self, shipped_root: Path): 176 monsters = shipped_root / "srd-5.2.1" / "sections" / "monsters" 177 monsters.mkdir(parents=True) 178 (monsters / "orc.md").write_text( 179 "---\ntype: monster\ncr: 0.5\ntags: [humanoid]\n---\n\n" 180 "# Orc\n\nBig and mean.\n" 181 ) 182 183 resolver = ContentResolver(world_id="test-world") 184 data = resolver.load("orc", content_type="monsters") 185 186 assert data is not None 187 assert data.get("type") == "monster" 188 assert data.get("cr") == 0.5 189 assert data.get("tags") == ["humanoid"] 190 assert "Orc" in data["body"] 191 192 def test_load_not_found(self, shipped_root: Path): 193 resolver = ContentResolver(world_id="test-world") 194 data = resolver.load("nonexistent", content_type="monsters") 195 assert data is None 196 197 198# --------------------------------------------------------------------------- 199# Untyped search (walks every content_type subdir in every layer). 200# --------------------------------------------------------------------------- 201 202 203class TestUntypedSearch: 204 def test_finds_goblin_without_content_type(self, shipped_goblin: Path): 205 resolver = ContentResolver(world_id="test-world") 206 hit = resolver.find("goblin") 207 assert hit == shipped_goblin 208 209 def test_world_wins_in_untyped_search( 210 self, 211 shipped_goblin: Path, 212 world_goblin: Path, 213 ): 214 resolver = ContentResolver(world_id="test-world") 215 hit = resolver.find("goblin") 216 assert hit == world_goblin