bandsaunter/tests/test_livemap.py
The Dust Council 2e20c48971 Mark the aerodromes in the window, and draw the lot on a vector display
The aerodromes were never on the window at all: only the animation drew
them, and the window's map had whatever airport glyphs the tiles happened to
carry.  They are drawn there now, in the same colour and the same square.

The first attempt at that had a bug worth naming, because it would have
looked like the feature simply not working.  They were fetched inside the
same pass of the fetching loop as a piece of map, so they queued behind a
hundred and twenty tiles coming off a network -- and once the map was in
hand there were no more passes, so they were never fetched again.  They are
their own question now, asked on the same thread but not behind the tiles,
and they arrive whether the tiles do or not.  An area that has been asked
about and has none in it is remembered as none, rather than asked about
again five times a second for the rest of the night.  And the thread now
starts if either the map or the aerodromes are wanted, so --no-basemap no
longer quietly takes the airports with it.

Then the themes, which change the window and the animated pictures together
because both read their colours out of the same palette.  night is what this
program has always drawn and is untouched.  digital, phosphor, amber and red
are the screens the phrase "air defence display" actually calls to mind: a
black tube, one phosphor, and thin bright vector lines with a halo round
them.

Three things follow from having one colour to spend, and they are
constraints rather than decoration.  Height becomes brightness, since hue is
no longer free -- low is dim and high burns, which is the trade those
displays made.  The map underneath drops to about a quarter of the
brightness asked for, because a tinted photograph of a county behind the
vectors is the one thing that stops a vector display looking like one.  And
a country is named in two letters rather than drawn as a flag, a flag being
half a dozen colours.

The glow is done twice, differently, because the two are different kinds of
picture.  The window lays each line down two or three times, wider and
fainter each pass, with the core last: trails, symbols, leader lines, box
borders and the aerodrome squares.  The animation cannot blend at all, a GIF
being indexed colour, so it dilates what it has drawn and fills the halo
with the dimmed copy of the colour underneath -- and the aircraft colours
already had dimmed copies, since those are the trail shades, so an aeroplane
glows into the colour its own trail is drawn in, which is the colour a
phosphor would have spread into.  The fixed colours get two rings each in
the palette for the purpose.  The halo goes over the map, the grid and the
background and over nothing else that was drawn, since a halo is what light
does to the dark around a line; where two rings meet the nearer wins.  It
costs about 55 ms a frame at 1400 by 1258 and the default theme skips the
pass entirely.

The palette is written over in place rather than replaced, because both
drawings and every one of their helpers hold a reference to that array and a
new one would leave half the program painting in the colours of the theme
before.  There is a test that every theme keeps the aerodrome colour more
than forty units of CIELAB from every altitude colour, stated as the
distance rather than as the colour, so that a new theme cannot quietly walk
an aircraft back into the airports.

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

