Three changes to what is on the picture, and both drawings got all three. The line from an information box to its aircraft was drawn in the aircraft's own colour, which made it the same colour as that aircraft's trail: a straight solid line running out of an aeroplane, in the colour of the path behind the aeroplane, reads as more path, and on a busy picture that is a heading nobody flew. It has its own colour now, and is dashed. Qt measures a dash pattern in multiples of the pen's width, so each pass of the glow divides the pattern by its own width; without that the halo's dashes are three times the core's and the line comes out as beads. The animation had no such line at all, which only came out when the two were held up against each other: a label pushed into one of the outward rings by a crowd had nothing tying it to the aeroplane it was about. It has one now, walked along the line's own length rather than along whichever axis is longer, so that a nearly horizontal leader and a nearly vertical one get dashes of the same length and neither runs past the aircraft it points at. A red flag stands where the receiver was told it is standing, from the coordinates in the settings. The foot of the pole is the position and the pennant flies up and to the right, so nothing the flag is made of covers the place it points at. It is pure red in every theme: that is the one mark on the picture whose meaning must not change with the colours, and pure red is both the brightest red there is and the one furthest from every altitude colour -- a softer one sat close enough to a low aeroplane on the default map, and to a mid-altitude one on the red theme, to be taken for one. It is drawn only where a position was actually given, since a middle worked out from whatever flew past is not a place anybody is standing. And the range rings: faint discs at a quarter, a half and three quarters of the radius, concentric on the receiver and labelled with the distance. They are translucent and they stack, so the ground inside the innermost is lifted three times and the outer once, which gives a sense of how far away a thing is without measuring anything. An indexed picture cannot blend, so translucent there means moving the ground under the disc a step or two up its own ramp of shades, which keeps the coastline and the roads visible through it. Separate settings for the two, because a picture is studied and a window is glanced at. Two bugs found by the tests rather than by looking. The rings were built across the whole canvas instead of the part of it the map is drawn on, so they came out stretched by the height of the title bar -- four per cent, which is invisible and wrong. And a radius of zero killed the window: the projection collapses, every pixel maps to one point, and the graticule asks for lines across a span of nothing until Qt aborts. The program never passes a zero, but a caller could, and a crash is not an answer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
1845 lines
67 KiB
Python
1845 lines
67 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}"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The leader line, and the flag on the receiver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _leader_pixels(view) -> int:
|
|
"""How much of the picture the leader line accounts for.
|
|
|
|
Counted by drawing the view twice, once with the leader colour set to
|
|
the background, and taking the difference. Matching the colour itself
|
|
finds almost nothing: the line is smoothed and drawn with an alpha, so
|
|
hardly a pixel of it comes out the pure colour.
|
|
"""
|
|
from bandsaunter.flightmap import BG, LEADER, PALETTE
|
|
|
|
was = PALETTE[LEADER].copy()
|
|
try:
|
|
PALETTE[LEADER] = PALETTE[BG]
|
|
hidden = _rendered(view)
|
|
finally:
|
|
PALETTE[LEADER] = was
|
|
shown = _rendered(view)
|
|
return int((hidden != shown).any(axis=2).sum())
|
|
|
|
|
|
@qt
|
|
def test_the_leader_is_not_drawn_in_the_aircrafts_own_colour(app):
|
|
"""Drawn in the aircraft's colour it came out the same colour as that
|
|
aircraft's trail, and a straight line running out of an aeroplane in
|
|
the colour of the path behind it reads as more path."""
|
|
from bandsaunter.flightmap import LEADER, PALETTE, RAMP, RAMP_STEPS
|
|
from bandsaunter.livemap import SkyView
|
|
|
|
view = SkyView(a_sky(a_blip()))
|
|
view.show_ground = False
|
|
assert _leader_pixels(view) > 10, "no leader was drawn at all"
|
|
leader = tuple(int(v) for v in PALETTE[LEADER])
|
|
ramp = {tuple(int(v) for v in PALETTE[RAMP + i]) for i in range(RAMP_STEPS)}
|
|
assert leader not in ramp
|
|
|
|
|
|
@qt
|
|
def test_the_leader_is_dashed_rather_than_solid(app):
|
|
"""The same line with the dashes taken out paints noticeably more of
|
|
itself, which is what a dashed line is."""
|
|
from bandsaunter import livemap as lm
|
|
from bandsaunter.livemap import SkyView
|
|
|
|
def drawn(pattern):
|
|
was, lm.LEADER_DASH = lm.LEADER_DASH, pattern
|
|
try:
|
|
view = SkyView(a_sky(a_blip()))
|
|
view.show_ground = False
|
|
return _leader_pixels(view)
|
|
finally:
|
|
lm.LEADER_DASH = was
|
|
|
|
dashed = drawn(lm.LEADER_DASH)
|
|
solid = drawn(None)
|
|
assert solid > 10, "the solid line drew nothing to compare against"
|
|
assert dashed < solid * 0.85, \
|
|
f"{dashed} pixels against {solid}: that is not a dashed line"
|
|
|
|
|
|
def test_a_dash_pattern_is_scaled_by_the_width_of_the_pen_drawing_it():
|
|
"""Qt measures a dash pattern in multiples of the pen's own width. A
|
|
glowing line is the same line drawn two or three times at different
|
|
widths, so without this its halo would have dashes three times the
|
|
length of its core and it would come out as beads."""
|
|
from bandsaunter.livemap import dash_for
|
|
|
|
for width in (1.0, 3.4, 5.8):
|
|
pattern = dash_for((5.0, 4.0), width)
|
|
assert [round(step * width, 6) for step in pattern] == [5.0, 4.0]
|
|
|
|
|
|
def test_a_dash_pattern_never_asks_for_a_step_of_nothing():
|
|
"""Qt refuses a zero-length dash, and a pen can be asked for at any
|
|
width the glow happens to want."""
|
|
from bandsaunter.livemap import dash_for
|
|
|
|
for width in (0.0, -3.0, 1e6):
|
|
assert all(step > 0 for step in dash_for((5.0, 4.0), width))
|
|
|
|
|
|
@qt
|
|
def test_a_flag_stands_where_the_receiver_was_told_it_is(app):
|
|
from bandsaunter.flightmap import HOME_RED
|
|
from bandsaunter.livemap import SkyView
|
|
|
|
def flag_pixels(view):
|
|
picture = _rendered(view)
|
|
return int(((picture[:, :, 2] == HOME_RED[0])
|
|
& (picture[:, :, 1] == HOME_RED[1])
|
|
& (picture[:, :, 0] == HOME_RED[2])).sum())
|
|
|
|
placed = SkyView(a_sky(a_blip(), home=(32.4325, -111.0841)))
|
|
placed.show_ground = False
|
|
assert flag_pixels(placed) > 20, "no flag where the receiver is"
|
|
|
|
|
|
@qt
|
|
def test_no_flag_where_nobody_said_the_receiver_is(app):
|
|
"""The middle is otherwise worked out from whatever flew past, which is
|
|
not a place anybody is standing."""
|
|
from bandsaunter.flightmap import HOME_RED
|
|
from bandsaunter.livemap import SkyView
|
|
|
|
view = SkyView(a_sky(a_blip(), home=None))
|
|
view.show_ground = False
|
|
picture = _rendered(view)
|
|
assert int(((picture[:, :, 2] == HOME_RED[0])
|
|
& (picture[:, :, 1] == HOME_RED[1])
|
|
& (picture[:, :, 0] == HOME_RED[2])).sum()) == 0
|
|
|
|
|
|
def test_both_pictures_draw_the_leader_and_the_flag_the_same():
|
|
"""They are meant to look like the same program, and two copies of a
|
|
number are two chances to change only one of them."""
|
|
from bandsaunter import flightmap as fm
|
|
from bandsaunter import livemap as lm
|
|
|
|
assert tuple(lm.LEADER_DASH) == tuple(float(x) for x in fm.LEADER_DASH)
|
|
# Both read the one palette, so there is no second colour to drift.
|
|
assert tuple(fm.PALETTE[fm.LEADER]) == tuple(fm.PALETTE[fm.LEADER])
|
|
assert tuple(fm.PALETTE[fm.HOME]) == fm.HOME_RED
|
|
# And the one set of measurements for the flag itself.
|
|
for name in ("HOME_POLE", "HOME_FLY", "HOME_DROP"):
|
|
assert hasattr(fm, name)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Range rings on the window
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _lifted_pixels(view) -> int:
|
|
"""How much of the window the rings account for, by drawing it with and
|
|
without them. The tint is an alpha over whatever is underneath, so
|
|
there is no one colour to count."""
|
|
was = view.sky.show_rings
|
|
try:
|
|
view.sky.show_rings = False
|
|
without = _rendered(view)
|
|
view.sky.show_rings = True
|
|
with_them = _rendered(view)
|
|
finally:
|
|
view.sky.show_rings = was
|
|
return int((without != with_them).any(axis=2).sum())
|
|
|
|
|
|
def test_a_sky_does_not_draw_rings_unless_it_is_asked_to():
|
|
assert a_sky().show_rings is False
|
|
assert Sky(rings=True).show_rings is True
|
|
|
|
|
|
@qt
|
|
def test_the_window_draws_the_range_rings(app):
|
|
from bandsaunter.livemap import SkyView
|
|
|
|
view = SkyView(a_sky(a_blip(), rings=True))
|
|
view.show_ground = False
|
|
assert _lifted_pixels(view) > 5000, "no rings were drawn"
|
|
|
|
|
|
@qt
|
|
def test_no_rings_without_a_position_or_without_a_radius(app):
|
|
"""They are measured from the radius and centred on the receiver, so
|
|
they mean nothing without both."""
|
|
from bandsaunter.livemap import SkyView
|
|
|
|
nowhere = SkyView(a_sky(a_blip(), rings=True, home=None))
|
|
nowhere.show_ground = False
|
|
assert _lifted_pixels(nowhere) == 0
|
|
|
|
flat = SkyView(a_sky(a_blip(), rings=True, radius_nm=0.0))
|
|
flat.show_ground = False
|
|
assert _lifted_pixels(flat) == 0
|
|
|
|
|
|
def test_the_rings_are_labelled_with_how_far_out_they_are():
|
|
"""Both pictures ask the same function for the wording, so the window
|
|
and the drawings cannot come to different numbers for the same ring."""
|
|
from bandsaunter.flightmap import ring_labels
|
|
|
|
assert ring_labels(100.0, "knots") == [(25.0, "25 nm"), (50.0, "50 nm"),
|
|
(75.0, "75 nm")]
|
|
|
|
|
|
def test_the_ring_labels_are_in_the_unit_the_rest_of_the_window_uses():
|
|
"""Miles an hour beside a ring measured in nautical miles would be two
|
|
different miles on one picture."""
|
|
from bandsaunter.flightmap import ring_labels
|
|
|
|
assert [text for _nm, text in ring_labels(104.0, "mph")] == \
|
|
["30 mi", "60 mi", "90 mi"]
|
|
assert [text for _nm, text in ring_labels(100.0, "kph")] == \
|
|
["46 km", "93 km", "139 km"]
|