486 lines
15 KiB
Python
486 lines
15 KiB
Python
"""Tests for agenda.add_new_conference."""
|
|
|
|
from datetime import date, datetime
|
|
import typing
|
|
|
|
import lxml.html
|
|
import pytest
|
|
import yaml
|
|
|
|
from agenda import add_new_conference
|
|
from agenda.conference import ConferenceSeries
|
|
|
|
|
|
def test_parse_osm_url_mlat_mlon() -> None:
|
|
"""OpenStreetMap URLs with mlat/mlon should parse."""
|
|
result = add_new_conference.parse_osm_url(
|
|
"https://www.openstreetmap.org/?mlat=51.5&mlon=-0.12"
|
|
)
|
|
assert result == (51.5, -0.12)
|
|
|
|
|
|
def test_extract_google_maps_latlon_at_pattern() -> None:
|
|
"""Google Maps @lat,lon URLs should parse."""
|
|
result = add_new_conference.extract_google_maps_latlon(
|
|
"https://www.google.com/maps/place/Venue/@51.5242464,-0.0997024,17z/"
|
|
)
|
|
assert result == (51.5242464, -0.0997024)
|
|
|
|
|
|
def test_url_has_year_component() -> None:
|
|
"""Only actual year or edition components should count as year-specific."""
|
|
cases = [
|
|
("https://www.foss4gna.org/", False),
|
|
("https://foss4g.asia/2026/", True),
|
|
("https://2027.fossy.ca/", True),
|
|
("https://www.socallinuxexpo.org/scale/24x/", True),
|
|
("https://2026.stateofthebrowser.com/", True),
|
|
]
|
|
for url, expected in cases:
|
|
assert add_new_conference.url_has_year_component(url) is expected
|
|
|
|
|
|
def test_insert_sorted_allows_same_url_different_year_without_year_component() -> None:
|
|
"""The same non-year-specific URL can be reused for a different year."""
|
|
conferences: list[dict[str, typing.Any]] = [
|
|
{
|
|
"name": "OldConf",
|
|
"start": date(2025, 6, 1),
|
|
"url": "https://example.com/conf",
|
|
}
|
|
]
|
|
new_conf: dict[str, typing.Any] = {
|
|
"name": "NewConf",
|
|
"start": date(2026, 6, 1),
|
|
"url": "https://example.com/conf",
|
|
}
|
|
|
|
updated = add_new_conference.insert_sorted(conferences, new_conf)
|
|
|
|
assert len(updated) == 2
|
|
assert updated[1]["name"] == "NewConf"
|
|
|
|
|
|
def test_insert_sorted_supports_nested_dates() -> None:
|
|
"""Nested dates should be used for sorting."""
|
|
conferences: list[dict[str, typing.Any]] = [
|
|
{
|
|
"name": "PyCascades",
|
|
"dates": {
|
|
"status": "approximate",
|
|
"label": "March 2027",
|
|
"earliest": date(2027, 3, 1),
|
|
"latest": date(2027, 3, 31),
|
|
},
|
|
}
|
|
]
|
|
new_conf: dict[str, typing.Any] = {
|
|
"name": "FOSDEM",
|
|
"dates": {
|
|
"status": "tentative",
|
|
"start": date(2027, 1, 30),
|
|
"end": date(2027, 1, 31),
|
|
},
|
|
}
|
|
|
|
updated = add_new_conference.insert_sorted(conferences, new_conf)
|
|
|
|
assert [conf["name"] for conf in updated] == ["FOSDEM", "PyCascades"]
|
|
|
|
|
|
def test_insert_sorted_updates_inexact_existing_entry() -> None:
|
|
"""Exact dates should replace an existing inexact series entry."""
|
|
conferences: list[dict[str, typing.Any]] = [
|
|
{
|
|
"name": "PyCascades",
|
|
"series": "pycascades",
|
|
"topic": "Python",
|
|
"location": "Seattle, Washington",
|
|
"dates": {
|
|
"status": "approximate",
|
|
"label": "March 2027",
|
|
"earliest": date(2027, 3, 1),
|
|
"latest": date(2027, 3, 31),
|
|
},
|
|
"url": "https://2027.pycascades.com/",
|
|
}
|
|
]
|
|
new_conf: dict[str, typing.Any] = {
|
|
"name": "PyCascades",
|
|
"series": "pycascades",
|
|
"topic": "Python",
|
|
"location": "Seattle, Washington",
|
|
"dates": {
|
|
"status": "exact",
|
|
"start": date(2027, 3, 12),
|
|
"end": date(2027, 3, 14),
|
|
},
|
|
"url": "https://2027.pycascades.com/",
|
|
"venue": "Example Hall",
|
|
}
|
|
|
|
updated = add_new_conference.insert_sorted(conferences, new_conf)
|
|
|
|
assert len(updated) == 1
|
|
assert updated[0]["dates"]["status"] == "exact"
|
|
assert updated[0]["dates"]["start"] == date(2027, 3, 12)
|
|
assert updated[0]["venue"] == "Example Hall"
|
|
|
|
|
|
def test_normalize_dates_field_moves_legacy_dates() -> None:
|
|
"""Legacy start/end model output should be converted before writing YAML."""
|
|
conf: dict[str, typing.Any] = {
|
|
"name": "PyCon",
|
|
"start": date(2026, 4, 10),
|
|
"end": date(2026, 4, 12),
|
|
}
|
|
|
|
add_new_conference.normalize_dates_field(conf)
|
|
|
|
assert "start" not in conf
|
|
assert "end" not in conf
|
|
assert conf["dates"] == {
|
|
"status": "exact",
|
|
"start": date(2026, 4, 10),
|
|
"end": date(2026, 4, 12),
|
|
}
|
|
|
|
|
|
def test_normalize_dates_field_parses_quoted_dates() -> None:
|
|
"""Quoted ISO dates from generated YAML should become date objects."""
|
|
conf: dict[str, typing.Any] = {
|
|
"name": "Git Merge",
|
|
"dates": {
|
|
"status": "exact",
|
|
"start": "2026-09-16",
|
|
"end": "2026-09-17",
|
|
},
|
|
}
|
|
|
|
add_new_conference.normalize_dates_field(conf)
|
|
|
|
assert conf["dates"]["start"] == date(2026, 9, 16)
|
|
assert conf["dates"]["end"] == date(2026, 9, 17)
|
|
|
|
|
|
def test_validate_generated_conference_reports_missing_dates() -> None:
|
|
"""Missing generated dates should raise a clear importer error."""
|
|
conf: dict[str, typing.Any] = {
|
|
"name": "Git Merge",
|
|
"topic": "Git",
|
|
"location": "TBC",
|
|
}
|
|
|
|
with pytest.raises(ValueError, match="missing valid date information"):
|
|
add_new_conference.validate_generated_conference(conf)
|
|
|
|
|
|
def test_webpage_to_text_includes_hidden_event_metadata() -> None:
|
|
"""Schema.org event metadata should be included even when visually hidden."""
|
|
root = lxml.html.fromstring("""
|
|
<html><body>
|
|
<div style="display:none" itemscope itemtype="http://schema.org/Event">
|
|
<meta itemprop="name" content="State of the Map Asia 2026 OSAKA">
|
|
<meta itemprop="startDate" content="2026-09-06T18:30">
|
|
</div>
|
|
</body></html>
|
|
""")
|
|
|
|
text = add_new_conference.webpage_to_text(root)
|
|
|
|
assert "- name: State of the Map Asia 2026 OSAKA" in text
|
|
assert "- startDate: 2026-09-06T18:30" in text
|
|
|
|
|
|
def test_apply_event_metadata_dates_fills_missing_generated_dates() -> None:
|
|
"""Structured page dates should recover incomplete model output."""
|
|
root = lxml.html.fromstring("""
|
|
<div itemscope itemtype="http://schema.org/Event">
|
|
<meta itemprop="startDate" content="2026-09-06T18:30">
|
|
</div>
|
|
""")
|
|
conf: dict[str, typing.Any] = {"name": "State of the Map Asia 2026"}
|
|
|
|
add_new_conference.apply_event_metadata_dates(conf, root)
|
|
|
|
assert conf["dates"] == {
|
|
"status": "exact",
|
|
"start": datetime(2026, 9, 6, 18, 30),
|
|
"end": datetime(2026, 9, 6, 18, 30),
|
|
}
|
|
|
|
|
|
def test_build_prompt_includes_nested_dates_and_series() -> None:
|
|
"""The prompt should describe nested dates and known series IDs."""
|
|
prompt = add_new_conference.build_prompt(
|
|
"https://example.com",
|
|
"Conference details",
|
|
None,
|
|
{
|
|
"pycascades": {
|
|
"name": "PyCascades",
|
|
"topic": "Python",
|
|
"usual_location": "Seattle, Washington",
|
|
"country": "us",
|
|
}
|
|
},
|
|
)
|
|
|
|
assert "Do not output legacy top-level `start`, `end`, or `date_status`" in prompt
|
|
assert "dates.status" in prompt
|
|
assert "- pycascades: PyCascades" in prompt
|
|
assert "March 2027" in prompt
|
|
assert "For an address written in a non-Latin script" in prompt
|
|
assert "If no reliable rendering is available, omit" in prompt
|
|
assert "When `free: true`, omit `price` and `currency`" in prompt
|
|
|
|
|
|
def test_validate_country_normalises_name() -> None:
|
|
"""Country names should be normalised to alpha-2 codes."""
|
|
conf: dict[str, typing.Any] = {"country": "United Kingdom"}
|
|
|
|
add_new_conference.validate_country(conf)
|
|
|
|
assert conf["country"] == "gb"
|
|
|
|
|
|
def test_validate_series_rejects_unknown_generated_id() -> None:
|
|
"""Generated series IDs must exist in conference_series.yaml."""
|
|
conf: dict[str, typing.Any] = {"series": "state-of-the-map-asia"}
|
|
|
|
with pytest.raises(ValueError, match="unknown series 'state-of-the-map-asia'"):
|
|
add_new_conference.validate_series(
|
|
conf,
|
|
{"state-of-the-map": {"name": "State of the Map"}},
|
|
)
|
|
|
|
|
|
def test_validate_series_accepts_known_id_or_missing_field() -> None:
|
|
"""Known series IDs and conferences without a series should pass."""
|
|
series: dict[str, ConferenceSeries] = {
|
|
"state-of-the-map": {"name": "State of the Map"}
|
|
}
|
|
|
|
add_new_conference.validate_series({"series": "state-of-the-map"}, series)
|
|
add_new_conference.validate_series({}, series)
|
|
|
|
|
|
def test_normalize_free_event_fields_removes_price_and_currency() -> None:
|
|
"""Free events should not retain redundant pricing fields."""
|
|
conf: dict[str, typing.Any] = {
|
|
"free": True,
|
|
"price": 0,
|
|
"currency": "JPY",
|
|
}
|
|
|
|
add_new_conference.normalize_free_event_fields(conf)
|
|
|
|
assert conf == {"free": True}
|
|
|
|
|
|
def test_normalize_free_event_fields_keeps_paid_event_pricing() -> None:
|
|
"""Pricing fields should remain for events that are not marked free."""
|
|
conf: dict[str, typing.Any] = {
|
|
"free": False,
|
|
"price": 25,
|
|
"currency": "GBP",
|
|
}
|
|
|
|
add_new_conference.normalize_free_event_fields(conf)
|
|
|
|
assert conf == {"free": False, "price": 25, "currency": "GBP"}
|
|
|
|
|
|
def test_normalise_end_field_defaults_single_day_date() -> None:
|
|
"""Non-Geomob conferences should default end to the start date."""
|
|
conf: dict[str, typing.Any] = {
|
|
"name": "PyCon",
|
|
"start": date(2026, 4, 10),
|
|
}
|
|
|
|
add_new_conference.normalise_end_field(conf, "plain text")
|
|
|
|
assert conf["end"] == date(2026, 4, 10)
|
|
|
|
|
|
def test_normalise_end_field_defaults_nested_exact_date() -> None:
|
|
"""Nested exact dates should get a default end date."""
|
|
conf: dict[str, typing.Any] = {
|
|
"name": "PyCon",
|
|
"dates": {
|
|
"status": "exact",
|
|
"start": date(2026, 4, 10),
|
|
},
|
|
}
|
|
|
|
add_new_conference.normalise_end_field(conf, "plain text")
|
|
|
|
assert conf["dates"]["end"] == date(2026, 4, 10)
|
|
|
|
|
|
def test_normalise_end_field_sets_geomob_end_time() -> None:
|
|
"""Geomob conferences should default to a 22:00 end time."""
|
|
conf: dict[str, typing.Any] = {
|
|
"name": "Geomob London",
|
|
"start": date(2026, 1, 28),
|
|
"url": "https://thegeomob.com/post/jan-28th-2026-geomoblon-details",
|
|
}
|
|
|
|
add_new_conference.normalise_end_field(conf, "see you there")
|
|
|
|
assert conf["end"] == datetime(2026, 1, 28, 22, 0)
|
|
|
|
|
|
def test_detect_page_coordinates_uses_first_supported_link() -> None:
|
|
"""Page coordinate detection should inspect anchor hrefs."""
|
|
root = lxml.html.fromstring(
|
|
(
|
|
"<html><body>"
|
|
'<a href="https://example.com">Example</a>'
|
|
'<a href="https://www.openstreetmap.org/?mlat=51.5&mlon=-0.12">Map</a>'
|
|
"</body></html>"
|
|
)
|
|
)
|
|
|
|
assert add_new_conference.detect_page_coordinates(root) == (51.5, -0.12)
|
|
|
|
|
|
def test_add_new_conference_updates_yaml(
|
|
tmp_path: typing.Any, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""The end-to-end import flow should append a generated conference."""
|
|
yaml_path = tmp_path / "conferences.yaml"
|
|
yaml_path.write_text(
|
|
yaml.dump(
|
|
[
|
|
{
|
|
"name": "ExistingConf",
|
|
"start": date(2026, 4, 1),
|
|
"end": date(2026, 4, 2),
|
|
"url": "https://example.com/existing",
|
|
}
|
|
],
|
|
sort_keys=False,
|
|
)
|
|
)
|
|
|
|
root = lxml.html.fromstring(
|
|
(
|
|
"<html><body>"
|
|
'<a href="https://www.openstreetmap.org/?mlat=40.0&mlon=-74.0">Map</a>'
|
|
"</body></html>"
|
|
)
|
|
)
|
|
|
|
monkeypatch.setattr(add_new_conference, "fetch_webpage", lambda url: root)
|
|
monkeypatch.setattr(
|
|
add_new_conference,
|
|
"webpage_to_text",
|
|
lambda parsed: "Conference details",
|
|
)
|
|
monkeypatch.setattr(
|
|
add_new_conference,
|
|
"get_from_open_ai",
|
|
lambda prompt: {
|
|
"yaml": yaml.dump(
|
|
{
|
|
"name": "NewConf",
|
|
"topic": "Tech",
|
|
"location": "New York",
|
|
"country": "United States",
|
|
"start": date(2026, 5, 3),
|
|
"url": "https://example.com/newconf",
|
|
},
|
|
sort_keys=False,
|
|
)
|
|
},
|
|
)
|
|
|
|
added = add_new_conference.add_new_conference(
|
|
"https://example.com/newconf", str(yaml_path)
|
|
)
|
|
|
|
assert added is True
|
|
written = yaml.safe_load(yaml_path.read_text())
|
|
assert len(written) == 2
|
|
assert written[1]["name"] == "NewConf"
|
|
assert written[1]["country"] == "us"
|
|
assert written[1]["dates"] == {
|
|
"status": "exact",
|
|
"start": date(2026, 5, 3),
|
|
"end": date(2026, 5, 3),
|
|
}
|
|
assert written[1]["latitude"] == 40.0
|
|
assert written[1]["longitude"] == -74.0
|
|
|
|
|
|
def test_add_new_conference_reuses_generic_url_for_new_year(
|
|
tmp_path: typing.Any, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Generic URLs with digits in the domain should not be skipped early."""
|
|
yaml_path = tmp_path / "conferences.yaml"
|
|
(tmp_path / "conference_series.yaml").write_text(
|
|
yaml.dump(
|
|
{"foss4g-north-america": {"name": "FOSS4G North America"}},
|
|
sort_keys=False,
|
|
)
|
|
)
|
|
yaml_path.write_text(
|
|
yaml.dump(
|
|
[
|
|
{
|
|
"name": "FOSS4G North America",
|
|
"series": "foss4g-north-america",
|
|
"dates": {
|
|
"status": "exact",
|
|
"start": date(2025, 11, 3),
|
|
"end": date(2025, 11, 5),
|
|
},
|
|
"url": "https://www.foss4gna.org/",
|
|
}
|
|
],
|
|
sort_keys=False,
|
|
)
|
|
)
|
|
|
|
root = lxml.html.fromstring("<html><body>Conference details</body></html>")
|
|
monkeypatch.setattr(add_new_conference, "fetch_webpage", lambda url: root)
|
|
monkeypatch.setattr(
|
|
add_new_conference,
|
|
"webpage_to_text",
|
|
lambda parsed: "FOSS4G North America 2026",
|
|
)
|
|
monkeypatch.setattr(
|
|
add_new_conference, "detect_page_coordinates", lambda parsed: None
|
|
)
|
|
monkeypatch.setattr(
|
|
add_new_conference,
|
|
"get_from_open_ai",
|
|
lambda prompt: {
|
|
"yaml": yaml.dump(
|
|
{
|
|
"name": "FOSS4G North America",
|
|
"series": "foss4g-north-america",
|
|
"topic": "Geospatial",
|
|
"location": "St. Louis, Missouri",
|
|
"country": "us",
|
|
"dates": {
|
|
"status": "exact",
|
|
"start": date(2026, 10, 26),
|
|
"end": date(2026, 10, 29),
|
|
},
|
|
"url": "https://www.foss4gna.org/",
|
|
},
|
|
sort_keys=False,
|
|
)
|
|
},
|
|
)
|
|
|
|
added = add_new_conference.add_new_conference(
|
|
"https://www.foss4gna.org/", str(yaml_path)
|
|
)
|
|
|
|
assert added is True
|
|
written = yaml.safe_load(yaml_path.read_text())
|
|
assert len(written) == 2
|
|
assert [conf["dates"]["start"].year for conf in written] == [2025, 2026]
|