From 412d8c7ba79ea1dd605aa03b2fc5a4366ba05e42 Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sun, 20 Jul 2025 01:10:56 +0200 Subject: [PATCH 1/3] Chronological order validation for YAML --- validate_yaml.py | 132 ++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 118 insertions(+), 14 deletions(-) diff --git a/validate_yaml.py b/validate_yaml.py index ccbdd0d..6cf4028 100755 --- a/validate_yaml.py +++ b/validate_yaml.py @@ -4,7 +4,7 @@ import os import sys import typing -from datetime import date, timedelta +from datetime import date, timedelta, datetime import yaml from rich.pretty import pprint @@ -33,7 +33,26 @@ def check_currency(item: agenda.types.StrDict) -> None: def check_trips() -> None: - """Check trips.""" + """Check trips and ensure they are in chronological order.""" + filepath = os.path.join(data_dir, "trips.yaml") + trips_data = yaml.safe_load(open(filepath, "r")) + + prev_trip = None + prev_trip_data = None + for trip_data in trips_data: + current_trip = normalize_datetime(trip_data["trip"]) + if prev_trip and current_trip < prev_trip: + print(f"Out of order trip found:") + print( + f" Previous: {prev_trip_data.get('trip')} - {prev_trip_data.get('name', 'No name')}" + ) + print( + f" Current: {trip_data.get('trip')} - {trip_data.get('name', 'No name')}" + ) + assert False, "Trips are not in chronological order by trip date." + prev_trip = current_trip + prev_trip_data = trip_data + trip_list = agenda.trip.build_trip_list(data_dir) print(len(trip_list), "trips") @@ -76,25 +95,68 @@ def check_flights(airlines: set[str]) -> None: ) +def normalize_datetime(dt_value): + """Convert date or datetime to datetime for comparison, removing timezone info.""" + if isinstance(dt_value, date) and not isinstance(dt_value, datetime): + return datetime.combine(dt_value, datetime.min.time()) + elif isinstance(dt_value, datetime): + # Remove timezone info to allow comparison between naive and aware datetimes + return dt_value.replace(tzinfo=None) + return dt_value + + def check_trains() -> None: - """Check trains.""" + """Check trains and ensure they are in chronological order.""" trains = agenda.travel.parse_yaml("trains", data_dir) + + prev_depart = None + prev_train = None + for train in trains: + current_depart = normalize_datetime(train["depart"]) + if prev_depart and current_depart < prev_depart: + print(f"Out of order train found:") + print( + f" Previous: {prev_train.get('depart')} {prev_train.get('from', '')} -> {prev_train.get('to', '')}" + ) + print( + f" Current: {train.get('depart')} {train.get('from', '')} -> {train.get('to', '')}" + ) + assert False, "Trains are not in chronological order by departure time." + prev_depart = current_depart + prev_train = train + print(len(trains), "trains") def check_conferences() -> None: - """Check conferences.""" + """Check conferences and ensure they are in chronological order.""" filepath = os.path.join(data_dir, "conferences.yaml") - conferences = [ - agenda.conference.Conference(**conf) - for conf in yaml.safe_load(open(filepath, "r")) - ] - for conf in conferences: + conferences_data = yaml.safe_load(open(filepath, "r")) + conferences = [agenda.conference.Conference(**conf) for conf in conferences_data] + + prev_start = None + prev_conf_data = None + for i, conf_data in enumerate(conferences_data): + conf = conferences[i] if not conf.currency or conf.currency in currencies: - continue - pprint(conf) - print(f"currency {conf.currency!r} not in {currencies!r}") - sys.exit(-1) + pass + else: + pprint(conf) + print(f"currency {conf.currency!r} not in {currencies!r}") + sys.exit(-1) + + current_start = normalize_datetime(conf_data["start"]) + if prev_start and current_start < prev_start: + print(f"Out of order conference found:") + print( + f" Previous: {prev_conf_data.get('start')} - {prev_conf_data.get('name', 'No name')}" + ) + print( + f" Current: {conf_data.get('start')} - {conf_data.get('name', 'No name')}" + ) + assert False, "Conferences are not in chronological order by start time." + prev_start = current_start + prev_conf_data = conf_data print(len(conferences), "conferences") @@ -118,12 +180,14 @@ def check_coordinates(item: agenda.types.StrDict) -> None: def check_accommodation() -> None: - """Check accommodation.""" + """Check accommodation and ensure they are in chronological order.""" filepath = os.path.join(data_dir, "accommodation.yaml") accommodation_list = yaml.safe_load(open(filepath)) required_fields = ["type", "name", "country", "location", "trip", "from", "to"] + prev_from = None + prev_stay = None for stay in accommodation_list: try: assert all(field in stay for field in required_fields) @@ -134,6 +198,21 @@ def check_accommodation() -> None: check_currency(stay) + current_from = normalize_datetime(stay["from"]) + if prev_from and current_from < prev_from: + print(f"Out of order accommodation found:") + print( + f" Previous: {prev_stay.get('from')} - {prev_stay.get('name', 'No name')} ({prev_stay.get('location', '')})" + ) + print( + f" Current: {stay.get('from')} - {stay.get('name', 'No name')} ({stay.get('location', '')})" + ) + assert ( + False + ), "Accommodation is not in chronological order by check-in time." + prev_from = current_from + prev_stay = stay + print(len(accommodation_list), "stays") @@ -157,6 +236,30 @@ def check_stations() -> None: assert agenda.get_country(station["country"]) +def check_ferries() -> None: + """Check ferries and ensure they are in chronological order.""" + ferries = agenda.travel.parse_yaml("ferries", data_dir) + + prev_depart = None + prev_ferry = None + for ferry in ferries: + current_depart = normalize_datetime(ferry["depart"]) + if prev_depart and current_depart < prev_depart: + print(f"Out of order ferry found:") + print( + f" Previous: {prev_ferry.get('depart')} {prev_ferry.get('from', '')} -> {prev_ferry.get('to', '')}" + ) + print( + f" Current: {ferry.get('depart')} {ferry.get('from', '')} -> {ferry.get('to', '')}" + ) + assert False, "Ferries are not in chronological order by departure time." + prev_depart = current_depart + prev_ferry = ferry + check_currency(ferry) + + print(len(ferries), "ferries") + + def check_airlines() -> list[agenda.types.StrDict]: """Check airlines.""" airlines = agenda.travel.parse_yaml("airlines", data_dir) @@ -185,6 +288,7 @@ def check() -> None: check_trips() check_flights({airline["iata"] for airline in airlines}) check_trains() + check_ferries() check_conferences() check_events() check_accommodation() From ea712b2063bdcef28bbec50b7d120ff2a73a118d Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sun, 20 Jul 2025 01:11:07 +0200 Subject: [PATCH 2/3] Add tests for GWR. --- tests/test_gwr.py | 208 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 tests/test_gwr.py diff --git a/tests/test_gwr.py b/tests/test_gwr.py new file mode 100644 index 0000000..03cd342 --- /dev/null +++ b/tests/test_gwr.py @@ -0,0 +1,208 @@ +"""Tests for agenda.gwr module.""" + +import os +import tempfile +from datetime import date +from typing import Any +from unittest.mock import AsyncMock, patch + +import pytest + +from agenda.gwr import ( + advance_ticket_date, + advance_tickets_page_html, + extract_dates, + extract_weekday_date, + parse_date_string, +) + + +class TestParseDateString: + """Tests for parse_date_string function.""" + + def test_parse_date_with_year(self) -> None: + """Test parsing date string with year included.""" + result = parse_date_string("Monday 25 December 2023") + assert result == date(2023, 12, 25) + + def test_parse_date_without_year(self) -> None: + """Test parsing date string without year (should use current year).""" + current_year = date.today().year + result = parse_date_string("Monday 25 December") + assert result == date(current_year, 12, 25) + + def test_parse_different_formats(self) -> None: + """Test parsing various date formats.""" + result = parse_date_string("Friday 1 January 2024") + assert result == date(2024, 1, 1) + + result = parse_date_string("Saturday 29 February 2024") + assert result == date(2024, 2, 29) + + +class TestExtractDates: + """Tests for extract_dates function.""" + + def test_extract_valid_dates(self) -> None: + """Test extracting dates from valid HTML.""" + html = """ + WeekdaysMonday 25 December 2023 + SaturdaysSaturday 23 December 2023 + SundaysSunday 24 December 2023 + """ + result = extract_dates(html) + expected = { + "Weekdays": date(2023, 12, 25), + "Saturdays": date(2023, 12, 23), + "Sundays": date(2023, 12, 24), + } + assert result == expected + + def test_extract_dates_with_asterisks(self) -> None: + """Test extracting dates when HTML contains asterisks.""" + html = """ + WeekdaysMonday 25 December 2023** + """ + result = extract_dates(html) + expected = {"Weekdays": date(2023, 12, 25)} + assert result == expected + + def test_extract_dates_no_match(self) -> None: + """Test extracting dates when no pattern matches.""" + html = "

