"""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("""
""") 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("""
""") 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( ( "" 'Example' 'Map' "" ) ) 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( ( "" 'Map' "" ) ) 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("Conference details") 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]