bandsaunter/tests/test_schedules.py
The Dust Council 8eb3bdbb86 Ask a service that knows which leg it is, and fetch a map worth the screen
A callsign is a flight number rather than a leg, and the free registers hold
one route per number, so an aircraft over Arizona kept being handed a hop
between two airports in Texas.  Nothing on the air settles it: ADS-B carries
no origin or destination.  A commercial schedule service does know, because
it holds the day's actual movements.

Four are wired up and all four are optional: FlightAware AeroAPI,
Flightradar24, OAG and Cirium.  Each is asked before the free databases and
each answers for the moment the aircraft was overhead rather than for the
flight number in general, so the leg chosen is the one that was in the air.
With no keys set nothing changes at all: a source with no key is skipped
rather than asked and refused, and the free databases answer as before.

Keys come from the environment and are never written to the settings file,
because a settings file is meant to be copied between machines and pasted
into a message asking for help, and an API key is not.  There is a test that
holds that line.

None of the four has been run against its live service, since each wants a
paid account.  They were written from the published response shapes and are
tested against those shapes, so each reader finds what it recognises and
returns nothing otherwise: a service that has changed since costs a route
rather than a scan.  Cirium's plain departureTime is local and carries no
offset, so the UTC field is preferred where it is there -- reading the local
one as UTC is up to half a day out, which is exactly far enough to pick the
wrong leg of the same number.  Reading now happens inside the same guard as
asking, as an answer shaped differently from the documented one is the
failure most likely to actually happen.

And the map.  The zoom is now chosen from how wide the picture is rather
than from the area alone, with half again over the width fetched and
averaged down, since a downscaled tile is sharp and an upscaled one is not.
The window fetches a little more world than it shows so panning does not
leave the ground blank, and now fetches that bigger piece at the bigger
piece's own size: rendering it into the window's own pixels and stretching
it back was a fifth of an upscale over the whole map, which is what a sharp
map looks like when it looks blurred.  The comment in the fetcher said the
opposite of what the code did, which is how it stayed hidden.

At 1920 by 1080 over a hundred miles the tiles now hold about 1.6 times the
pixels the window wants.  At 3840 by 2160 the tile budget is reached, the
zoom stops climbing and the map is enlarged after all; a smaller radius buys
the detail back, and somebody else's tile server is not a thing to fetch a
thousand tiles from for one picture.  The README says so rather than
implying otherwise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-04 20:19:53 -07:00

467 lines
18 KiB
Python