No relevant table data here

" + result = extract_dates(html) + assert result is None + + def test_extract_dates_whitespace_handling(self) -> None: + """Test extracting dates with various whitespace.""" + html = """ + Weekdays Monday 25 December 2023 + """ + result = extract_dates(html) + expected = {"Weekdays": date(2023, 12, 25)} + assert result == expected + + +class TestExtractWeekdayDate: + """Tests for extract_weekday_date function.""" + + def test_extract_valid_weekday_date(self) -> None: + """Test extracting weekday date from valid HTML.""" + html = """ + WeekdaysMonday 25 December 2023 + """ + result = extract_weekday_date(html) + assert result == date(2023, 12, 25) + + def test_extract_weekday_date_with_asterisks(self) -> None: + """Test extracting weekday date when HTML contains asterisks.""" + html = """ + WeekdaysMonday 25 December 2023** + """ + result = extract_weekday_date(html) + assert result == date(2023, 12, 25) + + def test_extract_weekday_date_no_match(self) -> None: + """Test extracting weekday date when no pattern matches.""" + html = "

No weekday data here

" + result = extract_weekday_date(html) + assert result is None + + def test_extract_weekday_date_multiline(self) -> None: + """Test extracting weekday date with multiline content.""" + html = """ + + Weekdays + Monday 25 December 2023 + + """ + result = extract_weekday_date(html) + assert result == date(2023, 12, 25) + + +class TestAdvanceTicketsPageHtml: + """Tests for advance_tickets_page_html function.""" + + @pytest.mark.asyncio + async def test_cache_hit(self) -> None: + """Test using cached HTML when file is fresh.""" + with tempfile.TemporaryDirectory() as temp_dir: + cache_file = os.path.join(temp_dir, "advance-tickets.html") + test_content = "test content" + + # Create a fresh cache file + with open(cache_file, "w") as f: + f.write(test_content) + + result = await advance_tickets_page_html(temp_dir, ttl=3600) + assert result == test_content + + @pytest.mark.asyncio + async def test_force_cache(self) -> None: + """Test forcing cache usage even when file doesn't exist.""" + with tempfile.TemporaryDirectory() as temp_dir: + cache_file = os.path.join(temp_dir, "advance-tickets.html") + test_content = "cached content" + + with open(cache_file, "w") as f: + f.write(test_content) + + result = await advance_tickets_page_html(temp_dir, force_cache=True) + assert result == test_content + + @pytest.mark.asyncio + @patch("httpx.AsyncClient") + async def test_fetch_from_web(self, mock_client: Any) -> None: + """Test fetching from web when cache is stale.""" + mock_response = AsyncMock() + mock_response.text = "fresh content" + mock_client.return_value.__aenter__.return_value.get.return_value = ( + mock_response + ) + + with tempfile.TemporaryDirectory() as temp_dir: + result = await advance_tickets_page_html(temp_dir, ttl=0) + assert result == "fresh content" + + # Check that content was cached + cache_file = os.path.join(temp_dir, "advance-tickets.html") + with open(cache_file) as f: + cached_content = f.read() + assert cached_content == "fresh content" + + +class TestAdvanceTicketDate: + """Tests for advance_ticket_date function.""" + + @pytest.mark.asyncio + @patch("agenda.gwr.advance_tickets_page_html") + async def test_advance_ticket_date_success(self, mock_html: Any) -> None: + """Test successfully extracting advance ticket date.""" + mock_html.return_value = """ + WeekdaysMonday 25 December 2023 + """ + + result = await advance_ticket_date("/fake/dir") + assert result == date(2023, 12, 25) + mock_html.assert_called_once_with("/fake/dir", force_cache=False) + + @pytest.mark.asyncio + @patch("agenda.gwr.advance_tickets_page_html") + async def test_advance_ticket_date_no_match(self, mock_html: Any) -> None: + """Test when no weekday date can be extracted.""" + mock_html.return_value = "

