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 363 lines 13 kB view raw
1"""Tests for campaign log functionality.""" 2 3from pathlib import Path 4 5import pytest 6 7from storied.log import ( 8 CampaignLog, 9 Duration, 10 GameTime, 11 LogEntry, 12 TranscriptLog, 13 load_log, 14 log_event, 15) 16 17 18class TestGameTime: 19 def test_default_values(self): 20 t = GameTime() 21 assert t.day == 1 22 assert t.hour == 6 23 assert t.minute == 0 24 25 def test_str(self): 26 assert str(GameTime(day=3, hour=14, minute=30)) == "Day 3, 14:30" 27 28 def test_to_anchor(self): 29 assert GameTime(day=1, hour=6, minute=0).to_anchor() == "#d1-0600" 30 assert GameTime(day=3, hour=14, minute=30).to_anchor() == "#d3-1430" 31 32 def test_from_anchor(self): 33 t = GameTime.from_anchor("#d1-0600") 34 assert t.day == 1 35 assert t.hour == 6 36 assert t.minute == 0 37 38 t = GameTime.from_anchor("d3-1430") # Without # 39 assert t.day == 3 40 assert t.hour == 14 41 assert t.minute == 30 42 43 def test_from_anchor_invalid(self): 44 with pytest.raises(ValueError, match="anchor"): 45 GameTime.from_anchor("invalid") 46 47 def test_add_duration_minutes(self): 48 t = GameTime(day=1, hour=6, minute=0) 49 result = t.add_duration(Duration(minutes=30)) 50 assert result.day == 1 51 assert result.hour == 6 52 assert result.minute == 30 53 54 def test_add_duration_hours(self): 55 t = GameTime(day=1, hour=6, minute=0) 56 result = t.add_duration(Duration(minutes=120)) 57 assert result.day == 1 58 assert result.hour == 8 59 assert result.minute == 0 60 61 def test_add_duration_crosses_day(self): 62 t = GameTime(day=1, hour=22, minute=0) 63 result = t.add_duration(Duration(minutes=180)) # 3 hours 64 assert result.day == 2 65 assert result.hour == 1 66 assert result.minute == 0 67 68 def test_period_of_day(self): 69 assert GameTime(hour=6).period_of_day() == "Morning" 70 assert GameTime(hour=11).period_of_day() == "Morning" 71 assert GameTime(hour=12).period_of_day() == "Afternoon" 72 assert GameTime(hour=17).period_of_day() == "Afternoon" 73 assert GameTime(hour=18).period_of_day() == "Evening" 74 assert GameTime(hour=23).period_of_day() == "Evening" 75 76 def test_atmosphere(self): 77 assert GameTime(hour=3).atmosphere() == "deep night" 78 assert GameTime(hour=5).atmosphere() == "first light" 79 assert GameTime(hour=9).atmosphere() == "morning light" 80 assert GameTime(hour=13).atmosphere() == "high sun" 81 assert GameTime(hour=15).atmosphere() == "afternoon" 82 assert GameTime(hour=18).atmosphere() == "fading light" 83 assert GameTime(hour=21).atmosphere() == "lamplight and shadow" 84 assert GameTime(hour=23).atmosphere() == "deep night" 85 86 87class TestDuration: 88 def test_parse_minutes(self): 89 d = Duration.parse("30 min") 90 assert d.minutes == 30 91 assert d.raw == "30 min" 92 93 def test_parse_hours(self): 94 d = Duration.parse("2 hours") 95 assert d.minutes == 120 96 97 def test_parse_days(self): 98 d = Duration.parse("3 days") 99 assert d.minutes == 3 * 24 * 60 100 101 def test_parse_rounds_short_fight_floors_to_one_minute(self): 102 # 5 rounds = 30 seconds, but the clock's minimum precision is 103 # one minute, so a sub-minute combat still advances the clock. 104 assert Duration.parse("5 rounds").minutes == 1 105 106 def test_parse_rounds_ten_rounds_is_one_minute(self): 107 # 10 rounds = 60 seconds — the one natural minute boundary. 108 assert Duration.parse("10 rounds").minutes == 1 109 110 def test_parse_rounds_twenty_rounds_is_two_minutes(self): 111 assert Duration.parse("20 rounds").minutes == 2 112 113 def test_parse_rounds_long_combat(self): 114 # 100 rounds = 10 minutes. 115 assert Duration.parse("100 rounds").minutes == 10 116 117 def test_parse_scene(self): 118 d = Duration.parse("scene") 119 assert d.minutes == 5 120 121 def test_parse_variations(self): 122 assert Duration.parse("1 hour").minutes == 60 123 assert Duration.parse("1 hr").minutes == 60 124 assert Duration.parse("1h").minutes == 60 125 assert Duration.parse("1 day").minutes == 24 * 60 126 127 def test_str_with_raw(self): 128 d = Duration.parse("30 min") 129 assert str(d) == "30 min" 130 131 def test_str_without_raw(self): 132 d = Duration(minutes=30) 133 assert str(d) == "30 min" 134 135 136class TestLogEntry: 137 def test_parse_basic(self): 138 entry = LogEntry.parse("- #d1-0600 | Arrived at Port Haven | 30 min") 139 assert entry is not None 140 assert entry.anchor == "#d1-0600" 141 assert entry.event == "Arrived at Port Haven" 142 assert entry.duration.minutes == 30 143 144 def test_parse_with_tags(self): 145 entry = LogEntry.parse("- #d1-0600 | Short rest | 1 hour | rest:short") 146 assert entry is not None 147 assert entry.tags == ["rest:short"] 148 149 def test_parse_invalid(self): 150 assert LogEntry.parse("Not a valid line") is None 151 assert LogEntry.parse("- no anchor") is None 152 153 def test_to_line(self): 154 entry = LogEntry( 155 anchor="#d1-0600", 156 event="Arrived", 157 duration=Duration.parse("30 min"), 158 ) 159 assert entry.to_line() == "- #d1-0600 | Arrived | 30 min" 160 161 def test_to_line_with_tags(self): 162 entry = LogEntry( 163 anchor="#d1-0600", 164 event="Rest", 165 duration=Duration.parse("1 hour"), 166 tags=["rest:short"], 167 ) 168 assert entry.to_line() == "- #d1-0600 | Rest | 1 hour | rest:short" 169 170 171class TestCampaignLog: 172 def test_new_log(self, tmp_path): 173 log = CampaignLog(world_id="test", base_path=tmp_path) 174 assert log.current_day == 1 175 assert log.current_time.hour == 6 176 assert log.current_entries == [] 177 178 def test_append_entry(self, tmp_path): 179 log = CampaignLog(world_id="test", base_path=tmp_path) 180 anchor = log.append_entry("Arrived at Port Haven", "30 min") 181 182 assert anchor == "#d1-0600" 183 assert len(log.current_entries) == 1 184 assert log.current_time.minute == 30 185 186 def test_append_multiple_entries(self, tmp_path): 187 log = CampaignLog(world_id="test", base_path=tmp_path) 188 log.append_entry("Event 1", "30 min") 189 log.append_entry("Event 2", "1 hour") 190 191 assert len(log.current_entries) == 2 192 assert log.current_time.hour == 7 193 assert log.current_time.minute == 30 194 195 def test_persistence(self, tmp_path): 196 log = CampaignLog(world_id="test", base_path=tmp_path) 197 log.append_entry("Test event", "30 min") 198 199 # Load fresh 200 log2 = CampaignLog(world_id="test", base_path=tmp_path) 201 assert len(log2.current_entries) == 1 202 assert log2.current_entries[0].event == "Test event" 203 204 def test_roll_day(self, tmp_path): 205 log = CampaignLog(world_id="test", base_path=tmp_path) 206 log.append_entry("Morning event", "2 hours") # 06:00 -> 08:00 207 log.append_entry("Long journey", "20 hours") # 08:00 -> 04:00 next day 208 209 assert log.current_day == 2 210 assert len(log.previous_summaries) == 1 211 assert "Morning event" in log.previous_summaries[0] 212 213 # Check day file was created 214 day_file = tmp_path / "worlds" / "test" / "log" / "day+001.md" 215 assert day_file.exists() 216 217 def test_roll_day_preserves_crossing_entry(self, tmp_path): 218 """Entry that triggers a day roll must appear in the old day's file.""" 219 log = CampaignLog(world_id="test", base_path=tmp_path) 220 log.append_entry("Morning event", "2 hours") 221 log.append_entry("Traveled all day", "20 hours") 222 223 day1 = tmp_path / "worlds" / "test" / "log" / "day+001.md" 224 content = day1.read_text() 225 assert "Morning event" in content 226 assert "Traveled all day" in content 227 228 def test_format_for_context(self, tmp_path): 229 log = CampaignLog(world_id="test", base_path=tmp_path) 230 log.append_entry("Arrived at tavern", "30 min") 231 log.append_entry("Met the barkeep", "1 hour") 232 233 context = log.format_for_context() 234 assert "Day 1" in context 235 assert "Arrived at tavern" in context 236 assert "Met the barkeep" in context 237 238 def test_time_since_rest_no_rest(self, tmp_path): 239 log = CampaignLog(world_id="test", base_path=tmp_path) 240 log.append_entry("Activity", "2 hours") 241 242 since = log.time_since_rest("short") 243 assert since.minutes >= 2 * 60 244 245 def test_time_since_rest_with_rest(self, tmp_path): 246 log = CampaignLog(world_id="test", base_path=tmp_path) 247 log.append_entry("Activity", "2 hours") 248 log.append_entry("Rest", "1 hour", tags=["rest:short"]) 249 log.append_entry("More activity", "30 min") 250 251 since = log.time_since_rest("short") 252 assert since.minutes == 30 253 254 255class TestConvenienceFunctions: 256 def test_load_log(self, tmp_path): 257 log = load_log(world_id="test", base_path=tmp_path) 258 assert isinstance(log, CampaignLog) 259 260 def test_log_event(self, tmp_path): 261 anchor = log_event( 262 "Test event", 263 "30 min", 264 world_id="test", 265 base_path=tmp_path, 266 ) 267 assert anchor == "#d1-0600" 268 269 # Verify it was saved 270 log = load_log(world_id="test", base_path=tmp_path) 271 assert len(log.current_entries) == 1 272 273 274# ── TranscriptLog ──────────────────────────────────────────────────────── 275 276 277@pytest.fixture 278def transcript(tmp_path: Path) -> TranscriptLog: 279 (tmp_path / "worlds" / "test").mkdir(parents=True) 280 return TranscriptLog("test", tmp_path) 281 282 283class TestTranscriptLog: 284 def test_append_creates_file(self, transcript: TranscriptLog, tmp_path: Path): 285 transcript.append_turn( 286 "I look around", 287 "The tavern is dimly lit.", 288 GameTime(day=1, hour=8, minute=0), 289 ) 290 path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" 291 assert path.exists() 292 293 def test_turn_format(self, transcript: TranscriptLog, tmp_path: Path): 294 transcript.append_turn( 295 "I look around", 296 "The tavern is dimly lit.", 297 GameTime(day=1, hour=8, minute=0), 298 ) 299 content = ( 300 tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" 301 ).read_text() 302 assert "> I look around" in content 303 assert "The tavern is dimly lit." in content 304 assert "### Day 1, 08:00" in content 305 306 def test_multiple_turns_append(self, transcript: TranscriptLog): 307 time1 = GameTime(day=1, hour=8, minute=0) 308 time2 = GameTime(day=1, hour=8, minute=15) 309 transcript.append_turn("First", "Response one.", time1) 310 transcript.append_turn("Second", "Response two.", time2) 311 context = transcript.recent_turns(1) 312 assert "First" in context 313 assert "Second" in context 314 315 def test_skips_system_messages(self, transcript: TranscriptLog, tmp_path: Path): 316 transcript.append_turn( 317 "[Session starting]", 318 "Welcome back!", 319 GameTime(day=1, hour=8, minute=0), 320 ) 321 path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" 322 assert not path.exists() 323 324 def test_recent_turns_limit(self, transcript: TranscriptLog): 325 for i in range(15): 326 transcript.append_turn( 327 f"Turn {i}", 328 f"Response {i}.", 329 GameTime(day=1, hour=8, minute=i), 330 ) 331 context = transcript.recent_turns(1, n=5) 332 assert "Turn 10" in context 333 assert "Turn 14" in context 334 assert "Turn 0" not in context 335 336 def test_recent_turns_empty(self, transcript: TranscriptLog): 337 assert transcript.recent_turns(1) == "" 338 339 def test_recent_turns_spans_days(self, transcript: TranscriptLog): 340 transcript.append_turn( 341 "Yesterday", 342 "Something happened.", 343 GameTime(day=1, hour=20, minute=0), 344 ) 345 transcript.append_turn( 346 "Today", 347 "Morning arrives.", 348 GameTime(day=2, hour=8, minute=0), 349 ) 350 context = transcript.recent_turns(2) 351 assert "Yesterday" in context 352 assert "Today" in context 353 354 def test_includes_display_blocks(self, transcript: TranscriptLog): 355 dm_response = "You see:\n\n```map Tavern\n+-+\n|X|\n+-+\n```\n\nThe tavern." 356 transcript.append_turn( 357 "Look around", 358 dm_response, 359 GameTime(day=1, hour=8, minute=0), 360 ) 361 context = transcript.recent_turns(1) 362 assert "```map" in context 363 assert "+-+" in context