The map brightness setting could not make the map visible on a vector theme, which is the one place it was needed. Those themes want the ground well out of the way -- a tinted photograph of a county behind the vectors is the one thing that stops a vector display looking like one -- and that was done by multiplying the setting by about a quarter. A multiplier is a ceiling: turned the whole way up, the setting still gave a map at a tenth the brightness the default theme gives, which is to say invisible, and no amount of turning it up did anything about that. It is a curve now rather than a ceiling. The theme raises the setting to a power, so the middle of the range is still quiet -- seventy per cent lands where the old quarter did, which is the look these themes are for -- and the top of the range is a full-brightness map on every theme there is. On the green phosphor the setting now spans a luminance of six to seventy where it used to stop at twenty-one. And the options are in six groups rather than one list: receiver, listening, aircraft, animation, the map, labels. Thirty-three of them on one screen is a wall rather than a menu. A number opens a group and a number inside it changes an option, with the numbers still being each option's place in the whole list so that the same number means the same option wherever it is typed -- which meant reordering the list so that every group is contiguous, and there is a test that says so. A group menu makes a known option harder to reach than a flat list did, so the name works too: typing "map brightness" at the top goes straight to it, and part of a name lists everything it could mean. A name that matches exactly wins outright, so "speed" reaches the setting called speed rather than that one and every other whose description happens to mention the word. One thing to know: a bare number at the top of the menu now opens a group where it used to edit the option of that number. The tests that drove the menu that way would have gone on silently editing whatever option shared the number, so they ask by name now, and one of them checks that a group number changes nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016PsWPTweCT6pwxKngvVxcg
336 lines
14 KiB
Python
336 lines
14 KiB
Python
"""The colour themes, and the glow that goes with the vector ones.
|
|
|
|
A theme changes both drawings at once, because both read their colours out
|
|
of the same palette: the animation looks indices up in it directly and the
|
|
window asks it for a QColor. So the tests here are mostly about that
|
|
palette -- that it is rewritten rather than replaced, that every theme keeps
|
|
the aerodromes distinguishable from the aircraft, and that the halo goes
|
|
where light would go and nowhere else.
|
|
"""
|
|
import numpy as np
|
|
import pytest
|
|
|
|
from bandsaunter import flightmap as fm
|
|
from bandsaunter import themes
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def back_to_night():
|
|
"""Every test leaves the program drawing the way it found it."""
|
|
yield
|
|
fm.set_theme(themes.DEFAULT_THEME)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Choosing one
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_every_theme_says_what_it_is():
|
|
for name, theme in themes.THEMES.items():
|
|
assert theme.name == name
|
|
assert theme.summary and not theme.summary.endswith(".")
|
|
assert len(theme.stops) >= 2
|
|
assert theme.stops[0][0] == 0.0
|
|
|
|
|
|
def test_a_theme_can_be_asked_for_by_name_or_by_what_it_looks_like():
|
|
assert themes.theme_named("phosphor").name == "phosphor"
|
|
assert themes.theme_named("green").name == "phosphor"
|
|
assert themes.theme_named("wargames").name == "digital"
|
|
assert themes.theme_named("BLUE").name == "digital"
|
|
assert themes.theme_named(" amber ").name == "amber"
|
|
|
|
|
|
def test_a_name_nobody_recognises_is_the_default_rather_than_a_refusal():
|
|
"""A theme is how the picture looks. Refusing to draw an evening's
|
|
flying because of a misspelt colour would be the wrong trade."""
|
|
assert themes.theme_named("puce").name == themes.DEFAULT_THEME
|
|
assert themes.theme_named("").name == themes.DEFAULT_THEME
|
|
assert themes.theme_named(None).name == themes.DEFAULT_THEME
|
|
|
|
|
|
def test_no_two_themes_share_a_name_or_an_alias():
|
|
seen = set()
|
|
for theme in themes.THEMES.values():
|
|
for word in (theme.name,) + tuple(theme.aliases):
|
|
assert word not in seen, word
|
|
seen.add(word)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# What setting one does to the palette
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_the_palette_is_written_over_rather_than_replaced():
|
|
"""Both drawings and every one of their helpers hold a reference to
|
|
this array. A new one would leave half the program painting in the
|
|
colours of the theme before."""
|
|
before = fm.PALETTE
|
|
fm.set_theme("phosphor")
|
|
assert fm.PALETTE is before
|
|
assert tuple(fm.PALETTE[fm.BG]) != (14, 16, 22)
|
|
|
|
|
|
def test_setting_a_theme_says_which_one_it_settled_on():
|
|
assert fm.set_theme("norad").name == "digital"
|
|
assert fm.set_theme("puce").name == themes.DEFAULT_THEME
|
|
|
|
|
|
def test_the_default_theme_draws_exactly_what_it_always_drew():
|
|
"""The colours this program has always used, unchanged: an existing
|
|
recording redrawn today has to come out the same picture."""
|
|
fm.set_theme("phosphor")
|
|
fm.set_theme("night")
|
|
assert tuple(fm.PALETTE[fm.BG]) == (14, 16, 22)
|
|
assert tuple(fm.PALETTE[fm.INK]) == (196, 204, 218)
|
|
assert tuple(fm.PALETTE[fm.RAMP]) == (252, 96, 72)
|
|
assert tuple(fm.PALETTE[fm.RAMP + fm.RAMP_STEPS - 1]) == (158, 142, 255)
|
|
|
|
|
|
def _lab(rgb):
|
|
c = np.asarray(rgb, float) / 255.0
|
|
c = np.where(c > 0.04045, ((c + 0.055) / 1.055) ** 2.4, c / 12.92)
|
|
m = np.array([[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722],
|
|
[0.0193, 0.1192, 0.9505]])
|
|
xyz = (c @ m.T) / np.array([0.9505, 1.0, 1.089])
|
|
f = np.where(xyz > 0.008856, np.cbrt(xyz), 7.787 * xyz + 16 / 116)
|
|
return np.array([116 * f[1] - 16, 500 * (f[0] - f[1]), 200 * (f[1] - f[2])])
|
|
|
|
|
|
def test_no_theme_lets_an_aerodrome_be_mistaken_for_an_aircraft():
|
|
"""The whole reason the aerodromes stopped being amber. Stated as the
|
|
distance rather than as the colour, so that a new theme cannot quietly
|
|
walk an aircraft back into the airports."""
|
|
for name in themes.THEMES:
|
|
fm.set_theme(name)
|
|
airport = _lab(fm.PALETTE[fm.AIRPORT])
|
|
apart = min(float(np.linalg.norm(airport - _lab(fm.PALETTE[fm.RAMP + i])))
|
|
for i in range(fm.RAMP_STEPS))
|
|
assert apart > 40, f"{name}: only {apart:.0f} units apart"
|
|
|
|
|
|
def test_a_phosphor_theme_reads_height_as_brightness():
|
|
"""One colour to spend, so it cannot be spent on hue. Low is dim and
|
|
high burns, which is the constraint those screens actually had."""
|
|
for name in ("digital", "phosphor", "amber", "red"):
|
|
theme = fm.set_theme(name)
|
|
assert theme.height_is_brightness
|
|
weights = [int(fm.PALETTE[fm.RAMP + i].astype(int).sum())
|
|
for i in range(fm.RAMP_STEPS)]
|
|
assert weights == sorted(weights), f"{name} is not monotonic"
|
|
assert weights[-1] > weights[0] * 3
|
|
|
|
|
|
def test_the_default_theme_reads_height_as_hue_instead():
|
|
fm.set_theme("night")
|
|
assert not fm.THEME.height_is_brightness
|
|
|
|
|
|
def test_every_theme_fills_the_palette_without_running_off_the_end():
|
|
for name in themes.THEMES:
|
|
fm.set_theme(name)
|
|
assert fm.PALETTE.shape == (256, 3)
|
|
assert fm.GLOW + 6 <= 256
|
|
# Nothing drawn with is left as the black the unused tail is.
|
|
for index in (fm.INK, fm.AIRPORT, fm.RAMP, fm.GROUND + 31):
|
|
assert fm.PALETTE[index].any(), (name, index)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The glow
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def _picture(width=80, height=60):
|
|
return np.full((height, width), fm.BG, dtype=np.uint8)
|
|
|
|
|
|
def test_the_default_theme_has_no_glow_at_all():
|
|
fm.set_theme("night")
|
|
img = _picture()
|
|
img[30, 10:70] = fm.RAMP + 20
|
|
assert np.array_equal(fm.bloom(img), img)
|
|
|
|
|
|
def test_a_line_on_a_vector_theme_gets_a_halo_either_side_of_it():
|
|
fm.set_theme("phosphor")
|
|
img = _picture()
|
|
img[30, 10:70] = fm.RAMP + 20
|
|
out = fm.bloom(img)
|
|
assert (out[30, 10:70] == fm.RAMP + 20).all(), "the core was painted over"
|
|
assert (out[29, 10:70] == fm.TRAIL + 20).all(), "no halo above the line"
|
|
assert (out[31, 10:70] == fm.TRAIL + 20).all(), "no halo below it"
|
|
assert (out[28, 10:70] == fm.OLD + 20).all(), "no second, fainter ring"
|
|
assert (out[27, 10:70] == fm.BG).all(), "the halo reaches too far"
|
|
|
|
|
|
def test_the_halo_is_the_colour_of_the_thing_that_cast_it():
|
|
"""A green aeroplane glows green and a red one red: the halo is the
|
|
aircraft's own dimmed colour, which is what a phosphor would spread."""
|
|
fm.set_theme("digital")
|
|
for step in (0, 12, 31):
|
|
img = _picture()
|
|
img[30, 10:70] = fm.RAMP + step
|
|
out = fm.bloom(img)
|
|
assert (out[29, 10:70] == fm.TRAIL + step).all()
|
|
|
|
|
|
def test_a_halo_never_paints_over_something_else_that_was_drawn():
|
|
"""A halo is what light does to the dark around a line. Painting it
|
|
over another line would be light doing something light does not do."""
|
|
fm.set_theme("phosphor")
|
|
img = _picture()
|
|
img[30, 10:70] = fm.RAMP + 20 # an aeroplane
|
|
img[29, 10:70] = fm.AIRPORT # an aerodrome right beside it
|
|
out = fm.bloom(img)
|
|
assert (out[29, 10:70] == fm.AIRPORT).all()
|
|
|
|
|
|
def test_the_halo_does_not_wrap_round_the_edge_of_the_picture():
|
|
"""Rolled and then cut: without the cut, a line down the left edge
|
|
would glow on the right edge of the picture."""
|
|
fm.set_theme("amber")
|
|
img = _picture()
|
|
img[:, 0] = fm.RAMP + 20
|
|
out = fm.bloom(img)
|
|
assert (out[:, 1] == fm.TRAIL + 20).all()
|
|
assert (out[:, -1] == fm.BG).all()
|
|
assert (out[:, -2] == fm.BG).all()
|
|
|
|
tall = _picture()
|
|
tall[0, :] = fm.AIRPORT
|
|
assert (fm.bloom(tall)[-1, :] == fm.BG).all()
|
|
|
|
|
|
def test_the_halo_goes_over_the_map_and_the_grid_but_not_the_aircraft():
|
|
fm.set_theme("digital")
|
|
img = np.full((60, 80), fm.GROUND + 10, dtype=np.uint8)
|
|
img[30, 40] = fm.INK
|
|
out = fm.bloom(img)
|
|
assert out[30, 41] != fm.GROUND + 10, "no halo over the map"
|
|
assert out[30, 40] == fm.INK
|
|
|
|
|
|
def test_the_nearer_ring_wins_where_two_meet():
|
|
"""Which is what happens on the tube as well."""
|
|
fm.set_theme("phosphor")
|
|
img = _picture()
|
|
img[30, 40] = fm.RAMP + 20
|
|
out = fm.bloom(img)
|
|
assert out[31, 40] == fm.TRAIL + 20 # near
|
|
assert out[32, 40] == fm.OLD + 20 # far
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# What else a vector theme changes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_a_phosphor_theme_names_the_country_instead_of_drawing_its_flag():
|
|
"""A flag is half a dozen colours and a phosphor screen has one. Two
|
|
letters are what a display of the period would have done anyway."""
|
|
from bandsaunter.flags import FLAG_H, FLAG_W
|
|
|
|
fm.set_theme("night")
|
|
flagged = np.full((40, 60), fm.BG, dtype=np.uint8)
|
|
fm.draw_flag(flagged, 5, 5, "US")
|
|
patch = flagged[5:5 + FLAG_H, 5:5 + FLAG_W]
|
|
assert ((patch >= fm.FLAG) & (patch < fm.FLAG + 12)).any()
|
|
|
|
fm.set_theme("phosphor")
|
|
lettered = np.full((40, 60), fm.BG, dtype=np.uint8)
|
|
fm.draw_flag(lettered, 5, 5, "US")
|
|
patch = lettered[5:5 + FLAG_H, 5:5 + FLAG_W]
|
|
assert not ((patch >= fm.FLAG) & (patch < fm.FLAG + 12)).any()
|
|
assert (lettered == fm.DIM).any(), "the letters were not drawn either"
|
|
|
|
|
|
def test_a_vector_theme_pushes_the_map_underneath_well_back():
|
|
"""In the middle of the range, where the setting usually sits: a tinted
|
|
photograph of a county behind the vectors is the one thing that stops a
|
|
vector display looking like one."""
|
|
levels = np.full((8, 8), fm.GROUND_SHADES - 1, dtype=np.uint8)
|
|
fm.set_theme("night")
|
|
plain = int(fm.dim_ground(levels, 0.7).max())
|
|
fm.set_theme("digital")
|
|
quiet = int(fm.dim_ground(levels, 0.7).max())
|
|
assert quiet < plain * 0.6, f"{quiet} is not much darker than {plain}"
|
|
|
|
|
|
def test_turning_the_brightness_the_whole_way_up_works_on_every_theme():
|
|
"""A curve, not a ceiling. Multiplied by a quarter instead, the setting
|
|
could not reach a visible map at all on a vector theme: turned the whole
|
|
way up it still came out at a tenth of what the default theme gives,
|
|
which is to say invisible."""
|
|
levels = np.full((8, 8), fm.GROUND_SHADES - 1, dtype=np.uint8)
|
|
for name in themes.THEMES:
|
|
fm.set_theme(name)
|
|
assert int(fm.dim_ground(levels, 1.0).max()) == fm.GROUND_SHADES - 1, \
|
|
f"{name} cannot reach a full-brightness map"
|
|
|
|
|
|
def test_the_brightness_setting_climbs_the_whole_way_on_a_vector_theme():
|
|
levels = np.full((8, 8), fm.GROUND_SHADES - 1, dtype=np.uint8)
|
|
fm.set_theme("phosphor")
|
|
steps = [int(fm.dim_ground(levels, b).max())
|
|
for b in (0.1, 0.3, 0.5, 0.7, 0.85, 1.0)]
|
|
assert steps == sorted(steps), steps
|
|
assert steps[-1] > steps[0] * 8, steps
|
|
# And the middle of the range is still quiet, which is the look.
|
|
assert steps[3] < steps[-1] * 0.4, steps
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The leader line, and the flag on the receiver
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_no_theme_lets_a_leader_line_be_mistaken_for_a_flight_path():
|
|
"""The whole reason it stopped being drawn in the aircraft's colour: a
|
|
straight line running out of an aeroplane, in the colour of the path
|
|
behind that aeroplane, reads as more path."""
|
|
for name in themes.THEMES:
|
|
fm.set_theme(name)
|
|
leader = _lab(fm.PALETTE[fm.LEADER])
|
|
apart = min(float(np.linalg.norm(leader - _lab(fm.PALETTE[fm.RAMP + i])))
|
|
for i in range(fm.RAMP_STEPS))
|
|
assert apart > 25, f"{name}: only {apart:.0f} units from a trail"
|
|
|
|
|
|
def test_the_receiver_flag_is_red_whatever_the_theme_is():
|
|
""""You are here" is the one mark on the picture whose meaning must not
|
|
change with the colours, so it does not take the theme's."""
|
|
for name in themes.THEMES:
|
|
fm.set_theme(name)
|
|
assert tuple(fm.PALETTE[fm.HOME]) == fm.HOME_RED
|
|
|
|
|
|
def test_the_flag_stands_clear_of_the_aircraft_on_every_theme():
|
|
"""Including the red one, which is the hard case and the reason the
|
|
flag is pure red rather than a softer one: a softened red sat close
|
|
enough to a low aeroplane on the default map, and to a mid-altitude one
|
|
on the red theme, to be taken for one."""
|
|
for name in themes.THEMES:
|
|
fm.set_theme(name)
|
|
red = _lab(fm.PALETTE[fm.HOME])
|
|
apart = min(float(np.linalg.norm(red - _lab(fm.PALETTE[fm.RAMP + i])))
|
|
for i in range(fm.RAMP_STEPS))
|
|
assert apart > 20, f"{name}: only {apart:.0f} units from an aircraft"
|
|
|
|
|
|
def test_the_flag_stands_on_the_spot_rather_than_covering_it():
|
|
"""The foot of the pole is the position. A blob would put the position
|
|
somewhere inside itself."""
|
|
img = np.full((40, 40), fm.BG, dtype=np.uint8)
|
|
fm.draw_home(img, 20, 34)
|
|
assert img[34, 20] == fm.HOME, "the pole does not stand on the spot"
|
|
assert img[35, 20] == fm.BG, "something is drawn below the position"
|
|
assert (img[34, :20] == fm.BG).all(), "the flag reaches left of the pole"
|
|
# The pennant flies up and to the right, and nothing else does.
|
|
top = 34 - fm.HOME_POLE
|
|
assert (img[top, 21:21 + fm.HOME_FLY] == fm.HOME).all()
|
|
assert (img[34 - 1, 21:] == fm.BG).all(), "the pennant hangs to the foot"
|
|
|
|
|
|
def test_the_flag_off_the_edge_of_the_picture_paints_nothing_absurd():
|
|
for x, y in ((-50, 20), (20, -50), (200, 20), (20, 200)):
|
|
img = np.full((40, 40), fm.BG, dtype=np.uint8)
|
|
fm.draw_home(img, x, y) # must not raise or wrap
|
|
assert img.shape == (40, 40)
|