The terminal board says what is overhead. This says where: a real map with the aircraft moving on it as the frames arrive, and beside each one a box carrying everything known about the flight -- type and registration, who operates it, where it came from and where it is going, height with a rate of climb, speed and heading, how far away and on what bearing, its position, how many frames it has sent and how long since the last one. Qt is asked for and not required. Four bindings are tried, the module imports on a machine with none of them, and asking for the window without one gets the instructions rather than a traceback -- before the receiver is opened, since nothing is gained by taking the dongle for a window that cannot be drawn. In the menu, "listen now" is now "passive capture" with a realtime display beside it. Closing the window leaves exactly the files pressing control-C leaves, because listen and watch share one read loop and one finishing step; the receiver runs on its own thread, so a slow repaint cannot cost a frame and a slow tile fetch cannot stall the picture. The animation's labels grew to match: flight level and speed, type and registration, and both ends of the route, each with a small flag of the country its airport is in. The flags are a table rather than a network -- twelve pixels by eight, where a flag is the arrangement that makes one recognisable rather than a rendering of the real thing -- and a country not in the table is named by its two letters, since a flag that is nearly another country's is worse than none. Where a route arrives as bare codes the country comes from the ICAO prefix. Four things found on the way. The window ignored --seconds, so "listen for ten minutes" meant something different with a window open; it closes itself now. The register was being asked twice per aircraft, once for labels and once for airport positions. Cached routes had no country in them, so the first real redraw drew no flags at all -- routes are versioned now. And past fourteen aircraft on one frame the labels go back to the callsign, the height and the speed, because five lines beside each of three hundred aircraft is a page of overlapping text with a map somewhere behind it. Long names are folded rather than allowed to stretch a box, breaking at the arrow of a route so the two ends stay whole; and the animation's label placement gained the same ring search the window uses, having only ever tried four spots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
654 lines
23 KiB
Python
654 lines
23 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)
|
|
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
|