No relevant data

" + + result = await advance_ticket_date("/fake/dir") + assert result is None + + @pytest.mark.asyncio + @patch("agenda.gwr.advance_tickets_page_html") + async def test_advance_ticket_date_force_cache(self, mock_html: Any) -> None: + """Test advance_ticket_date with force_cache parameter.""" + mock_html.return_value = """ + WeekdaysTuesday 26 December 2023 + """ + + result = await advance_ticket_date("/fake/dir", force_cache=True) + assert result == date(2023, 12, 26) + mock_html.assert_called_once_with("/fake/dir", force_cache=True) From 88ccd79cb26f7a650e8dd50079e8d66930eb261c Mon Sep 17 00:00:00 2001 From: Edward Betts Date: Sun, 20 Jul 2025 01:14:24 +0200 Subject: [PATCH 3/3] Add comprehensive tests for conference module. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- tests/test_conference.py | 370 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 tests/test_conference.py diff --git a/tests/test_conference.py b/tests/test_conference.py new file mode 100644 index 0000000..811bdb9 --- /dev/null +++ b/tests/test_conference.py @@ -0,0 +1,370 @@ +"""Tests for agenda.conference module.""" + +import decimal +import tempfile +from datetime import date, datetime +from typing import Any + +import pytest +import yaml + +from agenda.conference import Conference, get_list +from agenda.event import Event + + +class TestConference: + """Tests for Conference dataclass.""" + + def test_conference_creation_minimal(self) -> None: + """Test creating conference with minimal required fields.""" + conf = Conference( + name="PyCon", + topic="Python", + location="Portland", + start=date(2024, 5, 15), + end=date(2024, 5, 17), + ) + assert conf.name == "PyCon" + assert conf.topic == "Python" + assert conf.location == "Portland" + assert conf.start == date(2024, 5, 15) + assert conf.end == date(2024, 5, 17) + assert conf.trip is None + assert conf.going is False + assert conf.online is False + + def test_conference_creation_full(self) -> None: + """Test creating conference with all fields.""" + conf = Conference( + name="PyCon US", + topic="Python", + location="Portland", + start=date(2024, 5, 15), + end=date(2024, 5, 17), + trip=date(2024, 5, 14), + country="USA", + venue="Convention Center", + address="123 Main St", + url="https://pycon.org", + accommodation_booked=True, + transport_booked=True, + going=True, + registered=True, + speaking=True, + online=False, + price=decimal.Decimal("500.00"), + currency="USD", + latitude=45.5152, + longitude=-122.6784, + cfp_end=date(2024, 2, 1), + cfp_url="https://pycon.org/cfp", + free=False, + hackathon=True, + ticket_type="early_bird", + attendees=3000, + hashtag="#pycon2024", + ) + assert conf.name == "PyCon US" + assert conf.going is True + assert conf.price == decimal.Decimal("500.00") + assert conf.currency == "USD" + assert conf.latitude == 45.5152 + assert conf.longitude == -122.6784 + assert conf.cfp_end == date(2024, 2, 1) + assert conf.hashtag == "#pycon2024" + + def test_display_name_location_in_name(self) -> None: + """Test display_name when location is already in conference name.""" + conf = Conference( + name="PyCon Portland", + topic="Python", + location="Portland", + start=date(2024, 5, 15), + end=date(2024, 5, 17), + ) + assert conf.display_name == "PyCon Portland" + + def test_display_name_location_not_in_name(self) -> None: + """Test display_name when location is not in conference name.""" + conf = Conference( + name="PyCon", + topic="Python", + location="Portland", + start=date(2024, 5, 15), + end=date(2024, 5, 17), + ) + assert conf.display_name == "PyCon (Portland)" + + def test_display_name_partial_location_match(self) -> None: + """Test display_name when location is partially in name.""" + conf = Conference( + name="PyConf", + topic="Python", + location="Conference Center", + start=date(2024, 5, 15), + end=date(2024, 5, 17), + ) + assert conf.display_name == "PyConf (Conference Center)" + + def test_conference_with_datetime(self) -> None: + """Test conference with datetime objects.""" + start_dt = datetime(2024, 5, 15, 9, 0) + end_dt = datetime(2024, 5, 17, 17, 0) + conf = Conference( + name="PyCon", + topic="Python", + location="Portland", + start=start_dt, + end=end_dt, + ) + assert conf.start == start_dt + assert conf.end == end_dt + + +class TestGetList: + """Tests for get_list function.""" + + def test_get_list_single_conference(self) -> None: + """Test reading single conference from YAML.""" + yaml_data = [ + { + "name": "PyCon", + "topic": "Python", + "location": "Portland", + "start": date(2024, 5, 15), + "end": date(2024, 5, 17), + "url": "https://pycon.org", + "going": True, + } + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(yaml_data, f) + f.flush() + + events = get_list(f.name) + + assert len(events) == 1 + event = events[0] + assert isinstance(event, Event) + assert event.name == "conference" + assert event.date == date(2024, 5, 15) + assert event.end_date == date(2024, 5, 17) + assert event.title == "PyCon (Portland)" + assert event.url == "https://pycon.org" + assert event.going is True + + def test_get_list_conference_with_cfp(self) -> None: + """Test reading conference with CFP end date.""" + yaml_data = [ + { + "name": "PyCon", + "topic": "Python", + "location": "Portland", + "start": date(2024, 5, 15), + "end": date(2024, 5, 17), + "url": "https://pycon.org", + "cfp_end": date(2024, 2, 1), + "cfp_url": "https://pycon.org/cfp", + } + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(yaml_data, f) + f.flush() + + events = get_list(f.name) + + assert len(events) == 2 + + # Conference event + conf_event = events[0] + assert conf_event.name == "conference" + assert conf_event.title == "PyCon (Portland)" + assert conf_event.url == "https://pycon.org" + + # CFP end event + cfp_event = events[1] + assert cfp_event.name == "cfp_end" + assert cfp_event.date == date(2024, 2, 1) + assert cfp_event.title == "CFP end: PyCon (Portland)" + assert cfp_event.url == "https://pycon.org/cfp" + + def test_get_list_conference_cfp_no_url(self) -> None: + """Test reading conference with CFP end date but no CFP URL.""" + yaml_data = [ + { + "name": "PyCon", + "topic": "Python", + "location": "Portland", + "start": date(2024, 5, 15), + "end": date(2024, 5, 17), + "url": "https://pycon.org", + "cfp_end": date(2024, 2, 1), + } + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(yaml_data, f) + f.flush() + + events = get_list(f.name) + + assert len(events) == 2 + cfp_event = events[1] + assert cfp_event.url == "https://pycon.org" # Falls back to conference URL + + def test_get_list_multiple_conferences(self) -> None: + """Test reading multiple conferences from YAML.""" + yaml_data = [ + { + "name": "PyCon", + "topic": "Python", + "location": "Portland", + "start": date(2024, 5, 15), + "end": date(2024, 5, 17), + }, + { + "name": "EuroPython", + "topic": "Python", + "location": "Prague", + "start": date(2024, 7, 8), + "end": date(2024, 7, 14), + "cfp_end": date(2024, 3, 15), + }, + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(yaml_data, f) + f.flush() + + events = get_list(f.name) + + assert len(events) == 3 # 2 conferences + 1 CFP end + + # First conference + assert events[0].title == "PyCon (Portland)" + assert events[0].date == date(2024, 5, 15) + + # Second conference + assert events[1].title == "EuroPython (Prague)" + assert events[1].date == date(2024, 7, 8) + + # CFP end event for second conference + assert events[2].name == "cfp_end" + assert events[2].title == "CFP end: EuroPython (Prague)" + + def test_get_list_location_in_name(self) -> None: + """Test conference where location is already in name.""" + yaml_data = [ + { + "name": "PyCon Portland", + "topic": "Python", + "location": "Portland", + "start": date(2024, 5, 15), + "end": date(2024, 5, 17), + } + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(yaml_data, f) + f.flush() + + events = get_list(f.name) + + assert len(events) == 1 + assert events[0].title == "PyCon Portland" # No location appended + + def test_get_list_datetime_objects(self) -> None: + """Test reading conferences with datetime objects.""" + yaml_data = [ + { + "name": "PyCon", + "topic": "Python", + "location": "Portland", + "start": datetime(2024, 5, 15, 9, 0), + "end": datetime(2024, 5, 17, 17, 0), + } + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(yaml_data, f) + f.flush() + + events = get_list(f.name) + + assert len(events) == 1 + event = events[0] + assert event.date == datetime(2024, 5, 15, 9, 0) + assert event.end_date == datetime(2024, 5, 17, 17, 0) + + def test_get_list_invalid_date_order(self) -> None: + """Test that conferences with end before start raise assertion error.""" + yaml_data = [ + { + "name": "Invalid Conference", + "topic": "Testing", + "location": "Nowhere", + "start": date(2024, 5, 17), + "end": date(2024, 5, 15), # End before start + } + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(yaml_data, f) + f.flush() + + with pytest.raises(AssertionError): + get_list(f.name) + + def test_get_list_too_long_conference(self) -> None: + """Test that conferences longer than MAX_CONF_DAYS raise assertion error.""" + yaml_data = [ + { + "name": "Too Long Conference", + "topic": "Testing", + "location": "Nowhere", + "start": date(2024, 5, 1), + "end": date(2024, 6, 1), # More than 20 days + } + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(yaml_data, f) + f.flush() + + with pytest.raises(AssertionError): + get_list(f.name) + + def test_get_list_empty_file(self) -> None: + """Test reading empty YAML file.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump([], f) + f.flush() + + events = get_list(f.name) + + assert events == [] + + def test_get_list_same_day_conference(self) -> None: + """Test conference that starts and ends on same day.""" + yaml_data = [ + { + "name": "One Day Conference", + "topic": "Testing", + "location": "Test City", + "start": date(2024, 5, 15), + "end": date(2024, 5, 15), + } + ] + + with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f: + yaml.dump(yaml_data, f) + f.flush() + + events = get_list(f.name) + + assert len(events) == 1 + event = events[0] + assert event.date == date(2024, 5, 15) + assert event.end_date == date(2024, 5, 15)