diff --git a/agenda/add_new_conference.py b/agenda/add_new_conference.py index d5ce104..3b041b4 100644 --- a/agenda/add_new_conference.py +++ b/agenda/add_new_conference.py @@ -80,6 +80,7 @@ Common optional fields: or loosely translate an address. If no reliable rendering is available, omit `address`; coordinates are preferable to an unreadable or inaccurate address. - `free`, `price`, `currency`, `hackathon`, `online`, `attendees`. +- When `free: true`, omit `price` and `currency` because they are redundant. - Do not include `going`, `registered`, `accommodation_booked`, `transport_booked`, or `trip` unless the source explicitly says they apply to my attendance. @@ -528,6 +529,27 @@ def validate_country(conf: dict[str, typing.Any]) -> None: conf["country"] = match.alpha_2.lower() +def validate_series( + conf: dict[str, typing.Any], series: dict[str, ConferenceSeries] +) -> None: + """Ensure a generated series ID exists in conference_series.yaml.""" + series_id = conf.get("series") + if series_id is None: + return + if not isinstance(series_id, str) or series_id not in series: + raise ValueError( + f"Generated conference uses unknown series {series_id!r}. " + "Add it to conference_series.yaml first or remove the series field." + ) + + +def normalize_free_event_fields(conf: dict[str, typing.Any]) -> None: + """Remove redundant pricing fields from free conferences.""" + if conf.get("free") is True: + conf.pop("price", None) + conf.pop("currency", None) + + def parse_yaml_datetime(value: typing.Any) -> datetime | None: """Convert YAML date/datetime values to a datetime.""" if isinstance(value, datetime): @@ -749,6 +771,8 @@ def add_new_conference(url: str, yaml_path: str) -> bool: assert isinstance(new_conf, dict) validate_country(new_conf) + validate_series(new_conf, series) + normalize_free_event_fields(new_conf) apply_event_metadata_dates(new_conf, soup) normalize_dates_field(new_conf) normalise_end_field(new_conf, source_text) diff --git a/tests/test_add_new_conference.py b/tests/test_add_new_conference.py index ebe96ce..8038724 100644 --- a/tests/test_add_new_conference.py +++ b/tests/test_add_new_conference.py @@ -3,11 +3,12 @@ from datetime import date, datetime import typing -import lxml.html # type: ignore[import-untyped] +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: @@ -231,6 +232,7 @@ def test_build_prompt_includes_nested_dates_and_series() -> None: 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: @@ -242,6 +244,53 @@ def test_validate_country_normalises_name() -> None: 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] = { @@ -370,6 +419,12 @@ def test_add_new_conference_reuses_generic_url_for_new_year( ) -> 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( [