bandsaunter/tests/test_livemap.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

1004 lines
36 KiB
Python

"""The window: what it draws, and what it does without Qt.
Everything here runs on Qt's offscreen platform, so it needs no screen and
no window manager -- which is also what makes it runnable on a machine that
has never had one.
"""
import os
import time
import numpy as np
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from bandsaunter import livemap # noqa: E402
from bandsaunter.livemap import Blip, Sky, blip_for, place_box # noqa: E402
qt = pytest.mark.skipif(not livemap.available(), reason="no Qt installed")
# Held for the life of the process on purpose. Qt allows one application and
# expects it to outlive every widget; letting the fixture own it means that
# running one test from this file on its own destroys it while widgets are
# still about, and the interpreter comes down with a core dump.
_APPLICATION = None
if livemap.available():
_widgets = livemap._qt()[3]
_APPLICATION = _widgets.QApplication.instance() or _widgets.QApplication([])
def now() -> float:
"""The real clock.
The window works in real time -- an aircraft leaves the picture so many
seconds after its last frame -- so a fixture pinned to a made-up epoch
would have everything time out before it was ever drawn.
"""
return time.time()
@pytest.fixture
def app():
"""The one application, which outlives every test that uses it."""
if _APPLICATION is None:
pytest.skip("no Qt installed")
return _APPLICATION
def a_blip(icao="A76154", callsign="DAL538", lat=32.95, lon=-110.95,
**over) -> Blip:
settings = dict(icao=icao, callsign=callsign, latitude=lat, longitude=lon,
altitude_ft=32_500, ground_speed_kt=494.0, track_deg=325.0,
messages=1204, first_seen=now() - 700, last_seen=now() - 1)
settings.update(over)
return Blip(**settings)
def a_sky(*blips, **over) -> Sky:
settings = dict(unit="knots", hold=45.0, home=(32.4325, -111.0841),
radius_nm=100.0, brightness=0.70)
settings.update(over)
sky = Sky(**settings)
sky.started = now() - 600
if blips:
sky.update(list(blips), frames=7412, seen=len(blips))
return sky
# ---------------------------------------------------------------------------
# Without Qt at all
# ---------------------------------------------------------------------------
def test_importing_it_does_not_drag_qt_in():
"""A machine with no Qt loses this window and nothing else, so nothing
may import Qt merely because this module was loaded."""
import importlib
import sys
for name in ("SkyView", "Window"):
assert name not in livemap.__dict__ or True # built on demand only
fresh = importlib.reload(importlib.import_module("bandsaunter.livemap"))
assert "SkyView" not in fresh.__dict__
assert "Window" not in fresh.__dict__
del sys
def test_the_message_says_how_to_get_it():
assert "pip install PyQt6" in livemap.MISSING_QT
assert "apt install" in livemap.MISSING_QT
# And that nothing else is lost by not having it.
assert "passive capture still records" in livemap.MISSING_QT
def test_no_qt_means_a_message_rather_than_a_traceback(monkeypatch):
monkeypatch.setattr(livemap, "_qt", lambda: None)
monkeypatch.setattr(livemap._build, "_made", None, raising=False)
assert livemap.available() is False
assert livemap.binding() == ""
with pytest.raises(RuntimeError) as raised:
livemap.show(a_sky())
assert "Qt" in str(raised.value)
def test_the_menu_is_told_whether_a_window_can_be_opened(monkeypatch):
from bandsaunter import aircraft as air
monkeypatch.setattr(livemap, "available", lambda: False)
assert air.windowed() is False
monkeypatch.setattr(livemap, "available", lambda: True)
assert air.windowed() is True
def test_asking_to_watch_without_qt_says_so_and_stops(monkeypatch, tmp_path,
capsys):
"""It must not open the receiver, either: nothing is gained by taking
the dongle for a window that cannot be drawn."""
from rich.console import Console
from bandsaunter import aircraft as air
monkeypatch.setattr(livemap, "available", lambda: False)
monkeypatch.setattr(air, "open_device", lambda *a, **k:
pytest.fail("opened the receiver anyway"))
heard = air.watch(Console(width=100), air.AircraftOptions(), str(tmp_path))
assert heard.frames == 0
assert "Qt" in capsys.readouterr().out
# ---------------------------------------------------------------------------
# What goes in the box
# ---------------------------------------------------------------------------
def test_the_box_says_everything_that_is_known():
blip = a_blip(registration="N904DN", type_code="B739",
manufacturer="Boeing", model="737-932ER",
operator="Delta Air Lines", country="United States",
origin="Atlanta", destination="Phoenix",
vertical_rate_fpm=1600)
told = "\n".join(f"{a} {b}" for a, b, _ in
blip.lines("knots", home=(32.4325, -111.0841)))
for wanted in ("N904DN", "B739", "Boeing 737-932ER", "Delta Air Lines",
"from Atlanta", "to Phoenix", "32,500 ft", "494 kt",
"325° NW", "1,204 frames", "United States"):
assert wanted in told, wanted
assert "↑1,600 fpm" in told
def test_a_descent_is_marked_as_one():
told = " ".join(v for _, v, _ in
a_blip(vertical_rate_fpm=-900).lines("knots"))
assert "↓900 fpm" in told and "" not in told
def test_nothing_is_said_about_what_is_not_known():
"""A register that has not answered yet must leave no empty rows: they
would never fill in and the box would be mostly gaps."""
told = [label for label, _, _ in a_blip().lines("knots")]
assert "reg" not in told
assert all(x is not None for x in told)
text = " ".join(v for _, v, _ in a_blip().lines("knots"))
assert "from" not in text # no route was known
def _by_label(blip, unit, home):
return {label: value for label, value, _ in blip.lines(unit, home=home)}
def test_how_far_away_and_which_way_are_worked_out():
home = (32.4325, -111.0841)
told = _by_label(a_blip(lat=32.95, lon=-110.95), "knots", home)
assert "32 nm" in told["range"]
assert "012°" in told["range"]
def test_the_range_follows_the_unit_the_speeds_are_in():
home = (32.4325, -111.0841)
assert "37 mi" in _by_label(a_blip(), "mph", home)["range"]
assert "59 km" in _by_label(a_blip(), "kph", home)["range"]
def test_an_aircraft_that_never_said_its_name_is_known_by_its_address():
assert a_blip(callsign="").name == "A76154"
assert a_blip().name == "DAL538"
def test_what_the_registers_said_is_copied_onto_the_blip():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
craft = Aircraft(icao="4CA1FA", callsign="RYR1234")
craft.altitude_ft, craft.messages = 35_000, 12
entry = Flight(icao="4CA1FA", registration="EI-DYP", type_code="B738",
operator="Ryanair", origin="Stansted",
destination="East Midlands", owner_country="Ireland")
blip = blip_for(craft, entry)
assert (blip.registration, blip.type_code) == ("EI-DYP", "B738")
assert blip.operator == "Ryanair" and blip.country == "Ireland"
assert blip.origin == "Stansted"
def test_a_blip_works_with_no_register_at_all():
from bandsaunter.adsb import Aircraft
blip = blip_for(Aircraft(icao="4CA1FA", callsign="RYR1234"))
assert blip.icao == "4CA1FA" and blip.registration == ""
# ---------------------------------------------------------------------------
# Where the boxes go
# ---------------------------------------------------------------------------
BOUNDS = (0, 0, 1000, 700)
def test_a_box_goes_beside_the_aircraft_when_there_is_room():
x, y = place_box(400, 300, 200, 120, [], BOUNDS)
assert x > 400 # to the right, the usual place
assert 300 - 120 <= y <= 300
def test_a_box_never_lands_on_one_already_placed():
taken = []
for i in range(9):
spot = place_box(400 + (i % 3) * 12, 300 + (i // 3) * 12,
180, 110, taken, BOUNDS)
taken.append((spot[0], spot[1], 180, 110))
for i, one in enumerate(taken):
for two in taken[i + 1:]:
apart = (one[0] + one[2] <= two[0] or two[0] + two[2] <= one[0]
or one[1] + one[3] <= two[1] or two[1] + two[3] <= one[1])
assert apart, f"{one} overlaps {two}"
def test_a_box_stays_on_the_picture():
for x, y in ((5, 5), (995, 695), (0, 350), (500, 0)):
bx, by = place_box(x, y, 220, 130, [], BOUNDS)
assert 0 <= bx and bx + 220 <= 1000
assert 0 <= by and by + 130 <= 700
def test_a_full_screen_still_gets_a_box():
"""Overlapping is better than nothing: a box in an awkward place still
says what the aircraft is."""
wall = [(x, y, 100, 100) for x in range(0, 1000, 100)
for y in range(0, 700, 100)]
bx, by = place_box(500, 350, 200, 120, wall, BOUNDS)
assert 0 <= bx <= 1000 and 0 <= by <= 700
# ---------------------------------------------------------------------------
# What the window is told
# ---------------------------------------------------------------------------
def test_an_aircraft_appears_and_is_kept_in_order_first_heard():
first = a_blip(icao="111111", callsign="FIRST", first_seen=now() - 300)
second = a_blip(icao="222222", callsign="SECOND", first_seen=now() - 100)
sky = a_sky(second, first)
assert [b.callsign for b in sky.flying()] == ["FIRST", "SECOND"]
def test_one_nothing_has_been_heard_from_leaves_the_picture():
here = a_blip(icao="111111", last_seen=now() - 5)
gone = a_blip(icao="222222", last_seen=now() - 300)
assert [b.icao for b in a_sky(here, gone).flying()] == ["111111"]
def test_an_aircraft_with_no_position_is_not_drawn():
"""It has been heard but not placed; the table says so and the map
cannot."""
assert a_sky(a_blip(lat=0.0, lon=0.0)).flying() == []
def test_the_trail_grows_as_it_moves():
sky = a_sky()
for i in range(5):
sky.update([a_blip(lat=32.9 + i * 0.01)], frames=i, seen=1)
assert len(sky.trail("A76154")) == 5
def test_standing_still_does_not_lengthen_the_trail():
sky = a_sky()
for _ in range(9):
sky.update([a_blip()], frames=1, seen=1)
assert len(sky.trail("A76154")) == 1
def test_the_middle_of_the_map_is_where_the_receiver_was_said_to_be():
assert a_sky(a_blip()).centre() == (32.4325, -111.0841)
def test_with_no_receiver_position_the_middle_is_worked_out():
sky = a_sky(a_blip(icao="1", lat=30.0, lon=-110.0),
a_blip(icao="2", lat=31.0, lon=-111.0),
a_blip(icao="3", lat=32.0, lon=-112.0), home=None)
assert sky.centre() == (31.0, -111.0)
def test_nothing_heard_yet_has_no_middle():
assert a_sky(home=None).centre() is None
def test_a_map_is_used_for_any_view_it_covers():
"""The whole point of fetching more than is shown: a view that has
drifted a little is still inside what was fetched."""
sky = a_sky()
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "k",
(31.0, -113.0, 34.0, -109.0))
levels, box = sky.ground_covering(32.0, -112.0, 33.0, -110.0)
assert levels is not None and box == (31.0, -113.0, 34.0, -109.0)
def test_a_map_is_not_used_for_a_view_that_has_left_it():
sky = a_sky()
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "k",
(31.0, -113.0, 34.0, -109.0))
assert sky.ground_covering(40.0, -113.0, 42.0, -109.0)[0] is None
assert sky.ground_covering(31.0, -120.0, 34.0, -109.0)[0] is None
@qt
def test_the_map_is_fetched_at_the_size_the_bigger_box_needs(app):
"""Fetching a third more world into the same pixels and then cutting the
middle out of it is how a sharp map ends up looking like a photograph of
a map."""
from bandsaunter.livemap import GROUND_MARGIN, SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
view._draw_ground(_NoPainter(), view.projection())
wanted = sky.wanted_ground()
assert wanted is not None
_key, _box, (width, height) = wanted
assert width >= 900 * (1 + 2 * GROUND_MARGIN) - 2
assert height >= 650 * (1 + 2 * GROUND_MARGIN) - 2
class _NoPainter:
"""Stands in for a painter for the one call that only asks for a map."""
def drawImage(self, *a):
raise AssertionError("nothing should be drawn without a map")
def test_more_is_fetched_than_is_shown(app):
"""A window that fetched exactly what it needed would fetch again on
every resize and every small drift of the middle."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.resize(900, 650)
projection = view.projection()
box = view.ground_box(projection)
assert box[0] < projection.south and box[1] < projection.west
assert box[2] > projection.north and box[3] > projection.east
def test_a_map_that_could_not_be_fetched_is_not_asked_for_again():
"""Otherwise a machine with no network asks five times a second all
night."""
sky = a_sky()
sky.want_ground("a", (0, 0, 1, 1), (10, 10))
assert sky.wanted_ground() is not None
sky.set_ground(None, "a")
assert sky.wanted_ground() is None
# ---------------------------------------------------------------------------
# The map staying still
# ---------------------------------------------------------------------------
def test_the_middle_settles_and_then_stays_put():
"""Recomputing it on every paint moves the map a fraction of a mile
whenever an aircraft appears or leaves, which throws away the tiles
fetched for the old view and blinks the ground out several times a
minute."""
sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0),
a_blip(icao="2", lat=32.5, lon=-111.1),
a_blip(icao="3", lat=32.6, lon=-111.2), home=None)
settled = sky.centre()
assert settled is not None
# One more aircraft, a little to one side: the middle must not follow it.
sky.update([a_blip(icao="4", lat=32.9, lon=-110.7)], 1, 4)
assert sky.centre() == settled
def test_the_middle_does_move_if_it_is_really_somewhere_else():
"""Settling must not mean stuck: the first few aircraft can be anywhere."""
sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0), home=None)
first = sky.centre()
for i in range(6):
sky.update([a_blip(icao=f"far{i}", lat=40.0 + i * 0.1, lon=-90.0)],
1, i)
assert sky.centre() != first
assert abs(sky.centre()[0] - 40.0) < 2
def test_a_receiver_that_was_told_where_it_is_never_drifts_at_all():
sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0))
for i in range(5):
sky.update([a_blip(icao=f"x{i}", lat=35.0 + i, lon=-100.0)], 1, i)
assert sky.centre() == (32.4325, -111.0841)
@qt
def test_a_small_drift_does_not_throw_the_map_away(app):
"""The failure this is here for: the ground blinking out because the
view moved by less than a mile."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="1", lat=32.40, lon=-111.00),
a_blip(icao="2", lat=32.50, lon=-111.10), home=None)
view = SkyView(sky)
view.resize(900, 650)
first = view.projection()
sky.set_ground(np.full((650, 900), 20, dtype=np.uint8),
view.ground_key(first), view.ground_box(first))
# Another aircraft arrives a few miles away.
sky.update([a_blip(icao="3", lat=32.44, lon=-111.06)], 1, 3)
moved = view.projection()
assert sky.ground_covering(moved.south, moved.west,
moved.north, moved.east)[0] is not None
@qt
def test_the_crop_lines_the_map_up_with_the_view(app):
"""A map fetched for a bigger box has to be cut to the right piece, or
the coast is drawn in the wrong place and the aircraft with it."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.resize(100, 80)
projection = view.projection()
box = (projection.south - 1.0, projection.west - 1.0,
projection.north + 1.0, projection.east + 1.0)
# A map that is dark in the north and bright in the south.
levels = np.repeat(np.linspace(0, 31, 200).astype(np.uint8)[:, None],
200, axis=1)
cropped = view._crop(levels, box, projection)
assert cropped.shape == (projection.height, projection.width)
assert cropped[0].mean() < cropped[-1].mean() # north still on top
# And the piece taken is the middle of the fetched one, not all of it.
assert cropped.min() > levels.min() and cropped.max() < levels.max()
def test_the_map_is_fetched_off_the_painting_thread():
asked = []
def tile(z, x, y, **kw):
asked.append((z, x, y))
return None
sky = a_sky()
sky.want_ground("k", (32.0, -112.0, 33.0, -110.0), (40, 40))
import threading
worker = threading.Thread(target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": tile}, daemon=True)
worker.start()
for _ in range(50):
if sky.wanted_ground() is None:
break
time.sleep(0.05)
sky.stopping = True
worker.join(timeout=2.0)
assert asked, "the tile server was never asked"
# ---------------------------------------------------------------------------
# Painting
# ---------------------------------------------------------------------------
def _rendered(view, width=900, height=650):
"""The widget's pixels, as an array."""
from bandsaunter.livemap import _qt
_name, _core, gui, _widgets, _signal = _qt()
view.resize(width, height)
image = gui.QImage(width, height, _get(gui.QImage, "Format",
"Format_RGB32"))
image.fill(gui.QColor(0, 0, 0))
view.render(image)
bits = image.constBits()
bits.setsize(image.sizeInBytes())
# Copied on the way out. The array numpy builds is a view onto Qt's own
# buffer, and the image is about to go out of scope: reading it after
# that is a use-after-free, which reads as blank rows if you are lucky
# and a segmentation fault if you are not.
return np.frombuffer(bits, dtype=np.uint8).reshape(
height, width, 4).copy()
def _get(owner, group, name):
return getattr(getattr(owner, group, owner), name)
def _painted(picture) -> int:
"""How many pixels are something other than the empty background.
Counting non-black pixels would count all of them: the background is a
dark blue rather than black, on purpose.
"""
from bandsaunter.flightmap import BG, PALETTE
r, g, b = (int(v) for v in PALETTE[BG])
background = ((picture[:, :, 2] == r) & (picture[:, :, 1] == g)
& (picture[:, :, 0] == b))
return int((~background).sum())
def _in_colour(picture, feet: int):
"""The pixels painted exactly in one altitude's colour."""
from bandsaunter.flightmap import PALETTE, RAMP, altitude_step
r, g, b = (int(v) for v in PALETTE[RAMP + altitude_step(feet)])
return ((picture[:, :, 2] == r) & (picture[:, :, 1] == g)
& (picture[:, :, 0] == b))
def _lit(picture):
"""Where those pixels are."""
from bandsaunter.flightmap import BG, PALETTE
r, g, b = (int(v) for v in PALETTE[BG])
background = ((picture[:, :, 2] == r) & (picture[:, :, 1] == g)
& (picture[:, :, 0] == b))
return np.argwhere(~background)
@qt
def test_the_window_draws_the_aircraft_and_a_box(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(registration="N904DN", type_code="B739",
operator="Delta Air Lines"))
view = SkyView(sky)
picture = _rendered(view)
assert _painted(picture) > 500, "nothing was drawn at all"
# The symbol is filled with the aircraft's altitude colour exactly; the
# box around it is a hairline and comes out blended, so only the symbol
# itself is counted here.
assert _in_colour(picture, 32_500).sum() >= 10, \
"no aircraft drawn in its altitude colour"
@qt
def test_nothing_placed_yet_says_so_rather_than_drawing_a_blank(app):
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(home=None))
picture = _rendered(view)
assert _painted(picture) > 200, "an empty window said nothing at all"
@qt
def test_the_symbol_is_where_the_aircraft_is(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(lat=32.4325, lon=-111.0841)) # right on the receiver
view = SkyView(sky)
view.detail = 0 # symbols only
picture = _rendered(view, 900, 650)
rows, cols = np.where(_in_colour(picture, 32_500))
assert rows.size, "the aircraft was not drawn"
# An aircraft at the receiver belongs in the middle of the window.
assert abs(rows.mean() - 325) < 40, rows.mean()
assert abs(cols.mean() - 450) < 40, cols.mean()
@qt
def test_the_detail_can_be_turned_down_for_a_busy_sky(app):
from bandsaunter.livemap import SkyView
sky = a_sky(*[a_blip(icao=f"{i:06X}", callsign=f"FLT{i}",
lat=32.3 + i * 0.05, lon=-111.2 + i * 0.05)
for i in range(6)])
view = SkyView(sky)
drawn = []
for detail in (2, 1, 0):
view.detail = detail
drawn.append(_painted(_rendered(view)))
assert drawn[0] > drawn[1] > drawn[2], drawn
@qt
def test_the_trails_and_the_map_can_be_turned_off(app):
from bandsaunter.livemap import SkyView
sky = a_sky()
for i in range(20):
sky.update([a_blip(lat=32.6 + i * 0.02, lon=-111.0 + i * 0.02)],
frames=i, seen=1)
view = SkyView(sky)
view.detail = 0
with_trail = _painted(_rendered(view))
view.trails = False
without = _painted(_rendered(view))
assert with_trail > without
@qt
def test_the_map_underneath_is_drawn_when_there_is_one(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
projection = view.projection()
sky.set_ground(np.full((650, 900), 31, dtype=np.uint8),
view.ground_key(projection),
view.ground_box(projection))
lit = _rendered(view)[:, :, :3]
from bandsaunter.flightmap import (GROUND, GROUND_SHADES, PALETTE,
dim_ground)
# The brightest shade a drawing reaches is what the brightness allows,
# not the top of the palette.
top = int(dim_ground(np.array([[GROUND_SHADES - 1]]), sky.brightness)[0, 0])
r, g, b = (int(v) for v in PALETTE[GROUND + top])
covered = ((lit[:, :, 2] == r) & (lit[:, :, 1] == g) & (lit[:, :, 0] == b))
assert covered.mean() > 0.5, "the map was not painted under the aircraft"
@qt
def test_the_keys_do_what_the_header_says_they_do(app):
from bandsaunter.livemap import Window, _qt
_name, core, gui, _widgets, _signal = _qt()
window = Window(a_sky(a_blip()))
try:
def press(letter):
event = gui.QKeyEvent(_get(core.QEvent, "Type", "KeyPress"),
ord(letter.upper()),
_get(core.Qt, "KeyboardModifier",
"NoModifier"), letter)
window.keyPressEvent(event)
was = window.view.detail
press("d")
assert window.view.detail != was
press("t")
assert window.view.trails is False
press("g")
assert window.view.show_ground is False
tight = window.sky.radius_nm
press("-")
assert window.sky.radius_nm > tight
press("+")
assert window.sky.radius_nm < window.sky.radius_nm * 1.5
finally:
window.close()
@qt
def test_the_window_shuts_itself_when_the_listening_is_over(app):
""""Listen for ten minutes" has to mean the same thing whether or not a
window is open."""
from bandsaunter.livemap import Window
sky = a_sky(a_blip())
window = Window(sky)
window.show()
assert window.isVisible()
sky.finished = True
window._tick()
assert not window.isVisible()
@qt
def test_closing_the_window_stops_the_receiver(app):
from bandsaunter.livemap import Window
sky = a_sky(a_blip())
window = Window(sky)
window.show()
window.close()
assert sky.stopping is True
# ---------------------------------------------------------------------------
# Long names, narrow boxes
# ---------------------------------------------------------------------------
def test_something_short_is_left_alone():
from bandsaunter.livemap import wrap_value
assert wrap_value("Atlanta → Phoenix") == ["Atlanta → Phoenix"]
assert wrap_value("Boeing 737-932ER") == ["Boeing 737-932ER"]
assert wrap_value("") == [""]
def test_a_long_route_breaks_at_the_arrow_first():
"""The two ends of the flight stay whole and sit under one another,
where they read as a pair."""
from bandsaunter.livemap import wrap_value
folded = wrap_value("London Stansted Airport → East Midlands Airport, "
"Nottingham")
assert folded[0] == "London Stansted Airport"
assert folded[1].startswith("→ East Midlands")
assert not any(line.startswith("") for line in folded[2:])
def test_the_far_end_of_a_route_is_indented_when_it_wraps_too():
from bandsaunter.livemap import wrap_value
folded = wrap_value("Los Angeles International Airport → Dallas Fort "
"Worth International Airport")
onward = folded[folded.index(next(x for x in folded
if x.startswith(""))) + 1:]
assert onward and all(line.startswith(" ") for line in onward)
def test_a_long_name_folds_between_words():
from bandsaunter.livemap import wrap_value
folded = wrap_value("CELESTIAL AVIATION TRADING 14 LTD")
assert len(folded) == 2
assert " ".join(folded) == "CELESTIAL AVIATION TRADING 14 LTD"
def test_one_enormous_word_is_cut_rather_than_widening_the_box():
"""Nothing an aircraft sends looks like this; a register will send one
eventually."""
from bandsaunter.livemap import wrap_value
folded = wrap_value("Supercalifragilisticexpialidociousaerodrome")
assert len(folded) > 1
assert "".join(folded) == "Supercalifragilisticexpialidociousaerodrome"
@pytest.mark.parametrize("text", [
"Los Angeles International Airport → Dallas Fort Worth International Airport",
"London Stansted Airport → East Midlands Airport, Nottingham",
"CELESTIAL AVIATION TRADING 14 LTD",
"Hartsfield Jackson Atlanta International Airport",
"Supercalifragilisticexpialidociousaerodrome",
])
def test_nothing_comes_out_wider_than_the_limit(text):
from bandsaunter.livemap import WRAP_CHARS, wrap_value
assert all(len(line) <= WRAP_CHARS for line in wrap_value(text)), \
wrap_value(text)
@qt
def test_a_folded_row_carries_no_label_of_its_own(app):
"""The label belongs to the value as a whole; repeating it down the side
of a wrapped airport name would read as several different facts."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky())
rows = view._wrapped([("alt", "35,000 ft", ""),
("to", "East Midlands Airport, Nottingham", "GB")])
assert rows[0] == ("alt", "35,000 ft", "")
assert rows[1][0] == "to" and rows[1][2] == "GB"
assert all(label == "" and flag == "" for label, _, flag in rows[2:])
@qt
def test_a_long_route_no_longer_widens_the_box(app):
"""The whole point: one flight between two long names used to make its
box wider than the map it sits on."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky())
short = view._box_size(view._wrapped([("to", "Phoenix", "")]))
long = view._box_size(view._wrapped(
[("to", "Dallas Fort Worth International Airport", "")]))
assert long[0] < short[0] * 2, "the box grew with the name"
assert long[1] > short[1], "it should have got taller instead"
@qt
def test_the_widest_box_stays_within_reason(app):
"""Every field at its longest, and the box still fits on a small window."""
from bandsaunter.livemap import SkyView
blip = a_blip(registration="N904DN", type_code="B739",
manufacturer="Boeing", model="737-932ER Winglets",
operator="CELESTIAL AVIATION TRADING 14 LTD",
country="United States of America",
origin="Hartsfield Jackson Atlanta International Airport",
destination="Phoenix Sky Harbor International Airport")
view = SkyView(a_sky(blip))
width, _height = view._box_size(
view._wrapped(blip.lines("mph", home=(32.4325, -111.0841))))
assert width < 320, width
# ---------------------------------------------------------------------------
# How bright the map is
# ---------------------------------------------------------------------------
def test_the_map_brightness_reaches_the_window():
from bandsaunter import aircraft as air
sky = Sky(brightness=0.9)
assert sky.brightness == 0.9
assert air.AircraftOptions().map_brightness == 70
@qt
def test_turning_the_brightness_down_darkens_the_map(app):
from bandsaunter.livemap import SkyView
def painted(brightness):
sky = a_sky(a_blip(), brightness=brightness)
view = SkyView(sky)
view.resize(400, 300)
projection = view.projection()
sky.set_ground(np.full((300, 400), 31, dtype=np.uint8),
view.ground_key(projection),
view.ground_box(projection))
return _rendered(view, 400, 300)[:, :, :3].astype(float).mean()
assert painted(0.3) < painted(0.7) < painted(1.0)
@qt
def test_the_keys_change_the_brightness(app):
from bandsaunter.livemap import Window, _qt
_name, core, gui, _widgets, _signal = _qt()
window = Window(a_sky(a_blip()))
try:
def press(letter):
window.keyPressEvent(gui.QKeyEvent(
_get(core.QEvent, "Type", "KeyPress"), ord(letter),
_get(core.Qt, "KeyboardModifier", "NoModifier"), letter))
was = window.sky.brightness
press("]")
assert window.sky.brightness > was
press("[")
press("[")
assert window.sky.brightness < was
for _ in range(20): # and never off either end
press("[")
assert 0.0 < window.sky.brightness <= 1.0
finally:
window.close()
def test_the_country_of_registration_carries_a_flag():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
craft = Aircraft(icao="4CA1FA", callsign="RYR1234")
blip = blip_for(craft, Flight(icao="4CA1FA", owner_country="Ireland"))
assert blip.country == "Ireland" and blip.country_code == "IE"
rows = {label: (value, flag) for label, value, flag in blip.lines("knots")}
assert rows["reg'd"] == ("Ireland", "IE")
def test_a_country_with_no_code_gets_no_flag_rather_than_a_wrong_one():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
blip = blip_for(Aircraft(icao="4CA1FA"),
Flight(icao="4CA1FA", owner_country="Atlantis"))
assert blip.country_code == ""
# ---------------------------------------------------------------------------
# Fading out rather than blinking out
# ---------------------------------------------------------------------------
def test_an_aircraft_still_being_heard_is_drawn_in_full():
sky = a_sky(hold=10.0, fade=20.0)
assert sky.strength(a_blip(last_seen=now() - 1)) == 1.0
assert sky.strength(a_blip(last_seen=now() - 9)) == 1.0
def test_one_that_has_gone_quiet_fades_rather_than_vanishing():
"""Taking it off between one frame and the next says it stopped
existing; fading says it stopped talking, which is what happened."""
sky = a_sky(hold=10.0, fade=20.0)
at = now()
strengths = [sky.strength(a_blip(last_seen=at - age), at)
for age in (5, 15, 20, 25, 29)]
assert strengths == sorted(strengths, reverse=True)
assert strengths[0] == 1.0
assert 0.0 < strengths[-1] < 0.1
def test_it_is_gone_once_it_has_finished_fading():
sky = a_sky(hold=10.0, fade=20.0)
old = a_blip(last_seen=now() - 40)
assert sky.strength(old) == 0.0
sky.update([old], 1, 1)
assert old.icao not in [b.icao for b in sky.flying()]
def test_it_stays_on_the_picture_for_the_whole_fade():
sky = a_sky(hold=10.0, fade=20.0)
quiet = a_blip(last_seen=now() - 25)
sky.update([quiet], 1, 1)
assert [b.icao for b in sky.flying()] == [quiet.icao]
def test_no_fade_at_all_takes_it_away_at_once():
sky = a_sky(hold=10.0, fade=0.0)
assert sky.strength(a_blip(last_seen=now() - 9)) == 1.0
assert sky.strength(a_blip(last_seen=now() - 11)) == 0.0
def test_how_long_the_fade_takes_is_a_setting():
from bandsaunter import aircraft as air
assert air.AircraftOptions().fade == 20.0
quick = a_sky(hold=10.0, fade=4.0)
slow = a_sky(hold=10.0, fade=60.0)
faded = a_blip(last_seen=now() - 20)
assert quick.strength(faded) == 0.0
assert slow.strength(faded) > 0.5
def test_the_setting_reaches_the_window():
assert Sky(fade=7.5).fade == 7.5
@qt
def test_a_fading_aircraft_is_drawn_fainter(app):
from bandsaunter.livemap import SkyView
def brightness(age):
sky = a_sky(hold=5.0, fade=20.0)
sky.update([a_blip(last_seen=now() - age)], 1, 1)
view = SkyView(sky)
view.detail = 0
picture = _rendered(view, 500, 400)
lit = _lit(picture)
if not len(lit):
return 0.0
return float(picture[lit[:, 0], lit[:, 1], :3].mean())
assert brightness(1) > brightness(20)
@qt
def test_a_faded_aircraft_keeps_its_symbol_and_loses_its_box(app):
"""A box at a tenth of its colour is something in the way of the
aircraft still flying."""
from bandsaunter.livemap import SkyView
sky = a_sky(hold=5.0, fade=20.0)
sky.update([a_blip(last_seen=now() - 22)], 1, 1) # strength about 0.15
view = SkyView(sky)
fresh = _painted(_rendered(view, 600, 450))
sky2 = a_sky(hold=5.0, fade=20.0)
sky2.update([a_blip(last_seen=now() - 1)], 1, 1)
view2 = SkyView(sky2)
full = _painted(_rendered(view2, 600, 450))
assert fresh < full, "the box was still drawn on a nearly faded aircraft"
@qt
def test_one_that_has_gone_quiet_is_not_counted_as_overhead(app):
"""It is on the picture, fading; saying it is overhead says more than
was heard."""
from bandsaunter.livemap import SkyView
sky = a_sky(hold=5.0, fade=30.0)
sky.update([a_blip(icao="AAA111", last_seen=now() - 1),
a_blip(icao="BBB222", lat=32.6, last_seen=now() - 20)], 9, 2)
picture = _rendered(SkyView(sky), 900, 650)
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