1647 lines
60 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
# ---------------------------------------------------------------------------
# A box that has to move swings there rather than jumping
# ---------------------------------------------------------------------------
def test_the_glide_moves_towards_the_target_without_passing_it():
from bandsaunter.livemap import glide
here = (0.0, 0.0)
last = here
for _ in range(40):
here = glide(here, (100.0, -60.0), 0.05)
assert 0.0 <= here[0] <= 100.0 and -60.0 <= here[1] <= 0.0
assert here[0] >= last[0] and here[1] <= last[1]
last = here
assert here == (100.0, -60.0), "never actually arrived"
def test_the_glide_arrives_and_stops_asking_for_frames():
"""It has to land exactly on the target, not approach it forever: the
window repaints faster while anything is moving, and a box that is
always a thousandth of a pixel out never lets it stop."""
from bandsaunter.livemap import BOX_GLIDE, glide
here = glide((0.0, 0.0), (50.0, 50.0), BOX_GLIDE * 4)
assert here == (50.0, 50.0)
assert glide(here, (50.0, 50.0), 0.1) == (50.0, 50.0)
def test_the_glide_is_the_same_swing_at_any_frame_rate():
"""Eased by the distance left rather than counted in frames, so a busy
machine drawing half as often makes the same movement, not a slower
one."""
from bandsaunter.livemap import glide
fast = (0.0, 0.0)
for _ in range(8):
fast = glide(fast, (200.0, 0.0), 0.025)
slow = (0.0, 0.0)
for _ in range(2):
slow = glide(slow, (200.0, 0.0), 0.1)
assert abs(fast[0] - slow[0]) < 1.0
def test_a_frame_that_took_no_time_moves_nothing():
from bandsaunter.livemap import glide
assert glide((3.0, 4.0), (99.0, 99.0), 0.0) == (3.0, 4.0)
def test_a_window_left_buried_does_not_replay_the_moves():
"""Ten seconds behind another window is not ten seconds of animation
owed; it is a box that should already be where it belongs."""
from bandsaunter.livemap import glide
assert glide((0.0, 0.0), (300.0, 300.0), 10.0) == (300.0, 300.0)
@qt
def test_a_box_that_need_not_move_does_not_move(app):
"""The whole point of remembering the spot: one aircraft shifting a
pixel must not send its neighbour's box across the window."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.60, lon=-111.30),
a_blip(icao="A00002", callsign="AAL2", lat=32.20, lon=-110.60))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
first = dict(view._box_want)
assert first, "no box was placed at all"
for _ in range(4):
_rendered(view)
assert dict(view._box_want) == first
@qt
def test_a_spot_that_still_works_is_kept(app):
"""Hysteresis, and the reason boxes stay still: a box is only moved
when its own place is actually taken, never because the placer would
now prefer a different one."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.resize(900, 650)
view._box_want["A00001"] = (50, 20)
assert view._box_spot("A00001", 400, 300, (120, 60), []) == (450, 320)
@qt
def test_a_spot_that_has_been_taken_is_given_up(app):
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.resize(900, 650)
view._box_want["A00001"] = (50, 20)
spot = view._box_spot("A00001", 400, 300, (120, 60),
[(450, 320, 120, 60)])
assert spot != (450, 320)
assert view._box_want["A00001"] == (spot[0] - 400, spot[1] - 300)
@qt
def test_the_settled_place_is_what_is_spoken_for(app):
"""Laying the next box out against one still in mid-swing would move
that one too, and again when the first arrived; the screen would never
settle. So the target is what goes into the taken list."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.55, lon=-111.20))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
was = view._box_at["A00001"]
view._box_want["A00001"] = (was[0] + 200, was[1] + 100)
view._glide_at = time.time() - 0.05
_rendered(view)
# Mid-swing, and the spot it is heading for is the one it holds.
assert view._box_at["A00001"] != view._box_want["A00001"]
assert view._box_spot("A00001", 0, 0, (10, 10), []) == \
tuple(view._box_want["A00001"])
@qt
def test_the_placer_is_shown_the_settled_spot_not_the_gliding_one(app, monkeypatch):
"""The one that decides whether the screen settles. If a box in mid-
swing is what the next box is laid out against, that next box moves
too -- and moves back when the first one arrives."""
from bandsaunter.livemap import SkyView
early, late = now() - 900, now() - 800
sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.55, lon=-111.20,
first_seen=early),
a_blip(icao="B00002", callsign="AAL2", lat=32.25, lon=-110.70,
first_seen=late))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
was = view._box_at["A00001"]
settled = view._box_want["A00001"]
view._box_want["A00001"] = (was[0] + 220, was[1] + 130)
# The second one is made to ask for a place, so we can see what it is
# shown when it does.
view._box_want.pop("B00002")
seen = []
real = livemap.place_box
monkeypatch.setattr(livemap, "place_box",
lambda *a: (seen.append(list(a[4])), real(*a))[1])
view._glide_at = time.time() - 0.05
_rendered(view)
assert seen, "the second box never asked for a place"
offered = [(r[0], r[1]) for r in seen[-1]]
# The offsets are from the symbol, so they only mean anything once the
# symbol is where the picture put it.
ax, ay = view.projection().xy(32.55, -111.20)
target = view._box_want["A00001"]
drawn = view._box_at["A00001"]
assert drawn != settled and drawn != (float(target[0]), float(target[1])), \
"the first box was not in mid-swing, so this proves nothing"
going = (ax + target[0], ay + target[1])
got_to = (int(round(ax + drawn[0])), int(round(ay + drawn[1])))
assert going != got_to, "the two places are the same; nothing is proved"
assert going in offered, "the settled spot was not spoken for"
assert got_to not in offered, "laid out against a box in mid-swing"
@qt
def test_a_box_pushed_off_its_spot_swings_rather_than_jumps(app):
"""Drawn between where it was and where it now belongs, for several
frames, instead of being in the new place on the very next one."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.55, lon=-111.20))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
was = view._box_at["A00001"]
# Push its settled spot well away, as a crowd of new aircraft would.
view._box_want["A00001"] = (was[0] + 240, was[1] + 120)
view._glide_at = time.time() - 0.05 # a frame's worth of clock
_rendered(view)
# Read the target back rather than assuming it: a spot near the edge is
# clamped into the window, so the swing may be shorter than it was asked
# to be.
target = view._box_want["A00001"]
moved = view._box_at["A00001"]
assert target != was, "the test did not actually displace it"
assert moved != was, "it did not move at all"
assert moved != target, "it jumped the whole way in one frame"
part = (moved[0] - was[0]) / (target[0] - was[0])
assert 0.05 < part < 0.60, f"one frame carried it {part:.0%} of the way"
assert view._box_moving is True
# And it does get there, given the frames.
for _ in range(30):
view._glide_at = time.time() - 0.05
_rendered(view)
assert view._box_at["A00001"] == (float(target[0]), float(target[1]))
assert view._box_moving is False, "still asking for frames after arriving"
@qt
def test_a_box_follows_its_aeroplane_without_that_counting_as_a_move(app):
"""The offset is what is remembered, so an aircraft crossing the window
carries its box along instead of dragging it there a frame late."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.50, lon=-111.10))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
settled = view._box_at["A00001"]
sky.update([a_blip(icao="A00001", callsign="AAL1",
lat=32.70, lon=-110.70)], 1, 1)
_rendered(view)
assert view._box_at["A00001"] == settled # same offset, new place
assert view._box_moving is False
@qt
def test_an_aircraft_that_goes_is_forgotten(app):
"""Otherwise one that comes back an hour later glides in from wherever
it was standing then, across the whole window."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="A00001", callsign="AAL1"))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
assert "A00001" in view._box_at
view.detail = 0 # symbols only: no boxes
_rendered(view)
assert view._box_at == {} and view._box_want == {}
@qt
def test_a_box_in_motion_is_drawn_over_the_ones_standing_still(app):
"""It crosses its neighbours for a moment on the way, and the one that
is moving is the one being followed, so it is the one that has to stay
readable while it does."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.55, lon=-111.20,
first_seen=now() - 900),
a_blip(icao="B00002", callsign="AAL2", lat=32.25, lon=-110.70,
first_seen=now() - 800))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
order = []
real = SkyView._paint_label
def watch(self, painter, laid):
order.append((laid[0], laid[1].icao))
return real(self, painter, laid)
SkyView._paint_label = watch
try:
was = view._box_at["A00001"]
# Away from the other one, so that only this box is on the move.
view._box_want["A00001"] = (was[0] - 160, was[1] + 150)
view._glide_at = time.time() - 0.05
_rendered(view)
finally:
SkyView._paint_label = real
assert [icao for moving, icao in order if moving] == ["A00001"], \
f"expected only the displaced box to be moving, got {order}"
# Everything standing still goes down before anything that is moving.
assert order == sorted(order, key=lambda seen: seen[0])
assert order[-1][1] == "A00001"
# ---------------------------------------------------------------------------
# The ground is dimmed once, not every frame
# ---------------------------------------------------------------------------
def _with_ground(width=900, height=650, brightness=0.70):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(), brightness=brightness)
view = SkyView(sky)
view.resize(width, height)
projection = view.projection()
levels = np.random.default_rng(7).integers(
0, 32, (int(height * 1.3), int(width * 1.3)), dtype=np.uint8)
sky.set_ground(levels, view.ground_key(projection),
view.ground_box(projection))
return sky, view
@qt
def test_the_dimmed_map_is_kept_between_frames(app):
"""Cutting the view out, dimming it and looking every level up in the
palette is most of a tenth of a second over two megapixels. Doing it
again for a frame in which nothing about it changed put a ceiling of a
dozen frames a second on the window."""
sky, view = _with_ground()
_rendered(view)
made = view._ground_pixels
assert made is not None
_rendered(view)
assert view._ground_pixels is made, "the map was dimmed all over again"
@qt
def test_a_new_map_is_dimmed_afresh(app):
"""Same window, same brightness, same view: only the map itself is
different, which is what the count on it is for."""
sky, view = _with_ground()
_rendered(view)
made = view._ground_pixels
projection = view.projection()
sky.set_ground(np.full_like(sky._ground, 31),
view.ground_key(projection), view.ground_box(projection))
_rendered(view)
assert view._ground_pixels is not made
@qt
def test_turning_the_brightness_up_redraws_the_map(app):
sky, view = _with_ground()
_rendered(view)
made = view._ground_pixels.copy()
sky.brightness = 0.30
_rendered(view)
assert not np.array_equal(view._ground_pixels, made)
@qt
def test_resizing_the_window_redraws_the_map(app):
sky, view = _with_ground()
_rendered(view)
_rendered(view, width=700, height=500)
assert view._ground_pixels.shape[:2] == (500, 700)
def test_the_window_box_says_what_sort_of_aircraft_it_is():
"""The same two facts the animation shows, so the two look like the
same program: what it broadcast, and whether its address is military."""
military = a_blip(icao="AE07D3", callsign="PRIME04")
military.category = "heavy"
rows = military.lines("knots")
assert ("class", "military heavy", "") in rows
plain = a_blip(icao="AC4C44", callsign="SWA2444")
plain.category = "large"
assert ("class", "large", "") in plain.lines("knots")
quiet = a_blip(icao="AC4C44", callsign="SWA2444")
assert not any(label == "class" for label, _v, _f in quiet.lines("knots"))
def test_the_category_reaches_the_window_from_the_air():
from bandsaunter.adsb import Aircraft
craft = Aircraft(icao="AE07D3", callsign="PRIME04", latitude=32.5,
longitude=-111.0, category="heavy")
assert blip_for(craft).category == "heavy"
# ---------------------------------------------------------------------------
# The aerodromes under the window
# ---------------------------------------------------------------------------
TUCSON_AIRPORTS = [("KTUS", 32.116, -110.941), ("KDMA", 32.166, -110.883),
("KAVQ", 32.409, -111.218)]
def test_a_sky_does_not_reach_for_airports_unless_it_is_asked_to():
"""A question to a network the first time an area is drawn, so the
program turns it on and a library user has to say so on purpose."""
assert a_sky().show_airports is False
assert Sky(airports=True).show_airports is True
def test_the_airports_come_back_when_what_was_fetched_covers_the_view():
sky = a_sky()
sky.set_airports(TUCSON_AIRPORTS, (31.0, -112.0, 33.0, -110.0))
assert sky.airports_covering(31.5, -111.5, 32.5, -110.5) == TUCSON_AIRPORTS
def test_a_view_outside_what_was_fetched_asks_again():
"""None, not an empty list: nothing has been asked about this piece of
world, which is a different thing from having asked and found none."""
sky = a_sky()
sky.set_airports(TUCSON_AIRPORTS, (31.0, -112.0, 33.0, -110.0))
assert sky.airports_covering(40.0, -112.0, 41.0, -110.0) is None
assert a_sky().airports_covering(31.5, -111.5, 32.5, -110.5) is None
def test_an_area_with_no_aerodromes_is_not_asked_about_all_night():
"""An empty answer is an answer. Kept as an empty list, so that the
difference between "there are none" and "nobody has looked" survives."""
sky = a_sky()
sky.want_airports((31.0, -112.0, 33.0, -110.0))
sky.set_airports([], (31.0, -112.0, 33.0, -110.0))
assert sky.airports_covering(31.5, -111.5, 32.5, -110.5) == []
assert sky.wanted_airports() is None
def test_the_aerodromes_do_not_wait_behind_the_tiles():
"""The bug this is here for. They used to be fetched only in the same
pass as a piece of map, so they queued behind a hundred and twenty tiles
coming off a network -- and once the map was in hand there were no more
passes and they were never fetched at all."""
import threading
from bandsaunter import basemap
sky = a_sky()
sky.show_airports = True
sky.want_airports((32.0, -112.0, 33.0, -110.0))
real = basemap.airports_in
slow = threading.Event()
def tiles(*a, **kw): # a map that never arrives
slow.wait(10.0)
return None
basemap.airports_in = lambda s, w, n, e, **kw: [
{"code": c, "latitude": la, "longitude": lo}
for c, la, lo in TUCSON_AIRPORTS]
try:
# A map is asked for too, and the fetching of it hangs.
sky.want_ground("k", (32.0, -112.0, 33.0, -110.0), (40, 40))
worker = threading.Thread(
target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": tiles}, daemon=True)
worker.start()
for _ in range(60):
if sky.airports_covering(32.0, -112.0, 33.0, -110.0):
break
time.sleep(0.05)
got = sky.airports_covering(32.0, -112.0, 33.0, -110.0)
slow.set()
sky.stopping = True
worker.join(timeout=3.0)
finally:
basemap.airports_in = real
slow.set()
assert got == TUCSON_AIRPORTS, "they waited for a map that never came"
def test_the_airports_are_fetched_off_the_painting_thread():
import threading
from bandsaunter import basemap
sky = a_sky()
sky.show_airports = True
sky.want_airports((32.0, -112.0, 33.0, -110.0))
real = basemap.airports_in
basemap.airports_in = lambda s, w, n, e, **kw: [
{"code": c, "latitude": la, "longitude": lo}
for c, la, lo in TUCSON_AIRPORTS]
try:
worker = threading.Thread(
target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": lambda z, x, y, **kw: None}, daemon=True)
worker.start()
for _ in range(60):
if sky.airports_covering(32.0, -112.0, 33.0, -110.0):
break
time.sleep(0.05)
sky.stopping = True
worker.join(timeout=2.0)
finally:
basemap.airports_in = real
assert sky.airports_covering(32.0, -112.0, 33.0, -110.0) == TUCSON_AIRPORTS
def test_an_area_that_could_not_be_asked_about_is_not_asked_about_again():
import threading
from bandsaunter import basemap
sky = a_sky()
sky.show_airports = True
sky.want_airports((32.0, -112.0, 33.0, -110.0))
real = basemap.airports_in
tries = []
def broken(*a, **kw):
tries.append(1)
raise OSError("no network tonight")
basemap.airports_in = broken
try:
worker = threading.Thread(
target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": lambda z, x, y, **kw: None}, daemon=True)
worker.start()
time.sleep(0.6)
sky.stopping = True
worker.join(timeout=2.0)
finally:
basemap.airports_in = real
assert len(tries) == 1, f"asked {len(tries)} times after failing once"
assert sky.airports_covering(32.0, -112.0, 33.0, -110.0) == []
def test_no_airports_are_fetched_when_they_are_turned_off():
import threading
from bandsaunter import basemap
sky = a_sky() # show_airports is False
sky.want_airports((32.0, -112.0, 33.0, -110.0))
real = basemap.airports_in
def refuse(*a, **kw):
raise AssertionError("asked for airports with them turned off")
basemap.airports_in = refuse
try:
worker = threading.Thread(
target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": lambda z, x, y, **kw: None}, daemon=True)
worker.start()
time.sleep(0.5)
sky.stopping = True
worker.join(timeout=2.0)
finally:
basemap.airports_in = real
assert sky.airports_covering(32.0, -112.0, 33.0, -110.0) is None
def _airport_pixels(picture) -> int:
"""Pixels in the aerodrome colour, which nothing else on the window is."""
from bandsaunter.flightmap import AIRPORT, PALETTE
want = PALETTE[AIRPORT]
return int(((picture[:, :, 2] == want[0]) & (picture[:, :, 1] == want[1])
& (picture[:, :, 0] == want[2])).sum())
@qt
def test_the_window_marks_the_aerodromes_under_it(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(), airports=True)
view = SkyView(sky)
view.show_ground = False
bare = _rendered(view)
assert _airport_pixels(bare) == 0, "something else is already that colour"
projection = view.projection()
sky.set_airports(TUCSON_AIRPORTS,
(projection.south - 1, projection.west - 1,
projection.north + 1, projection.east + 1))
marked = _rendered(view)
assert _airport_pixels(marked) > 60, "no aerodrome was drawn"
@qt
def test_the_window_draws_no_aerodromes_when_they_are_turned_off(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip()) # show_airports is False
view = SkyView(sky)
view.show_ground = False
projection = view.projection()
sky.set_airports(TUCSON_AIRPORTS,
(projection.south - 1, projection.west - 1,
projection.north + 1, projection.east + 1))
assert _airport_pixels(_rendered(view)) == 0
@qt
def test_an_aerodrome_outside_the_view_is_nowhere_on_the_picture(app):
"""What is fetched covers rather more world than is shown, so on a
zoomed-in view most of the county's airports are outside it.
Projecting one gives coordinates off the widget and Qt clips them, so
this holds whether or not they are skipped first; it is here to catch
anyone who later clamps a position into the view instead, which would
pile every airport in the county along the border of the picture.
"""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(), airports=True)
view = SkyView(sky)
view.show_ground = False
projection = view.projection()
box = (projection.south - 5, projection.west - 5,
projection.north + 5, projection.east + 5)
sky.set_airports([("KJFK", projection.north + 3, projection.east + 3)], box)
assert _airport_pixels(_rendered(view)) == 0
# ---------------------------------------------------------------------------
# The window follows the theme
# ---------------------------------------------------------------------------
@qt
def test_the_window_draws_in_whatever_theme_is_set(app):
"""Both drawings read the same palette, so setting a theme changes the
window as well as the animation and there is nothing to keep in step."""
from bandsaunter import flightmap as fm
from bandsaunter.livemap import SkyView
def colours(theme):
fm.set_theme(theme)
sky = a_sky(a_blip(), airports=True)
view = SkyView(sky)
view.show_ground = False
projection = view.projection()
sky.set_airports([("KTUS", projection.south + 0.1,
projection.west + 0.1)],
(projection.south - 1, projection.west - 1,
projection.north + 1, projection.east + 1))
picture = _rendered(view)
return {tuple(int(v) for v in rgb) for rgb in
picture[:, :, [2, 1, 0]].reshape(-1, 3)}
try:
night = colours("night")
green = colours("phosphor")
finally:
fm.set_theme("night")
assert (255, 64, 200) in night, "the night aerodrome colour is missing"
assert (255, 64, 200) not in green, "a magenta aerodrome on a green screen"
assert (255, 184, 72) in green, "the phosphor aerodrome colour is missing"
@qt
def test_a_vector_theme_lays_a_halo_round_what_it_draws(app):
"""The window does its glow by laying the same line down two or three
times, wider and fainter each pass. So a themed window paints more
distinct colours than a plain one drawing the same aircraft."""
from bandsaunter import flightmap as fm
from bandsaunter.livemap import SkyView
def shades(theme):
fm.set_theme(theme)
sky = a_sky(a_blip())
view = SkyView(sky)
view.show_ground = False
picture = _rendered(view)
return len({tuple(int(v) for v in rgb) for rgb in
picture[:, :, [2, 1, 0]].reshape(-1, 3)})
try:
plain = shades("night")
glowing = shades("phosphor")
finally:
fm.set_theme("night")
assert glowing > plain, f"{glowing} shades is no more than {plain}"