"""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) 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_only_used_for_the_view_it_was_fetched_for(): sky = a_sky() sky.set_ground(np.zeros((4, 4), dtype=np.uint8), "a") assert sky.ground("a") is not None assert sky.ground("b") is None 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 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) key = view.ground_key(view.projection()) sky.set_ground(np.full((650, 900), 31, dtype=np.uint8), key) lit = _rendered(view)[:, :, :3] from bandsaunter.flightmap import GROUND, GROUND_SHADES, PALETTE r, g, b = (int(v) for v in PALETTE[GROUND + GROUND_SHADES - 1]) 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