"""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