"""The commercial schedule services.
None of these has been run against its live service, because each wants a
paid key. What is tested is everything that can be: that a source with no
key is skipped rather than tried, that each reader takes the documented
shape of its answers and finds the leg in it, that the leg chosen is the one
in the air at the moment being asked about, and that a service which has
changed since costs a route rather than a scan.
The recorded shapes below are written from each service's published
documentation. If one of them stops matching reality, this is where it will
show, and the failure will be a route that is not found rather than one that
is wrong.
"""
import json
import pytest
from bandsaunter import schedules
NOON = 1_788_600_000.0 # a moment to ask about
def at(offset_hours: float) -> str:
"""A time, written the way these services write it."""
from datetime import datetime, timezone
return datetime.fromtimestamp(NOON + offset_hours * 3600,
timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
# ---------------------------------------------------------------------------
# Without keys, which is how nearly everyone runs
# ---------------------------------------------------------------------------
def test_every_source_says_what_it_needs():
for kind in schedules.SOURCES:
source = kind()
assert source.name and source.needs and source.signup
assert all(n.startswith("BANDSAUNTER_") for n in source.needs)
def test_a_source_with_no_key_is_not_available(monkeypatch):
for kind in schedules.SOURCES:
for name in kind.needs:
monkeypatch.delenv(name, raising=False)
assert kind().available() is False
assert kind().missing() == list(kind.needs)
def test_a_source_with_no_key_is_never_asked(monkeypatch):
"""Not asked, rather than asked and refused: there is nothing to ask
with, and a request would only be a way of finding that out slowly."""
for kind in schedules.SOURCES:
for name in kind.needs:
monkeypatch.delenv(name, raising=False)
def refuse(self, url, headers=None):
raise AssertionError("asked a service with no key")
monkeypatch.setattr(schedules.Schedule, "fetch", refuse)
assert schedules.route_for("SWA930", NOON) is None
def test_half_a_key_is_no_key(monkeypatch):
"""Cirium wants two; one of them is not enough."""
monkeypatch.setenv("BANDSAUNTER_CIRIUM_APP_ID", "abc")
monkeypatch.delenv("BANDSAUNTER_CIRIUM_APP_KEY", raising=False)
source = schedules.source_named("cirium")
assert source.available() is False
assert source.missing() == ["BANDSAUNTER_CIRIUM_APP_KEY"]
def test_only_the_ones_with_keys_are_listed(monkeypatch):
for kind in schedules.SOURCES:
for name in kind.needs:
monkeypatch.delenv(name, raising=False)
assert schedules.available_sources() == []
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
assert [s.name for s in schedules.available_sources()] == ["flightaware"]
def test_they_are_asked_in_the_order_given(monkeypatch):
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
monkeypatch.setenv("BANDSAUNTER_OAG_KEY", "k")
assert [s.name for s in schedules.available_sources(["oag", "flightaware"])] \
== ["oag", "flightaware"]
def test_a_name_that_is_not_a_service_is_ignored():
assert schedules.source_named("nonesuch") is None
assert schedules.available_sources(["nonesuch"]) == []
# ---------------------------------------------------------------------------
# FlightAware AeroAPI
# ---------------------------------------------------------------------------
AEROAPI = {"flights": [
{"ident": "SWA930", "operator": "SWA",
"origin": {"code_icao": "KLAS", "name": "Harry Reid International"},
"destination": {"code_icao": "KMDW", "name": "Chicago Midway"},
"scheduled_off": at(-9), "scheduled_on": at(-6)},
{"ident": "SWA930", "operator": "SWA",
"origin": {"code_icao": "KMDW", "name": "Chicago Midway"},
"destination": {"code_icao": "KSAN", "name": "San Diego International"},
"actual_off": at(-1), "estimated_on": at(2)},
{"ident": "SWA930", "operator": "SWA",
"origin": {"code_icao": "KSAN", "name": "San Diego International"},
"destination": {"code_icao": "KOAK", "name": "Oakland International"},
"scheduled_off": at(4), "scheduled_on": at(6)},
]}
def _read(source_name, body, when=NOON):
"""Read one service's answer, without needing a key to do it."""
return schedules.source_named(source_name).read(body, when)
def test_flightaware_finds_the_leg_that_is_in_the_air():
"""Three legs of one flight number in a day; the one being watched is
the one whose window holds the moment."""
got = _read("flightaware", AEROAPI)
assert got["origin_code"] == "KMDW"
assert got["destination_code"] == "KSAN"
assert "Midway" in got["origin"]
assert got["stops"] == ("KMDW", "KSAN")
def test_flightaware_picks_a_different_leg_at_a_different_hour():
early = _read("flightaware", AEROAPI, when=NOON - 8 * 3600)
assert (early["origin_code"], early["destination_code"]) == ("KLAS", "KMDW")
late = _read("flightaware", AEROAPI, when=NOON + 5 * 3600)
assert (late["origin_code"], late["destination_code"]) == ("KSAN", "KOAK")
def test_flightaware_says_nothing_about_a_day_it_has_no_flights_for():
assert _read("flightaware", AEROAPI, when=NOON + 5 * 86_400) is None
def test_flightaware_survives_an_answer_it_does_not_recognise():
for body in ({}, {"flights": []}, {"flights": [{}]},
{"flights": [{"origin": None, "destination": None}]},
{"flights": [{"origin": {"code_icao": "KLAS"}}]}):
assert _read("flightaware", body) is None
# ---------------------------------------------------------------------------
# Flightradar24
# ---------------------------------------------------------------------------
FR24 = {"data": [
{"fr24_id": "1", "flight": "SWA930", "operating_as": "SWA",
"orig_icao": "KMDW", "dest_icao": "KSAN",
"datetime_takeoff": at(-1), "datetime_landed": at(2)},
{"fr24_id": "2", "flight": "SWA930", "operating_as": "SWA",
"orig_icao": "KSAN", "dest_icao": "KOAK",
"datetime_takeoff": at(4), "datetime_landed": at(6)},
]}
def test_flightradar24_finds_the_leg_in_the_air():
got = _read("flightradar24", FR24)
assert (got["origin_code"], got["destination_code"]) == ("KMDW", "KSAN")
def test_flightradar24_reads_a_bare_list_too():
"""Some of its endpoints wrap the rows and some do not."""
got = _read("flightradar24", FR24["data"])
assert got is not None and got["origin_code"] == "KMDW"
def test_flightradar24_survives_nonsense():
for body in ({}, {"data": []}, {"data": [{}]}, [], None):
assert _read("flightradar24", body) is None
# ---------------------------------------------------------------------------
# OAG
# ---------------------------------------------------------------------------
OAG = {"data": [
{"carrier": {"icao": "SWA"},
"departure": {"airport": {"icao": "KMDW", "name": "Chicago Midway"},
"date": {"utc": at(-1)[:10]},
"time": {"utc": at(-1)[11:19]}},
"arrival": {"airport": {"icao": "KSAN", "name": "San Diego"},
"date": {"utc": at(2)[:10]}, "time": {"utc": at(2)[11:19]}}},
]}
def test_oag_finds_the_leg():
got = _read("oag", OAG)
assert (got["origin_code"], got["destination_code"]) == ("KMDW", "KSAN")
assert "Midway" in got["origin"]
def test_oag_survives_nonsense():
for body in ({}, {"data": []}, {"data": [{}]},
{"data": [{"departure": {}, "arrival": {}}]}):
assert _read("oag", body) is None
def test_oag_needs_an_airline_callsign(monkeypatch):
monkeypatch.setenv("BANDSAUNTER_OAG_KEY", "k")
source = schedules.source_named("oag")
with pytest.raises(schedules.SourceError):
source.route("N517HP", NOON) # a registration, not a flight
# ---------------------------------------------------------------------------
# Cirium
# ---------------------------------------------------------------------------
CIRIUM = {
"scheduledFlights": [
{"carrierFsCode": "WN", "flightNumber": "930",
"departureAirportFsCode": "MDW", "arrivalAirportFsCode": "SAN",
"departureTime": at(-1), "arrivalTime": at(2)},
],
"appendix": {"airports": [
{"fs": "MDW", "icao": "KMDW", "name": "Chicago Midway",
"countryCode": "US", "latitude": 41.786, "longitude": -87.752},
{"fs": "SAN", "icao": "KSAN", "name": "San Diego International",
"countryCode": "US", "latitude": 32.733, "longitude": -117.19},
]},
}
def test_cirium_finds_the_leg_and_where_its_airports_are():
got = _read("cirium", CIRIUM)
assert (got["origin_code"], got["destination_code"]) == ("KMDW", "KSAN")
assert got["origin_country"] == "US"
assert got["origin_lat"] == pytest.approx(41.786)
assert got["destination_lon"] == pytest.approx(-117.19)
def test_cirium_survives_nonsense():
for body in ({}, {"scheduledFlights": []}, {"scheduledFlights": [{}]},
{"scheduledFlights": [{"departureAirportFsCode": "MDW"}],
"appendix": {}}):
assert _read("cirium", body) is None
# ---------------------------------------------------------------------------
# Reading the answers
# ---------------------------------------------------------------------------
def test_a_callsign_splits_into_an_airline_and_a_number():
assert schedules._split_callsign("SWA930") == ("SWA", "930")
assert schedules._split_callsign("BAW49") == ("BAW", "49")
assert schedules._split_callsign("N517HP")[1] == "" # a registration
assert schedules._split_callsign("")[1] == ""
@pytest.mark.parametrize("text,ok", [
("2026-09-04T12:00:00Z", True),
("2026-09-04T12:00:00+00:00", True),
("1788600000", True),
("", False), ("not a time", False), ("2026-13-45", False),
])
def test_the_several_ways_a_time_is_written_are_all_read(text, ok):
got = schedules._seconds(text)
assert (got > 0) is ok
def test_the_leg_whose_window_holds_the_moment_wins():
legs = [(NOON - 7200, NOON - 3600, {"n": "before"}),
(NOON - 600, NOON + 600, {"n": "now"}),
(NOON + 3600, NOON + 7200, {"n": "after"})]
assert schedules._closest(legs, NOON)["n"] == "now"
def test_the_nearest_leg_wins_when_none_quite_holds_it():
"""A departure runs late and the windows no longer line up; the nearest
is a better answer than none, and much better than the first in the
list."""
legs = [(NOON - 7 * 3600, NOON - 6 * 3600, {"n": "long before"}),
(NOON + 600, NOON + 3600, {"n": "just after"})]
assert schedules._closest(legs, NOON)["n"] == "just after"
def test_nothing_within_half_a_day_is_not_this_flight():
legs = [(NOON - 40 * 3600, NOON - 39 * 3600, {"n": "yesterday"})]
assert schedules._closest(legs, NOON) is None
assert schedules._closest([], NOON) is None
def test_a_leg_needs_both_ends():
assert schedules._leg("KMDW", "") is None
assert schedules._leg("", "KSAN") is None
assert schedules._leg("kmdw", "ksan")["origin_code"] == "KMDW"
def test_a_leg_comes_back_in_the_shape_the_rest_of_the_program_reads():
from bandsaunter.flights import Flight
leg = schedules._leg("EGLL", "KSEA")
fields = set(Flight().__dict__)
for key in leg:
if key in ("stops", "airline"):
continue
assert key in fields, key
assert leg["origin_country"] == "GB" # worked out from the code
assert leg["destination_country"] == "US"
# ---------------------------------------------------------------------------
# And what happens when one falls over
# ---------------------------------------------------------------------------
def test_a_service_that_fails_does_not_stop_the_next_one(monkeypatch):
"""A key that has run out of quota should cost that service and not the
others."""
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
monkeypatch.setenv("BANDSAUNTER_FR24_TOKEN", "k")
def fetch(self, url, headers=None):
if self.name == "flightaware":
raise OSError("quota exceeded")
return FR24
monkeypatch.setattr(schedules.Schedule, "fetch", fetch)
got = schedules.route_for("SWA930", NOON,
["flightaware", "flightradar24"])
assert got is not None
assert got["origin_code"] == "KMDW"
assert got["source"] == "flightradar24"
def test_the_answer_says_which_service_gave_it(monkeypatch):
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
monkeypatch.setattr(schedules.Schedule, "fetch",
lambda self, url, headers=None: AEROAPI)
got = schedules.route_for("SWA930", NOON, ["flightaware"])
assert got["source"] == "flightaware"
def test_the_key_is_sent_the_way_the_service_wants_it(monkeypatch):
"""Each of them asks for its key somewhere different."""
seen = {}
def fetch(self, url, headers=None):
seen[self.name] = (url, headers or {})
return {}
monkeypatch.setattr(schedules.Schedule, "fetch", fetch)
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "aero-key")
monkeypatch.setenv("BANDSAUNTER_FR24_TOKEN", "fr24-token")
monkeypatch.setenv("BANDSAUNTER_OAG_KEY", "oag-key")
monkeypatch.setenv("BANDSAUNTER_CIRIUM_APP_ID", "cid")
monkeypatch.setenv("BANDSAUNTER_CIRIUM_APP_KEY", "ckey")
for source in schedules.available_sources():
source.route("SWA930", NOON)
assert seen["flightaware"][1]["x-apikey"] == "aero-key"
assert "fr24-token" in seen["flightradar24"][1]["Authorization"]
assert seen["oag"][1]["Subscription-Key"] == "oag-key"
assert "appId=cid" in seen["cirium"][0]
assert "appKey=ckey" in seen["cirium"][0]
# And no key is ever put in a URL that did not ask for one there.
assert "aero-key" not in seen["flightaware"][0]
assert "fr24-token" not in seen["flightradar24"][0]
assert "oag-key" not in seen["oag"][0]
def test_a_key_is_never_written_into_the_settings_file():
"""A settings file gets copied between machines and pasted into messages
asking for help; an API key does not belong in one."""
from bandsaunter.aircraft import AircraftOptions
text = json.dumps(AircraftOptions().to_dict())
for kind in schedules.SOURCES:
for name in kind.needs:
assert name not in text
def test_the_book_asks_the_services_before_the_free_databases(monkeypatch):
"""They know the leg; the free databases hold one route per number."""
import tempfile
from pathlib import Path
from bandsaunter.flights import FlightBook
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
monkeypatch.setattr(schedules.Schedule, "fetch",
lambda self, url, headers=None: AEROAPI)
asked = []
def free(self, url):
asked.append(url)
raise OSError("should not have been needed")
monkeypatch.setattr(FlightBook, "_request", free)
book = FlightBook(cache=Path(tempfile.mkdtemp()) / "c.json")
entry = book.get("AC0FB6", "SWA930", when=NOON)
book.wait(5.0)
assert entry.origin_code == "KMDW"
assert entry.route_source == "flightaware"
assert not any("callsign" in url for url in asked), \
"asked a free database when a schedule service had answered"
def test_with_no_keys_the_free_databases_answer_as_before(monkeypatch):
import tempfile
from pathlib import Path
from bandsaunter.flights import FlightBook
for kind in schedules.SOURCES:
for name in kind.needs:
monkeypatch.delenv(name, raising=False)
answers = {"adsbdb.com/v0/callsign": {"response": {"flightroute": {
"callsign": "SWA930",
"origin": {"icao_code": "KHOU", "name": "Hobby",
"country_iso_name": "US"},
"destination": {"icao_code": "KSAT", "name": "San Antonio",
"country_iso_name": "US"}}}}}
def request(self, url):
for fragment, body in answers.items():
if fragment in url:
return body
raise OSError("not found")
monkeypatch.setattr(FlightBook, "_request", request)
book = FlightBook(cache=Path(tempfile.mkdtemp()) / "c.json")
entry = book.get("AC0FB6", "SWA930", when=NOON)
book.wait(5.0)
assert entry.origin_code == "KHOU"
assert entry.route_source == ""
# ---------------------------------------------------------------------------
# Two things that would go wrong quietly
# ---------------------------------------------------------------------------
def test_cirium_prefers_the_utc_time_over_the_local_one():
"""Its plain departureTime is local and carries no offset, so reading
that as UTC puts a leg up to half a day from where it belongs -- which
is exactly far enough to pick the wrong leg of the same number."""
body = json.loads(json.dumps(CIRIUM))
flight = body["scheduledFlights"][0]
flight["departureTimeUtc"] = at(-1)
flight["arrivalTimeUtc"] = at(2)
# The local times say the small hours, nine time zones away.
flight["departureTime"] = at(-10)[:-1]
flight["arrivalTime"] = at(-7)[:-1]
got = _read("cirium", body)
assert got is not None
assert (got["origin_code"], got["destination_code"]) == ("KMDW", "KSAN")
def test_a_reader_that_throws_is_a_source_that_does_not_know(monkeypatch):
"""An answer shaped differently from the documented one is the failure
most likely to actually happen. It has to come back as this service
not knowing, not as a traceback out of the middle of a scan."""
monkeypatch.setenv("BANDSAUNTER_AEROAPI_KEY", "k")
source = schedules.source_named("flightaware")
monkeypatch.setattr(type(source), "ask", lambda self, c, w: {"flights": 7})
with pytest.raises(schedules.SourceError):
source.route("SWA930", NOON)
# and the caller above it turns that into silence, not a crash
monkeypatch.setattr(schedules.Schedule, "fetch",
lambda self, url, headers=None: {"flights": 7})
assert schedules.route_for("SWA930", NOON) is None