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 231 lines 8.5 kB view raw
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