A window on the sky, and flags on the routes
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
This commit is contained in:
parent
e50d43d6e2
commit
df080f6571
21 changed files with 2848 additions and 145 deletions
139
tests/test_flags.py
Normal file
139
tests/test_flags.py
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
"""The little flags, and where a country comes from.
|
||||
|
||||
A flag at twelve pixels by eight is not a rendering of the real thing, so
|
||||
what is checked here is what it has to get right to be worth drawing: the
|
||||
right shape of arrangement, the right colours, and never a flag that belongs
|
||||
to somebody else.
|
||||
"""
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from bandsaunter import flags
|
||||
|
||||
|
||||
def test_every_flag_is_the_size_it_says_it_is():
|
||||
for code, rows in flags.FLAGS.items():
|
||||
assert len(rows) == flags.FLAG_H, code
|
||||
assert all(len(row) == flags.FLAG_W for row in rows), code
|
||||
|
||||
|
||||
def test_every_flag_uses_colours_that_exist():
|
||||
"""A typo in a flag would otherwise come out as silent white."""
|
||||
used = {letter for rows in flags.FLAGS.values()
|
||||
for row in rows for letter in row}
|
||||
assert used <= set(flags.COLOURS), used - set(flags.COLOURS)
|
||||
|
||||
|
||||
def test_the_colours_are_a_fixed_order():
|
||||
"""An index into the animation's palette has to mean one colour for the
|
||||
life of the file it is written into."""
|
||||
assert flags.COLOUR_ORDER == tuple(flags.COLOURS)
|
||||
assert len(flags.COLOUR_ORDER) == len(set(flags.COLOUR_ORDER))
|
||||
|
||||
|
||||
def test_a_country_with_no_flag_here_gets_none_rather_than_a_wrong_one():
|
||||
assert flags.flag_for("ZZ") is None
|
||||
assert flags.pixels_for("ZZ") is None
|
||||
assert flags.known("ZZ") is False
|
||||
assert flags.known("us") is True # case does not matter
|
||||
|
||||
|
||||
def test_a_flag_comes_back_as_pixels():
|
||||
picture = flags.pixels_for("JP")
|
||||
assert picture.shape == (flags.FLAG_H, flags.FLAG_W, 3)
|
||||
assert picture.dtype == np.uint8
|
||||
# White at the corner, red in the middle: that is the flag of Japan.
|
||||
assert tuple(picture[0, 0]) == flags.COLOURS["w"]
|
||||
assert tuple(picture[4, 6]) == flags.COLOURS["r"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code,hoist,middle,fly", [
|
||||
("FR", "b", "w", "r"), # blue at the hoist, white, red at the fly
|
||||
("IT", "g", "w", "r"),
|
||||
("IE", "g", "w", "o"),
|
||||
("BE", "k", "y", "r"),
|
||||
])
|
||||
def test_a_vertical_tricolour_runs_left_to_right(code, hoist, middle, fly):
|
||||
picture = flags.pixels_for(code)
|
||||
assert tuple(picture[4, 0]) == flags.COLOURS[hoist]
|
||||
assert tuple(picture[4, 6]) == flags.COLOURS[middle]
|
||||
assert tuple(picture[4, 11]) == flags.COLOURS[fly]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code,top,middle,bottom", [
|
||||
("DE", "k", "r", "y"), # black over red over gold
|
||||
("NL", "r", "w", "b"),
|
||||
("RU", "w", "b", "r"),
|
||||
("HU", "r", "w", "g"),
|
||||
])
|
||||
def test_a_horizontal_tricolour_runs_top_to_bottom(code, top, middle, bottom):
|
||||
picture = flags.pixels_for(code)
|
||||
assert tuple(picture[0, 6]) == flags.COLOURS[top]
|
||||
assert tuple(picture[4, 6]) == flags.COLOURS[middle]
|
||||
assert tuple(picture[7, 6]) == flags.COLOURS[bottom]
|
||||
|
||||
|
||||
def test_the_two_ways_round_are_not_the_same_flag():
|
||||
"""Ireland and Italy are the same three colours in the same order; the
|
||||
Netherlands and Russia are the same three the other way up."""
|
||||
assert flags.flag_for("IE") != flags.flag_for("IT")
|
||||
assert flags.flag_for("NL") != flags.flag_for("RU")
|
||||
|
||||
|
||||
def test_a_nordic_cross_is_off_towards_the_hoist():
|
||||
"""It is what makes those five flags recognisable at any size."""
|
||||
rows = flags.flag_for("SE")
|
||||
upright = [x for x in range(flags.FLAG_W) if rows[0][x] == "y"]
|
||||
assert upright, "no cross at all"
|
||||
assert max(upright) < flags.FLAG_W // 2 + 2, upright
|
||||
|
||||
|
||||
def test_the_two_countries_most_likely_to_be_confused_are_not():
|
||||
"""The United States and Malaysia really do look alike; they must at
|
||||
least differ here."""
|
||||
assert flags.flag_for("US") != flags.flag_for("MY")
|
||||
|
||||
|
||||
def test_the_flags_a_receiver_actually_needs_are_all_here():
|
||||
for code in ("US", "CA", "MX", "GB", "IE", "FR", "DE", "NL", "ES", "IT",
|
||||
"PT", "CH", "AT", "DK", "NO", "SE", "FI", "PL", "RU", "TR",
|
||||
"GR", "JP", "CN", "KR", "IN", "AU", "NZ", "BR", "AR", "ZA",
|
||||
"AE", "QA", "SA", "IL", "EG", "SG", "TH", "PH"):
|
||||
assert flags.known(code), code
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Which country an airport is in
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@pytest.mark.parametrize("code,country", [
|
||||
("KSEA", "US"), ("KATL", "US"), ("CYYZ", "CA"), ("EGLL", "GB"),
|
||||
("EIDW", "IE"), ("LFPG", "FR"), ("EDDF", "DE"), ("EHAM", "NL"),
|
||||
("LEMD", "ES"), ("LIRF", "IT"), ("RJTT", "JP"), ("ZBAA", "CN"),
|
||||
("YSSY", "AU"), ("NZAA", "NZ"), ("SBGR", "BR"), ("OMDB", "AE"),
|
||||
("VIDP", "IN"), ("WSSS", "SG"), ("MMMX", "MX"), ("FAOR", "ZA"),
|
||||
])
|
||||
def test_an_airport_code_says_which_country_it_is_in(code, country):
|
||||
"""Some routes arrive as nothing but a pair of codes, and the code is
|
||||
enough: the first letter or two is a region."""
|
||||
assert flags.country_of_icao(code) == country
|
||||
|
||||
|
||||
def test_the_longer_prefix_wins():
|
||||
"""K is the United States and KE is not a prefix at all, but LE is Spain
|
||||
while L on its own is nothing."""
|
||||
assert flags.country_of_icao("LEMD") == "ES"
|
||||
assert flags.country_of_icao("KMIA") == "US"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code", ["", "XX", "XXXX", "K", "1234", "KSE", None])
|
||||
def test_something_that_is_not_an_airport_code_says_nothing(code):
|
||||
assert flags.country_of_icao(code) == ""
|
||||
|
||||
|
||||
def test_a_country_with_no_flag_still_has_a_code_to_fall_back_on():
|
||||
"""The point of the fallback: an airport in a country not drawn here is
|
||||
still labelled, just in letters."""
|
||||
country = flags.country_of_icao("FQMA") # Mozambique
|
||||
assert country == "MZ"
|
||||
assert flags.flag_for(country) is None
|
||||
|
|
@ -733,3 +733,192 @@ def test_nothing_survives_that_needed_an_impossible_speed():
|
|||
if 0 < b.at - a.at <= 300 and distance_nm(
|
||||
a.latitude, a.longitude, b.latitude, b.longitude) > 2:
|
||||
assert implied_speed_kt(a, b) <= MAX_GROUND_SPEED_KT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What is written beside each aircraft
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class _Entry:
|
||||
"""A register's answer, in the shape flightmap reads it."""
|
||||
|
||||
def __init__(self, **over):
|
||||
self.type_code = over.get("type_code", "B739")
|
||||
self.model = over.get("model", "737-932ER")
|
||||
self.registration = over.get("registration", "N904DN")
|
||||
self.origin_code = over.get("origin_code", "KATL")
|
||||
self.origin = over.get("origin", "Atlanta")
|
||||
self.origin_country = over.get("origin_country", "US")
|
||||
self.destination_code = over.get("destination_code", "EGLL")
|
||||
self.destination = over.get("destination", "London Heathrow")
|
||||
self.destination_country = over.get("destination_country", "GB")
|
||||
# The map draws an airport where a lookup gave it a position.
|
||||
self.origin_lat = over.get("origin_lat", 33.6367)
|
||||
self.origin_lon = over.get("origin_lon", -84.4281)
|
||||
self.destination_lat = over.get("destination_lat", 51.4706)
|
||||
self.destination_lon = over.get("destination_lon", -0.4619)
|
||||
|
||||
|
||||
def test_the_label_says_height_speed_type_and_both_ends_of_the_route():
|
||||
track = straight()
|
||||
rows = fm.label_lines(track, track.fixes[0], "knots", _Entry())
|
||||
text = [line for line, _flag in rows]
|
||||
assert text[0].startswith("350") # flight level
|
||||
assert "480KT" in text[0]
|
||||
assert "B739 N904DN" in text
|
||||
assert "KATL" in text and "EGLL" in text
|
||||
|
||||
|
||||
def test_each_end_of_the_route_carries_its_own_country():
|
||||
track = straight()
|
||||
rows = dict((line, flag) for line, flag in
|
||||
fm.label_lines(track, track.fixes[0], "knots", _Entry()))
|
||||
assert rows["KATL"] == "US"
|
||||
assert rows["EGLL"] == "GB"
|
||||
|
||||
|
||||
def test_with_no_register_the_label_is_what_the_aircraft_itself_said():
|
||||
track = straight()
|
||||
rows = fm.label_lines(track, track.fixes[0], "knots", None)
|
||||
assert len(rows) == 1 and rows[0][1] == ""
|
||||
|
||||
|
||||
def test_an_airport_with_no_code_is_named_short_rather_than_in_full():
|
||||
track = straight()
|
||||
entry = _Entry(origin_code="", origin="Hartsfield Jackson Atlanta "
|
||||
"International Airport")
|
||||
said = [line for line, _ in fm.label_lines(track, track.fixes[0],
|
||||
"knots", entry)]
|
||||
assert "Hartsfield Jackson" in said
|
||||
assert not any(len(line) > 20 for line in said), said
|
||||
|
||||
|
||||
def test_the_flag_is_drawn_in_the_flag_colours():
|
||||
from bandsaunter.flags import COLOUR_ORDER, FLAG_H, FLAG_W
|
||||
|
||||
img = np.full((40, 60), fm.BG, dtype=np.uint8)
|
||||
fm.draw_flag(img, 5, 5, "JP")
|
||||
patch = img[5:5 + FLAG_H, 5:5 + FLAG_W]
|
||||
assert (patch >= fm.FLAG).all()
|
||||
assert (patch < fm.FLAG + len(COLOUR_ORDER)).all()
|
||||
# White at the corner and red in the middle: the flag of Japan.
|
||||
assert patch[0, 0] == fm.flag_index("w")
|
||||
assert patch[4, 6] == fm.flag_index("r")
|
||||
|
||||
|
||||
def test_a_country_with_no_flag_is_named_in_letters_instead():
|
||||
img = np.full((40, 60), fm.BG, dtype=np.uint8)
|
||||
fm.draw_flag(img, 5, 5, "ZZ")
|
||||
assert (img == fm.GRID).any() # the letters, in grey
|
||||
assert not (img >= fm.FLAG).any() # and no flag pretending to be one
|
||||
|
||||
|
||||
def test_a_flag_off_the_edge_of_the_picture_paints_nothing():
|
||||
for x, y in ((-40, 5), (5, -40), (200, 5), (5, 200)):
|
||||
img = np.full((40, 60), fm.BG, dtype=np.uint8)
|
||||
fm.draw_flag(img, x, y, "US")
|
||||
assert (img == fm.BG).all(), (x, y)
|
||||
|
||||
|
||||
def test_the_flag_colours_fit_in_the_palette_beside_everything_else():
|
||||
from bandsaunter.flags import COLOUR_ORDER
|
||||
|
||||
assert fm.FLAG + len(COLOUR_ORDER) < fm.TRANSPARENT
|
||||
assert fm.PALETTE.shape == (256, 3)
|
||||
# And each index really is the colour it claims.
|
||||
from bandsaunter.flags import COLOURS
|
||||
|
||||
for letter in COLOUR_ORDER:
|
||||
assert tuple(int(v) for v in fm.PALETTE[fm.flag_index(letter)]) == \
|
||||
COLOURS[letter]
|
||||
|
||||
|
||||
def test_the_register_is_asked_once_per_aircraft_not_once_per_frame(tmp_path):
|
||||
"""A five-hundred-frame animation asking the same question five hundred
|
||||
times would be five hundred times as rude."""
|
||||
asked = []
|
||||
|
||||
class _Book:
|
||||
def get(self, icao, callsign=""):
|
||||
asked.append(icao)
|
||||
return _Entry()
|
||||
|
||||
fm.animate(two_aircraft(), tmp_path / "counted.gif", fps=8, seconds=3,
|
||||
width=400, book=_Book())
|
||||
assert asked, "the register was never asked at all"
|
||||
assert len(asked) == len(set(asked)), f"asked twice about the same: {asked}"
|
||||
|
||||
|
||||
def test_the_route_reaches_the_drawn_picture(tmp_path):
|
||||
"""End to end: a register with a route, and the flags on the frame."""
|
||||
class _Book:
|
||||
def get(self, icao, callsign=""):
|
||||
return _Entry()
|
||||
|
||||
tracks = two_aircraft()
|
||||
view = fm.fit(tracks, width=700)
|
||||
base = fm.background(view)
|
||||
known = {t.icao: _Entry() for t in tracks}
|
||||
frame = fm.render_frame(base, view, tracks, tracks[0].first_seen + 60,
|
||||
known=known)
|
||||
assert (frame >= fm.FLAG).any(), "no flag was drawn"
|
||||
assert _has_text(frame, "KATL")
|
||||
assert _has_text(frame, "B739")
|
||||
|
||||
|
||||
def test_a_crowded_frame_goes_back_to_the_short_label():
|
||||
"""Five lines beside each of three hundred aircraft is not more
|
||||
information, it is a page of overlapping text with a map behind it."""
|
||||
many = [straight(icao=f"{i:06X}", callsign=f"FLT{i}",
|
||||
lat=51.0 + i * 0.02, lon=-1.0 + i * 0.02)
|
||||
for i in range(fm.CROWDED + 4)]
|
||||
view = fm.fit(many, width=900)
|
||||
base = fm.background(view)
|
||||
known = {t.icao: _Entry() for t in many}
|
||||
frame = fm.render_frame(base, view, many, many[0].first_seen + 60,
|
||||
known=known)
|
||||
assert not (frame >= fm.FLAG).any(), "still drawing flags when crowded"
|
||||
assert not _has_text(frame, "B739")
|
||||
|
||||
|
||||
def test_a_quiet_frame_keeps_every_detail():
|
||||
tracks = two_aircraft()
|
||||
view = fm.fit(tracks, width=700)
|
||||
base = fm.background(view)
|
||||
known = {t.icao: _Entry() for t in tracks}
|
||||
frame = fm.render_frame(base, view, tracks, tracks[0].first_seen + 60,
|
||||
known=known)
|
||||
assert (frame >= fm.FLAG).any()
|
||||
assert _has_text(frame, "B739")
|
||||
|
||||
|
||||
def test_the_callsign_and_height_survive_a_crowd():
|
||||
"""Whatever else goes, what the aircraft itself said stays."""
|
||||
many = [straight(icao=f"{i:06X}", callsign=f"FLT{i}",
|
||||
lat=51.0 + i * 0.02, lon=-1.0 + i * 0.02)
|
||||
for i in range(fm.CROWDED + 4)]
|
||||
view = fm.fit(many, width=900)
|
||||
frame = fm.render_frame(fm.background(view), view, many,
|
||||
many[0].first_seen + 60,
|
||||
known={t.icao: _Entry() for t in many})
|
||||
assert _has_text(frame, "FLT0")
|
||||
|
||||
|
||||
def test_labels_step_aside_rather_than_landing_on_each_other():
|
||||
"""Two aircraft passing close together is exactly the moment somebody is
|
||||
looking at that part of the picture."""
|
||||
close = [straight(icao=f"{i:06X}", callsign=f"FLT{i}",
|
||||
lat=51.0 + i * 0.004, lon=-1.0 + i * 0.004, seconds=60)
|
||||
for i in range(6)]
|
||||
view = fm.fit(close, width=800, box=(50.8, -1.4, 51.3, -0.6))
|
||||
taken = []
|
||||
base = fm.background(view)
|
||||
fm.render_frame(base, view, close, close[0].first_seen, labels=True)
|
||||
# Place them by hand so the boxes can be compared.
|
||||
for track in close:
|
||||
now = track.at(track.first_seen)
|
||||
x, y = view.xy(now.latitude, now.longitude)
|
||||
fm._label(base, x, y, track, now, fm.RAMP, taken)
|
||||
for i, one in enumerate(taken):
|
||||
for two in taken[i + 1:]:
|
||||
assert not fm._overlaps(one, two), f"{one} overlaps {two}"
|
||||
|
|
|
|||
|
|
@ -547,3 +547,54 @@ def test_what_one_track_says_of_itself_follows_the_unit(tmp_path):
|
|||
assert "kt" in track.describe()
|
||||
assert "mph" in track.describe("mph")
|
||||
assert "km/h" in track.describe("kph")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Which country each end of a route is in
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def test_each_end_of_a_route_comes_back_with_its_country(register):
|
||||
"""The flags on the map are drawn from these."""
|
||||
book, asked, answers = register
|
||||
answers["adsbdb.com/v0/callsign"] = ADSBDB_ROUTE
|
||||
entry = book.get("4CA1FA", "RYR1234")
|
||||
book.wait(5.0)
|
||||
assert entry.origin_country == "GB"
|
||||
assert entry.destination_country == "GB"
|
||||
|
||||
|
||||
def test_a_route_that_is_only_two_codes_still_says_which_countries(register):
|
||||
"""hexdb sends "EGLL-KSEA" and nothing else; the codes are enough."""
|
||||
book, asked, answers = register
|
||||
answers["hexdb.io/api/v1/route"] = {"flight": "BAW49",
|
||||
"route": "EGLL-KSEA"}
|
||||
entry = book.get("400001", "BAW49")
|
||||
book.wait(5.0)
|
||||
assert (entry.origin_country, entry.destination_country) == ("GB", "US")
|
||||
|
||||
|
||||
def test_a_route_cached_before_the_countries_existed_is_asked_again(tmp_path,
|
||||
register):
|
||||
"""Otherwise a month of cached routes would draw no flags at all."""
|
||||
from bandsaunter.flights import CACHE_VERSION
|
||||
|
||||
book, asked, answers = register
|
||||
stale = {"routes": {"RYR1234": {"origin_code": "EGSS", "origin": "Stansted",
|
||||
"destination_code": "EGNX",
|
||||
"destination": "East Midlands",
|
||||
"fetched_at": time.time(), "version": 1}}}
|
||||
book.cache_path.write_text(json.dumps(stale))
|
||||
again = FlightBook(cache=book.cache_path)
|
||||
assert again._routes == {}, "kept a route with no country in it"
|
||||
assert CACHE_VERSION >= 2
|
||||
|
||||
|
||||
def test_a_route_written_now_is_kept(tmp_path, register):
|
||||
book, asked, answers = register
|
||||
answers["adsbdb.com/v0/callsign"] = ADSBDB_ROUTE
|
||||
book.get("4CA1FA", "RYR1234")
|
||||
book.wait(5.0)
|
||||
book.save()
|
||||
again = FlightBook(cache=book.cache_path)
|
||||
entry = again.get("4CA1FA", "RYR1234")
|
||||
assert entry.origin_country == "GB"
|
||||
|
|
|
|||
|
|
@ -128,7 +128,7 @@ def test_it_warns_about_the_thing_every_debian_user_hits_first():
|
|||
def test_it_lists_the_optional_dependencies_and_what_each_one_buys():
|
||||
body = INSTALL.read_text()
|
||||
for optional in ("espeak-ng", "ffmpeg", "faster-whisper", "vosk",
|
||||
"rtl-sdr"):
|
||||
"rtl-sdr", "pyqt6"):
|
||||
assert optional in body, optional
|
||||
assert "Optional dependencies" in body
|
||||
|
||||
|
|
|
|||
654
tests/test_livemap.py
Normal file
654
tests/test_livemap.py
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
"""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
|
||||
Loading…
Add table
Add a link
Reference in a new issue