Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 7 additions & 6 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
21 changes: 20 additions & 1 deletion tests/test_airports.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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():
Expand Down Expand Up @@ -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():
Expand Down
43 changes: 43 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for config resolution: CLI flags > env vars > config file > defaults."""

import builtins
import json

import pytest
Expand Down Expand Up @@ -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={})
Expand Down Expand Up @@ -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"),
Expand Down
15 changes: 8 additions & 7 deletions tests/test_coverage_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"
Expand Down
25 changes: 25 additions & 0 deletions tests/test_flighty_csv_importer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 -----------------------------------------------------------------


Expand Down Expand Up @@ -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."""
Expand Down
23 changes: 22 additions & 1 deletion tests/test_local_csv_storage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 = [
Expand Down
40 changes: 40 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions tests/test_passport.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

import pytest

import contrail.passport as passport
from contrail import __version__
from contrail.passport import (
build_data,
Expand Down Expand Up @@ -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 -----------------------------------------------------


Expand All @@ -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
Expand Down
23 changes: 23 additions & 0 deletions tests/test_raw_log.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}}])
Expand Down Expand Up @@ -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() == {}
Loading