From e6327780aa25d556e16f6727370aaf624740fa5d Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sun, 20 Jul 2025 11:39:49 +0200 Subject: [PATCH] Add comprehensive test coverage for 8 modules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increases overall test coverage from 53% to 56% by adding tests for: - accommodation.py (60% → 100%) - birthday.py (24% → 100%) - calendar.py (19% → 100%) - carnival.py (33% → 100%) - domains.py (75% → 100%) - events_yaml.py (50% → 96%) - fx.py (14% → 21% for tested functions) - sun.py (55% → 100%) 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- tests/test_accommodation.py | 186 +++++++++++++++++++++++++ tests/test_birthday.py | 227 ++++++++++++++++++++++++++++++ tests/test_calendar.py | 270 ++++++++++++++++++++++++++++++++++++ tests/test_carnival.py | 125 +++++++++++++++++ tests/test_domains.py | 168 ++++++++++++++++++++++ tests/test_events_yaml.py | 49 +++++++ tests/test_fx.py | 182 ++++++++++++++++++++++++ tests/test_sun.py | 183 ++++++++++++++++++++++++ 8 files changed, 1390 insertions(+) create mode 100644 tests/test_accommodation.py create mode 100644 tests/test_birthday.py create mode 100644 tests/test_calendar.py create mode 100644 tests/test_carnival.py create mode 100644 tests/test_domains.py create mode 100644 tests/test_events_yaml.py create mode 100644 tests/test_fx.py create mode 100644 tests/test_sun.py diff --git a/tests/test_accommodation.py b/tests/test_accommodation.py new file mode 100644 index 0000000..451cbc6 --- /dev/null +++ b/tests/test_accommodation.py @@ -0,0 +1,186 @@ +"""Tests for accommodation functionality.""" + +import tempfile +from datetime import date, datetime +from typing import Any + +import pytest +import yaml + +from agenda.accommodation import get_events +from agenda.event import Event + + +class TestGetEvents: + """Test the get_events function.""" + + def test_get_events_airbnb(self) -> None: + """Test getting accommodation events for Airbnb.""" + accommodation_data = [ + { + "from": date(2024, 6, 1), + "to": date(2024, 6, 5), + "location": "Paris", + "operator": "airbnb", + "url": "https://airbnb.com/rooms/123" + } + ] + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(accommodation_data, f) + filepath = f.name + + try: + events = get_events(filepath) + + assert len(events) == 1 + event = events[0] + assert event.date == date(2024, 6, 1) + assert event.end_date == date(2024, 6, 5) + assert event.name == "accommodation" + assert event.title == "Paris Airbnb" + assert event.url == "https://airbnb.com/rooms/123" + + finally: + import os + os.unlink(filepath) + + def test_get_events_hotel(self) -> None: + """Test getting accommodation events for hotel.""" + accommodation_data = [ + { + "from": date(2024, 6, 1), + "to": date(2024, 6, 5), + "name": "Hilton Hotel", + "url": "https://hilton.com" + } + ] + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(accommodation_data, f) + filepath = f.name + + try: + events = get_events(filepath) + + assert len(events) == 1 + event = events[0] + assert event.date == date(2024, 6, 1) + assert event.end_date == date(2024, 6, 5) + assert event.name == "accommodation" + assert event.title == "Hilton Hotel" + assert event.url == "https://hilton.com" + + finally: + import os + os.unlink(filepath) + + def test_get_events_no_url(self) -> None: + """Test getting accommodation events without URL.""" + accommodation_data = [ + { + "from": date(2024, 6, 1), + "to": date(2024, 6, 5), + "name": "Local B&B" + } + ] + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(accommodation_data, f) + filepath = f.name + + try: + events = get_events(filepath) + + assert len(events) == 1 + event = events[0] + assert event.url is None + assert event.title == "Local B&B" + + finally: + import os + os.unlink(filepath) + + def test_get_events_multiple_accommodations(self) -> None: + """Test getting multiple accommodation events.""" + accommodation_data = [ + { + "from": date(2024, 6, 1), + "to": date(2024, 6, 5), + "location": "London", + "operator": "airbnb" + }, + { + "from": date(2024, 6, 10), + "to": date(2024, 6, 15), + "name": "Royal Hotel" + } + ] + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(accommodation_data, f) + filepath = f.name + + try: + events = get_events(filepath) + + assert len(events) == 2 + + # Check first accommodation (Airbnb) + assert events[0].title == "London Airbnb" + assert events[0].date == date(2024, 6, 1) + + # Check second accommodation (Hotel) + assert events[1].title == "Royal Hotel" + assert events[1].date == date(2024, 6, 10) + + finally: + import os + os.unlink(filepath) + + def test_get_events_empty_file(self) -> None: + """Test getting events from empty file.""" + accommodation_data: list[dict[str, Any]] = [] + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(accommodation_data, f) + filepath = f.name + + try: + events = get_events(filepath) + assert events == [] + + finally: + import os + os.unlink(filepath) + + def test_get_events_file_not_found(self) -> None: + """Test error handling when file doesn't exist.""" + with pytest.raises(FileNotFoundError): + get_events("/nonexistent/file.yaml") + + def test_get_events_datetime_objects(self) -> None: + """Test with datetime objects instead of dates.""" + accommodation_data = [ + { + "from": datetime(2024, 6, 1, 15, 0), + "to": datetime(2024, 6, 5, 11, 0), + "name": "Hotel Example" + } + ] + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(accommodation_data, f) + filepath = f.name + + try: + events = get_events(filepath) + + assert len(events) == 1 + event = events[0] + assert event.date == datetime(2024, 6, 1, 15, 0) + assert event.end_date == datetime(2024, 6, 5, 11, 0) + + finally: + import os + os.unlink(filepath) \ No newline at end of file diff --git a/tests/test_birthday.py b/tests/test_birthday.py new file mode 100644 index 0000000..c36a4e8 --- /dev/null +++ b/tests/test_birthday.py @@ -0,0 +1,227 @@ +"""Tests for birthday functionality.""" + +import tempfile +from datetime import date +from typing import Any + +import pytest +import yaml + +from agenda.birthday import YEAR_NOT_KNOWN, get_birthdays, next_birthday +from agenda.event import Event + + +class TestNextBirthday: + """Test the next_birthday function.""" + + def test_birthday_this_year_future(self) -> None: + """Test birthday that hasn't occurred this year.""" + from_date = date(2024, 3, 15) + birth_date = date(1990, 6, 20) + + next_bday, age = next_birthday(from_date, birth_date) + + assert next_bday == date(2024, 6, 20) + assert age == 34 + + def test_birthday_this_year_past(self) -> None: + """Test birthday that already occurred this year.""" + from_date = date(2024, 8, 15) + birth_date = date(1990, 6, 20) + + next_bday, age = next_birthday(from_date, birth_date) + + assert next_bday == date(2025, 6, 20) + assert age == 35 + + def test_birthday_today(self) -> None: + """Test when today is the birthday.""" + from_date = date(2024, 6, 20) + birth_date = date(1990, 6, 20) + + next_bday, age = next_birthday(from_date, birth_date) + + assert next_bday == date(2024, 6, 20) + assert age == 34 + + def test_birthday_unknown_year(self) -> None: + """Test birthday with unknown year.""" + from_date = date(2024, 3, 15) + birth_date = date(YEAR_NOT_KNOWN, 6, 20) + + next_bday, age = next_birthday(from_date, birth_date) + + assert next_bday == date(2024, 6, 20) + assert age is None + + def test_birthday_unknown_year_past(self) -> None: + """Test birthday with unknown year that already passed.""" + from_date = date(2024, 8, 15) + birth_date = date(YEAR_NOT_KNOWN, 6, 20) + + next_bday, age = next_birthday(from_date, birth_date) + + assert next_bday == date(2025, 6, 20) + assert age is None + + def test_leap_year_birthday(self) -> None: + """Test birthday on leap day.""" + from_date = date(2024, 1, 15) # 2024 is a leap year + birth_date = date(2000, 2, 29) # Born on leap day + + next_bday, age = next_birthday(from_date, birth_date) + + assert next_bday == date(2024, 2, 29) + assert age == 24 + + +class TestGetBirthdays: + """Test the get_birthdays function.""" + + def test_get_birthdays_with_year(self) -> None: + """Test getting birthdays with known birth years.""" + birthday_data = [ + { + "label": "John Doe", + "birthday": {"year": 1990, "month": 6, "day": 20} + }, + { + "label": "Jane Smith", + "birthday": {"year": 1985, "month": 12, "day": 15} + } + ] + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(birthday_data, f) + filepath = f.name + + try: + from_date = date(2024, 3, 15) + events = get_birthdays(from_date, filepath) + + # Should have 4 events (2 people × 2 years each) + assert len(events) == 4 + + # Check John's birthdays + john_events = [e for e in events if "John Doe" in e.title] + assert len(john_events) == 2 + assert john_events[0].date == date(2024, 6, 20) + assert "aged 34" in john_events[0].title + assert john_events[1].date == date(2025, 6, 20) + assert "aged 35" in john_events[1].title + + # Check Jane's birthdays + jane_events = [e for e in events if "Jane Smith" in e.title] + assert len(jane_events) == 2 + assert jane_events[0].date == date(2024, 12, 15) + assert "aged 39" in jane_events[0].title + + # All events should be birthday events + for event in events: + assert event.name == "birthday" + assert isinstance(event, Event) + + finally: + import os + os.unlink(filepath) + + def test_get_birthdays_without_year(self) -> None: + """Test getting birthdays with unknown birth years.""" + birthday_data = [ + { + "label": "Anonymous Person", + "birthday": {"month": 6, "day": 20} + } + ] + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(birthday_data, f) + filepath = f.name + + try: + from_date = date(2024, 3, 15) + events = get_birthdays(from_date, filepath) + + # Should have 2 events (1 person × 2 years) + assert len(events) == 2 + + # Check that age is unknown + for event in events: + assert "age unknown" in event.title + assert "Anonymous Person" in event.title + assert event.name == "birthday" + + finally: + import os + os.unlink(filepath) + + def test_get_birthdays_mixed_data(self) -> None: + """Test getting birthdays with mixed known/unknown years.""" + birthday_data = [ + { + "label": "Known Person", + "birthday": {"year": 1990, "month": 6, "day": 20} + }, + { + "label": "Unknown Person", + "birthday": {"month": 8, "day": 15} + } + ] + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(birthday_data, f) + filepath = f.name + + try: + from_date = date(2024, 3, 15) + events = get_birthdays(from_date, filepath) + + # Should have 4 events total + assert len(events) == 4 + + # Check known person events + known_events = [e for e in events if "Known Person" in e.title] + assert len(known_events) == 2 + assert all("aged" in e.title and "age unknown" not in e.title for e in known_events) + + # Check unknown person events + unknown_events = [e for e in events if "Unknown Person" in e.title] + assert len(unknown_events) == 2 + assert all("age unknown" in e.title for e in unknown_events) + + finally: + import os + os.unlink(filepath) + + def test_get_birthdays_empty_file(self) -> None: + """Test getting birthdays from empty file.""" + birthday_data: list[dict[str, Any]] = [] + + with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f: + yaml.dump(birthday_data, f) + filepath = f.name + + try: + from_date = date(2024, 3, 15) + events = get_birthdays(from_date, filepath) + + assert events == [] + + finally: + import os + os.unlink(filepath) + + def test_get_birthdays_file_not_found(self) -> None: + """Test error handling when file doesn't exist.""" + from_date = date(2024, 3, 15) + + with pytest.raises(FileNotFoundError): + get_birthdays(from_date, "/nonexistent/file.yaml") + + +class TestConstants: + """Test module constants.""" + + def test_year_not_known_constant(self) -> None: + """Test that YEAR_NOT_KNOWN has expected value.""" + assert YEAR_NOT_KNOWN == 1900 \ No newline at end of file diff --git a/tests/test_calendar.py b/tests/test_calendar.py new file mode 100644 index 0000000..29ec0f1 --- /dev/null +++ b/tests/test_calendar.py @@ -0,0 +1,270 @@ +"""Tests for calendar functionality.""" + +from datetime import date, datetime, timedelta +from typing import Any + +from agenda.calendar import build_events, colors, event_type_color_map +from agenda.event import Event + + +class TestEventTypeColorMap: + """Test the event type color mapping.""" + + def test_event_type_color_map_contains_expected_types(self) -> None: + """Test that color map contains expected event types.""" + expected_types = { + "bank_holiday", "conference", "us_holiday", + "birthday", "waste_schedule" + } + assert set(event_type_color_map.keys()) == expected_types + + def test_event_type_color_map_values_are_valid(self) -> None: + """Test that all color values are valid color names.""" + expected_colors = { + "success-subtle", "primary-subtle", "secondary-subtle", + "info-subtle", "danger-subtle" + } + assert set(event_type_color_map.values()).issubset(expected_colors) + + +class TestColorsDict: + """Test the colors dictionary.""" + + def test_colors_contains_expected_keys(self) -> None: + """Test that colors dict contains expected color keys.""" + expected_keys = { + "primary-subtle", "secondary-subtle", "success-subtle", + "info-subtle", "warning-subtle", "danger-subtle" + } + assert set(colors.keys()) == expected_keys + + def test_colors_values_are_hex_codes(self) -> None: + """Test that all color values are valid hex codes.""" + for color_value in colors.values(): + assert color_value.startswith("#") + assert len(color_value) == 7 + # Check that remaining characters are valid hex + hex_part = color_value[1:] + int(hex_part, 16) # This will raise ValueError if not valid hex + + +class TestBuildEvents: + """Test the build_events function.""" + + def test_build_events_empty_list(self) -> None: + """Test building events with empty list.""" + result = build_events([]) + assert result == [] + + def test_build_events_today_event_filtered(self) -> None: + """Test that 'today' events are filtered out.""" + events = [ + Event(date=date(2024, 1, 1), name="today", title="Today"), + Event(date=date(2024, 1, 2), name="birthday", title="Birthday"), + ] + result = build_events(events) + + assert len(result) == 1 + assert result[0]["title"] == "🎈 Birthday" + + def test_build_events_simple_event(self) -> None: + """Test building a simple event without time.""" + events = [ + Event( + date=date(2024, 1, 15), + name="birthday", + title="John's Birthday" + ) + ] + result = build_events(events) + + assert len(result) == 1 + item = result[0] + assert item["allDay"] is True + assert item["title"] == "🎈 John's Birthday" + assert item["start"] == "2024-01-15" + assert item["end"] == "2024-01-16" # Next day + assert item["color"] == colors["info-subtle"] + assert item["textColor"] == "black" + + def test_build_events_with_time(self) -> None: + """Test building an event with time.""" + event_datetime = datetime(2024, 1, 15, 14, 30) + events = [ + Event( + date=event_datetime, + name="meeting", + title="Team Meeting" + ) + ] + result = build_events(events) + + assert len(result) == 1 + item = result[0] + assert item["allDay"] is False + assert item["title"] == "Team Meeting" + assert item["start"] == "2024-01-15T14:30:00" + assert item["end"] == "2024-01-15T15:00:00" # 30 minutes later + + def test_build_events_with_end_date(self) -> None: + """Test building an event with end date.""" + events = [ + Event( + date=date(2024, 1, 15), + name="conference", + title="Tech Conference", + end_date=date(2024, 1, 17) + ) + ] + result = build_events(events) + + assert len(result) == 1 + item = result[0] + assert item["allDay"] is True + assert item["start"] == "2024-01-15" + assert item["end"] == "2024-01-18" # End date + 1 day + + def test_build_events_with_time_and_end_date(self) -> None: + """Test building an event with time and end date.""" + start_datetime = datetime(2024, 1, 15, 9, 0) + end_datetime = datetime(2024, 1, 15, 17, 0) + events = [ + Event( + date=start_datetime, + name="workshop", + title="Python Workshop", + end_date=end_datetime + ) + ] + result = build_events(events) + + assert len(result) == 1 + item = result[0] + assert item["allDay"] is False + assert item["start"] == "2024-01-15T09:00:00" + assert item["end"] == "2024-01-15T17:00:00" + + def test_build_events_with_url(self) -> None: + """Test building an event with URL.""" + events = [ + Event( + date=date(2024, 1, 15), + name="conference", + title="Tech Conference", + url="https://example.com" + ) + ] + result = build_events(events) + + assert len(result) == 1 + item = result[0] + assert item["url"] == "https://example.com" + + def test_build_events_accommodation(self) -> None: + """Test building accommodation events.""" + events = [ + Event( + date=datetime(2024, 1, 15, 15, 0), # Check-in time + name="accommodation", + title="Hotel Stay", + end_date=datetime(2024, 1, 17, 11, 0), # Check-out time + url="https://hotel.com" + ) + ] + result = build_events(events) + + # Should create 3 events: main accommodation + check-in + check-out + assert len(result) == 3 + + # Main accommodation event + main_event = result[0] + assert main_event["allDay"] is True + assert main_event["title"] == "🏨 Hotel Stay" + assert main_event["start"] == "2024-01-15" + assert main_event["end"] == "2024-01-18" # End date + 1 day + assert main_event["url"] == "https://hotel.com" + + # Check-in event + checkin_event = result[1] + assert checkin_event["allDay"] is False + assert checkin_event["title"] == "check-in: Hotel Stay" + assert checkin_event["start"] == "2024-01-15T15:00:00" + assert checkin_event["url"] == "https://hotel.com" + + # Check-out event + checkout_event = result[2] + assert checkout_event["allDay"] is False + assert checkout_event["title"] == "checkout: Hotel Stay" + assert checkout_event["start"] == "2024-01-17T11:00:00" + assert checkout_event["url"] == "https://hotel.com" + + def test_build_events_no_color_for_unknown_type(self) -> None: + """Test that events with unknown types don't get color.""" + events = [ + Event( + date=date(2024, 1, 15), + name="unknown_type", + title="Unknown Event" + ) + ] + result = build_events(events) + + assert len(result) == 1 + item = result[0] + assert "color" not in item + assert "textColor" not in item + + def test_build_events_multiple_event_types(self) -> None: + """Test building events with different types and colors.""" + events = [ + Event(date=date(2024, 1, 15), name="bank_holiday", title="New Year"), + Event(date=date(2024, 1, 16), name="conference", title="PyCon"), + Event(date=date(2024, 1, 17), name="birthday", title="Birthday"), + Event(date=date(2024, 1, 18), name="waste_schedule", title="Recycling"), + Event(date=date(2024, 1, 19), name="us_holiday", title="MLK Day"), + ] + result = build_events(events) + + assert len(result) == 5 + + # Check colors are assigned correctly + assert result[0]["color"] == colors["success-subtle"] # bank_holiday + assert result[1]["color"] == colors["primary-subtle"] # conference + assert result[2]["color"] == colors["info-subtle"] # birthday + assert result[3]["color"] == colors["danger-subtle"] # waste_schedule + assert result[4]["color"] == colors["secondary-subtle"] # us_holiday + + # All should have black text + for item in result: + assert item["textColor"] == "black" + + def test_build_events_mixed_scenarios(self) -> None: + """Test building events with mixed scenarios.""" + events = [ + # Today event (should be filtered) + Event(date=date(2024, 1, 1), name="today", title="Today"), + # Simple event + Event(date=date(2024, 1, 15), name="birthday", title="Birthday"), + # Event with time + Event(date=datetime(2024, 1, 16, 10, 0), name="meeting", title="Meeting"), + # Accommodation + Event( + date=datetime(2024, 1, 20, 15, 0), + name="accommodation", + title="Hotel", + end_date=datetime(2024, 1, 22, 11, 0), + url="https://hotel.com" + ), + ] + result = build_events(events) + + # Should have 5 events total (today filtered, accommodation creates 3) + assert len(result) == 5 + + # Verify titles + titles = [item["title"] for item in result] + assert "🎈 Birthday" in titles + assert "Meeting" in titles + assert "🏨 Hotel" in titles + assert "check-in: Hotel" in titles + assert "checkout: Hotel" in titles \ No newline at end of file diff --git a/tests/test_carnival.py b/tests/test_carnival.py new file mode 100644 index 0000000..5985262 --- /dev/null +++ b/tests/test_carnival.py @@ -0,0 +1,125 @@ +"""Tests for carnival functionality.""" + +from datetime import date + +from agenda.carnival import rio_carnival_events +from agenda.event import Event + + +class TestRioCarnivalEvents: + """Test the rio_carnival_events function.""" + + def test_carnival_events_single_year(self) -> None: + """Test getting carnival events for a single year.""" + # 2024 Easter is March 31, so carnival should be around Feb 9-14 + start_date = date(2024, 1, 1) + end_date = date(2024, 12, 31) + + events = rio_carnival_events(start_date, end_date) + + assert len(events) == 1 + event = events[0] + assert event.name == "carnival" + assert event.title == "Rio Carnival" + assert event.url == "https://en.wikipedia.org/wiki/Rio_Carnival" + assert event.date.year == 2024 + assert event.end_date is not None + assert event.end_date.year == 2024 + # Should be about 51 days before Easter (around early-mid February) + assert event.date.month == 2 + assert event.end_date.month == 2 + + def test_carnival_events_multiple_years(self) -> None: + """Test getting carnival events for multiple years.""" + start_date = date(2023, 1, 1) + end_date = date(2025, 12, 31) + + events = rio_carnival_events(start_date, end_date) + + # Should have carnival for 2023, 2024, and 2025 + assert len(events) == 3 + + years = [event.date.year for event in events] + assert sorted(years) == [2023, 2024, 2025] + + # All events should be carnival events + for event in events: + assert event.name == "carnival" + assert event.title == "Rio Carnival" + assert event.url == "https://en.wikipedia.org/wiki/Rio_Carnival" + + def test_carnival_events_no_overlap(self) -> None: + """Test when date range doesn't overlap with carnival.""" + # Choose a range that's unlikely to include carnival (summer) + start_date = date(2024, 6, 1) + end_date = date(2024, 8, 31) + + events = rio_carnival_events(start_date, end_date) + + assert events == [] + + def test_carnival_events_partial_overlap_start(self) -> None: + """Test when carnival start overlaps with date range.""" + # 2024 carnival should be around Feb 9-14 + start_date = date(2024, 2, 10) # Might overlap with carnival start + end_date = date(2024, 2, 15) + + events = rio_carnival_events(start_date, end_date) + + # Should include carnival if there's any overlap + if events: + assert len(events) == 1 + assert events[0].name == "carnival" + + def test_carnival_events_partial_overlap_end(self) -> None: + """Test when carnival end overlaps with date range.""" + # 2024 carnival should be around Feb 9-14 + start_date = date(2024, 2, 12) + end_date = date(2024, 2, 20) # Might overlap with carnival end + + events = rio_carnival_events(start_date, end_date) + + # Should include carnival if there's any overlap + if events: + assert len(events) == 1 + assert events[0].name == "carnival" + + def test_carnival_dates_relative_to_easter(self) -> None: + """Test that carnival dates are correctly calculated relative to Easter.""" + start_date = date(2024, 1, 1) + end_date = date(2024, 12, 31) + + events = rio_carnival_events(start_date, end_date) + + assert len(events) == 1 + event = events[0] + + # Carnival should be 5 days long + duration = (event.end_date - event.date).days + 1 + assert duration == 6 # 51 days before to 46 days before Easter (6 days total) + + # Both dates should be in February for 2024 + assert event.date.month == 2 + assert event.end_date.month == 2 + + # End date should be after start date + assert event.end_date > event.date + + def test_carnival_events_empty_date_range(self) -> None: + """Test with empty date range.""" + start_date = date(2024, 6, 15) + end_date = date(2024, 6, 10) # End before start + + events = rio_carnival_events(start_date, end_date) + + # Should return empty list for invalid range + assert events == [] + + def test_carnival_events_same_start_end_date(self) -> None: + """Test with same start and end date.""" + # Pick a date that's definitely not carnival + test_date = date(2024, 7, 15) + + events = rio_carnival_events(test_date, test_date) + + assert events == [] \ No newline at end of file diff --git a/tests/test_domains.py b/tests/test_domains.py new file mode 100644 index 0000000..5024385 --- /dev/null +++ b/tests/test_domains.py @@ -0,0 +1,168 @@ +"""Tests for domain renewal functionality.""" + +import csv +import os +import tempfile +from datetime import date +from typing import Any + +import pytest + +from agenda.domains import renewal_dates, url +from agenda.event import Event + + +class TestRenewalDates: + """Test the renewal_dates function.""" + + def test_renewal_dates_empty_directory(self) -> None: + """Test with empty directory.""" + with tempfile.TemporaryDirectory() as temp_dir: + with pytest.raises(ValueError, match="max\\(\\) iterable argument is empty"): + renewal_dates(temp_dir) + + def test_renewal_dates_no_matching_files(self) -> None: + """Test with directory containing no matching files.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create some non-matching files + with open(os.path.join(temp_dir, "other_file.csv"), "w") as f: + f.write("test") + with open(os.path.join(temp_dir, "not_export.csv"), "w") as f: + f.write("test") + + with pytest.raises(ValueError, match="max\\(\\) iterable argument is empty"): + renewal_dates(temp_dir) + + def test_renewal_dates_single_file(self) -> None: + """Test with single domain export file.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create a domain export file + filename = "export_domains_01_15_2024_02_30_PM.csv" + filepath = os.path.join(temp_dir, filename) + + # Create CSV content + csv_data = [ + ["fqdn", "date_registry_end_utc"], + ["example.com", "2024-06-15T00:00:00Z"], + ["test.org", "2024-12-31T00:00:00Z"] + ] + + with open(filepath, "w", newline="") as f: + writer = csv.writer(f) + writer.writerows(csv_data) + + result = renewal_dates(temp_dir) + + assert len(result) == 2 + + # Check first domain + assert result[0].name == "domain" + assert result[0].title == "🌐 example.com" + assert result[0].date == date(2024, 6, 15) + assert result[0].url == url + "example.com" + + # Check second domain + assert result[1].name == "domain" + assert result[1].title == "🌐 test.org" + assert result[1].date == date(2024, 12, 31) + assert result[1].url == url + "test.org" + + def test_renewal_dates_multiple_files_latest_used(self) -> None: + """Test that the latest file is used when multiple exist.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create older file + older_filename = "export_domains_01_15_2024_02_30_PM.csv" + older_filepath = os.path.join(temp_dir, older_filename) + older_csv_data = [ + ["fqdn", "date_registry_end_utc"], + ["old.com", "2024-06-15T00:00:00Z"] + ] + with open(older_filepath, "w", newline="") as f: + writer = csv.writer(f) + writer.writerows(older_csv_data) + + # Create newer file + newer_filename = "export_domains_01_16_2024_03_45_PM.csv" + newer_filepath = os.path.join(temp_dir, newer_filename) + newer_csv_data = [ + ["fqdn", "date_registry_end_utc"], + ["new.com", "2024-08-20T00:00:00Z"] + ] + with open(newer_filepath, "w", newline="") as f: + writer = csv.writer(f) + writer.writerows(newer_csv_data) + + result = renewal_dates(temp_dir) + + # Should only get data from newer file + assert len(result) == 1 + assert result[0].title == "🌐 new.com" + assert result[0].date == date(2024, 8, 20) + + def test_renewal_dates_empty_csv(self) -> None: + """Test with empty CSV file.""" + with tempfile.TemporaryDirectory() as temp_dir: + filename = "export_domains_01_15_2024_02_30_PM.csv" + filepath = os.path.join(temp_dir, filename) + + # Create CSV with only headers + csv_data = [["fqdn", "date_registry_end_utc"]] + + with open(filepath, "w", newline="") as f: + writer = csv.writer(f) + writer.writerows(csv_data) + + result = renewal_dates(temp_dir) + assert result == [] + + def test_renewal_dates_directory_not_found(self) -> None: + """Test error handling when directory doesn't exist.""" + with pytest.raises(FileNotFoundError): + renewal_dates("/nonexistent/directory") + + def test_renewal_dates_malformed_filename(self) -> None: + """Test with malformed filename that can't be parsed.""" + with tempfile.TemporaryDirectory() as temp_dir: + # Create file with name that starts correctly but can't be parsed as date + filename = "export_domains_invalid_date.csv" + filepath = os.path.join(temp_dir, filename) + + with open(filepath, "w") as f: + f.write("test") + + # Should raise ValueError when trying to parse the malformed date + with pytest.raises(ValueError, match="time data .* does not match format"): + renewal_dates(temp_dir) + + def test_renewal_dates_various_date_formats(self) -> None: + """Test with various date formats in the CSV.""" + with tempfile.TemporaryDirectory() as temp_dir: + filename = "export_domains_01_15_2024_02_30_PM.csv" + filepath = os.path.join(temp_dir, filename) + + csv_data = [ + ["fqdn", "date_registry_end_utc"], + ["example1.com", "2024-06-15T12:34:56Z"], + ["example2.com", "2024-12-31T00:00:00+00:00"], + ["example3.com", "2024-03-20T23:59:59.999Z"] + ] + + with open(filepath, "w", newline="") as f: + writer = csv.writer(f) + writer.writerows(csv_data) + + result = renewal_dates(temp_dir) + + assert len(result) == 3 + assert result[0].date == date(2024, 6, 15) + assert result[1].date == date(2024, 12, 31) + assert result[2].date == date(2024, 3, 20) + + +class TestConstants: + """Test module constants.""" + + def test_url_constant(self) -> None: + """Test that URL constant has expected value.""" + expected_url = "https://admin.gandi.net/domain/01578ef0-a84b-11e7-bdf3-00163e6dc886/" + assert url == expected_url \ No newline at end of file diff --git a/tests/test_events_yaml.py b/tests/test_events_yaml.py new file mode 100644 index 0000000..14b5ce7 --- /dev/null +++ b/tests/test_events_yaml.py @@ -0,0 +1,49 @@ +"""Tests for events_yaml functionality.""" + +from datetime import date, datetime, time + +from agenda.events_yaml import midnight + + +class TestMidnight: + """Test the midnight function.""" + + def test_midnight_with_date(self) -> None: + """Test converting date to midnight datetime.""" + test_date = date(2024, 6, 15) + result = midnight(test_date) + + assert isinstance(result, datetime) + assert result.date() == test_date + assert result.time() == time(0, 0, 0) # Midnight + assert result == datetime(2024, 6, 15, 0, 0, 0) + + def test_midnight_different_dates(self) -> None: + """Test midnight function with different dates.""" + test_cases = [ + date(2024, 1, 1), # New Year's Day + date(2024, 12, 31), # New Year's Eve + date(2024, 2, 29), # Leap day + date(2024, 7, 4), # Independence Day + ] + + for test_date in test_cases: + result = midnight(test_date) + assert result.date() == test_date + assert result.time() == time(0, 0, 0) + assert result.hour == 0 + assert result.minute == 0 + assert result.second == 0 + assert result.microsecond == 0 + + def test_midnight_preserves_year_month_day(self) -> None: + """Test that midnight preserves the year, month, and day.""" + test_date = date(2023, 11, 27) + result = midnight(test_date) + + assert result.year == 2023 + assert result.month == 11 + assert result.day == 27 + assert result.hour == 0 + assert result.minute == 0 + assert result.second == 0 \ No newline at end of file diff --git a/tests/test_fx.py b/tests/test_fx.py new file mode 100644 index 0000000..b291300 --- /dev/null +++ b/tests/test_fx.py @@ -0,0 +1,182 @@ +"""Tests for foreign exchange functionality.""" + +import json +import os +import tempfile +from decimal import Decimal +from unittest.mock import Mock, patch + +import pytest + +from agenda.fx import read_cached_rates + + +class TestReadCachedRates: + """Test the read_cached_rates function.""" + + def test_read_cached_rates_none_filename(self) -> None: + """Test with None filename returns empty dict.""" + result = read_cached_rates(None, ["USD", "EUR"]) + assert result == {} + + def test_read_cached_rates_valid_file(self) -> None: + """Test reading valid cached rates file.""" + currencies = ["USD", "EUR", "JPY"] + data = { + "quotes": { + "GBPUSD": 1.25, + "GBPEUR": 1.15, + "GBPJPY": 150.0, + "GBPCAD": 1.70 # Not requested + } + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(data, f) + filepath = f.name + + try: + result = read_cached_rates(filepath, currencies) + + assert len(result) == 3 + assert result["USD"] == Decimal("1.25") + assert result["EUR"] == Decimal("1.15") + assert result["JPY"] == Decimal("150.0") + assert "CAD" not in result # Not requested + + finally: + os.unlink(filepath) + + def test_read_cached_rates_missing_currencies(self) -> None: + """Test with some currencies missing from data.""" + currencies = ["USD", "EUR", "CHF"] # CHF not in data + data = { + "quotes": { + "GBPUSD": 1.25, + "GBPEUR": 1.15 + # GBPCHF missing + } + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(data, f) + filepath = f.name + + try: + result = read_cached_rates(filepath, currencies) + + assert len(result) == 2 + assert result["USD"] == Decimal("1.25") + assert result["EUR"] == Decimal("1.15") + assert "CHF" not in result # Missing from data + + finally: + os.unlink(filepath) + + def test_read_cached_rates_empty_currencies_list(self) -> None: + """Test with empty currencies list.""" + data = { + "quotes": { + "GBPUSD": 1.25, + "GBPEUR": 1.15 + } + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(data, f) + filepath = f.name + + try: + result = read_cached_rates(filepath, []) + assert result == {} + + finally: + os.unlink(filepath) + + def test_read_cached_rates_no_quotes_key(self) -> None: + """Test with data missing quotes key.""" + currencies = ["USD", "EUR"] + data = {"other_key": "value"} # No quotes key + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(data, f) + filepath = f.name + + try: + with pytest.raises(KeyError): + read_cached_rates(filepath, currencies) + + finally: + os.unlink(filepath) + + def test_read_cached_rates_file_not_found(self) -> None: + """Test error handling when file doesn't exist.""" + with pytest.raises(FileNotFoundError): + read_cached_rates("/nonexistent/file.json", ["USD"]) + + def test_read_cached_rates_invalid_json(self) -> None: + """Test error handling with invalid JSON.""" + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + f.write("invalid json content") + filepath = f.name + + try: + with pytest.raises(json.JSONDecodeError): + read_cached_rates(filepath, ["USD"]) + + finally: + os.unlink(filepath) + + def test_read_cached_rates_decimal_precision(self) -> None: + """Test that rates are returned as Decimal with proper precision.""" + currencies = ["USD"] + data = { + "quotes": { + "GBPUSD": 1.234567890123456789 # High precision + } + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(data, f) + filepath = f.name + + try: + result = read_cached_rates(filepath, currencies) + + assert isinstance(result["USD"], Decimal) + # Should preserve reasonable precision from the JSON + # Python's JSON precision may be limited to float precision + expected = Decimal("1.234567890123456789") + assert abs(result["USD"] - expected) < Decimal("0.0000000000000001") + + finally: + os.unlink(filepath) + + def test_read_cached_rates_various_currency_codes(self) -> None: + """Test with various currency codes.""" + currencies = ["USD", "EUR", "JPY", "CHF", "CAD", "AUD"] + data = { + "quotes": { + "GBPUSD": 1.25, + "GBPEUR": 1.15, + "GBPJPY": 150.0, + "GBPCHF": 1.12, + "GBPCAD": 1.70, + "GBPAUD": 1.85 + } + } + + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as f: + json.dump(data, f) + filepath = f.name + + try: + result = read_cached_rates(filepath, currencies) + + assert len(result) == 6 + for currency in currencies: + assert currency in result + assert isinstance(result[currency], Decimal) + + finally: + os.unlink(filepath) \ No newline at end of file diff --git a/tests/test_sun.py b/tests/test_sun.py new file mode 100644 index 0000000..cc35979 --- /dev/null +++ b/tests/test_sun.py @@ -0,0 +1,183 @@ +"""Tests for sun functionality.""" + +from datetime import datetime +from unittest.mock import Mock, patch + +import pytest + +from agenda.sun import bristol, sunrise, sunset + + +class TestBristol: + """Test the bristol function.""" + + def test_bristol_returns_observer(self) -> None: + """Test that bristol returns an ephem.Observer with correct coordinates.""" + observer = bristol() + + # Check that it's an observer object with the right attributes + assert hasattr(observer, 'lat') + assert hasattr(observer, 'lon') + + # Check coordinates are set to Bristol + assert str(observer.lat) == "51:27:16.2" # 51.4545 degrees + assert str(observer.lon) == "-2:35:16.4" # -2.5879 degrees + + def test_bristol_observer_type(self) -> None: + """Test that bristol returns the correct type.""" + observer = bristol() + + # Should have methods needed for sun calculations + assert hasattr(observer, 'next_rising') + assert hasattr(observer, 'next_setting') + + +class TestSunrise: + """Test the sunrise function.""" + + @patch('agenda.sun.ephem') + def test_sunrise_returns_datetime(self, mock_ephem: Mock) -> None: + """Test that sunrise returns a datetime object.""" + # Mock the observer and sun + mock_observer = Mock() + mock_sun = Mock() + mock_ephem.Sun.return_value = mock_sun + + # Mock the rising calculation + mock_rising = Mock() + mock_rising.datetime.return_value = datetime(2024, 6, 15, 5, 30, 0) + mock_observer.next_rising.return_value = mock_rising + + result = sunrise(mock_observer) + + assert isinstance(result, datetime) + assert result == datetime(2024, 6, 15, 5, 30, 0) + + # Verify ephem calls + mock_ephem.Sun.assert_called_once_with(mock_observer) + mock_observer.next_rising.assert_called_once_with(mock_sun) + + def test_sunrise_with_real_observer(self) -> None: + """Test sunrise with a real observer (integration test).""" + observer = bristol() + + # Set a specific date for the observer + observer.date = "2024/6/21" # Summer solstice + + result = sunrise(observer) + + # Should return a datetime object + assert isinstance(result, datetime) + + # On summer solstice in Bristol, sunrise should be early morning + assert 3 <= result.hour <= 6 # Roughly between 3-6 AM + + # Should be in 2024 + assert result.year == 2024 + + def test_sunrise_different_dates(self) -> None: + """Test sunrise for different dates.""" + observer = bristol() + + # Summer solstice (longest day) + observer.date = "2024/6/21" + summer_sunrise = sunrise(observer) + + # Winter solstice (shortest day) + observer.date = "2024/12/21" + winter_sunrise = sunrise(observer) + + # Summer sunrise should be earlier than winter sunrise + assert summer_sunrise.hour < winter_sunrise.hour + + +class TestSunset: + """Test the sunset function.""" + + @patch('agenda.sun.ephem') + def test_sunset_returns_datetime(self, mock_ephem: Mock) -> None: + """Test that sunset returns a datetime object.""" + # Mock the observer and sun + mock_observer = Mock() + mock_sun = Mock() + mock_ephem.Sun.return_value = mock_sun + + # Mock the setting calculation + mock_setting = Mock() + mock_setting.datetime.return_value = datetime(2024, 6, 15, 20, 30, 0) + mock_observer.next_setting.return_value = mock_setting + + result = sunset(mock_observer) + + assert isinstance(result, datetime) + assert result == datetime(2024, 6, 15, 20, 30, 0) + + # Verify ephem calls + mock_ephem.Sun.assert_called_once_with(mock_observer) + mock_observer.next_setting.assert_called_once_with(mock_sun) + + def test_sunset_with_real_observer(self) -> None: + """Test sunset with a real observer (integration test).""" + observer = bristol() + + # Set a specific date for the observer + observer.date = "2024/6/21" # Summer solstice + + result = sunset(observer) + + # Should return a datetime object + assert isinstance(result, datetime) + + # On summer solstice in Bristol, sunset should be in evening + assert 19 <= result.hour <= 22 # Roughly between 7-10 PM + + # Should be in 2024 + assert result.year == 2024 + + def test_sunset_different_dates(self) -> None: + """Test sunset for different dates.""" + observer = bristol() + + # Summer solstice (longest day) + observer.date = "2024/6/21" + summer_sunset = sunset(observer) + + # Winter solstice (shortest day) + observer.date = "2024/12/21" + winter_sunset = sunset(observer) + + # Summer sunset should be later than winter sunset + assert summer_sunset.hour > winter_sunset.hour + + +class TestSunIntegration: + """Integration tests for sun calculations.""" + + def test_day_length_summer_vs_winter(self) -> None: + """Test that summer days are longer than winter days.""" + observer = bristol() + + # Summer solstice + observer.date = "2024/6/21" + summer_sunrise = sunrise(observer) + summer_sunset = sunset(observer) + summer_day_length = summer_sunset - summer_sunrise + + # Winter solstice + observer.date = "2024/12/21" + winter_sunrise = sunrise(observer) + winter_sunset = sunset(observer) + winter_day_length = winter_sunset - winter_sunrise + + # Summer day should be longer than winter day + assert summer_day_length > winter_day_length + + def test_sunrise_before_sunset(self) -> None: + """Test that sunrise is always before sunset.""" + observer = bristol() + observer.date = "2024/6/15" # Arbitrary date + + sunrise_time = sunrise(observer) + sunset_time = sunset(observer) + + assert sunrise_time < sunset_time \ No newline at end of file