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
This commit is contained in:
parent
87b0954f0c
commit
8eb3bdbb86
20 changed files with 1758 additions and 66 deletions
|
|
@ -1186,3 +1186,26 @@ def test_the_faint_colours_are_the_same_hues_only_dimmer():
|
|||
def test_the_palette_still_has_room_after_the_fade_colours():
|
||||
assert fm.FAINT + fm.RAMP_STEPS < fm.TRANSPARENT
|
||||
assert fm.PALETTE.shape == (256, 3)
|
||||
|
||||
|
||||
def test_a_route_the_aircraft_cannot_be_flying_is_not_drawn_beside_it():
|
||||
"""A route beside an aircraft reads as a statement about that aircraft,
|
||||
and a callsign is a flight number rather than a leg."""
|
||||
track = straight(lat=32.7, lon=-110.4)
|
||||
entry = _Entry(origin_code="KHOU", origin_lat=29.65, origin_lon=-95.28,
|
||||
destination_code="KSAT", destination_lat=29.53,
|
||||
destination_lon=-98.47)
|
||||
said = [text for text, _flag in
|
||||
fm.label_lines(track, track.fixes[0], "knots", entry)]
|
||||
assert "KHOU" not in said and "KSAT" not in said
|
||||
assert any("B739" in line for line in said), "the rest of it went too"
|
||||
|
||||
|
||||
def test_a_route_it_could_be_flying_is_drawn():
|
||||
track = straight(lat=32.7, lon=-110.4)
|
||||
entry = _Entry(origin_code="KLAX", origin_lat=33.94, origin_lon=-118.41,
|
||||
destination_code="KDFW", destination_lat=32.90,
|
||||
destination_lon=-97.04)
|
||||
said = [text for text, _flag in
|
||||
fm.label_lines(track, track.fixes[0], "knots", entry)]
|
||||
assert "KLAX" in said and "KDFW" in said
|
||||
|
|
|
|||
|
|
@ -598,3 +598,191 @@ def test_a_route_written_now_is_kept(tmp_path, register):
|
|||
again = FlightBook(cache=book.cache_path)
|
||||
entry = again.get("4CA1FA", "RYR1234")
|
||||
assert entry.origin_country == "GB"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# A callsign is a flight number, not a leg
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _route(origin=(29.65, -95.28), destination=(29.53, -98.47)) -> Flight:
|
||||
"""Houston Hobby to San Antonio: a half-hour hop across Texas."""
|
||||
return Flight(icao="AC0FB6", callsign="SWA930",
|
||||
origin_code="KHOU", origin="Houston",
|
||||
origin_lat=origin[0], origin_lon=origin[1],
|
||||
destination_code="KSAT", destination="San Antonio",
|
||||
destination_lat=destination[0], destination_lon=destination[1])
|
||||
|
||||
|
||||
def test_an_aircraft_on_its_route_is_believed():
|
||||
from bandsaunter.flights import route_fits
|
||||
|
||||
# Halfway between the two, and at either end.
|
||||
assert route_fits(_route(), 29.6, -96.9)
|
||||
assert route_fits(_route(), 29.65, -95.28)
|
||||
assert route_fits(_route(), 29.53, -98.47)
|
||||
|
||||
|
||||
def test_an_aircraft_nowhere_near_its_route_is_not():
|
||||
"""The one that prompted this: a 737 over Arizona at cruise, given a
|
||||
thirty-minute hop between two airports in Texas. An airline runs the
|
||||
same flight number over several legs in a day and a register holds one
|
||||
route for it."""
|
||||
from bandsaunter.flights import route_fits
|
||||
|
||||
assert not route_fits(_route(), 32.7086, -110.4061)
|
||||
|
||||
|
||||
def test_a_long_route_is_given_more_room_than_a_short_one():
|
||||
"""An aircraft on a transcontinental leg wanders further from the great
|
||||
circle than one on a hop, and neither is a wrong route."""
|
||||
from bandsaunter.flights import route_fits
|
||||
|
||||
coast = _route(origin=(40.69, -74.17), destination=(33.43, -112.01))
|
||||
assert route_fits(coast, 32.7086, -110.4061) # Newark to Phoenix
|
||||
assert route_fits(coast, 39.0, -95.0)
|
||||
|
||||
|
||||
def test_a_route_with_no_positions_is_left_alone():
|
||||
"""Not knowing is not the same as knowing it is wrong."""
|
||||
from bandsaunter.flights import route_fits
|
||||
|
||||
bare = Flight(icao="A", origin_code="KHOU", destination_code="KSAT")
|
||||
assert route_fits(bare, 32.7, -110.4)
|
||||
assert route_fits(Flight(icao="A"), 0.0, 0.0)
|
||||
|
||||
|
||||
def test_a_route_that_cannot_be_flown_is_still_written_down(tmp_path):
|
||||
"""Said rather than hidden: it is what the register holds for that
|
||||
flight number, and worth having."""
|
||||
from bandsaunter.flightlog import report
|
||||
|
||||
log = _log(tmp_path)
|
||||
for i in range(3):
|
||||
log.append(_Frame(icao="AC0FB6", callsign="SWA930"),
|
||||
_Craft(32.7086, -110.4061 + i * 0.01, "SWA930"),
|
||||
when=1_000_000.0 + i)
|
||||
log.close()
|
||||
|
||||
class _Book:
|
||||
def get(self, icao, callsign=""):
|
||||
return _route()
|
||||
|
||||
text = "\n".join(report(read_logs(log.path), _Book()))
|
||||
assert "Houston" in text and "San Antonio" in text
|
||||
assert "nowhere near it" in text
|
||||
|
||||
|
||||
def test_a_route_that_fits_is_not_second_guessed(tmp_path):
|
||||
from bandsaunter.flightlog import report
|
||||
|
||||
log = _log(tmp_path)
|
||||
for i in range(3):
|
||||
log.append(_Frame(icao="AC0FB6", callsign="SWA930"),
|
||||
_Craft(29.6, -96.9 + i * 0.01, "SWA930"),
|
||||
when=1_000_000.0 + i)
|
||||
log.close()
|
||||
|
||||
class _Book:
|
||||
def get(self, icao, callsign=""):
|
||||
return _route()
|
||||
|
||||
text = "\n".join(report(read_logs(log.path), _Book()))
|
||||
assert "Houston" in text
|
||||
assert "nowhere near" not in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Picking the leg out of a day's work
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
HEXDB_DAY = {"flight": "AAL2465", "route": "KORD-KEWR-KORD"}
|
||||
HEXDB_LEG = {"flight": "BAW49", "route": "EGLL-KSEA"}
|
||||
|
||||
AIRPORTS = {
|
||||
"KORD": {"code": "KORD", "name": "Chicago O'Hare", "latitude": 41.978,
|
||||
"longitude": -87.905, "country": "US"},
|
||||
"KEWR": {"code": "KEWR", "name": "Newark Liberty", "latitude": 40.692,
|
||||
"longitude": -74.169, "country": "US"},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def with_airports(register, monkeypatch):
|
||||
"""A book that knows where a handful of airports are, and no network."""
|
||||
book, asked, answers = register
|
||||
monkeypatch.setattr(type(book), "airport",
|
||||
lambda self, code: AIRPORTS.get(code.upper(), {}))
|
||||
return book, asked, answers
|
||||
|
||||
|
||||
def test_two_stops_are_a_route(register):
|
||||
book, asked, answers = register
|
||||
answers["hexdb.io/api/v1/route"] = HEXDB_LEG
|
||||
entry = book.get("400001", "BAW49")
|
||||
book.wait(5.0)
|
||||
assert (entry.origin_code, entry.destination_code) == ("EGLL", "KSEA")
|
||||
assert entry.stops == ("EGLL", "KSEA")
|
||||
|
||||
|
||||
def test_a_whole_day_of_stops_is_not_read_as_one_flight(register):
|
||||
""""KORD-KEWR-KORD" read from its ends is Chicago to Chicago, which is
|
||||
not a flight."""
|
||||
book, asked, answers = register
|
||||
answers["hexdb.io/api/v1/route"] = HEXDB_DAY
|
||||
entry = book.get("AD64CD", "AAL2465")
|
||||
book.wait(5.0)
|
||||
assert entry.stops == ("KORD", "KEWR", "KORD")
|
||||
assert entry.origin_code == "" and entry.destination_code == ""
|
||||
|
||||
|
||||
def test_the_leg_is_picked_out_by_where_the_aircraft_is(with_airports):
|
||||
book, asked, answers = with_airports
|
||||
answers["hexdb.io/api/v1/route"] = HEXDB_DAY
|
||||
entry = book.get("AD64CD", "AAL2465")
|
||||
book.wait(5.0)
|
||||
# Over Pennsylvania, which is on the way from Chicago to Newark.
|
||||
assert book.leg_for(entry, 40.8, -78.0) == ("KORD", "KEWR")
|
||||
|
||||
|
||||
def test_resolving_fills_the_leg_in(with_airports):
|
||||
book, asked, answers = with_airports
|
||||
answers["hexdb.io/api/v1/route"] = HEXDB_DAY
|
||||
entry = book.get("AD64CD", "AAL2465")
|
||||
book.wait(5.0)
|
||||
got = book.resolve(entry, 40.8, -78.0)
|
||||
assert got.origin_code == "KORD" and got.destination_code == "KEWR"
|
||||
assert "Chicago" in got.origin and got.origin_country == "US"
|
||||
assert got.origin_lat == pytest.approx(41.978)
|
||||
|
||||
|
||||
def test_an_aircraft_on_none_of_the_legs_is_given_none_of_them(with_airports):
|
||||
"""Which is an answer, and a better one than naming a leg it cannot
|
||||
be on."""
|
||||
book, asked, answers = with_airports
|
||||
answers["hexdb.io/api/v1/route"] = HEXDB_DAY
|
||||
entry = book.get("AD64CD", "AAL2465")
|
||||
book.wait(5.0)
|
||||
assert book.leg_for(entry, 32.7, -110.4) is None # over Arizona
|
||||
assert book.resolve(entry, 32.7, -110.4).origin_code == ""
|
||||
|
||||
|
||||
def test_resolving_leaves_an_ordinary_route_alone(with_airports):
|
||||
book, asked, answers = with_airports
|
||||
answers["hexdb.io/api/v1/route"] = HEXDB_LEG
|
||||
entry = book.get("400001", "BAW49")
|
||||
book.wait(5.0)
|
||||
assert book.resolve(entry, 51.0, -20.0) is entry
|
||||
|
||||
|
||||
def test_an_airport_nobody_can_place_costs_only_its_own_leg(register,
|
||||
monkeypatch):
|
||||
book, asked, answers = register
|
||||
monkeypatch.setattr(type(book), "airport",
|
||||
lambda self, code: AIRPORTS.get(code.upper(), {}))
|
||||
answers["hexdb.io/api/v1/route"] = {"flight": "X", "route":
|
||||
"KORD-ZZZZ-KEWR"}
|
||||
entry = book.get("AD64CD", "X")
|
||||
book.wait(5.0)
|
||||
# The middle stop cannot be placed, so neither leg touching it can be
|
||||
# tested; nothing is claimed.
|
||||
assert book.leg_for(entry, 40.8, -78.0) is None
|
||||
|
|
|
|||
|
|
@ -495,3 +495,24 @@ def test_a_simulated_transmission_becomes_a_file_on_disk(tmp_path):
|
|||
if not p.name.endswith("_waterfall.png")]
|
||||
assert written
|
||||
assert _read_png(written[0]).shape[1] == 320
|
||||
|
||||
|
||||
def test_a_q_cannot_be_read_as_a_nought():
|
||||
"""A registration came back as N87650 when the aircraft was N8765Q."""
|
||||
from bandsaunter.images import GLYPHS
|
||||
|
||||
assert GLYPHS["Q"] != GLYPHS["0"]
|
||||
assert GLYPHS["Q"] != GLYPHS["O"]
|
||||
# The tail hangs below and to the right of the letter, where a nought
|
||||
# has nothing at all.
|
||||
assert GLYPHS["Q"][-1].rstrip("0").endswith("1")
|
||||
assert GLYPHS["Q"][-1][:3] == "000"
|
||||
assert GLYPHS["0"][-1] != GLYPHS["Q"][-1]
|
||||
|
||||
|
||||
def test_the_letters_most_easily_confused_are_all_different():
|
||||
from bandsaunter.images import GLYPHS
|
||||
|
||||
for group in ("O0Q", "1I", "5S", "2Z", "8B"):
|
||||
shapes = [GLYPHS[c] for c in group]
|
||||
assert len(set(shapes)) == len(shapes), group
|
||||
|
|
|
|||
|
|
@ -967,3 +967,38 @@ def test_one_that_has_gone_quiet_is_not_counted_as_overhead(app):
|
|||
assert _painted(picture) > 100 # both are drawn
|
||||
assert len(sky.flying()) == 2
|
||||
assert sum(1 for b in sky.flying() if sky.strength(b) >= 1.0) == 1
|
||||
|
||||
|
||||
def test_a_route_the_aircraft_cannot_be_flying_is_left_out_of_the_box():
|
||||
from bandsaunter.adsb import Aircraft
|
||||
from bandsaunter.flights import Flight
|
||||
|
||||
craft = Aircraft(icao="AC0FB6", callsign="SWA930")
|
||||
craft.latitude, craft.longitude = 32.7086, -110.4061
|
||||
entry = Flight(icao="AC0FB6", origin="Houston", origin_code="KHOU",
|
||||
origin_lat=29.65, origin_lon=-95.28,
|
||||
destination="San Antonio", destination_code="KSAT",
|
||||
destination_lat=29.53, destination_lon=-98.47,
|
||||
registration="N8765Q")
|
||||
blip = blip_for(craft, entry)
|
||||
assert blip.route_fits is False
|
||||
labels = [label for label, _v, _f in blip.lines("knots")]
|
||||
assert "from" not in labels and "to" not in labels
|
||||
# And everything the aircraft itself said is still there.
|
||||
assert any(v == "N8765Q" for _l, v, _f in blip.lines("knots"))
|
||||
|
||||
|
||||
def test_a_route_it_could_be_flying_stays_in_the_box():
|
||||
from bandsaunter.adsb import Aircraft
|
||||
from bandsaunter.flights import Flight
|
||||
|
||||
craft = Aircraft(icao="AD64CD", callsign="AAL2465")
|
||||
craft.latitude, craft.longitude = 33.1059, -110.5428
|
||||
entry = Flight(icao="AD64CD", origin="Santa Ana", origin_code="KSNA",
|
||||
origin_lat=33.68, origin_lon=-117.87,
|
||||
destination="Dallas", destination_code="KDFW",
|
||||
destination_lat=32.90, destination_lon=-97.04)
|
||||
blip = blip_for(craft, entry)
|
||||
assert blip.route_fits is True
|
||||
labels = [label for label, _v, _f in blip.lines("knots")]
|
||||
assert "from" in labels and "to" in labels
|
||||
|
|
|
|||
467
tests/test_schedules.py
Normal file
467
tests/test_schedules.py
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue