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
2667 lines
98 KiB
Python
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
|