A 5e storytelling engine with an LLM DM
1# pyright: reportOptionalSubscript=false, reportReturnType=false
2# pyright: reportOperatorIssue=false
3# Tests subscript load_session results without null-narrowing — setup
4# guarantees the file exists.
5"""Tests for session state management."""
6
7from pathlib import Path
8
9import pytest
10
11from storied.session import (
12 extract_wiki_links,
13 format_session_context,
14 load_session,
15 name_to_slug,
16 parse_session,
17 resolve_wiki_link,
18 save_session,
19 update_session,
20)
21
22
23@pytest.fixture
24def session_base(tmp_path: Path) -> Path:
25 """Create a base directory with player structure."""
26 (tmp_path / "players" / "test-player").mkdir(parents=True)
27 return tmp_path
28
29
30@pytest.fixture
31def saved_session(session_base: Path) -> dict:
32 """Create and save a session with situation and threads."""
33 data = {
34 "location": "rusty-anchor",
35 "body": (
36 "## Situation\n"
37 "At the tavern, talking to Vera.\n\n"
38 "## Present\n"
39 "- [[Vera Blackwater]]\n"
40 "- [[Henrik]]\n\n"
41 "## Open Threads\n"
42 "- Investigate the warehouse\n"
43 "- Find the missing merchant"
44 ),
45 }
46 save_session("test-player", data)
47 return load_session("test-player")
48
49
50# ── Parsing ──────────────────────────────────────────────────────────────
51
52
53class TestParseSession:
54 def test_with_frontmatter(self):
55 content = "---\nlocation: tavern\n---\n\n## Situation\nAt the bar."
56 result = parse_session(content)
57 assert result["location"] == "tavern"
58 assert "Situation" in result["body"]
59
60 def test_without_frontmatter(self):
61 result = parse_session("Just notes")
62 assert result["body"] == "Just notes"
63
64 def test_empty_frontmatter(self):
65 result = parse_session("---\n---\n\nBody here")
66 assert result["body"] == "Body here"
67
68
69# ── Load / Save ──────────────────────────────────────────────────────────
70
71
72class TestLoadSaveSession:
73 def test_save_and_load(self, session_base: Path):
74 save_session("test-player", {"location": "docks", "body": "At the docks."})
75 loaded = load_session("test-player")
76 assert loaded["location"] == "docks"
77 assert "At the docks" in loaded["body"]
78
79 def test_load_nonexistent(self, session_base: Path):
80 assert load_session("nobody") is None
81
82 def test_save_adds_timestamp(self, session_base: Path):
83 save_session("test-player", {"body": ""})
84 loaded = load_session("test-player")
85 assert "updated" in loaded
86
87
88# ── Updates ──────────────────────────────────────────────────────────────
89
90
91class TestUpdateSession:
92 def test_update_situation(self, session_base: Path, saved_session: dict):
93 result = update_session(
94 "test-player",
95 {"situation": "Escaped to the harbor."},
96 )
97 assert "Updated situation" in result
98 loaded = load_session("test-player")
99 assert "Escaped to the harbor" in loaded["body"]
100
101 def test_update_threads_from_list(self, session_base: Path, saved_session: dict):
102 update_session(
103 "test-player",
104 {"threads": ["New thread one", "New thread two"]},
105 )
106 loaded = load_session("test-player")
107 assert "- New thread one" in loaded["body"]
108 assert "- New thread two" in loaded["body"]
109
110 def test_update_present_from_list(self, session_base: Path, saved_session: dict):
111 update_session(
112 "test-player",
113 {"present": ["[[Captain Harrik]]"]},
114 )
115 loaded = load_session("test-player")
116 assert "Captain Harrik" in loaded["body"]
117
118 def test_update_location(self, session_base: Path, saved_session: dict):
119 result = update_session(
120 "test-player",
121 {"location": "harbor"},
122 )
123 assert "location = harbor" in result
124 loaded = load_session("test-player")
125 assert loaded["location"] == "harbor"
126
127 def test_creates_session_if_missing(self, session_base: Path):
128 update_session(
129 "test-player",
130 {"situation": "Starting fresh."},
131 )
132 loaded = load_session("test-player")
133 assert "Starting fresh" in loaded["body"]
134
135 def test_no_changes(self, session_base: Path, saved_session: dict):
136 result = update_session("test-player", {})
137 assert "No changes" in result
138
139 def test_appends_new_section(self, session_base: Path):
140 save_session("test-player", {"body": ""})
141 update_session(
142 "test-player",
143 {"situation": "Brand new situation."},
144 )
145 loaded = load_session("test-player")
146 assert "## Situation" in loaded["body"]
147 assert "Brand new situation" in loaded["body"]
148
149
150# ── Wiki links ───────────────────────────────────────────────────────────
151
152
153class TestExtractWikiLinks:
154 def test_single_link(self):
155 assert extract_wiki_links("Talk to [[Vera]]") == ["Vera"]
156
157 def test_multiple_links(self):
158 result = extract_wiki_links("[[Vera]] and [[Henrik]] at [[The Rusty Anchor]]")
159 assert result == ["Vera", "Henrik", "The Rusty Anchor"]
160
161 def test_no_links(self):
162 assert extract_wiki_links("No links here") == []
163
164 def test_empty_string(self):
165 assert extract_wiki_links("") == []
166
167
168class TestResolveWikiLink:
169 def test_finds_npc(self, tmp_path: Path):
170 npc_dir = tmp_path / "worlds" / "default" / "npcs"
171 npc_dir.mkdir(parents=True)
172 (npc_dir / "Vera Blackwater.md").write_text("---\nname: Vera\n---\n")
173 result = resolve_wiki_link("Vera Blackwater", "default")
174 assert result is not None
175 assert result.name == "Vera Blackwater.md"
176
177 def test_finds_location(self, tmp_path: Path):
178 loc_dir = tmp_path / "worlds" / "default" / "locations"
179 loc_dir.mkdir(parents=True)
180 (loc_dir / "The Rusty Anchor.md").write_text(
181 "---\nname: The Rusty Anchor\n---\n"
182 )
183 result = resolve_wiki_link("The Rusty Anchor", "default")
184 assert result is not None
185
186 def test_not_found(self, tmp_path: Path):
187 (tmp_path / "worlds" / "default").mkdir(parents=True)
188 assert resolve_wiki_link("Nobody", "default") is None
189
190 def test_priority_order(self, tmp_path: Path):
191 """NPCs are checked before locations."""
192 for entity_type in ("npcs", "locations"):
193 d = tmp_path / "worlds" / "default" / entity_type
194 d.mkdir(parents=True)
195 (d / "Ambiguous.md").write_text(f"---\ntype: {entity_type}\n---\n")
196 result = resolve_wiki_link("Ambiguous", "default")
197 assert "npcs" in str(result)
198
199
200# ── Slugification ────────────────────────────────────────────────────────
201
202
203class TestNameToSlug:
204 def test_simple_name(self):
205 assert name_to_slug("Vera Blackwater") == "vera-blackwater"
206
207 def test_special_characters(self):
208 assert name_to_slug("The Rusty Anchor!") == "the-rusty-anchor"
209
210 def test_multiple_spaces(self):
211 assert name_to_slug("Captain Harrik") == "captain-harrik"
212
213
214# ── Context formatting ───────────────────────────────────────────────────
215
216
217class TestFormatSessionContext:
218 def test_includes_location(self):
219 data = {"location": "harbor", "body": ""}
220 result = format_session_context(data)
221 assert "harbor" in result
222
223 def test_includes_body(self):
224 data = {"body": "## Situation\nAt the docks."}
225 result = format_session_context(data)
226 assert "At the docks" in result
227
228 def test_no_location(self):
229 data = {"body": "Just a body"}
230 result = format_session_context(data)
231 assert "Location" not in result