bandsaunter/tests/test_livemap.py
The Dust Council 2665a17a22 Cache what has been looked up, make it browsable, and say where this lives
Four things asked for in turn, landing together because they run through the
same files.

THE MAPS.  The tiles were already cached and always had been -- a second
evening on the same view was measured at nought network requests -- but the
work done on them was not.  Every window open decoded forty PNGs and resampled
a megapixel and a half into this program's own projection, for an answer that
cannot have changed, because a coastline does not move.  The finished map is
kept beside the tiles now: 0.61 seconds become 0.01, byte for byte identical,
two hundred kilobytes a view.  Both windows and both kinds of still picture get
it, all four reaching the ground through one function.  A map with squares
missing is deliberately not kept, since caching a hole would keep it for a
month and the point of calling a partial map provisional is that it is asked
for again.  And because this adds a disk consumer, the whole cache is now
pruned to four hundred megabytes, least recently *used* first: a tile fetched a
year ago and looked at last night is the receiver's own neighbourhood, and
discarding that to keep last week's holiday is the wrong way round.

THE LOOKUPS.  APRS and FT8 now ask who each station is licensed to.  Every
other mode that hears a callsign already did -- speech transcripts, Morse
idents, the recording browser -- and all five resolve through one file, so a
callsign heard on two bands is asked about once.  The rules for finding the
licensed callsign inside a heard one are now in one place rather than per band,
because getting them wrong is silent: a register asked about W1AW-9 returns
nothing, which looks exactly like a station that is not licensed.  An SSID, a
rover suffix, a guest prefix, a digipeater alias and an unspelled hash all come
off or are refused.

The bug worth recording is that the first cut of this did the lookups and threw
them away.  The book has to be told to save and was not, so every evening would
have asked the register about the same net again -- which is the one thing
caching them was for, and is invisible from inside a single run because the
answers are all in memory while it lasts.  Caught by looking at the file on
disk rather than at the display.  There is a test for each side of it now, and a
register of which modules resolve callsigns at all, which fails when a new one
starts so that somebody has to decide whether it should.

THE REGISTER.  c in saunterbrowse opens everything ever looked up: sixty-odd
callsigns and sixteen hundred aircraft here, every field of each in two columns
because a licence has a dozen and a screen is wider than it is tall.  tab
switches, / searches every field rather than the name -- the question is
usually "who was in Arizona" rather than "which callsign" -- and g opens the
place in a browser.  Only the coordinates go into that link: a map does not
need to be told whose licence it is looking at, and the link is the one part of
this that leaves the machine.  A headless box, which is the normal case for a
receiver, gets the coordinates printed instead.  Callsigns no register could
place are kept rather than dropped, because "asked about, and in no register
reachable from here" is a fact about a station.

THE FRONT OF IT.  A title screen for each program: five rows of blocks cut by
hand, a figlet dependency to draw eleven letters being the largest thing that
would then be in the requirements, coloured blue to red across the width, which
is the ramp every waterfall here already uses because it is what a spectrum
looks like.  The interesting part is where it does not appear -- everything
here can be piped into something else and a banner in the middle of that is
corruption rather than decoration, so anything that is not a terminal gets
nothing, --help is untouched because it is drawn after parsing, --no-splash
turns it off for a run and BANDSAUNTER_NO_SPLASH=1 for good.

And the address.  INSTALL.md said "git clone <the repository>" for a long time:
a placeholder in the first command anybody types, unnoticed because nothing
reads install instructions except somebody installing, who then cannot.  It is
filled in, along with the readme, the metadata, both manuals and the Homepage
field of all three packages.  The tile server's User-Agent pointed at a topic
listing on somebody else's site for want of an address of its own; the usage
policy of that service asks for one naming the application and giving somewhere
to look it up, so an operator with a question about the traffic has somebody to
ask, and now it gives the real one.  Seven tests so the placeholder cannot come
back, verified by putting it back and watching two of them fail.

One thing forced by all this: the keys page in saunterbrowse was exactly as
tall as an eighty-by-twenty-four terminal, so the register entry pushed "q
quit" off the bottom.  A test caught it.  Home and End have merged into the
Page Up line, which were always the same thought.

THE WINDOWS.  They open maximised now, this being a map and the thing anybody
wants more of being map; f goes to true full screen and back, and is written
along the top of the screen because a window with no frame is one somebody has
to know a key to get out of.  Maximised rather than frameless by default,
because the title bar is where the band and the frequency are written.

And a map made bigger now gets a sharper map, which it did not.  The window
only ever re-examined the ground when the view left the box that had been
fetched, so a window opened at its default size and taken to the whole screen
kept the map it started with until an aircraft wandered far enough to move it
-- on a quiet band, a long time to look at a blurred coastline.  Measured
before it was believed: 1100 to 1920 asked for nothing and stretched a
1364-pixel map across 1920, then across 3840.  It now compares map pixels per
degree in hand against what the view wants, and asks when it is being blown up
by more than fifteen per cent.

That comparison has to be per degree rather than pixel against pixel, which a
surviving mutation was what established: the fetched box is a quarter wider
than the view, so a map with exactly as many pixels as the window is wide has
only four fifths of them on the screen.  The two ways of measuring agree
everywhere except a narrow band, and the realistic case sits inside it.  There
is a test pinning that case now.  Asking is safe at any size, because the
request is keyed on the window's dimensions: once answered, nothing more is
asked, which is what stops a window larger than the tile budget can cover from
asking all evening.

The braille, while this was open.  The banners were blocks in capitals; they
are braille in mixed case, two dots wide and four tall to a character, which is
eight times the detail and is what makes room for two heights of letter at
once -- a five-row block font has no second height to spend, so the name came
out shouted.  Two attempts failed first: thin one-dot strokes came out as
confetti, because braille dots render as dots and a one-dot stroke reads as a
dotted line rather than a line.  What worked was cutting the font small, at cap
height seven where there is only one way to draw each letter, and doubling it.
Coloured deep blue through cyan to a cool white, which is deliberately not the
waterfall ramp the rest of the program draws in: that one has to run to red
because it stands in for a spectrum, and a title screen stands in for nothing.

One hundred and four new tests.  Full suite 2892 passed.  Built as
2026-09-24_01.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
2026-09-24 00:36:18 -07:00

2667 lines
98 KiB
Python

