diff --git a/AGENTS.md b/AGENTS.md index 6914eeb..bf23476 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -72,12 +72,13 @@ Two keys, and the difference matters: `[tool.coverage.run]` in `pyproject.toml`, not the CI command line, is what sets the scope, so a local `--cov` run measures what CI measures. Two - settings there are load-bearing. `source = ["src/contrail"]` measures what - ships rather than what the tests imported: it keeps `tests/` out of the - report, and it reports a module no test imports at all at 0% instead of - omitting it, which is the difference between a visible gap and an invisible - one. `branch = true` counts the untaken side of a condition, which is where - the parsing and resync code actually hides its gaps. + settings there are load-bearing. `source = ["src/contrail", "scripts"]` + measures the package and its maintainer tooling rather than what the tests + imported: it keeps `tests/` out of the report, and it reports a module no test + imports at all at 0% instead of omitting it, which is the difference between + a visible gap and an invisible one. `branch = true` counts the untaken side + of a condition, which is where the parsing and resync code actually hides its + gaps. `tests/test_coverage_config.py` guards the arrangement. - **Every workflow is named after its own file**, and the description goes on diff --git a/pyproject.toml b/pyproject.toml index 3db6a34..2af12fc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -98,12 +98,13 @@ select = ["E", "F", "I", "UP", "B", "SIM"] testpaths = ["tests"] [tool.coverage.run] -# Scoped to what ships, not to whatever the tests happened to import. Naming a -# source directory rather than passing `--cov=` paths does two things: files -# under `tests/` stay out of the report, and a module no test imports at all is -# reported at 0% rather than being absent from the report entirely. That is the -# difference between a gap you can see and one you cannot. -source = ["src/contrail"] +# Scoped to the package and its maintainer tooling, not to whatever the tests +# happened to import. Naming source directories rather than passing `--cov=` +# paths does two things: files under `tests/` stay out of the report, and a +# module no test imports at all is reported at 0% rather than being absent from +# the report entirely. That is the difference between a gap you can see and one +# you cannot. +source = ["src/contrail", "scripts"] branch = true [tool.coverage.report] diff --git a/tests/test_airports.py b/tests/test_airports.py index aee3900..4e638d8 100644 --- a/tests/test_airports.py +++ b/tests/test_airports.py @@ -2,7 +2,14 @@ from datetime import UTC, date, datetime, timedelta, timezone -from contrail.airports import departure_date, timezone_for +import contrail.airports as airports +from contrail.airports import ( + arrival_datetime, + departure_date, + departure_datetime, + details_for, + timezone_for, +) def utc(y, m, d, hh, mm=0): @@ -19,6 +26,16 @@ def test_unknown_or_missing_airports_return_none(): assert timezone_for("QQQ") is None assert timezone_for("") is None assert timezone_for(None) is None + assert details_for(None) is None + + +def test_an_invalid_timezone_is_treated_as_unknown(monkeypatch): + """A stale database entry must degrade like an unknown airport, not abort + every import on a machine whose timezone database cannot resolve it.""" + monkeypatch.setattr(airports, "details_for", lambda _code: {"tz": "Not/A-Timezone"}) + airports._ZONES.pop("BAD", None) + + assert timezone_for("BAD") is None def test_evening_departure_west_of_utc_keeps_its_own_date(): @@ -67,6 +84,8 @@ def test_naive_datetimes_are_treated_as_already_local(): wrong — it is already the date the traveller would say.""" naive = datetime(2026, 7, 4, 21, 30) assert departure_date(naive, "JFK") == date(2026, 7, 4) + assert departure_datetime(naive, "JFK") is naive + assert arrival_datetime(naive, "LHR") is naive def test_non_utc_offsets_are_honoured(): diff --git a/tests/test_config.py b/tests/test_config.py index 52aca49..5a6e09a 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,5 +1,6 @@ """Tests for config resolution: CLI flags > env vars > config file > defaults.""" +import builtins import json import pytest @@ -121,6 +122,32 @@ def test_yaml_config(tmp_path): assert config.sources[0]["url"] == "https://y.invalid/f.ics" +def test_yaml_config_explains_when_pyyaml_is_unavailable(tmp_path, monkeypatch): + """YAML is an optional extra, so its import failure should name the extra + rather than escape as an implementation-level ModuleNotFoundError.""" + path = tmp_path / "config.yaml" + path.write_text("{}") + real_import = builtins.__import__ + + def import_without_yaml(name, *args, **kwargs): + if name == "yaml": + raise ImportError + return real_import(name, *args, **kwargs) + + monkeypatch.setattr(builtins, "__import__", import_without_yaml) + with pytest.raises(ConfigError, match="pip install 'contrail\\[yaml\\]'"): + load_config(config_path=str(path), env={}) + + +def test_config_top_level_must_be_a_mapping(tmp_path): + """A syntactically valid list has no section names and should be rejected at + the file boundary, before later config code tries mapping operations on it.""" + path = write_config(tmp_path, []) + + with pytest.raises(ConfigError, match="object at the top level"): + load_config(config_path=str(path), env={}) + + def test_missing_explicit_config_file_is_an_error(tmp_path): with pytest.raises(ConfigError, match="not found"): load_config(config_path=str(tmp_path / "nope.json"), env={}) @@ -389,6 +416,22 @@ def test_a_new_key_wins_over_the_one_it_replaced(tmp_path, capsys): assert "storage.flights.path is already set. Drop 'csv_path'." in capsys.readouterr().err +def test_modern_importers_beat_the_legacy_tripit_url(tmp_path): + """Once an importer list exists, the retired flat URL must not append a + second source and import the same itinerary twice.""" + write_config( + tmp_path, + { + "importers": [{"type": "flighty_csv", "path": "flighty/"}], + "TRIPIT_ICAL_URL": "https://legacy.invalid/feed.ics", + }, + ) + + config = load_config(env={}, directory=str(tmp_path)) + + assert config.importers == [{"type": "flighty_csv", "path": "flighty/"}] + + SUPERSEDING_LAYERS = [ ({"emissions": {"provider": "tim"}}, {"EMISSIONS_PROVIDER": "tim"}, "EMISSIONS_PROVIDER"), ({"csv_path": "old.csv"}, {"CSV_PATH": "env.csv"}, "CSV_PATH"), diff --git a/tests/test_coverage_config.py b/tests/test_coverage_config.py index 34c974a..08289dc 100644 --- a/tests/test_coverage_config.py +++ b/tests/test_coverage_config.py @@ -7,9 +7,10 @@ the upload so a Codecov outage fails a pull request that is fine. The scope settings in particular look like defaults worth deleting and are -not. `source` naming the package is what makes a module no test imports appear -at 0% instead of vanishing from the report; without it the least covered files -are the ones that silently leave, and the number goes *up*. +not. `source` naming the package and maintainer scripts is what makes a module +no test imports appear at 0% instead of vanishing from the report; without it +the least covered files are the ones that silently leave, and the number goes +*up*. """ from __future__ import annotations @@ -26,13 +27,13 @@ COVERAGE_JOB = CI["jobs"]["coverage"] -def test_coverage_measures_the_shipped_package(): +def test_coverage_measures_the_package_and_maintainer_scripts(): """Not `--cov=` on the CI command line: the config is what a local run reads too.""" source = PYPROJECT["tool"]["coverage"]["run"]["source"] - assert source == ["src/contrail"], ( - f"coverage source is {source!r}; it should name the package directory so " - "a module no test imports is reported at 0% rather than left out" + assert source == ["src/contrail", "scripts"], ( + f"coverage source is {source!r}; it should name the package and maintainer " + "script directories so an untested module is reported at 0% rather than left out" ) for package in PYPROJECT["tool"]["hatch"]["build"]["targets"]["wheel"]["packages"]: assert package in source, f"{package} ships but is not measured" diff --git a/tests/test_flighty_csv_importer.py b/tests/test_flighty_csv_importer.py index 1eb68b6..cab087f 100644 --- a/tests/test_flighty_csv_importer.py +++ b/tests/test_flighty_csv_importer.py @@ -159,6 +159,22 @@ def test_a_non_numeric_flight_number_does_not_parse(importer, sample_flighty_pat assert items[0].partial["flight_number"] == "N/A" +def test_an_invalid_date_falls_back_to_the_departure(importer, sample_flighty_path): + """The scheduled departure still supplies the local flight date when the + redundant Date column is malformed.""" + lines = edited(sample_flighty_path, **{"Date": "not-a-date"}) + + assert only_sfo(importer, lines).flight_date.isoformat() == "2019-05-17" + + +def test_a_missing_flighty_id_gets_a_stable_content_id(importer, sample_flighty_path): + """A row without Flighty's UUID still needs a repeatable nonempty key, or + separate flights mask one another and every sync imports them again.""" + lines = edited(sample_flighty_path, **{"Flight Flighty ID": ""}) + + assert only_sfo(importer, lines).source_id == "row:2019-05-17-BAW-286-SFO-LHR" + + # -- identity ----------------------------------------------------------------- @@ -214,6 +230,15 @@ def test_an_empty_directory_yields_nothing(tmp_path, importer): assert list(export_files(str(tmp_path))) == [] +def test_fetch_can_override_airline_lookup(tmp_path, importer): + """The per-source switch must reach the resolver even when an empty export + directory gives the importer no rows to resolve.""" + assert importer.resolver.lookup is False + + assert list(importer.fetch({"path": str(tmp_path), "airline_lookup": True})) == [] + assert importer.resolver.lookup is True + + def test_a_path_that_matches_nothing_warns_rather_than_failing(tmp_path, importer, capsys): """An export arrives by hand, so "not there yet" is ordinary. Raising would let one unconfigured source take down a sync with a good TripIt feed in it.""" diff --git a/tests/test_local_csv_storage.py b/tests/test_local_csv_storage.py index d96d9aa..41ac77f 100644 --- a/tests/test_local_csv_storage.py +++ b/tests/test_local_csv_storage.py @@ -4,7 +4,7 @@ import pytest -from contrail.storage import normalize_rows, total_kg +from contrail.storage import kg_value, normalize_rows, total_kg from contrail.storage.local_csv import ( CSV_FIELDS, LocalCSVStorage, @@ -194,9 +194,30 @@ def test_actual_kg_prefers_the_known_cabin(): def test_actual_kg_falls_back_to_economy(): + """A blank or unusable stated cabin cannot erase a usable economy estimate, + which is the conservative figure the row used before cabin data arrived.""" assert actual_kg(row("uid-1", "2026-03-04", economy="100")) == "100" # An unrecognised or blank cabin must not silently produce nothing. assert actual_kg(row("uid-1", "2026-03-04", economy="100", cabin_class_known="couch")) == "100" + assert ( + actual_kg( + row( + "uid-1", + "2026-03-04", + economy="100", + cabin_class_known="business", + emissions_kg_business="", + ) + ) + == "100" + ) + + +@pytest.mark.parametrize("value", [object(), "not-a-number"]) +def test_an_invalid_actual_kg_counts_as_zero(value): + """Totals consume old and user-edited CSVs, so one unusable cell must not + prevent the remaining valid flights from being summed.""" + assert kg_value({"emissions_kg_actual": value}) == 0.0 LEGACY_HEADER = [ diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..1149069 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,40 @@ +"""Focused tests for behavior owned by the shared data models.""" + +from datetime import UTC, date, datetime + +from contrail.models import EmissionsResult, FlightRecord + + +def flight(**extra): + """A complete record with only the model behavior under test varied.""" + values = { + "source": "test", + "source_id": "one", + "flight_date": date(2026, 9, 11), + "carrier_code": "BA", + "flight_number": "1", + "origin": "LHR", + "destination": "JFK", + } + values.update(extra) + return FlightRecord(**values) + + +def test_an_aware_departure_uses_the_exact_instant(): + """A source timestamp with an offset gives a precise freeze boundary, so a + flight later today must not be treated as already departed.""" + departure = datetime(2026, 9, 11, 12, tzinfo=UTC) + + assert flight(departure_time=departure).has_departed(departure) + assert not flight(departure_time=departure).has_departed( + datetime(2026, 9, 11, 11, 59, tzinfo=UTC) + ) + + +def test_emissions_are_selected_by_cabin_name(): + """Providers expose one figure per supported cabin while unknown cabin names + must remain unavailable rather than raising an attribute error.""" + result = EmissionsResult(method="exact", grams_business=123) + + assert result.grams_for("business") == 123 + assert result.grams_for("unknown") is None diff --git a/tests/test_passport.py b/tests/test_passport.py index e22ce20..92746c8 100644 --- a/tests/test_passport.py +++ b/tests/test_passport.py @@ -10,6 +10,7 @@ import pytest +import contrail.passport as passport from contrail import __version__ from contrail.passport import ( build_data, @@ -71,6 +72,14 @@ def test_an_airport_with_no_coordinates_has_no_distance(): assert only([row(destination="ZZZ")])["end"] is None +def test_an_airport_with_incomplete_coordinates_is_not_plotted(monkeypatch): + """One coordinate cannot place an airport or form a distance, and treating a + missing value as zero would draw a plausible but false route.""" + monkeypatch.setattr(passport, "details_for", lambda _iata: {"lat": 51.5, "lon": None}) + + assert great_circle_km("LHR", "JFK") is None + + # -- scheduled block time ----------------------------------------------------- @@ -89,6 +98,12 @@ def test_a_naive_instant_is_not_usable(): assert scheduled_hours(row(arrival_time="2026-03-05T08:15:00")) is None +def test_a_malformed_instant_is_not_usable(): + """A hand-edited timestamp should remove the derived duration instead of + preventing the entire Passport from rendering.""" + assert scheduled_hours(row(arrival_time="not-a-time")) is None + + @pytest.mark.parametrize("hours", [-2, 0, 37]) def test_a_duration_no_flight_has_is_refused(hours): """Arrival before departure is what a source that forgot the overnight date diff --git a/tests/test_raw_log.py b/tests/test_raw_log.py index a1f9aa6..90632bf 100644 --- a/tests/test_raw_log.py +++ b/tests/test_raw_log.py @@ -49,6 +49,17 @@ def test_an_unchanged_answer_is_not_recorded_again(tmp_path): assert len(log.read()) == 2 +def test_unchanged_filtering_can_be_disabled(tmp_path): + """Inspection tools may need every observation even when consecutive API + answers match, so callers can explicitly opt out of deduplication.""" + log = JSONLRawLog(str(tmp_path / "raw.jsonl")) + payload = [{"key": "a", "response": {"economy": 100}}] + + assert log.append(payload) == 1 + assert log.append(payload, skip_unchanged=False) == 1 + assert len(log.read()) == 2 + + def test_each_flight_is_tracked_separately(tmp_path): log = JSONLRawLog(str(tmp_path / "raw.jsonl")) log.append([{"key": "a", "response": {"x": 1}}, {"key": "b", "response": {"x": 2}}]) @@ -112,3 +123,15 @@ def test_a_corrupt_line_does_not_break_every_future_sync(tmp_path): assert [e["key"] for e in log.read()] == ["a"] assert log.append([{"key": "c", "response": {"economy": 5}}]) == 1 + + +def test_blank_lines_and_entries_without_keys_are_ignored_by_latest(tmp_path): + """A recoverable append-only log can contain harmless blank lines or valid + JSON metadata that does not identify a flight.""" + path = tmp_path / "raw.jsonl" + path.write_text('\n{"captured_at": "NOW", "response": {"x": 1}}\n') + + log = JSONLRawLog(str(path)) + + assert len(log.read()) == 1 + assert log.latest_by_key() == {} diff --git a/tests/test_refresh_airline_codes.py b/tests/test_refresh_airline_codes.py new file mode 100644 index 0000000..73cbe07 --- /dev/null +++ b/tests/test_refresh_airline_codes.py @@ -0,0 +1,182 @@ +"""The offline generator for the airline lookup table.""" + +import csv +import runpy +from importlib.util import module_from_spec, spec_from_file_location +from pathlib import Path +from unittest.mock import Mock, mock_open + +import pytest + +SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "refresh_airline_codes.py" +SPEC = spec_from_file_location("refresh_airline_codes", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +refresh = module_from_spec(SPEC) +SPEC.loader.exec_module(refresh) + + +def binding(item, iata, icao, name="", aliases="", dissolved=""): + """A compact Wikidata binding using the nested value shape its API returns.""" + values = { + "item": item, + "iata": iata, + "icao": icao, + "name": name, + "aliases": aliases, + "dissolved": dissolved, + } + return {key: {"value": value} for key, value in values.items() if value} + + +def test_normalize_and_value_match_runtime_lookup_keys(): + """Generated names must use the same whitespace and case rules as the + resolver that will later read them, including absent optional bindings.""" + assert refresh.normalize(" British AIRWAYS ") == "british airways" + assert refresh.value({"name": {"value": " Example Air "}}, "name") == "Example Air" + assert refresh.value({}, "name") == "" + + +def test_fetch_uses_the_wikidata_query_contract(monkeypatch): + """The manual refresh identifies itself, asks for JSON, and cannot wait + forever on the public SPARQL endpoint.""" + response = Mock() + response.json.return_value = {"results": {"bindings": [{"item": {"value": "one"}}]}} + get = Mock(return_value=response) + monkeypatch.setattr(refresh.requests, "get", get) + + assert refresh.fetch() == [{"item": {"value": "one"}}] + get.assert_called_once_with( + refresh.SPARQL_URL, + params={"query": refresh.QUERY, "format": "json"}, + headers={ + "User-Agent": refresh.USER_AGENT, + "Accept": "application/sparql-results+json", + }, + timeout=refresh.REQUEST_TIMEOUT, + ) + response.raise_for_status.assert_called_once_with() + + +def test_build_rows_rejects_bad_and_ambiguous_designators(): + """Invalid shapes, conflicted entities, and live carriers sharing one ICAO + code are safer omitted than resolved confidently to the wrong airline.""" + bindings = [ + binding("bad-iata", "ABC", "BAD"), + binding("bad-icao", "AB", "LONG"), + binding("many-iata", "AA", "MUL"), + binding("many-iata", "BB", "MUL"), + binding("many-icao", "CC", "ONE"), + binding("many-icao", "CC", "TWO"), + binding("ambiguous-a", "DD", "DUP", "First Air"), + binding("ambiguous-b", "EE", "DUP", "Second Air"), + ] + + rows, dropped = refresh.build_rows(bindings) + + assert rows == [] + assert dropped == ["ICAO DUP: DD, EE"] + + +def test_build_rows_prefers_live_carriers_and_filters_aliases(): + """A current carrier wins over dissolved records, while designators, + duplicates, and aliases beyond the cap stay out of the shipped table.""" + aliases = "Active Air|AA|ACT|Shared|Shared|One|Two|Three|Four|Five|Six|Seven" + bindings = [ + binding("active", "AA", "ACT", "Active Air", aliases), + binding("former", "AA", "ACT", "Former Air", "Former Alias", "2020-01-01"), + binding("unnamed", "AA", "ACT", dissolved="2020-01-01"), + binding("old-code", "BB", "NOW", "Old Name", dissolved="2020-01-01"), + binding("live-code", "CC", "NOW", "Current Name"), + ] + + rows, dropped = refresh.build_rows(bindings) + + assert dropped == [] + assert rows == [ + { + "iata": "AA", + "icao": "ACT", + "name": "Active Air", + "aliases": "Shared|One|Two|Three|Four|Five", + }, + {"iata": "CC", "icao": "NOW", "name": "Current Name", "aliases": ""}, + ] + + +def test_ambiguous_names_are_removed_without_touching_unique_ones(): + """A prose name owned by several IATA codes must fall back to live lookup, + without discarding names and aliases that still resolve uniquely.""" + rows = [ + {"iata": "AA", "name": " Shared Name ", "aliases": "Unique A|Clash||"}, + {"iata": "BB", "name": "Other", "aliases": "shared name|Clash|Unique B"}, + ] + + assert refresh.drop_ambiguous_names(rows) == ["clash", "shared name"] + assert rows == [ + {"iata": "AA", "name": "", "aliases": "Unique A"}, + {"iata": "BB", "name": "Other", "aliases": "Unique B"}, + ] + + unique = [{"iata": "CC", "name": "Solo", "aliases": "Only"}] + assert refresh.drop_ambiguous_names(unique) == [] + assert unique[0]["name"] == "Solo" + + +def test_main_writes_lf_csv_and_reports_every_kind_of_ambiguity(tmp_path, monkeypatch, capsys): + """The command writes the complete deterministic CSV and makes discarded + data visible, while capping a potentially long diagnostic list.""" + output = tmp_path / "nested" / "airline_codes.csv" + rows = [ + {"iata": "AA", "icao": "AAA", "name": "Alpha", "aliases": "A"}, + {"iata": "BB", "icao": "BBB", "name": "", "aliases": ""}, + ] + dropped_names = [f"ambiguous {number}" for number in range(21)] + monkeypatch.setattr(refresh, "OUTPUT", output) + monkeypatch.setattr(refresh, "fetch", lambda: [{"binding": True}]) + monkeypatch.setattr(refresh, "build_rows", lambda _bindings: (rows, ["ICAO DUP: AA, BB"])) + monkeypatch.setattr(refresh, "drop_ambiguous_names", lambda _rows: dropped_names) + + assert refresh.main() == 0 + + with output.open(newline="") as handle: + assert list(csv.DictReader(handle)) == rows + assert b"\r\n" not in output.read_bytes() + report = capsys.readouterr().out + assert "1 binding(s) returned" in report + assert "1 carry a usable name, 1 are ICAO-only" in report + assert "Dropped 1 ambiguous ICAO code(s)" in report + assert "Blanked 21 name(s)" in report + assert "... and 1 more" in report + + monkeypatch.setattr(refresh, "drop_ambiguous_names", lambda _rows: dropped_names[:1]) + assert refresh.main() == 0 + assert "... and" not in capsys.readouterr().out + + +def test_main_has_no_ambiguity_report_when_nothing_was_dropped(tmp_path, monkeypatch, capsys): + """A clean refresh should report its output counts without printing empty + warning sections that imply data was discarded.""" + monkeypatch.setattr(refresh, "OUTPUT", tmp_path / "airline_codes.csv") + monkeypatch.setattr(refresh, "fetch", lambda: []) + monkeypatch.setattr(refresh, "build_rows", lambda _bindings: ([], [])) + monkeypatch.setattr(refresh, "drop_ambiguous_names", lambda _rows: []) + + assert refresh.main() == 0 + + report = capsys.readouterr().out + assert "Dropped" not in report + assert "Blanked" not in report + + +def test_script_entrypoint_exits_with_main_result(monkeypatch): + """Executing the file directly follows the same successful path as calling + main, with network and the generated output intercepted by the test.""" + response = Mock() + response.json.return_value = {"results": {"bindings": []}} + monkeypatch.setattr(refresh.requests, "get", Mock(return_value=response)) + monkeypatch.setattr("builtins.open", mock_open()) + + with pytest.raises(SystemExit) as caught: + runpy.run_path(refresh.__file__, run_name="__main__") + + assert caught.value.code == 0 diff --git a/tests/test_tripit_ical_importer.py b/tests/test_tripit_ical_importer.py index 12b3eef..ca48eca 100644 --- a/tests/test_tripit_ical_importer.py +++ b/tests/test_tripit_ical_importer.py @@ -6,6 +6,7 @@ """ from datetime import date +from unittest.mock import Mock import pytest @@ -123,17 +124,51 @@ def test_partial_summary_is_topped_up_from_blob(): assert (carrier, number) == ("AB", "100") +def test_parenthesized_airport_codes_are_the_last_extraction_fallback(): + """Some calendar prose has neither from/to wording nor a code pair, but two + parenthesized airport codes still preserve their source order.""" + result = extract_flight_fields( + summary="AB100 itinerary", + description="Depart airport (LHR), arrive airport (JFK)", + location="", + ) + + assert result == ("AB", "100", "LHR", "JFK") + + def test_fetch_reads_a_local_path(sample_feed_path): """Local paths and file:// URLs work, so CI can run --dry-run without network.""" assert fetch_ical(str(sample_feed_path)).startswith(b"BEGIN:VCALENDAR") assert fetch_ical(sample_feed_path.as_uri()).startswith(b"BEGIN:VCALENDAR") +def test_fetch_reads_http_with_a_timeout(monkeypatch): + """Remote feeds must use the bounded request path and surface HTTP failures + before their response body reaches the calendar parser.""" + response = Mock(content=b"calendar") + get = Mock(return_value=response) + monkeypatch.setattr("contrail.importers.tripit_ical.requests.get", get) + + assert fetch_ical("https://example.invalid/feed.ics") == b"calendar" + get.assert_called_once_with("https://example.invalid/feed.ics", timeout=30) + response.raise_for_status.assert_called_once_with() + + def test_fetch_requires_a_url_in_config(): with pytest.raises(ValueError, match="needs a 'url'"): list(TripItICalImporter().fetch({})) +def test_fetch_can_override_airline_lookup(sample_feed_path): + """A source can forbid live airline resolution while still parsing its local + feed, which keeps scheduled and CI runs hermetic.""" + importer = TripItICalImporter() + + list(importer.fetch({"url": str(sample_feed_path), "airline_lookup": False})) + + assert importer.resolver.lookup is False + + UID_LESS_FEED = b"""BEGIN:VCALENDAR VERSION:2.0 PRODID:-//exporter without uids//EN diff --git a/tests/test_version.py b/tests/test_version.py index 73229c7..d91b790 100644 --- a/tests/test_version.py +++ b/tests/test_version.py @@ -6,12 +6,15 @@ is silent and only visible to someone who installed the package. """ +import runpy import tomllib from pathlib import Path +from unittest.mock import patch from contrail import __version__ PYPROJECT = Path(__file__).resolve().parent.parent / "pyproject.toml" +PACKAGE_INIT = PYPROJECT.parent / "src" / "contrail" / "__init__.py" def project() -> dict: @@ -36,3 +39,14 @@ def test_the_declared_version_is_what_is_installed(): """`pyproject.toml` is the single source of truth, and release-please owns it. A stale editable install is the usual reason these drift apart.""" assert __version__ == project()["version"] + + +def test_a_source_tree_without_distribution_metadata_uses_the_dev_version(): + """Importing directly from an unpacked source tree has no installed metadata, + but the package must remain importable for development tools.""" + from importlib.metadata import PackageNotFoundError + + with patch("importlib.metadata.version", side_effect=PackageNotFoundError): + namespace = runpy.run_path(str(PACKAGE_INIT)) + + assert namespace["__version__"] == "0.0.0.dev0"