A 5e storytelling engine with an LLM DM
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