"""The window: what it draws, and what it does without Qt.
Everything here runs on Qt's offscreen platform, so it needs no screen and
no window manager -- which is also what makes it runnable on a machine that
has never had one.
"""
import os
import time
import numpy as np
import pytest
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from bandsaunter import livemap # noqa: E402
from bandsaunter.livemap import Blip, Sky, blip_for, place_box # noqa: E402
qt = pytest.mark.skipif(not livemap.available(), reason="no Qt installed")
# Held for the life of the process on purpose. Qt allows one application and
# expects it to outlive every widget; letting the fixture own it means that
# running one test from this file on its own destroys it while widgets are
# still about, and the interpreter comes down with a core dump.
_APPLICATION = None
if livemap.available():
_widgets = livemap._qt()[3]
_APPLICATION = _widgets.QApplication.instance() or _widgets.QApplication([])
def now() -> float:
"""The real clock.
The window works in real time -- an aircraft leaves the picture so many
seconds after its last frame -- so a fixture pinned to a made-up epoch
would have everything time out before it was ever drawn.
"""
return time.time()
@pytest.fixture
def app():
"""The one application, which outlives every test that uses it."""
if _APPLICATION is None:
pytest.skip("no Qt installed")
return _APPLICATION
def a_blip(icao="A76154", callsign="DAL538", lat=32.95, lon=-110.95,
**over) -> Blip:
settings = dict(icao=icao, callsign=callsign, latitude=lat, longitude=lon,
altitude_ft=32_500, ground_speed_kt=494.0, track_deg=325.0,
messages=1204, first_seen=now() - 700, last_seen=now() - 1)
settings.update(over)
return Blip(**settings)
def a_sky(*blips, **over) -> Sky:
settings = dict(unit="knots", hold=45.0, home=(32.4325, -111.0841),
radius_nm=100.0, brightness=0.70)
settings.update(over)
sky = Sky(**settings)
sky.started = now() - 600
if blips:
sky.update(list(blips), frames=7412, seen=len(blips))
return sky
# ---------------------------------------------------------------------------
# Without Qt at all
# ---------------------------------------------------------------------------
def test_importing_it_does_not_drag_qt_in():
"""A machine with no Qt loses this window and nothing else, so nothing
may import Qt merely because this module was loaded."""
import importlib
import sys
for name in ("SkyView", "Window"):
assert name not in livemap.__dict__ or True # built on demand only
fresh = importlib.reload(importlib.import_module("bandsaunter.livemap"))
assert "SkyView" not in fresh.__dict__
assert "Window" not in fresh.__dict__
del sys
def test_the_message_says_how_to_get_it():
assert "pip install PyQt6" in livemap.MISSING_QT
assert "apt install" in livemap.MISSING_QT
# And that nothing else is lost by not having it.
assert "passive capture still records" in livemap.MISSING_QT
def test_no_qt_means_a_message_rather_than_a_traceback(monkeypatch):
monkeypatch.setattr(livemap, "_qt", lambda: None)
monkeypatch.setattr(livemap._build, "_made", None, raising=False)
assert livemap.available() is False
assert livemap.binding() == ""
with pytest.raises(RuntimeError) as raised:
livemap.show(a_sky())
assert "Qt" in str(raised.value)
def test_the_menu_is_told_whether_a_window_can_be_opened(monkeypatch):
from bandsaunter import aircraft as air
monkeypatch.setattr(livemap, "available", lambda: False)
assert air.windowed() is False
monkeypatch.setattr(livemap, "available", lambda: True)
assert air.windowed() is True
def test_asking_to_watch_without_qt_says_so_and_stops(monkeypatch, tmp_path,
capsys):
"""It must not open the receiver, either: nothing is gained by taking
the dongle for a window that cannot be drawn."""
from rich.console import Console
from bandsaunter import aircraft as air
monkeypatch.setattr(livemap, "available", lambda: False)
monkeypatch.setattr(air, "open_device", lambda *a, **k:
pytest.fail("opened the receiver anyway"))
heard = air.watch(Console(width=100), air.AircraftOptions(), str(tmp_path))
assert heard.frames == 0
assert "Qt" in capsys.readouterr().out
# ---------------------------------------------------------------------------
# What goes in the box
# ---------------------------------------------------------------------------
def test_the_box_says_everything_that_is_known():
blip = a_blip(registration="N904DN", type_code="B739",
manufacturer="Boeing", model="737-932ER",
operator="Delta Air Lines", country="United States",
origin="Atlanta", destination="Phoenix",
vertical_rate_fpm=1600)
told = "\n".join(f"{a} {b}" for a, b, _ in
blip.lines("knots", home=(32.4325, -111.0841)))
for wanted in ("N904DN", "B739", "Boeing 737-932ER", "Delta Air Lines",
"from Atlanta", "to Phoenix", "32,500 ft", "494 kt",
"325° NW", "1,204 frames", "United States"):
assert wanted in told, wanted
assert "↑1,600 fpm" in told
def test_a_descent_is_marked_as_one():
told = " ".join(v for _, v, _ in
a_blip(vertical_rate_fpm=-900).lines("knots"))
assert "↓900 fpm" in told and "↑" not in told
def test_nothing_is_said_about_what_is_not_known():
"""A register that has not answered yet must leave no empty rows: they
would never fill in and the box would be mostly gaps."""
told = [label for label, _, _ in a_blip().lines("knots")]
assert "reg" not in told
assert all(x is not None for x in told)
text = " ".join(v for _, v, _ in a_blip().lines("knots"))
assert "from" not in text # no route was known
def _by_label(blip, unit, home):
return {label: value for label, value, _ in blip.lines(unit, home=home)}
def test_how_far_away_and_which_way_are_worked_out():
home = (32.4325, -111.0841)
told = _by_label(a_blip(lat=32.95, lon=-110.95), "knots", home)
assert "32 nm" in told["range"]
assert "012°" in told["range"]
def test_the_range_follows_the_unit_the_speeds_are_in():
home = (32.4325, -111.0841)
assert "37 mi" in _by_label(a_blip(), "mph", home)["range"]
assert "59 km" in _by_label(a_blip(), "kph", home)["range"]
def test_an_aircraft_that_never_said_its_name_is_known_by_its_address():
assert a_blip(callsign="").name == "A76154"
assert a_blip().name == "DAL538"
def test_what_the_registers_said_is_copied_onto_the_blip():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
craft = Aircraft(icao="4CA1FA", callsign="RYR1234")
craft.altitude_ft, craft.messages = 35_000, 12
entry = Flight(icao="4CA1FA", registration="EI-DYP", type_code="B738",
operator="Ryanair", origin="Stansted",
destination="East Midlands", owner_country="Ireland")
blip = blip_for(craft, entry)
assert (blip.registration, blip.type_code) == ("EI-DYP", "B738")
assert blip.operator == "Ryanair" and blip.country == "Ireland"
assert blip.origin == "Stansted"
def test_a_blip_works_with_no_register_at_all():
from bandsaunter.adsb import Aircraft
blip = blip_for(Aircraft(icao="4CA1FA", callsign="RYR1234"))
assert blip.icao == "4CA1FA" and blip.registration == ""
# ---------------------------------------------------------------------------
# Where the boxes go
# ---------------------------------------------------------------------------
BOUNDS = (0, 0, 1000, 700)
def test_a_box_goes_beside_the_aircraft_when_there_is_room():
x, y = place_box(400, 300, 200, 120, [], BOUNDS)
assert x > 400 # to the right, the usual place
assert 300 - 120 <= y <= 300
def test_a_box_never_lands_on_one_already_placed():
taken = []
for i in range(9):
spot = place_box(400 + (i % 3) * 12, 300 + (i // 3) * 12,
180, 110, taken, BOUNDS)
taken.append((spot[0], spot[1], 180, 110))
for i, one in enumerate(taken):
for two in taken[i + 1:]:
apart = (one[0] + one[2] <= two[0] or two[0] + two[2] <= one[0]
or one[1] + one[3] <= two[1] or two[1] + two[3] <= one[1])
assert apart, f"{one} overlaps {two}"
def test_a_box_stays_on_the_picture():
for x, y in ((5, 5), (995, 695), (0, 350), (500, 0)):
bx, by = place_box(x, y, 220, 130, [], BOUNDS)
assert 0 <= bx and bx + 220 <= 1000
assert 0 <= by and by + 130 <= 700
def test_a_full_screen_still_gets_a_box():
"""Overlapping is better than nothing: a box in an awkward place still
says what the aircraft is."""
wall = [(x, y, 100, 100) for x in range(0, 1000, 100)
for y in range(0, 700, 100)]
bx, by = place_box(500, 350, 200, 120, wall, BOUNDS)
assert 0 <= bx <= 1000 and 0 <= by <= 700
# ---------------------------------------------------------------------------
# What the window is told
# ---------------------------------------------------------------------------
def test_an_aircraft_appears_and_is_kept_in_order_first_heard():
first = a_blip(icao="111111", callsign="FIRST", first_seen=now() - 300)
second = a_blip(icao="222222", callsign="SECOND", first_seen=now() - 100)
sky = a_sky(second, first)
assert [b.callsign for b in sky.flying()] == ["FIRST", "SECOND"]
def test_one_nothing_has_been_heard_from_leaves_the_picture():
here = a_blip(icao="111111", last_seen=now() - 5)
gone = a_blip(icao="222222", last_seen=now() - 300)
assert [b.icao for b in a_sky(here, gone).flying()] == ["111111"]
def test_an_aircraft_with_no_position_is_not_drawn():
"""It has been heard but not placed; the table says so and the map
cannot."""
assert a_sky(a_blip(lat=0.0, lon=0.0)).flying() == []
def test_the_trail_grows_as_it_moves():
sky = a_sky()
for i in range(5):
sky.update([a_blip(lat=32.9 + i * 0.01)], frames=i, seen=1)
assert len(sky.trail("A76154")) == 5
def test_standing_still_does_not_lengthen_the_trail():
sky = a_sky()
for _ in range(9):
sky.update([a_blip()], frames=1, seen=1)
assert len(sky.trail("A76154")) == 1
def test_the_middle_of_the_map_is_where_the_receiver_was_said_to_be():
assert a_sky(a_blip()).centre() == (32.4325, -111.0841)
def test_with_no_receiver_position_the_middle_is_worked_out():
sky = a_sky(a_blip(icao="1", lat=30.0, lon=-110.0),
a_blip(icao="2", lat=31.0, lon=-111.0),
a_blip(icao="3", lat=32.0, lon=-112.0), home=None)
assert sky.centre() == (31.0, -111.0)
def test_nothing_heard_yet_has_no_middle():
assert a_sky(home=None).centre() is None
def test_a_map_is_used_for_any_view_it_covers():
"""The whole point of fetching more than is shown: a view that has
drifted a little is still inside what was fetched."""
sky = a_sky()
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "k",
(31.0, -113.0, 34.0, -109.0))
levels, box = sky.ground_covering(32.0, -112.0, 33.0, -110.0)
assert levels is not None and box == (31.0, -113.0, 34.0, -109.0)
def test_a_map_is_not_used_for_a_view_that_has_left_it():
sky = a_sky()
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "k",
(31.0, -113.0, 34.0, -109.0))
assert sky.ground_covering(40.0, -113.0, 42.0, -109.0)[0] is None
assert sky.ground_covering(31.0, -120.0, 34.0, -109.0)[0] is None
@qt
def test_the_map_is_fetched_at_the_size_the_bigger_box_needs(app):
"""Fetching a third more world into the same pixels and then cutting the
middle out of it is how a sharp map ends up looking like a photograph of
a map."""
from bandsaunter.livemap import GROUND_MARGIN, SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
view._draw_ground(_NoPainter(), view.projection())
wanted = sky.wanted_ground()
assert wanted is not None
_key, _box, (width, height) = wanted
assert width >= 900 * (1 + 2 * GROUND_MARGIN) - 2
assert height >= 650 * (1 + 2 * GROUND_MARGIN) - 2
class _NoPainter:
"""Stands in for a painter for the one call that only asks for a map."""
def drawImage(self, *a):
raise AssertionError("nothing should be drawn without a map")
def test_more_is_fetched_than_is_shown(app):
"""A window that fetched exactly what it needed would fetch again on
every resize and every small drift of the middle."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.resize(900, 650)
projection = view.projection()
box = view.ground_box(projection)
assert box[0] < projection.south and box[1] < projection.west
assert box[2] > projection.north and box[3] > projection.east
def test_a_map_that_could_not_be_fetched_is_not_asked_for_again():
"""Otherwise a machine with no network asks five times a second all
night."""
sky = a_sky()
sky.want_ground("a", (0, 0, 1, 1), (10, 10))
assert sky.wanted_ground() is not None
sky.set_ground(None, "a")
assert sky.wanted_ground() is None
def test_a_map_with_squares_missing_is_asked_for_again():
"""A resize picks a sharper zoom and asks for a hundred tiles that have
never been on this disk at once, and a busy server refuses some of
them. Most of a map gets drawn; it does not get kept."""
from bandsaunter.livemap import GROUND_RETRY_S
sky = a_sky()
sky.want_ground("a", (0, 0, 1, 1), (10, 10))
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1),
settled=False)
assert sky.wanted_ground() is None, "not before the server has a rest"
assert not sky.ground_settled()
sky.reask_ground()
assert sky.wanted_ground() is None, "still too soon"
sky._ground_at -= GROUND_RETRY_S + 1
sky.reask_ground()
# Asked for again, and for the map it already asked for rather than for
# whatever the view has drifted to since.
assert sky.wanted_ground() == ("a", (0, 0, 1, 1), (10, 10))
def test_a_whole_map_is_never_asked_for_again():
sky = a_sky()
sky.want_ground("a", (0, 0, 1, 1), (10, 10))
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1))
assert sky.ground_settled()
sky._ground_at -= 10_000.0
sky.reask_ground()
assert sky.wanted_ground() is None
def test_a_square_that_is_missing_for_good_is_not_asked_for_all_night():
"""A tile can be absent because there is no such tile. Asking for it
every half minute until morning is the same discourtesy more slowly."""
from bandsaunter.livemap import GROUND_RETRY_S, GROUND_TRIES
sky = a_sky()
sky.want_ground("a", (0, 0, 1, 1), (10, 10))
asks = 0
for _ in range(GROUND_TRIES + 5):
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1),
settled=False)
sky._ground_at -= GROUND_RETRY_S + 1
sky.reask_ground()
if sky.wanted_ground() is not None:
asks += 1
sky._wanted = None
assert asks == GROUND_TRIES - 1, asks
def test_moving_the_view_starts_the_asking_over():
"""The count is against one view's tiles. A window that was resized
twice has not used up its patience on the second view."""
from bandsaunter.livemap import GROUND_RETRY_S, GROUND_TRIES
sky = a_sky()
for _ in range(GROUND_TRIES + 2):
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "a", (0, 0, 1, 1),
settled=False)
sky.want_ground("b", (0, 0, 2, 2), (20, 20))
sky.set_ground(np.zeros((40, 40), dtype=np.uint8), "b", (0, 0, 2, 2),
settled=False)
sky._wanted = None
sky._ground_at -= GROUND_RETRY_S + 1
sky.reask_ground()
assert sky.wanted_ground() is not None
@qt
def test_drawing_most_of_a_map_draws_it_and_asks_for_the_rest(app):
"""Both halves: the window does not go bare while it waits, and it does
not settle for the map it was given."""
from bandsaunter.livemap import GROUND_RETRY_S, SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
view._draw_ground(_NoPainter(), view.projection()) # asks
wanted = sky.wanted_ground()
assert wanted is not None
key, box, _size = wanted
sky.set_ground(np.full((760, 1050), 12, dtype=np.uint8), key, box,
settled=False)
drawn = []
class _Painter(_NoPainter):
def drawImage(self, *a):
drawn.append(a)
sky._ground_at -= GROUND_RETRY_S + 1
view._draw_ground(_Painter(), view.projection())
assert drawn, "most of a map is still worth drawing"
assert sky.wanted_ground() is not None, "and worth finishing"
@qt
def test_drawing_a_whole_map_asks_for_nothing(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
view._draw_ground(_NoPainter(), view.projection())
key, box, _size = sky.wanted_ground()
sky.set_ground(np.full((760, 1050), 12, dtype=np.uint8), key, box)
sky._wanted = None
class _Painter(_NoPainter):
def drawImage(self, *a):
pass
sky._ground_at -= 10_000.0
view._draw_ground(_Painter(), view.projection())
assert sky.wanted_ground() is None
# ---------------------------------------------------------------------------
# The map staying still
# ---------------------------------------------------------------------------
def test_the_middle_settles_and_then_stays_put():
"""Recomputing it on every paint moves the map a fraction of a mile
whenever an aircraft appears or leaves, which throws away the tiles
fetched for the old view and blinks the ground out several times a
minute."""
sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0),
a_blip(icao="2", lat=32.5, lon=-111.1),
a_blip(icao="3", lat=32.6, lon=-111.2), home=None)
settled = sky.centre()
assert settled is not None
# One more aircraft, a little to one side: the middle must not follow it.
sky.update([a_blip(icao="4", lat=32.9, lon=-110.7)], 1, 4)
assert sky.centre() == settled
def test_the_middle_does_move_if_it_is_really_somewhere_else():
"""Settling must not mean stuck: the first few aircraft can be anywhere."""
sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0), home=None)
first = sky.centre()
for i in range(6):
sky.update([a_blip(icao=f"far{i}", lat=40.0 + i * 0.1, lon=-90.0)],
1, i)
assert sky.centre() != first
assert abs(sky.centre()[0] - 40.0) < 2
def test_a_receiver_that_was_told_where_it_is_never_drifts_at_all():
sky = a_sky(a_blip(icao="1", lat=32.4, lon=-111.0))
for i in range(5):
sky.update([a_blip(icao=f"x{i}", lat=35.0 + i, lon=-100.0)], 1, i)
assert sky.centre() == (32.4325, -111.0841)
@qt
def test_a_small_drift_does_not_throw_the_map_away(app):
"""The failure this is here for: the ground blinking out because the
view moved by less than a mile."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="1", lat=32.40, lon=-111.00),
a_blip(icao="2", lat=32.50, lon=-111.10), home=None)
view = SkyView(sky)
view.resize(900, 650)
first = view.projection()
sky.set_ground(np.full((650, 900), 20, dtype=np.uint8),
view.ground_key(first), view.ground_box(first))
# Another aircraft arrives a few miles away.
sky.update([a_blip(icao="3", lat=32.44, lon=-111.06)], 1, 3)
moved = view.projection()
assert sky.ground_covering(moved.south, moved.west,
moved.north, moved.east)[0] is not None
@qt
def test_the_crop_lines_the_map_up_with_the_view(app):
"""A map fetched for a bigger box has to be cut to the right piece, or
the coast is drawn in the wrong place and the aircraft with it."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.resize(100, 80)
projection = view.projection()
box = (projection.south - 1.0, projection.west - 1.0,
projection.north + 1.0, projection.east + 1.0)
# A map that is dark in the north and bright in the south.
levels = np.repeat(np.linspace(0, 31, 200).astype(np.uint8)[:, None],
200, axis=1)
cropped = view._crop(levels, box, projection)
assert cropped.shape == (projection.height, projection.width)
assert cropped[0].mean() < cropped[-1].mean() # north still on top
# And the piece taken is the middle of the fetched one, not all of it.
assert cropped.min() > levels.min() and cropped.max() < levels.max()
def test_the_map_is_fetched_off_the_painting_thread():
asked = []
def tile(z, x, y, **kw):
asked.append((z, x, y))
return None
sky = a_sky()
sky.want_ground("k", (32.0, -112.0, 33.0, -110.0), (40, 40))
import threading
worker = threading.Thread(target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": tile}, daemon=True)
worker.start()
for _ in range(50):
if sky.wanted_ground() is None:
break
time.sleep(0.05)
sky.stopping = True
worker.join(timeout=2.0)
assert asked, "the tile server was never asked"
# ---------------------------------------------------------------------------
# Painting
# ---------------------------------------------------------------------------
def _rendered(view, width=900, height=650):
"""The widget's pixels, as an array."""
from bandsaunter.livemap import _qt
_name, _core, gui, _widgets, _signal = _qt()
view.resize(width, height)
image = gui.QImage(width, height, _get(gui.QImage, "Format",
"Format_RGB32"))
image.fill(gui.QColor(0, 0, 0))
view.render(image)
bits = image.constBits()
bits.setsize(image.sizeInBytes())
# Copied on the way out. The array numpy builds is a view onto Qt's own
# buffer, and the image is about to go out of scope: reading it after
# that is a use-after-free, which reads as blank rows if you are lucky
# and a segmentation fault if you are not.
return np.frombuffer(bits, dtype=np.uint8).reshape(
height, width, 4).copy()
def _get(owner, group, name):
return getattr(getattr(owner, group, owner), name)
def _painted(picture) -> int:
"""How many pixels are something other than the empty background.
Counting non-black pixels would count all of them: the background is a
dark blue rather than black, on purpose.
"""
from bandsaunter.flightmap import BG, PALETTE
r, g, b = (int(v) for v in PALETTE[BG])
background = ((picture[:, :, 2] == r) & (picture[:, :, 1] == g)
& (picture[:, :, 0] == b))
return int((~background).sum())
def _in_colour(picture, feet: int):
"""The pixels painted exactly in one altitude's colour."""
from bandsaunter.flightmap import PALETTE, RAMP, altitude_step
r, g, b = (int(v) for v in PALETTE[RAMP + altitude_step(feet)])
return ((picture[:, :, 2] == r) & (picture[:, :, 1] == g)
& (picture[:, :, 0] == b))
def _lit(picture):
"""Where those pixels are."""
from bandsaunter.flightmap import BG, PALETTE
r, g, b = (int(v) for v in PALETTE[BG])
background = ((picture[:, :, 2] == r) & (picture[:, :, 1] == g)
& (picture[:, :, 0] == b))
return np.argwhere(~background)
@qt
def test_the_window_draws_the_aircraft_and_a_box(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(registration="N904DN", type_code="B739",
operator="Delta Air Lines"))
view = SkyView(sky)
picture = _rendered(view)
assert _painted(picture) > 500, "nothing was drawn at all"
# The symbol is filled with the aircraft's altitude colour exactly; the
# box around it is a hairline and comes out blended, so only the symbol
# itself is counted here.
assert _in_colour(picture, 32_500).sum() >= 10, \
"no aircraft drawn in its altitude colour"
@qt
def test_nothing_placed_yet_says_so_rather_than_drawing_a_blank(app):
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(home=None))
picture = _rendered(view)
assert _painted(picture) > 200, "an empty window said nothing at all"
@qt
def test_the_symbol_is_where_the_aircraft_is(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(lat=32.4325, lon=-111.0841)) # right on the receiver
view = SkyView(sky)
view.detail = 0 # symbols only
picture = _rendered(view, 900, 650)
rows, cols = np.where(_in_colour(picture, 32_500))
assert rows.size, "the aircraft was not drawn"
# An aircraft at the receiver belongs in the middle of the window.
assert abs(rows.mean() - 325) < 40, rows.mean()
assert abs(cols.mean() - 450) < 40, cols.mean()
@qt
def test_the_detail_can_be_turned_down_for_a_busy_sky(app):
from bandsaunter.livemap import SkyView
sky = a_sky(*[a_blip(icao=f"{i:06X}", callsign=f"FLT{i}",
lat=32.3 + i * 0.05, lon=-111.2 + i * 0.05)
for i in range(6)])
view = SkyView(sky)
drawn = []
for detail in (2, 1, 0):
view.detail = detail
drawn.append(_painted(_rendered(view)))
assert drawn[0] > drawn[1] > drawn[2], drawn
@qt
def test_the_trails_and_the_map_can_be_turned_off(app):
from bandsaunter.livemap import SkyView
sky = a_sky()
for i in range(20):
sky.update([a_blip(lat=32.6 + i * 0.02, lon=-111.0 + i * 0.02)],
frames=i, seen=1)
view = SkyView(sky)
view.detail = 0
with_trail = _painted(_rendered(view))
view.trails = False
without = _painted(_rendered(view))
assert with_trail > without
@qt
def test_the_map_underneath_is_drawn_when_there_is_one(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
projection = view.projection()
sky.set_ground(np.full((650, 900), 31, dtype=np.uint8),
view.ground_key(projection),
view.ground_box(projection))
lit = _rendered(view)[:, :, :3]
from bandsaunter.flightmap import (GROUND, GROUND_SHADES, PALETTE,
dim_ground)
# The brightest shade a drawing reaches is what the brightness allows,
# not the top of the palette.
top = int(dim_ground(np.array([[GROUND_SHADES - 1]]), sky.brightness)[0, 0])
r, g, b = (int(v) for v in PALETTE[GROUND + top])
covered = ((lit[:, :, 2] == r) & (lit[:, :, 1] == g) & (lit[:, :, 0] == b))
assert covered.mean() > 0.5, "the map was not painted under the aircraft"
@qt
def test_the_keys_do_what_the_header_says_they_do(app):
from bandsaunter.livemap import Window, _qt
_name, core, gui, _widgets, _signal = _qt()
window = Window(a_sky(a_blip()))
try:
def press(letter):
event = gui.QKeyEvent(_get(core.QEvent, "Type", "KeyPress"),
ord(letter.upper()),
_get(core.Qt, "KeyboardModifier",
"NoModifier"), letter)
window.keyPressEvent(event)
was = window.view.detail
press("d")
assert window.view.detail != was
press("t")
assert window.view.trails is False
press("g")
assert window.view.show_ground is False
tight = window.sky.radius_nm
press("-")
assert window.sky.radius_nm > tight
press("+")
assert window.sky.radius_nm < window.sky.radius_nm * 1.5
finally:
window.close()
@qt
def test_the_window_shuts_itself_when_the_listening_is_over(app):
""""Listen for ten minutes" has to mean the same thing whether or not a
window is open."""
from bandsaunter.livemap import Window
sky = a_sky(a_blip())
window = Window(sky)
window.show()
assert window.isVisible()
sky.finished = True
window._tick()
assert not window.isVisible()
@qt
def test_closing_the_window_stops_the_receiver(app):
from bandsaunter.livemap import Window
sky = a_sky(a_blip())
window = Window(sky)
window.show()
window.close()
assert sky.stopping is True
# ---------------------------------------------------------------------------
# Long names, narrow boxes
# ---------------------------------------------------------------------------
def test_something_short_is_left_alone():
from bandsaunter.livemap import wrap_value
assert wrap_value("Atlanta → Phoenix") == ["Atlanta → Phoenix"]
assert wrap_value("Boeing 737-932ER") == ["Boeing 737-932ER"]
assert wrap_value("") == [""]
def test_a_long_route_breaks_at_the_arrow_first():
"""The two ends of the flight stay whole and sit under one another,
where they read as a pair."""
from bandsaunter.livemap import wrap_value
folded = wrap_value("London Stansted Airport → East Midlands Airport, "
"Nottingham")
assert folded[0] == "London Stansted Airport"
assert folded[1].startswith("→ East Midlands")
assert not any(line.startswith("→") for line in folded[2:])
def test_the_far_end_of_a_route_is_indented_when_it_wraps_too():
from bandsaunter.livemap import wrap_value
folded = wrap_value("Los Angeles International Airport → Dallas Fort "
"Worth International Airport")
onward = folded[folded.index(next(x for x in folded
if x.startswith("→"))) + 1:]
assert onward and all(line.startswith(" ") for line in onward)
def test_a_long_name_folds_between_words():
from bandsaunter.livemap import wrap_value
folded = wrap_value("CELESTIAL AVIATION TRADING 14 LTD")
assert len(folded) == 2
assert " ".join(folded) == "CELESTIAL AVIATION TRADING 14 LTD"
def test_one_enormous_word_is_cut_rather_than_widening_the_box():
"""Nothing an aircraft sends looks like this; a register will send one
eventually."""
from bandsaunter.livemap import wrap_value
folded = wrap_value("Supercalifragilisticexpialidociousaerodrome")
assert len(folded) > 1
assert "".join(folded) == "Supercalifragilisticexpialidociousaerodrome"
@pytest.mark.parametrize("text", [
"Los Angeles International Airport → Dallas Fort Worth International Airport",
"London Stansted Airport → East Midlands Airport, Nottingham",
"CELESTIAL AVIATION TRADING 14 LTD",
"Hartsfield Jackson Atlanta International Airport",
"Supercalifragilisticexpialidociousaerodrome",
])
def test_nothing_comes_out_wider_than_the_limit(text):
from bandsaunter.livemap import WRAP_CHARS, wrap_value
assert all(len(line) <= WRAP_CHARS for line in wrap_value(text)), \
wrap_value(text)
@qt
def test_a_folded_row_carries_no_label_of_its_own(app):
"""The label belongs to the value as a whole; repeating it down the side
of a wrapped airport name would read as several different facts."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky())
rows = view._wrapped([("alt", "35,000 ft", ""),
("to", "East Midlands Airport, Nottingham", "GB")])
assert rows[0] == ("alt", "35,000 ft", "")
assert rows[1][0] == "to" and rows[1][2] == "GB"
assert all(label == "" and flag == "" for label, _, flag in rows[2:])
@qt
def test_a_long_route_no_longer_widens_the_box(app):
"""The whole point: one flight between two long names used to make its
box wider than the map it sits on."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky())
short = view._box_size(view._wrapped([("to", "Phoenix", "")]))
long = view._box_size(view._wrapped(
[("to", "Dallas Fort Worth International Airport", "")]))
assert long[0] < short[0] * 2, "the box grew with the name"
assert long[1] > short[1], "it should have got taller instead"
@qt
def test_the_widest_box_stays_within_reason(app):
"""Every field at its longest, and the box still fits on a small window."""
from bandsaunter.livemap import SkyView
blip = a_blip(registration="N904DN", type_code="B739",
manufacturer="Boeing", model="737-932ER Winglets",
operator="CELESTIAL AVIATION TRADING 14 LTD",
country="United States of America",
origin="Hartsfield Jackson Atlanta International Airport",
destination="Phoenix Sky Harbor International Airport")
view = SkyView(a_sky(blip))
width, _height = view._box_size(
view._wrapped(blip.lines("mph", home=(32.4325, -111.0841))))
assert width < 320, width
# ---------------------------------------------------------------------------
# How bright the map is
# ---------------------------------------------------------------------------
def test_the_map_brightness_reaches_the_window():
from bandsaunter import aircraft as air
sky = Sky(brightness=0.9)
assert sky.brightness == 0.9
assert air.AircraftOptions().map_brightness == 70
@qt
def test_turning_the_brightness_down_darkens_the_map(app):
from bandsaunter.livemap import SkyView
def painted(brightness):
sky = a_sky(a_blip(), brightness=brightness)
view = SkyView(sky)
view.resize(400, 300)
projection = view.projection()
sky.set_ground(np.full((300, 400), 31, dtype=np.uint8),
view.ground_key(projection),
view.ground_box(projection))
return _rendered(view, 400, 300)[:, :, :3].astype(float).mean()
assert painted(0.3) < painted(0.7) < painted(1.0)
@qt
def test_the_keys_change_the_brightness(app):
from bandsaunter.livemap import Window, _qt
_name, core, gui, _widgets, _signal = _qt()
window = Window(a_sky(a_blip()))
try:
def press(letter):
window.keyPressEvent(gui.QKeyEvent(
_get(core.QEvent, "Type", "KeyPress"), ord(letter),
_get(core.Qt, "KeyboardModifier", "NoModifier"), letter))
was = window.sky.brightness
press("]")
assert window.sky.brightness > was
press("[")
press("[")
assert window.sky.brightness < was
for _ in range(20): # and never off either end
press("[")
assert 0.0 < window.sky.brightness <= 1.0
finally:
window.close()
def test_the_country_of_registration_carries_a_flag():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
craft = Aircraft(icao="4CA1FA", callsign="RYR1234")
blip = blip_for(craft, Flight(icao="4CA1FA", owner_country="Ireland"))
assert blip.country == "Ireland" and blip.country_code == "IE"
rows = {label: (value, flag) for label, value, flag in blip.lines("knots")}
assert rows["reg'd"] == ("Ireland", "IE")
def test_a_country_with_no_code_gets_no_flag_rather_than_a_wrong_one():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
blip = blip_for(Aircraft(icao="4CA1FA"),
Flight(icao="4CA1FA", owner_country="Atlantis"))
assert blip.country_code == ""
# ---------------------------------------------------------------------------
# Fading out rather than blinking out
# ---------------------------------------------------------------------------
def test_an_aircraft_still_being_heard_is_drawn_in_full():
sky = a_sky(hold=10.0, fade=20.0)
assert sky.strength(a_blip(last_seen=now() - 1)) == 1.0
assert sky.strength(a_blip(last_seen=now() - 9)) == 1.0
def test_one_that_has_gone_quiet_fades_rather_than_vanishing():
"""Taking it off between one frame and the next says it stopped
existing; fading says it stopped talking, which is what happened."""
sky = a_sky(hold=10.0, fade=20.0)
at = now()
strengths = [sky.strength(a_blip(last_seen=at - age), at)
for age in (5, 15, 20, 25, 29)]
assert strengths == sorted(strengths, reverse=True)
assert strengths[0] == 1.0
assert 0.0 < strengths[-1] < 0.1
def test_it_is_gone_once_it_has_finished_fading():
sky = a_sky(hold=10.0, fade=20.0)
old = a_blip(last_seen=now() - 40)
assert sky.strength(old) == 0.0
sky.update([old], 1, 1)
assert old.icao not in [b.icao for b in sky.flying()]
def test_it_stays_on_the_picture_for_the_whole_fade():
sky = a_sky(hold=10.0, fade=20.0)
quiet = a_blip(last_seen=now() - 25)
sky.update([quiet], 1, 1)
assert [b.icao for b in sky.flying()] == [quiet.icao]
def test_no_fade_at_all_takes_it_away_at_once():
sky = a_sky(hold=10.0, fade=0.0)
assert sky.strength(a_blip(last_seen=now() - 9)) == 1.0
assert sky.strength(a_blip(last_seen=now() - 11)) == 0.0
def test_how_long_the_fade_takes_is_a_setting():
from bandsaunter import aircraft as air
assert air.AircraftOptions().fade == 20.0
quick = a_sky(hold=10.0, fade=4.0)
slow = a_sky(hold=10.0, fade=60.0)
faded = a_blip(last_seen=now() - 20)
assert quick.strength(faded) == 0.0
assert slow.strength(faded) > 0.5
def test_the_setting_reaches_the_window():
assert Sky(fade=7.5).fade == 7.5
@qt
def test_a_fading_aircraft_is_drawn_fainter(app):
from bandsaunter.livemap import SkyView
def brightness(age):
sky = a_sky(hold=5.0, fade=20.0)
sky.update([a_blip(last_seen=now() - age)], 1, 1)
view = SkyView(sky)
view.detail = 0
picture = _rendered(view, 500, 400)
lit = _lit(picture)
if not len(lit):
return 0.0
return float(picture[lit[:, 0], lit[:, 1], :3].mean())
assert brightness(1) > brightness(20)
@qt
def test_a_faded_aircraft_keeps_its_symbol_and_loses_its_box(app):
"""A box at a tenth of its colour is something in the way of the
aircraft still flying."""
from bandsaunter.livemap import SkyView
sky = a_sky(hold=5.0, fade=20.0)
sky.update([a_blip(last_seen=now() - 22)], 1, 1) # strength about 0.15
view = SkyView(sky)
fresh = _painted(_rendered(view, 600, 450))
sky2 = a_sky(hold=5.0, fade=20.0)
sky2.update([a_blip(last_seen=now() - 1)], 1, 1)
view2 = SkyView(sky2)
full = _painted(_rendered(view2, 600, 450))
assert fresh < full, "the box was still drawn on a nearly faded aircraft"
@qt
def test_one_that_has_gone_quiet_is_not_counted_as_overhead(app):
"""It is on the picture, fading; saying it is overhead says more than
was heard."""
from bandsaunter.livemap import SkyView
sky = a_sky(hold=5.0, fade=30.0)
sky.update([a_blip(icao="AAA111", last_seen=now() - 1),
a_blip(icao="BBB222", lat=32.6, last_seen=now() - 20)], 9, 2)
picture = _rendered(SkyView(sky), 900, 650)
assert _painted(picture) > 100 # both are drawn
assert len(sky.flying()) == 2
assert sum(1 for b in sky.flying() if sky.strength(b) >= 1.0) == 1
def test_a_route_the_aircraft_cannot_be_flying_is_left_out_of_the_box():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
craft = Aircraft(icao="AC0FB6", callsign="SWA930")
craft.latitude, craft.longitude = 32.7086, -110.4061
entry = Flight(icao="AC0FB6", origin="Houston", origin_code="KHOU",
origin_lat=29.65, origin_lon=-95.28,
destination="San Antonio", destination_code="KSAT",
destination_lat=29.53, destination_lon=-98.47,
registration="N8765Q")
blip = blip_for(craft, entry)
assert blip.route_fits is False
labels = [label for label, _v, _f in blip.lines("knots")]
assert "from" not in labels and "to" not in labels
# And everything the aircraft itself said is still there.
assert any(v == "N8765Q" for _l, v, _f in blip.lines("knots"))
def test_a_route_it_could_be_flying_stays_in_the_box():
from bandsaunter.adsb import Aircraft
from bandsaunter.flights import Flight
craft = Aircraft(icao="AD64CD", callsign="AAL2465")
craft.latitude, craft.longitude = 33.1059, -110.5428
entry = Flight(icao="AD64CD", origin="Santa Ana", origin_code="KSNA",
origin_lat=33.68, origin_lon=-117.87,
destination="Dallas", destination_code="KDFW",
destination_lat=32.90, destination_lon=-97.04)
blip = blip_for(craft, entry)
assert blip.route_fits is True
labels = [label for label, _v, _f in blip.lines("knots")]
assert "from" in labels and "to" in labels
# ---------------------------------------------------------------------------
# A box that has to move swings there rather than jumping
# ---------------------------------------------------------------------------
def test_the_glide_moves_towards_the_target_without_passing_it():
from bandsaunter.livemap import glide
here = (0.0, 0.0)
last = here
for _ in range(40):
here = glide(here, (100.0, -60.0), 0.05)
assert 0.0 <= here[0] <= 100.0 and -60.0 <= here[1] <= 0.0
assert here[0] >= last[0] and here[1] <= last[1]
last = here
assert here == (100.0, -60.0), "never actually arrived"
def test_the_glide_arrives_and_stops_asking_for_frames():
"""It has to land exactly on the target, not approach it forever: the
window repaints faster while anything is moving, and a box that is
always a thousandth of a pixel out never lets it stop."""
from bandsaunter.livemap import BOX_GLIDE, glide
here = glide((0.0, 0.0), (50.0, 50.0), BOX_GLIDE * 4)
assert here == (50.0, 50.0)
assert glide(here, (50.0, 50.0), 0.1) == (50.0, 50.0)
def test_the_glide_is_the_same_swing_at_any_frame_rate():
"""Eased by the distance left rather than counted in frames, so a busy
machine drawing half as often makes the same movement, not a slower
one."""
from bandsaunter.livemap import glide
fast = (0.0, 0.0)
for _ in range(8):
fast = glide(fast, (200.0, 0.0), 0.025)
slow = (0.0, 0.0)
for _ in range(2):
slow = glide(slow, (200.0, 0.0), 0.1)
assert abs(fast[0] - slow[0]) < 1.0
def test_a_frame_that_took_no_time_moves_nothing():
from bandsaunter.livemap import glide
assert glide((3.0, 4.0), (99.0, 99.0), 0.0) == (3.0, 4.0)
def test_a_window_left_buried_does_not_replay_the_moves():
"""Ten seconds behind another window is not ten seconds of animation
owed; it is a box that should already be where it belongs."""
from bandsaunter.livemap import glide
assert glide((0.0, 0.0), (300.0, 300.0), 10.0) == (300.0, 300.0)
@qt
def test_a_box_that_need_not_move_does_not_move(app):
"""The whole point of remembering the spot: one aircraft shifting a
pixel must not send its neighbour's box across the window."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.60, lon=-111.30),
a_blip(icao="A00002", callsign="AAL2", lat=32.20, lon=-110.60))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
first = dict(view._box_want)
assert first, "no box was placed at all"
for _ in range(4):
_rendered(view)
assert dict(view._box_want) == first
@qt
def test_a_spot_that_still_works_is_kept(app):
"""Hysteresis, and the reason boxes stay still: a box is only moved
when its own place is actually taken, never because the placer would
now prefer a different one."""
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.resize(900, 650)
view._box_want["A00001"] = (50, 20)
assert view._box_spot("A00001", 400, 300, (120, 60), []) == (450, 320)
@qt
def test_a_spot_that_has_been_taken_is_given_up(app):
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.resize(900, 650)
view._box_want["A00001"] = (50, 20)
spot = view._box_spot("A00001", 400, 300, (120, 60),
[(450, 320, 120, 60)])
assert spot != (450, 320)
assert view._box_want["A00001"] == (spot[0] - 400, spot[1] - 300)
@qt
def test_the_settled_place_is_what_is_spoken_for(app):
"""Laying the next box out against one still in mid-swing would move
that one too, and again when the first arrived; the screen would never
settle. So the target is what goes into the taken list."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.55, lon=-111.20))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
was = view._box_at["A00001"]
view._box_want["A00001"] = (was[0] + 200, was[1] + 100)
view._glide_at = time.time() - 0.05
_rendered(view)
# Mid-swing, and the spot it is heading for is the one it holds.
assert view._box_at["A00001"] != view._box_want["A00001"]
assert view._box_spot("A00001", 0, 0, (10, 10), []) == \
tuple(view._box_want["A00001"])
@qt
def test_the_placer_is_shown_the_settled_spot_not_the_gliding_one(app, monkeypatch):
"""The one that decides whether the screen settles. If a box in mid-
swing is what the next box is laid out against, that next box moves
too -- and moves back when the first one arrives."""
from bandsaunter.livemap import SkyView
early, late = now() - 900, now() - 800
sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.55, lon=-111.20,
first_seen=early),
a_blip(icao="B00002", callsign="AAL2", lat=32.25, lon=-110.70,
first_seen=late))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
was = view._box_at["A00001"]
settled = view._box_want["A00001"]
view._box_want["A00001"] = (was[0] + 220, was[1] + 130)
# The second one is made to ask for a place, so we can see what it is
# shown when it does.
view._box_want.pop("B00002")
seen = []
real = livemap.place_box
monkeypatch.setattr(livemap, "place_box",
lambda *a: (seen.append(list(a[4])), real(*a))[1])
view._glide_at = time.time() - 0.05
_rendered(view)
assert seen, "the second box never asked for a place"
offered = [(r[0], r[1]) for r in seen[-1]]
# The offsets are from the symbol, so they only mean anything once the
# symbol is where the picture put it.
ax, ay = view.projection().xy(32.55, -111.20)
target = view._box_want["A00001"]
drawn = view._box_at["A00001"]
assert drawn != settled and drawn != (float(target[0]), float(target[1])), \
"the first box was not in mid-swing, so this proves nothing"
going = (ax + target[0], ay + target[1])
got_to = (int(round(ax + drawn[0])), int(round(ay + drawn[1])))
assert going != got_to, "the two places are the same; nothing is proved"
assert going in offered, "the settled spot was not spoken for"
assert got_to not in offered, "laid out against a box in mid-swing"
@qt
def test_a_box_pushed_off_its_spot_swings_rather_than_jumps(app):
"""Drawn between where it was and where it now belongs, for several
frames, instead of being in the new place on the very next one."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.55, lon=-111.20))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
was = view._box_at["A00001"]
# Push its settled spot well away, as a crowd of new aircraft would.
view._box_want["A00001"] = (was[0] + 240, was[1] + 120)
view._glide_at = time.time() - 0.05 # a frame's worth of clock
_rendered(view)
# Read the target back rather than assuming it: a spot near the edge is
# clamped into the window, so the swing may be shorter than it was asked
# to be.
target = view._box_want["A00001"]
moved = view._box_at["A00001"]
assert target != was, "the test did not actually displace it"
assert moved != was, "it did not move at all"
assert moved != target, "it jumped the whole way in one frame"
part = (moved[0] - was[0]) / (target[0] - was[0])
assert 0.05 < part < 0.60, f"one frame carried it {part:.0%} of the way"
assert view._box_moving is True
# And it does get there, given the frames.
for _ in range(30):
view._glide_at = time.time() - 0.05
_rendered(view)
assert view._box_at["A00001"] == (float(target[0]), float(target[1]))
assert view._box_moving is False, "still asking for frames after arriving"
@qt
def test_a_box_follows_its_aeroplane_without_that_counting_as_a_move(app):
"""The offset is what is remembered, so an aircraft crossing the window
carries its box along instead of dragging it there a frame late."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.50, lon=-111.10))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
settled = view._box_at["A00001"]
sky.update([a_blip(icao="A00001", callsign="AAL1",
lat=32.70, lon=-110.70)], 1, 1)
_rendered(view)
assert view._box_at["A00001"] == settled # same offset, new place
assert view._box_moving is False
@qt
def test_an_aircraft_that_goes_is_forgotten(app):
"""Otherwise one that comes back an hour later glides in from wherever
it was standing then, across the whole window."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="A00001", callsign="AAL1"))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
assert "A00001" in view._box_at
view.detail = 0 # symbols only: no boxes
_rendered(view)
assert view._box_at == {} and view._box_want == {}
@qt
def test_a_box_in_motion_is_drawn_over_the_ones_standing_still(app):
"""It crosses its neighbours for a moment on the way, and the one that
is moving is the one being followed, so it is the one that has to stay
readable while it does."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(icao="A00001", callsign="AAL1", lat=32.55, lon=-111.20,
first_seen=now() - 900),
a_blip(icao="B00002", callsign="AAL2", lat=32.25, lon=-110.70,
first_seen=now() - 800))
view = SkyView(sky)
view.resize(900, 650)
_rendered(view)
order = []
real = SkyView._paint_label
def watch(self, painter, laid):
order.append((laid[0], laid[1].icao))
return real(self, painter, laid)
SkyView._paint_label = watch
try:
was = view._box_at["A00001"]
# Away from the other one, so that only this box is on the move.
view._box_want["A00001"] = (was[0] - 160, was[1] + 150)
view._glide_at = time.time() - 0.05
_rendered(view)
finally:
SkyView._paint_label = real
assert [icao for moving, icao in order if moving] == ["A00001"], \
f"expected only the displaced box to be moving, got {order}"
# Everything standing still goes down before anything that is moving.
assert order == sorted(order, key=lambda seen: seen[0])
assert order[-1][1] == "A00001"
# ---------------------------------------------------------------------------
# The ground is dimmed once, not every frame
# ---------------------------------------------------------------------------
def _with_ground(width=900, height=650, brightness=0.70):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(), brightness=brightness)
view = SkyView(sky)
view.resize(width, height)
projection = view.projection()
levels = np.random.default_rng(7).integers(
0, 32, (int(height * 1.3), int(width * 1.3)), dtype=np.uint8)
sky.set_ground(levels, view.ground_key(projection),
view.ground_box(projection))
return sky, view
@qt
def test_the_dimmed_map_is_kept_between_frames(app):
"""Cutting the view out, dimming it and looking every level up in the
palette is most of a tenth of a second over two megapixels. Doing it
again for a frame in which nothing about it changed put a ceiling of a
dozen frames a second on the window."""
sky, view = _with_ground()
_rendered(view)
made = view._ground_pixels
assert made is not None
_rendered(view)
assert view._ground_pixels is made, "the map was dimmed all over again"
@qt
def test_a_new_map_is_dimmed_afresh(app):
"""Same window, same brightness, same view: only the map itself is
different, which is what the count on it is for."""
sky, view = _with_ground()
_rendered(view)
made = view._ground_pixels
projection = view.projection()
sky.set_ground(np.full_like(sky._ground, 31),
view.ground_key(projection), view.ground_box(projection))
_rendered(view)
assert view._ground_pixels is not made
@qt
def test_turning_the_brightness_up_redraws_the_map(app):
sky, view = _with_ground()
_rendered(view)
made = view._ground_pixels.copy()
sky.brightness = 0.30
_rendered(view)
assert not np.array_equal(view._ground_pixels, made)
@qt
def test_resizing_the_window_redraws_the_map(app):
sky, view = _with_ground()
_rendered(view)
_rendered(view, width=700, height=500)
assert view._ground_pixels.shape[:2] == (500, 700)
def test_the_window_box_says_what_sort_of_aircraft_it_is():
"""The same two facts the animation shows, so the two look like the
same program: what it broadcast, and whether its address is military."""
military = a_blip(icao="AE07D3", callsign="PRIME04")
military.category = "heavy"
rows = military.lines("knots")
assert ("class", "military heavy", "") in rows
plain = a_blip(icao="AC4C44", callsign="SWA2444")
plain.category = "large"
assert ("class", "large", "") in plain.lines("knots")
quiet = a_blip(icao="AC4C44", callsign="SWA2444")
assert not any(label == "class" for label, _v, _f in quiet.lines("knots"))
def test_the_category_reaches_the_window_from_the_air():
from bandsaunter.adsb import Aircraft
craft = Aircraft(icao="AE07D3", callsign="PRIME04", latitude=32.5,
longitude=-111.0, category="heavy")
assert blip_for(craft).category == "heavy"
# ---------------------------------------------------------------------------
# The aerodromes under the window
# ---------------------------------------------------------------------------
TUCSON_AIRPORTS = [("KTUS", 32.116, -110.941), ("KDMA", 32.166, -110.883),
("KAVQ", 32.409, -111.218)]
def test_a_sky_does_not_reach_for_airports_unless_it_is_asked_to():
"""A question to a network the first time an area is drawn, so the
program turns it on and a library user has to say so on purpose."""
assert a_sky().show_airports is False
assert Sky(airports=True).show_airports is True
def test_the_airports_come_back_when_what_was_fetched_covers_the_view():
sky = a_sky()
sky.set_airports(TUCSON_AIRPORTS, (31.0, -112.0, 33.0, -110.0))
assert sky.airports_covering(31.5, -111.5, 32.5, -110.5) == TUCSON_AIRPORTS
def test_a_view_outside_what_was_fetched_asks_again():
"""None, not an empty list: nothing has been asked about this piece of
world, which is a different thing from having asked and found none."""
sky = a_sky()
sky.set_airports(TUCSON_AIRPORTS, (31.0, -112.0, 33.0, -110.0))
assert sky.airports_covering(40.0, -112.0, 41.0, -110.0) is None
assert a_sky().airports_covering(31.5, -111.5, 32.5, -110.5) is None
def test_an_area_with_no_aerodromes_is_not_asked_about_all_night():
"""An empty answer is an answer. Kept as an empty list, so that the
difference between "there are none" and "nobody has looked" survives."""
sky = a_sky()
sky.want_airports((31.0, -112.0, 33.0, -110.0))
sky.set_airports([], (31.0, -112.0, 33.0, -110.0))
assert sky.airports_covering(31.5, -111.5, 32.5, -110.5) == []
assert sky.wanted_airports() is None
def test_the_aerodromes_do_not_wait_behind_the_tiles():
"""The bug this is here for. They used to be fetched only in the same
pass as a piece of map, so they queued behind a hundred and twenty tiles
coming off a network -- and once the map was in hand there were no more
passes and they were never fetched at all."""
import threading
from bandsaunter import basemap
sky = a_sky()
sky.show_airports = True
sky.want_airports((32.0, -112.0, 33.0, -110.0))
real = basemap.airports_in
slow = threading.Event()
def tiles(*a, **kw): # a map that never arrives
slow.wait(10.0)
return None
basemap.airports_in = lambda s, w, n, e, **kw: [
{"code": c, "latitude": la, "longitude": lo}
for c, la, lo in TUCSON_AIRPORTS]
try:
# A map is asked for too, and the fetching of it hangs.
sky.want_ground("k", (32.0, -112.0, 33.0, -110.0), (40, 40))
worker = threading.Thread(
target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": tiles}, daemon=True)
worker.start()
for _ in range(60):
if sky.airports_covering(32.0, -112.0, 33.0, -110.0):
break
time.sleep(0.05)
got = sky.airports_covering(32.0, -112.0, 33.0, -110.0)
slow.set()
sky.stopping = True
worker.join(timeout=3.0)
finally:
basemap.airports_in = real
slow.set()
assert got == TUCSON_AIRPORTS, "they waited for a map that never came"
def test_the_airports_are_fetched_off_the_painting_thread():
import threading
from bandsaunter import basemap
sky = a_sky()
sky.show_airports = True
sky.want_airports((32.0, -112.0, 33.0, -110.0))
real = basemap.airports_in
basemap.airports_in = lambda s, w, n, e, **kw: [
{"code": c, "latitude": la, "longitude": lo}
for c, la, lo in TUCSON_AIRPORTS]
try:
worker = threading.Thread(
target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": lambda z, x, y, **kw: None}, daemon=True)
worker.start()
for _ in range(60):
if sky.airports_covering(32.0, -112.0, 33.0, -110.0):
break
time.sleep(0.05)
sky.stopping = True
worker.join(timeout=2.0)
finally:
basemap.airports_in = real
assert sky.airports_covering(32.0, -112.0, 33.0, -110.0) == TUCSON_AIRPORTS
def test_an_area_that_could_not_be_asked_about_is_not_asked_about_again():
import threading
from bandsaunter import basemap
sky = a_sky()
sky.show_airports = True
sky.want_airports((32.0, -112.0, 33.0, -110.0))
real = basemap.airports_in
tries = []
def broken(*a, **kw):
tries.append(1)
raise OSError("no network tonight")
basemap.airports_in = broken
try:
worker = threading.Thread(
target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": lambda z, x, y, **kw: None}, daemon=True)
worker.start()
time.sleep(0.6)
sky.stopping = True
worker.join(timeout=2.0)
finally:
basemap.airports_in = real
assert len(tries) == 1, f"asked {len(tries)} times after failing once"
assert sky.airports_covering(32.0, -112.0, 33.0, -110.0) == []
def test_no_airports_are_fetched_when_they_are_turned_off():
import threading
from bandsaunter import basemap
sky = a_sky() # show_airports is False
sky.want_airports((32.0, -112.0, 33.0, -110.0))
real = basemap.airports_in
def refuse(*a, **kw):
raise AssertionError("asked for airports with them turned off")
basemap.airports_in = refuse
try:
worker = threading.Thread(
target=livemap.fetch_ground, args=(sky,),
kwargs={"fetch": lambda z, x, y, **kw: None}, daemon=True)
worker.start()
time.sleep(0.5)
sky.stopping = True
worker.join(timeout=2.0)
finally:
basemap.airports_in = real
assert sky.airports_covering(32.0, -112.0, 33.0, -110.0) is None
def _airport_pixels(picture) -> int:
"""Pixels in the aerodrome colour, which nothing else on the window is."""
from bandsaunter.flightmap import AIRPORT, PALETTE
want = PALETTE[AIRPORT]
return int(((picture[:, :, 2] == want[0]) & (picture[:, :, 1] == want[1])
& (picture[:, :, 0] == want[2])).sum())
@qt
def test_the_window_marks_the_aerodromes_under_it(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(), airports=True)
view = SkyView(sky)
view.show_ground = False
bare = _rendered(view)
assert _airport_pixels(bare) == 0, "something else is already that colour"
projection = view.projection()
sky.set_airports(TUCSON_AIRPORTS,
(projection.south - 1, projection.west - 1,
projection.north + 1, projection.east + 1))
marked = _rendered(view)
assert _airport_pixels(marked) > 60, "no aerodrome was drawn"
@qt
def test_the_window_draws_no_aerodromes_when_they_are_turned_off(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip()) # show_airports is False
view = SkyView(sky)
view.show_ground = False
projection = view.projection()
sky.set_airports(TUCSON_AIRPORTS,
(projection.south - 1, projection.west - 1,
projection.north + 1, projection.east + 1))
assert _airport_pixels(_rendered(view)) == 0
@qt
def test_an_aerodrome_outside_the_view_is_nowhere_on_the_picture(app):
"""What is fetched covers rather more world than is shown, so on a
zoomed-in view most of the county's airports are outside it.
Projecting one gives coordinates off the widget and Qt clips them, so
this holds whether or not they are skipped first; it is here to catch
anyone who later clamps a position into the view instead, which would
pile every airport in the county along the border of the picture.
"""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(), airports=True)
view = SkyView(sky)
view.show_ground = False
projection = view.projection()
box = (projection.south - 5, projection.west - 5,
projection.north + 5, projection.east + 5)
sky.set_airports([("KJFK", projection.north + 3, projection.east + 3)], box)
assert _airport_pixels(_rendered(view)) == 0
# ---------------------------------------------------------------------------
# The window follows the theme
# ---------------------------------------------------------------------------
@qt
def test_the_window_draws_in_whatever_theme_is_set(app):
"""Both drawings read the same palette, so setting a theme changes the
window as well as the animation and there is nothing to keep in step."""
from bandsaunter import flightmap as fm
from bandsaunter.livemap import SkyView
def colours(theme):
fm.set_theme(theme)
sky = a_sky(a_blip(), airports=True)
view = SkyView(sky)
view.show_ground = False
projection = view.projection()
sky.set_airports([("KTUS", projection.south + 0.1,
projection.west + 0.1)],
(projection.south - 1, projection.west - 1,
projection.north + 1, projection.east + 1))
picture = _rendered(view)
return {tuple(int(v) for v in rgb) for rgb in
picture[:, :, [2, 1, 0]].reshape(-1, 3)}
try:
night = colours("night")
green = colours("phosphor")
finally:
fm.set_theme("night")
assert (255, 64, 200) in night, "the night aerodrome colour is missing"
assert (255, 64, 200) not in green, "a magenta aerodrome on a green screen"
assert (255, 184, 72) in green, "the phosphor aerodrome colour is missing"
@qt
def test_a_vector_theme_lays_a_halo_round_what_it_draws(app):
"""The window does its glow by laying the same line down two or three
times, wider and fainter each pass. So a themed window paints more
distinct colours than a plain one drawing the same aircraft."""
from bandsaunter import flightmap as fm
from bandsaunter.livemap import SkyView
def shades(theme):
fm.set_theme(theme)
sky = a_sky(a_blip())
view = SkyView(sky)
view.show_ground = False
picture = _rendered(view)
return len({tuple(int(v) for v in rgb) for rgb in
picture[:, :, [2, 1, 0]].reshape(-1, 3)})
try:
plain = shades("night")
glowing = shades("phosphor")
finally:
fm.set_theme("night")
assert glowing > plain, f"{glowing} shades is no more than {plain}"
# ---------------------------------------------------------------------------
# The leader line, and the flag on the receiver
# ---------------------------------------------------------------------------
def _leader_pixels(view) -> int:
"""How much of the picture the leader line accounts for.
Counted by drawing the view twice, once with the leader colour set to
the background, and taking the difference. Matching the colour itself
finds almost nothing: the line is smoothed and drawn with an alpha, so
hardly a pixel of it comes out the pure colour.
"""
from bandsaunter.flightmap import BG, LEADER, PALETTE
was = PALETTE[LEADER].copy()
try:
PALETTE[LEADER] = PALETTE[BG]
hidden = _rendered(view)
finally:
PALETTE[LEADER] = was
shown = _rendered(view)
return int((hidden != shown).any(axis=2).sum())
@qt
def test_the_leader_is_not_drawn_in_the_aircrafts_own_colour(app):
"""Drawn in the aircraft's colour it came out the same colour as that
aircraft's trail, and a straight line running out of an aeroplane in
the colour of the path behind it reads as more path."""
from bandsaunter.flightmap import LEADER, PALETTE, RAMP, RAMP_STEPS
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip()))
view.show_ground = False
assert _leader_pixels(view) > 10, "no leader was drawn at all"
leader = tuple(int(v) for v in PALETTE[LEADER])
ramp = {tuple(int(v) for v in PALETTE[RAMP + i]) for i in range(RAMP_STEPS)}
assert leader not in ramp
@qt
def test_the_leader_is_dashed_rather_than_solid(app):
"""The same line with the dashes taken out paints noticeably more of
itself, which is what a dashed line is."""
from bandsaunter import livemap as lm
from bandsaunter.livemap import SkyView
def drawn(pattern):
was, lm.LEADER_DASH = lm.LEADER_DASH, pattern
try:
view = SkyView(a_sky(a_blip()))
view.show_ground = False
return _leader_pixels(view)
finally:
lm.LEADER_DASH = was
dashed = drawn(lm.LEADER_DASH)
solid = drawn(None)
assert solid > 10, "the solid line drew nothing to compare against"
assert dashed < solid * 0.85, \
f"{dashed} pixels against {solid}: that is not a dashed line"
def test_a_dash_pattern_is_scaled_by_the_width_of_the_pen_drawing_it():
"""Qt measures a dash pattern in multiples of the pen's own width. A
glowing line is the same line drawn two or three times at different
widths, so without this its halo would have dashes three times the
length of its core and it would come out as beads."""
from bandsaunter.livemap import dash_for
for width in (1.0, 3.4, 5.8):
pattern = dash_for((5.0, 4.0), width)
assert [round(step * width, 6) for step in pattern] == [5.0, 4.0]
def test_a_dash_pattern_never_asks_for_a_step_of_nothing():
"""Qt refuses a zero-length dash, and a pen can be asked for at any
width the glow happens to want."""
from bandsaunter.livemap import dash_for
for width in (0.0, -3.0, 1e6):
assert all(step > 0 for step in dash_for((5.0, 4.0), width))
@qt
def test_a_flag_stands_where_the_receiver_was_told_it_is(app):
from bandsaunter.flightmap import HOME_RED
from bandsaunter.livemap import SkyView
def flag_pixels(view):
picture = _rendered(view)
return int(((picture[:, :, 2] == HOME_RED[0])
& (picture[:, :, 1] == HOME_RED[1])
& (picture[:, :, 0] == HOME_RED[2])).sum())
placed = SkyView(a_sky(a_blip(), home=(32.4325, -111.0841)))
placed.show_ground = False
assert flag_pixels(placed) > 20, "no flag where the receiver is"
@qt
def test_no_flag_where_nobody_said_the_receiver_is(app):
"""The middle is otherwise worked out from whatever flew past, which is
not a place anybody is standing."""
from bandsaunter.flightmap import HOME_RED
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip(), home=None))
view.show_ground = False
picture = _rendered(view)
assert int(((picture[:, :, 2] == HOME_RED[0])
& (picture[:, :, 1] == HOME_RED[1])
& (picture[:, :, 0] == HOME_RED[2])).sum()) == 0
def test_both_pictures_draw_the_leader_and_the_flag_the_same():
"""They are meant to look like the same program, and two copies of a
number are two chances to change only one of them."""
from bandsaunter import flightmap as fm
from bandsaunter import livemap as lm
assert tuple(lm.LEADER_DASH) == tuple(float(x) for x in fm.LEADER_DASH)
# Both read the one palette, so there is no second colour to drift.
assert tuple(fm.PALETTE[fm.LEADER]) == tuple(fm.PALETTE[fm.LEADER])
assert tuple(fm.PALETTE[fm.HOME]) == fm.HOME_RED
# And the one set of measurements for the flag itself.
for name in ("HOME_POLE", "HOME_FLY", "HOME_DROP"):
assert hasattr(fm, name)
# ---------------------------------------------------------------------------
# Range rings on the window
# ---------------------------------------------------------------------------
def _lifted_pixels(view) -> int:
"""How much of the window the rings account for, by drawing it with and
without them. The tint is an alpha over whatever is underneath, so
there is no one colour to count."""
was = view.sky.show_rings
try:
view.sky.show_rings = False
without = _rendered(view)
view.sky.show_rings = True
with_them = _rendered(view)
finally:
view.sky.show_rings = was
return int((without != with_them).any(axis=2).sum())
def test_a_sky_does_not_draw_rings_unless_it_is_asked_to():
assert a_sky().show_rings is False
assert Sky(rings=True).show_rings is True
@qt
def test_the_window_draws_the_range_rings(app):
from bandsaunter.livemap import SkyView
view = SkyView(a_sky(a_blip(), rings=True))
view.show_ground = False
assert _lifted_pixels(view) > 5000, "no rings were drawn"
@qt
def test_no_rings_without_a_position_or_without_a_radius(app):
"""They are measured from the radius and centred on the receiver, so
they mean nothing without both."""
from bandsaunter.livemap import SkyView
nowhere = SkyView(a_sky(a_blip(), rings=True, home=None))
nowhere.show_ground = False
assert _lifted_pixels(nowhere) == 0
flat = SkyView(a_sky(a_blip(), rings=True, radius_nm=0.0))
flat.show_ground = False
assert _lifted_pixels(flat) == 0
def test_the_rings_are_labelled_with_how_far_out_they_are():
"""Both pictures ask the same function for the wording, so the window
and the drawings cannot come to different numbers for the same ring."""
from bandsaunter.flightmap import ring_labels
assert ring_labels(100.0, "knots") == [(25.0, "25 nm"), (50.0, "50 nm"),
(75.0, "75 nm")]
def test_the_ring_labels_are_in_the_unit_the_rest_of_the_window_uses():
"""Miles an hour beside a ring measured in nautical miles would be two
different miles on one picture."""
from bandsaunter.flightmap import ring_labels
assert [text for _nm, text in ring_labels(104.0, "mph")] == \
["30 mi", "60 mi", "90 mi"]
assert [text for _nm, text in ring_labels(100.0, "kph")] == \
["46 km", "93 km", "139 km"]
# ---------------------------------------------------------------------------
# The card behind a box, and the flag over everything
# ---------------------------------------------------------------------------
@qt
def test_the_card_behind_a_box_is_as_solid_as_it_was_asked_to_be(app):
"""The words beside an aircraft are readable over water and not over a
city, so a card goes behind them; how much of the map it hides is the
setting."""
from bandsaunter.livemap import SkyView
from bandsaunter.flightmap import PALETTE, PANEL
want = PALETTE[PANEL]
def solid(part):
"""Pixels that came out the panel colour exactly, which only a card
drawn at full strength does."""
view = SkyView(a_sky(a_blip(), box_opacity=part))
view.show_ground = False
picture = _rendered(view)
return int(((picture[:, :, 2] == want[0])
& (picture[:, :, 1] == want[1])
& (picture[:, :, 0] == want[2])).sum())
# Measured against the picture with no card at all: the header strip is
# drawn in the panel colour too, and that is not a card.
none = solid(0.0)
assert solid(1.0) - none > 500, "no solid card was drawn"
# Half way is neither one nor the other: the map shows through it, so
# hardly a pixel comes out the panel colour exactly.
assert solid(0.5) - none < (solid(1.0) - none) / 4
@qt
def test_a_box_with_no_card_lets_the_map_through(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip(), box_opacity=0.0)
assert sky.box_opacity == 0.0
view = SkyView(sky)
view.show_ground = False
assert _rendered(view) is not None # it draws, and does not fail
def test_the_card_setting_is_kept_inside_its_range():
assert Sky(box_opacity=-2.0).box_opacity == 0.0
assert Sky(box_opacity=9.0).box_opacity == 1.0
@qt
def test_nothing_in_the_window_is_drawn_over_the_flag(app):
"""It says where the receiver is standing, which is the one thing on
the picture that must never end up behind an aeroplane that happened to
fly over it, or behind the box belonging to one."""
from bandsaunter.flightmap import HOME_POLE, HOME_RED
from bandsaunter.livemap import SkyView
home = (32.4325, -111.0841)
# An aircraft sitting exactly on the receiver, with a solid box.
sky = a_sky(a_blip(lat=home[0], lon=home[1]), home=home, box_opacity=1.0)
view = SkyView(sky)
view.show_ground = False
picture = _rendered(view)
projection = view.projection()
x, y = projection.xy(*home)
def is_flag(px, py):
pixel = picture[py, px]
return (int(pixel[2]), int(pixel[1]), int(pixel[0])) == HOME_RED
assert is_flag(x, y), "the foot of the pole was painted over"
assert all(is_flag(x, y - up) for up in range(HOME_POLE)), \
"part of the pole was painted over"
# ---------------------------------------------------------------------------
# Pulsing aircraft, and the rings that leave them
# ---------------------------------------------------------------------------
def test_a_sky_draws_neither_effect_unless_it_is_asked_to():
plain = a_sky()
assert plain.pulse == 0.0 and plain.echo == 0.0
lively = Sky(pulse=2.2, echo=3.0, echo_reach=46)
assert lively.pulse == 2.2 and lively.echo == 3.0
def test_the_effect_settings_are_kept_inside_their_range():
quiet = Sky(pulse=-1.0, echo=-2.0, echo_reach=-5.0)
assert quiet.pulse == 0.0 and quiet.echo == 0.0 and quiet.echo_reach == 0.0
def _at_own_peak(sky, icao, want=0.5):
"""Wind the clock so this aircraft is `want` of the way round its pulse.
Each is offset by its own address, so a test cannot assume that half a
cycle on the clock is half a cycle for any particular aeroplane.
"""
from bandsaunter.flightmap import phase_of
rate = sky.pulse or sky.echo
sky.started = time.time() - rate * ((want - phase_of(icao)) % 1.0)
@qt
def test_an_aircraft_burns_at_the_top_of_its_pulse(app):
"""Brighter, and wearing a halo grown out of its own shape: what a
phosphor does when the beam sits in one place a little too long."""
from bandsaunter.livemap import SkyView
def drawn(want):
sky = a_sky(a_blip(), pulse=2.2)
_at_own_peak(sky, "A76154", want)
view = SkyView(sky)
view.show_ground = False
view.detail = 0 # the symbol alone
picture = _rendered(view)
return int(picture[:, :, :3].astype(int).sum())
assert drawn(0.5) > drawn(0.0), "it did not brighten at the top"
@qt
def test_a_pulse_turned_off_leaves_the_aircraft_alone(app):
"""Asked of the level rather than of the pixels: the header carries a
running clock, so two whole frames are never identical anyway."""
from bandsaunter.livemap import SkyView
off = SkyView(a_sky(a_blip()))
assert off._pulse_level("A76154") is None
on = SkyView(a_sky(a_blip(), pulse=2.2))
for want in (0.0, 0.25, 0.5, 0.75):
_at_own_peak(on.sky, "A76154", want)
level = on._pulse_level("A76154")
assert level is not None and 0.0 <= level <= 1.0
_at_own_peak(on.sky, "A76154", 0.5)
assert on._pulse_level("A76154") > 0.98, "it does not reach the top"
_at_own_peak(on.sky, "A76154", 0.0)
assert on._pulse_level("A76154") < 0.02, "nor the bottom"
@qt
def test_a_ring_leaves_an_aircraft_and_grows(app):
from bandsaunter.livemap import SkyView
def spread(want):
"""How far the ring has got from its aircraft.
Measured against the same view with the echo turned off, and only
within a box round the aeroplane: the header carries a running
clock and the box a running age, and neither is a ring.
"""
sky = a_sky(a_blip(), echo=3.0, echo_reach=60)
_at_own_peak(sky, "A76154", want)
view = SkyView(sky)
view.show_ground = False
view.detail = 0
with_it = _rendered(view)
sky.echo = 0.0
without = _rendered(view)
x, y = view.projection().xy(a_blip().latitude, a_blip().longitude)
top, left = max(0, y - 90), max(0, x - 90)
near = slice(top, y + 90), slice(left, x + 90)
differs = (with_it[near] != without[near]).any(axis=2)
ys, xs = np.where(differs)
if not len(xs):
return 0
return int(np.hypot(xs + left - x, ys + top - y).max())
early, late = spread(0.15), spread(0.8)
assert early > 0, "no ring at all"
assert late > early, f"the ring did not grow: {early} then {late}"
@qt
def test_the_window_keeps_asking_for_frames_while_anything_is_alive(app):
"""A pulse and an echo are never finished, so the window has to keep
repainting the way it does while a box is on its way somewhere."""
from bandsaunter.livemap import SkyView
lively = SkyView(a_sky(a_blip(), pulse=2.2))
lively.show_ground = False
_rendered(lively)
assert lively._glide_timer.isActive(), "it stopped asking for frames"
still = SkyView(a_sky(a_blip()))
still.show_ground = False
_rendered(still)
assert not still._glide_timer.isActive(), \
"it is repainting with nothing to animate"
# ---------------------------------------------------------------------------
# Marks that are not aeroplanes
# ---------------------------------------------------------------------------
#
# The window draws an APRS station as well as an aircraft. Everything it
# knows how to do -- fetch a map, place a box where it covers nothing, glide
# it when its owner moves, draw rings and put a flag where the aerial is --
# has nothing to do with aviation, so the three things that do are hooks
# rather than a second window.
def test_an_aircraft_still_fades_when_it_stops_transmitting():
"""The guard on the other side of the hook: nothing about aircraft moved."""
sky = Sky(hold=10.0, fade=10.0)
old = Blip(icao="A1", latitude=47.0, longitude=-122.0,
last_seen=now() - 15.0)
sky.update([old], 1, 1)
assert 0.0 < sky.strength(old) < 1.0
gone = Blip(icao="A2", latitude=47.0, longitude=-122.0,
last_seen=now() - 400.0)
sky.update([gone], 1, 1)
assert [b.icao for b in sky.flying()] == ["A1"]
def test_a_station_never_fades_and_never_leaves_the_picture():
"""A fixed amateur station that stops transmitting is still where it was.
It beacons every half hour, so the gaps are silence rather than absence,
and an evening's listening should fill a map rather than empty one.
"""
sky = Sky(hold=10.0, fade=10.0, fades=False)
ancient = Blip(icao="W1AW", latitude=47.0, longitude=-122.0,
last_seen=now() - 86_400.0)
sky.update([ancient], 1, 1)
assert sky.strength(ancient) == 1.0
assert [b.icao for b in sky.flying()] == ["W1AW"]
def test_an_accumulating_map_puts_the_most_recent_first():
"""Boxes are laid out in this order and a map that has been filling all
evening has more marks on it than it has room for boxes, so the ones
worth reading are the ones that just spoke."""
sky = Sky(fades=False)
sky.update([Blip(icao="OLD", latitude=47.0, longitude=-122.0,
last_seen=now() - 3_600.0),
Blip(icao="NEW", latitude=47.1, longitude=-122.1,
last_seen=now() - 5.0)], 2, 2)
assert [b.icao for b in sky.flying()] == ["NEW", "OLD"]
def test_a_mark_may_be_given_its_own_colour_instead_of_an_altitude():
"""There is nothing about a weather station that an altitude ramp says."""
from bandsaunter.flightmap import RAMP
assert Blip(icao="X").colour_index is None # an aeroplane
assert Blip(icao="X", colour_index=RAMP + 4).colour_index == RAMP + 4
def test_a_mark_may_be_given_its_own_box_contents():
rows = (("symbol", "weather station", ""), ("away", "5 km NNE", ""))
assert Blip(icao="X", details=rows).lines("kph") == list(rows)
def test_an_aircraft_with_no_details_still_builds_its_box_from_the_registers():
lines = Blip(icao="A835AF", callsign="UAL1", altitude_ft=35_000).lines("knots")
assert lines and any("UAL1" in str(row) or "35" in str(row)
for row in lines)
@qt
def test_every_shape_draws_something(app):
"""Three shapes: a triangle that points, a body with a stalk that also
points, and a diamond that does not, because a place has no front."""
from bandsaunter.livemap import _build
for shape in ("aircraft", "vehicle", "station"):
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
shape=shape, last_seen=now())], 1, 1)
view = _build()["SkyView"](sky)
painted = _painted(_rendered(view))
assert painted > 500, f"{shape} drew almost nothing"
@qt
def test_a_station_map_draws_the_stations_and_their_boxes(app):
from bandsaunter import aprs, packets
from bandsaunter.livemap import _build
net = aprs.Net()
when = now()
for source, info, at in [
("W1AW-1", "=4736.37N/12219.93W#Seattle wide digi", when - 74),
("KB1XYZ", "@092345z4735.40N/12216.20W_220/004g011t058h62b10132",
when - 22),
("N0ABC-7", "=4737.00N/12221.00Wb out for a ride", when - 3_600)]:
packet = packets.parse_info(info)
packet.source, packet.at, packet.snr = source, at, 28.0
net.add(packet)
home = (47.55, -122.30)
sky = Sky(unit="kph", home=home, radius_nm=40 / 1.852, fades=False,
rings=True, channel="144.39 MHz", subject="on the map",
counted="packets")
sky.started = when - 600
sky.update([aprs.blip_for(s, home) for s in net.all() if s.position],
148, len(net))
view = _build()["SkyView"](sky)
assert _painted(_rendered(view)) > 5_000
# Including the one heard an hour ago, which has not faded.
assert {b.icao for b in sky.flying()} == {"W1AW-1", "KB1XYZ", "N0ABC-7"}
@qt
def test_the_strip_along_the_top_says_what_it_is_looking_at(app):
"""It said 1090 MHz and "overhead" and "frames" whatever it was drawing."""
from bandsaunter.livemap import _build
sky = Sky(fades=False, channel="144.39 MHz", subject="on the map",
counted="packets", home=(47.55, -122.30))
sky.update([Blip(icao="W1AW", latitude=47.55, longitude=-122.30,
last_seen=now())], 9, 1)
view = _build()["SkyView"](sky)
assert _painted(_rendered(view)) > 500
assert sky.channel == "144.39 MHz" and sky.counted == "packets"
def _has_colour(picture, rgb_wanted) -> bool:
"""Whether a given colour was actually painted.
Exactly, not nearly: a mark is filled at full alpha, so its own colour
appears unblended in the middle of it however the edges are softened.
"""
want = np.array(rgb_wanted, dtype=np.uint8)
# Rendered as BGRA, so the first three channels are blue, green, red.
body = picture[:, :, :3][:, :, ::-1]
return bool((body == want).all(axis=2).any())
@qt
def test_a_given_colour_is_the_one_that_reaches_the_pixels(app):
"""Not merely stored on the mark and then ignored in favour of an
altitude the mark has not got."""
from bandsaunter.flightmap import PALETTE, RAMP
from bandsaunter.livemap import _build
chosen = RAMP + 25
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
sky.update([Blip(icao="W1AW", latitude=47.55, longitude=-122.30,
shape="station", colour_index=chosen,
last_seen=now())], 1, 1)
picture = _rendered(_build()["SkyView"](sky))
assert _has_colour(picture, tuple(int(v) for v in PALETTE[chosen]))
@qt
def test_two_sorts_of_station_are_not_painted_the_same_colour(app):
from bandsaunter.flightmap import PALETTE, RAMP
from bandsaunter.livemap import _build
seen = []
for index in (RAMP + 3, RAMP + 28):
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
shape="station", colour_index=index,
last_seen=now())], 1, 1)
seen.append(_rendered(_build()["SkyView"](sky)))
for picture, index in zip(seen, (RAMP + 3, RAMP + 28)):
assert _has_colour(picture, tuple(int(v) for v in PALETTE[index]))
def _around_the_mark(shape, track=0.0, span=15):
"""Just the pixels the symbol itself occupies.
Cropped, and cropped for a reason: the strip along the top carries a
running clock, so two renders taken a millisecond apart differ by a few
hundred pixels whatever is on the map. Comparing whole frames would
pass whatever the symbol did.
"""
from bandsaunter.livemap import _build
sky = Sky(fades=False, home=(47.55, -122.30), radius_nm=30.0)
sky.update([Blip(icao="X", latitude=47.55, longitude=-122.30,
shape=shape, colour_index=20, track_deg=track,
last_seen=now())], 1, 1)
view = _build()["SkyView"](sky)
picture = _rendered(view, 400, 300)
where = view.projection()
x, y = where.xy(47.55, -122.30)
return picture[y - span:y + span, x - span:x + span, :3]
@qt
def test_a_place_is_not_drawn_with_the_same_outline_as_an_aeroplane(app):
"""A diamond has no front, which is the point of using one for a house
that beacons twice an hour: a triangle would have it pointing north for
no reason at all."""
plane = _around_the_mark("aircraft")
place = _around_the_mark("station")
assert plane.shape == place.shape and plane.size > 0
differing = int((plane != place).any(axis=2).sum())
assert differing > 20, f"only {differing} pixels differ between the two"
@qt
def test_something_moving_is_drawn_differently_again(app):
moving = _around_the_mark("vehicle", track=70.0)
place = _around_the_mark("station", track=70.0)
differing = int((moving != place).any(axis=2).sum())
assert differing > 20, f"only {differing} pixels differ between the two"
@qt
def test_a_symbol_that_points_turns_with_its_heading(app):
"""Which is what tells a mark that is going somewhere from one that is
not, and is the whole reason a moving station is not a diamond."""
north = _around_the_mark("vehicle", track=0.0)
east = _around_the_mark("vehicle", track=90.0)
assert int((north != east).any(axis=2).sum()) > 20
@qt
def test_an_empty_picture_says_what_it_is_waiting_for(app):
"""What has to arrive before a mark can be placed is a fact about the
signal, not about the window: an aeroplane needs two position frames of
opposite parity, an amateur station needs to have mentioned where it is,
and the window had the first of those written into it."""
from bandsaunter.livemap import _build
plane = Sky()
assert "1090 MHz" in plane.waiting and "position frame" in plane.waiting
station = Sky(channel="144.39 MHz", fades=False,
waiting="listening on 144.39 MHz\n\nnothing placed yet")
assert "1090" not in station.waiting
# And with nowhere to centre on, that is what gets drawn.
assert station.centre() is None
view = _build()["SkyView"](station)
assert _painted(_rendered(view)) > 200
@qt
def test_the_waiting_words_follow_the_channel_when_none_are_given(app):
assert "144.39 MHz" in Sky(channel="144.39 MHz").waiting
@qt
def test_the_empty_picture_draws_the_words_it_was_given(app):
"""Holding the sentence is not the same as painting it. The complaint
that started this was about what the window said while it waited, and a
window that stores one sentence and draws another is that fault exactly
-- so the test has to read the pixels, not the attribute."""
from bandsaunter.livemap import _build
def below_the_header(sky):
# Cropped past the header, whose clock ticks: two pictures taken a
# moment apart differ up there for reasons that have nothing to do
# with the words in the middle.
return _rendered(_build()["SkyView"](sky))[60:, :, :]
plane = below_the_header(Sky())
assert not (plane != below_the_header(Sky())).any(), "not steady"
station = below_the_header(Sky(
channel="144.39 MHz", fades=False,
waiting="listening on 144.39 MHz\n\nnothing placed yet — a station "
"appears\nonce it has said where it is"))
assert int((plane != station).any(axis=2).sum()) > 200
# ---------------------------------------------------------------------------
# Keeping up with the window
# ---------------------------------------------------------------------------
class _Quiet(_NoPainter):
"""A painter that lets the ground be drawn, once there is any.
_NoPainter refuses, on purpose, so that a test of "it asked and drew
nothing" cannot pass by drawing something. These tests are about what
happens once there *is* a map.
"""
def drawImage(self, *a):
pass
def _grown(sky, view, painter, width, height):
"""Resize, paint the ground, and say what was asked for."""
view.resize(width, height)
view._draw_ground(painter, view.projection())
return sky.wanted_ground()
@qt
def test_a_window_made_bigger_fetches_a_sharper_map(app):
"""A map fetched for a smaller window is still a map of the right piece
of world, so nothing else notices it is being stretched. Opening at the
default size and then going to the whole screen used to keep the first
map until an aircraft wandered far enough to move the view out of the
fetched box -- which on a quiet band is a long time to look at a blurred
coastline.
"""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
painter = _Quiet()
asked = _grown(sky, view, painter, 900, 650)
assert asked is not None
key, box, size = asked
first = size[0]
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
# Twice as wide: the map in hand now covers each of its pixels with two.
asked = _grown(sky, view, painter, 1800, 1300)
assert asked is not None, "it kept stretching the map it had"
assert asked[2][0] > first * 1.5, asked[2]
@qt
def test_a_window_nudged_a_few_pixels_does_not_refetch(app):
"""Dragging an edge must not send somebody's evening to a tile server a
hundred times."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
painter = _Quiet()
key, box, size = _grown(sky, view, painter, 1200, 850)
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
for extra in (1, 4, 20, 60):
sky._wanted = None
view.resize(1200 + extra, 850)
view._draw_ground(painter, view.projection())
assert sky.wanted_ground() is None, f"refetched over {extra} pixels"
@qt
def test_it_stops_asking_once_the_sharper_map_has_arrived(app):
"""The check is "am I being stretched", and a window larger than the
tile budget can cover is stretched by design -- so this had better be
answered by the request rather than by the result, or a 4K window asks
for a map for the rest of the evening."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
painter = _Quiet()
key, box, size = _grown(sky, view, painter, 3840, 2160)
# Answered with a map of the size asked for, which is what the fetching
# does however far the zoom had to be capped to get it.
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
for _ in range(5):
sky._wanted = None
view._draw_ground(painter, view.projection())
assert sky.wanted_ground() is None, "it is still asking"
@qt
def test_a_window_made_smaller_does_not_refetch(app):
"""Shrinking leaves the map finer than it needs to be, which costs
nothing and looks perfect."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
painter = _Quiet()
key, box, size = _grown(sky, view, painter, 1800, 1300)
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
sky._wanted = None
view.resize(900, 650)
view._draw_ground(painter, view.projection())
assert sky.wanted_ground() is None
@qt
def test_the_stretch_is_measured_in_map_pixels_per_degree(app):
"""Not in window pixels: the fetched box is wider than the view, so the
two numbers are not the same and comparing the wrong pair would either
never refetch or always."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(1000, 700)
projection = view.projection()
box = view.ground_box(projection)
across = box[3] - box[1]
shown = projection.east - projection.west
assert across > shown, "the fetched box is not wider than the view"
# A map at exactly the density the view wants is not stretched; one at
# half that is.
want = projection.width / shown
exact = np.zeros((10, int(round(want * across))), dtype=np.uint8)
assert not SkyView._stretched(exact, box, projection)
half = np.zeros((10, max(1, exact.shape[1] // 2)), dtype=np.uint8)
assert SkyView._stretched(half, box, projection)
# The case that tells the two ways of measuring apart, and the realistic
# one: a map with exactly as many pixels as the window is wide, spread
# over a box a quarter wider than the view. Only four fifths of those
# pixels land on the screen, so it *is* being stretched -- and counting
# raw pixels against raw pixels would call it fine.
same = np.zeros((10, projection.width), dtype=np.uint8)
assert SkyView._stretched(same, box, projection), (
"the stretch is being measured in window pixels rather than in map "
"pixels per degree")
@qt
def test_nothing_is_stretched_when_there_is_nothing_to_stretch(app):
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(900, 650)
projection = view.projection()
assert not SkyView._stretched(None, (0.0, 0.0, 1.0, 1.0), projection)
assert not SkyView._stretched(np.zeros((4, 4), dtype=np.uint8), None,
projection)
# A box with no width at all cannot be divided by.
assert not SkyView._stretched(np.zeros((4, 4), dtype=np.uint8),
(1.0, 2.0, 1.0, 2.0), projection)
@qt
def test_the_window_opens_maximised(app):
"""This is a map, and the thing somebody wants more of is map. It used
to open at eleven hundred by eight hundred whatever the screen was."""
from bandsaunter.livemap import _build
window = _build()["Window"](a_sky(a_blip()), "test")
assert window.isMaximized()
assert not window.isFullScreen()
@qt
def test_un_maximising_gives_back_a_usable_window(app):
"""Set before maximising, so that there is a size to come back to: a
window maximised from nothing restores to whatever the toolkit guessed."""
from bandsaunter.livemap import _build
window = _build()["Window"](a_sky(a_blip()), "test")
restored = window.normalGeometry()
assert restored.width() >= 800 and restored.height() >= 600
@qt
def test_f_goes_to_true_full_screen_and_back_to_maximised(app):
"""Back to maximised rather than to the size it was built at: leaving
full screen should not shrink the map to a quarter of the screen."""
from bandsaunter.livemap import _build, _qt
_name, core, gui, _widgets, _signal = _qt()
window = _build()["Window"](a_sky(a_blip()), "test")
def press(letter):
window.keyPressEvent(gui.QKeyEvent(
core.QEvent.Type.KeyPress, 0,
core.Qt.KeyboardModifier.NoModifier, letter))
press("f")
assert window.isFullScreen() and not window.isMaximized()
press("f")
assert window.isMaximized() and not window.isFullScreen()
@qt
def test_the_keys_along_the_top_say_how_to_go_full_screen(app):
"""A window with no frame is one somebody has to know a key to get out
of, so the key is on the screen rather than only in the manual."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
view.resize(1400, 700)
said = []
class _Spy:
"""Swallows everything a painter is asked to do, and keeps the text."""
def __getattr__(self, name):
return lambda *a, **k: None
def drawText(self, *a):
said.append(a[-1])
view._draw_header(_Spy(), 1)
assert any("f full" in line for line in said), said
@qt
def test_opening_maximised_fetches_a_map_for_the_size_it_opened_at(app):
"""The two halves of this belong together: a window that opens big is no
use if the map it fetches is for a window that opened small."""
from bandsaunter.livemap import SkyView
sky = a_sky(a_blip())
view = SkyView(sky)
painter = _Quiet()
# The small size a window used to open at, answered.
key, box, size = _grown(sky, view, painter, 1100, 800)
small = size[0]
sky.set_ground(np.zeros((size[1], size[0]), dtype=np.uint8), key, box)
# Now the size a maximised window on an ordinary screen has.
sky._wanted = None
asked = _grown(sky, view, painter, 1920, 1080)
assert asked is not None, "it opened big and kept the small map"
assert asked[2][0] > small