"""Tests for campaign log functionality.""" from pathlib import Path import pytest from storied.log import ( CampaignLog, Duration, GameTime, LogEntry, TranscriptLog, load_log, log_event, ) class TestGameTime: def test_default_values(self): t = GameTime() assert t.day == 1 assert t.hour == 6 assert t.minute == 0 def test_str(self): assert str(GameTime(day=3, hour=14, minute=30)) == "Day 3, 14:30" def test_to_anchor(self): assert GameTime(day=1, hour=6, minute=0).to_anchor() == "#d1-0600" assert GameTime(day=3, hour=14, minute=30).to_anchor() == "#d3-1430" def test_from_anchor(self): t = GameTime.from_anchor("#d1-0600") assert t.day == 1 assert t.hour == 6 assert t.minute == 0 t = GameTime.from_anchor("d3-1430") # Without # assert t.day == 3 assert t.hour == 14 assert t.minute == 30 def test_from_anchor_invalid(self): with pytest.raises(ValueError, match="anchor"): GameTime.from_anchor("invalid") def test_add_duration_minutes(self): t = GameTime(day=1, hour=6, minute=0) result = t.add_duration(Duration(minutes=30)) assert result.day == 1 assert result.hour == 6 assert result.minute == 30 def test_add_duration_hours(self): t = GameTime(day=1, hour=6, minute=0) result = t.add_duration(Duration(minutes=120)) assert result.day == 1 assert result.hour == 8 assert result.minute == 0 def test_add_duration_crosses_day(self): t = GameTime(day=1, hour=22, minute=0) result = t.add_duration(Duration(minutes=180)) # 3 hours assert result.day == 2 assert result.hour == 1 assert result.minute == 0 def test_period_of_day(self): assert GameTime(hour=6).period_of_day() == "Morning" assert GameTime(hour=11).period_of_day() == "Morning" assert GameTime(hour=12).period_of_day() == "Afternoon" assert GameTime(hour=17).period_of_day() == "Afternoon" assert GameTime(hour=18).period_of_day() == "Evening" assert GameTime(hour=23).period_of_day() == "Evening" def test_atmosphere(self): assert GameTime(hour=3).atmosphere() == "deep night" assert GameTime(hour=5).atmosphere() == "first light" assert GameTime(hour=9).atmosphere() == "morning light" assert GameTime(hour=13).atmosphere() == "high sun" assert GameTime(hour=15).atmosphere() == "afternoon" assert GameTime(hour=18).atmosphere() == "fading light" assert GameTime(hour=21).atmosphere() == "lamplight and shadow" assert GameTime(hour=23).atmosphere() == "deep night" class TestDuration: def test_parse_minutes(self): d = Duration.parse("30 min") assert d.minutes == 30 assert d.raw == "30 min" def test_parse_hours(self): d = Duration.parse("2 hours") assert d.minutes == 120 def test_parse_days(self): d = Duration.parse("3 days") assert d.minutes == 3 * 24 * 60 def test_parse_rounds_short_fight_floors_to_one_minute(self): # 5 rounds = 30 seconds, but the clock's minimum precision is # one minute, so a sub-minute combat still advances the clock. assert Duration.parse("5 rounds").minutes == 1 def test_parse_rounds_ten_rounds_is_one_minute(self): # 10 rounds = 60 seconds — the one natural minute boundary. assert Duration.parse("10 rounds").minutes == 1 def test_parse_rounds_twenty_rounds_is_two_minutes(self): assert Duration.parse("20 rounds").minutes == 2 def test_parse_rounds_long_combat(self): # 100 rounds = 10 minutes. assert Duration.parse("100 rounds").minutes == 10 def test_parse_scene(self): d = Duration.parse("scene") assert d.minutes == 5 def test_parse_variations(self): assert Duration.parse("1 hour").minutes == 60 assert Duration.parse("1 hr").minutes == 60 assert Duration.parse("1h").minutes == 60 assert Duration.parse("1 day").minutes == 24 * 60 def test_str_with_raw(self): d = Duration.parse("30 min") assert str(d) == "30 min" def test_str_without_raw(self): d = Duration(minutes=30) assert str(d) == "30 min" class TestLogEntry: def test_parse_basic(self): entry = LogEntry.parse("- #d1-0600 | Arrived at Port Haven | 30 min") assert entry is not None assert entry.anchor == "#d1-0600" assert entry.event == "Arrived at Port Haven" assert entry.duration.minutes == 30 def test_parse_with_tags(self): entry = LogEntry.parse("- #d1-0600 | Short rest | 1 hour | rest:short") assert entry is not None assert entry.tags == ["rest:short"] def test_parse_invalid(self): assert LogEntry.parse("Not a valid line") is None assert LogEntry.parse("- no anchor") is None def test_to_line(self): entry = LogEntry( anchor="#d1-0600", event="Arrived", duration=Duration.parse("30 min"), ) assert entry.to_line() == "- #d1-0600 | Arrived | 30 min" def test_to_line_with_tags(self): entry = LogEntry( anchor="#d1-0600", event="Rest", duration=Duration.parse("1 hour"), tags=["rest:short"], ) assert entry.to_line() == "- #d1-0600 | Rest | 1 hour | rest:short" class TestCampaignLog: def test_new_log(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) assert log.current_day == 1 assert log.current_time.hour == 6 assert log.current_entries == [] def test_append_entry(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) anchor = log.append_entry("Arrived at Port Haven", "30 min") assert anchor == "#d1-0600" assert len(log.current_entries) == 1 assert log.current_time.minute == 30 def test_append_multiple_entries(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Event 1", "30 min") log.append_entry("Event 2", "1 hour") assert len(log.current_entries) == 2 assert log.current_time.hour == 7 assert log.current_time.minute == 30 def test_persistence(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Test event", "30 min") # Load fresh log2 = CampaignLog(world_id="test", base_path=tmp_path) assert len(log2.current_entries) == 1 assert log2.current_entries[0].event == "Test event" def test_roll_day(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Morning event", "2 hours") # 06:00 -> 08:00 log.append_entry("Long journey", "20 hours") # 08:00 -> 04:00 next day assert log.current_day == 2 assert len(log.previous_summaries) == 1 assert "Morning event" in log.previous_summaries[0] # Check day file was created day_file = tmp_path / "worlds" / "test" / "log" / "day+001.md" assert day_file.exists() def test_roll_day_preserves_crossing_entry(self, tmp_path): """Entry that triggers a day roll must appear in the old day's file.""" log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Morning event", "2 hours") log.append_entry("Traveled all day", "20 hours") day1 = tmp_path / "worlds" / "test" / "log" / "day+001.md" content = day1.read_text() assert "Morning event" in content assert "Traveled all day" in content def test_format_for_context(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Arrived at tavern", "30 min") log.append_entry("Met the barkeep", "1 hour") context = log.format_for_context() assert "Day 1" in context assert "Arrived at tavern" in context assert "Met the barkeep" in context def test_time_since_rest_no_rest(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Activity", "2 hours") since = log.time_since_rest("short") assert since.minutes >= 2 * 60 def test_time_since_rest_with_rest(self, tmp_path): log = CampaignLog(world_id="test", base_path=tmp_path) log.append_entry("Activity", "2 hours") log.append_entry("Rest", "1 hour", tags=["rest:short"]) log.append_entry("More activity", "30 min") since = log.time_since_rest("short") assert since.minutes == 30 class TestConvenienceFunctions: def test_load_log(self, tmp_path): log = load_log(world_id="test", base_path=tmp_path) assert isinstance(log, CampaignLog) def test_log_event(self, tmp_path): anchor = log_event( "Test event", "30 min", world_id="test", base_path=tmp_path, ) assert anchor == "#d1-0600" # Verify it was saved log = load_log(world_id="test", base_path=tmp_path) assert len(log.current_entries) == 1 # ── TranscriptLog ──────────────────────────────────────────────────────── @pytest.fixture def transcript(tmp_path: Path) -> TranscriptLog: (tmp_path / "worlds" / "test").mkdir(parents=True) return TranscriptLog("test", tmp_path) class TestTranscriptLog: def test_append_creates_file(self, transcript: TranscriptLog, tmp_path: Path): transcript.append_turn( "I look around", "The tavern is dimly lit.", GameTime(day=1, hour=8, minute=0), ) path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" assert path.exists() def test_turn_format(self, transcript: TranscriptLog, tmp_path: Path): transcript.append_turn( "I look around", "The tavern is dimly lit.", GameTime(day=1, hour=8, minute=0), ) content = ( tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" ).read_text() assert "> I look around" in content assert "The tavern is dimly lit." in content assert "### Day 1, 08:00" in content def test_multiple_turns_append(self, transcript: TranscriptLog): time1 = GameTime(day=1, hour=8, minute=0) time2 = GameTime(day=1, hour=8, minute=15) transcript.append_turn("First", "Response one.", time1) transcript.append_turn("Second", "Response two.", time2) context = transcript.recent_turns(1) assert "First" in context assert "Second" in context def test_skips_system_messages(self, transcript: TranscriptLog, tmp_path: Path): transcript.append_turn( "[Session starting]", "Welcome back!", GameTime(day=1, hour=8, minute=0), ) path = tmp_path / "worlds" / "test" / "transcripts" / "day+001.md" assert not path.exists() def test_recent_turns_limit(self, transcript: TranscriptLog): for i in range(15): transcript.append_turn( f"Turn {i}", f"Response {i}.", GameTime(day=1, hour=8, minute=i), ) context = transcript.recent_turns(1, n=5) assert "Turn 10" in context assert "Turn 14" in context assert "Turn 0" not in context def test_recent_turns_empty(self, transcript: TranscriptLog): assert transcript.recent_turns(1) == "" def test_recent_turns_spans_days(self, transcript: TranscriptLog): transcript.append_turn( "Yesterday", "Something happened.", GameTime(day=1, hour=20, minute=0), ) transcript.append_turn( "Today", "Morning arrives.", GameTime(day=2, hour=8, minute=0), ) context = transcript.recent_turns(2) assert "Yesterday" in context assert "Today" in context def test_includes_display_blocks(self, transcript: TranscriptLog): dm_response = "You see:\n\n```map Tavern\n+-+\n|X|\n+-+\n```\n\nThe tavern." transcript.append_turn( "Look around", dm_response, GameTime(day=1, hour=8, minute=0), ) context = transcript.recent_turns(1) assert "```map" in context assert "+-+" in context