Compare commits

..

3 commits

Author SHA1 Message Date
Edward Betts 88ccd79cb2 Add comprehensive tests for conference module.
🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-20 01:14:24 +02:00
Edward Betts ea712b2063 Add tests for GWR. 2025-07-20 01:11:07 +02:00
Edward Betts 412d8c7ba7 Chronological order validation for YAML 2025-07-20 01:10:56 +02:00
3 changed files with 696 additions and 14 deletions

370
tests/test_conference.py Normal file
View file

@ -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)

208
tests/test_gwr.py Normal file
View file

@ -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 = """
<tr><td>Weekdays</td><td>Monday 25 December 2023</td></tr>
<tr><td>Saturdays</td><td>Saturday 23 December 2023</td></tr>
<tr><td>Sundays</td><td>Sunday 24 December 2023</td></tr>
"""
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 = """
<tr><td>Weekdays</td><td>Monday 25 December 2023**</td></tr>
"""
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 = "<p>No relevant table data here</p>"
result = extract_dates(html)
assert result is None
def test_extract_dates_whitespace_handling(self) -> None:
"""Test extracting dates with various whitespace."""
html = """
<tr> <td>Weekdays</td> <td>Monday 25 December 2023</td> </tr>
"""
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 = """
<tr><td>Weekdays</td><td>Monday 25 December 2023</td></tr>
"""
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 = """
<tr><td>Weekdays</td><td>Monday 25 December 2023**</td></tr>
"""
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 = "<p>No weekday data here</p>"
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 = """
<tr>
<td>Weekdays</td>
<td>Monday 25 December 2023</td>
</tr>
"""
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 = "<html>test content</html>"
# 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 = "<html>cached content</html>"
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 = "<html>fresh content</html>"
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 == "<html>fresh content</html>"
# 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 == "<html>fresh content</html>"
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 = """
<tr><td>Weekdays</td><td>Monday 25 December 2023</td></tr>
"""
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 = "<p>No relevant data</p>"
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 = """
<tr><td>Weekdays</td><td>Tuesday 26 December 2023</td></tr>
"""
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)

View file

@ -4,7 +4,7 @@
import os import os
import sys import sys
import typing import typing
from datetime import date, timedelta from datetime import date, timedelta, datetime
import yaml import yaml
from rich.pretty import pprint from rich.pretty import pprint
@ -33,7 +33,26 @@ def check_currency(item: agenda.types.StrDict) -> None:
def check_trips() -> 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) trip_list = agenda.trip.build_trip_list(data_dir)
print(len(trip_list), "trips") 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: def check_trains() -> None:
"""Check trains.""" """Check trains and ensure they are in chronological order."""
trains = agenda.travel.parse_yaml("trains", data_dir) 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") print(len(trains), "trains")
def check_conferences() -> None: def check_conferences() -> None:
"""Check conferences.""" """Check conferences and ensure they are in chronological order."""
filepath = os.path.join(data_dir, "conferences.yaml") filepath = os.path.join(data_dir, "conferences.yaml")
conferences = [ conferences_data = yaml.safe_load(open(filepath, "r"))
agenda.conference.Conference(**conf) conferences = [agenda.conference.Conference(**conf) for conf in conferences_data]
for conf in yaml.safe_load(open(filepath, "r"))
] prev_start = None
for conf in conferences: prev_conf_data = None
for i, conf_data in enumerate(conferences_data):
conf = conferences[i]
if not conf.currency or conf.currency in currencies: if not conf.currency or conf.currency in currencies:
continue pass
pprint(conf) else:
print(f"currency {conf.currency!r} not in {currencies!r}") pprint(conf)
sys.exit(-1) 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") print(len(conferences), "conferences")
@ -118,12 +180,14 @@ def check_coordinates(item: agenda.types.StrDict) -> None:
def check_accommodation() -> None: def check_accommodation() -> None:
"""Check accommodation.""" """Check accommodation and ensure they are in chronological order."""
filepath = os.path.join(data_dir, "accommodation.yaml") filepath = os.path.join(data_dir, "accommodation.yaml")
accommodation_list = yaml.safe_load(open(filepath)) accommodation_list = yaml.safe_load(open(filepath))
required_fields = ["type", "name", "country", "location", "trip", "from", "to"] required_fields = ["type", "name", "country", "location", "trip", "from", "to"]
prev_from = None
prev_stay = None
for stay in accommodation_list: for stay in accommodation_list:
try: try:
assert all(field in stay for field in required_fields) assert all(field in stay for field in required_fields)
@ -134,6 +198,21 @@ def check_accommodation() -> None:
check_currency(stay) 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") print(len(accommodation_list), "stays")
@ -157,6 +236,30 @@ def check_stations() -> None:
assert agenda.get_country(station["country"]) 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]: def check_airlines() -> list[agenda.types.StrDict]:
"""Check airlines.""" """Check airlines."""
airlines = agenda.travel.parse_yaml("airlines", data_dir) airlines = agenda.travel.parse_yaml("airlines", data_dir)
@ -185,6 +288,7 @@ def check() -> None:
check_trips() check_trips()
check_flights({airline["iata"] for airline in airlines}) check_flights({airline["iata"] for airline in airlines})
check_trains() check_trains()
check_ferries()
check_conferences() check_conferences()
check_events() check_events()
check_accommodation() check_accommodation()