Files
beefcarver ec2ca72efb fix(frame): start the corner fade at the brightest palette stop
A ladder is a hue walk, not a brightness walk, so painting the hot end
backwards put the dim colour at the corner.  Sort the ramp by stock
brightness and follow the pane height for the bottom-left glow.
2026-11-04 15:49:26 -05:00

260 lines
8.8 KiB
Python

"""The colour palettes: sixteen ladders of ANSI names, and the `c` key."""
from __future__ import annotations
import html
import re
import pytest
from beefy_player import config
from beefy_player.app import BeefyPlayerApp
from beefy_player.palettes import (
DEFAULT_PALETTE,
PALETTE_ORDER,
PALETTES,
brightness,
palette_stops,
)
from beefy_player.widgets import frame
from beefy_player.widgets.help import HelpScreen
from beefy_player.widgets.station_list import StationList
ANSI_NAMES = {
"black",
"red",
"green",
"yellow",
"blue",
"magenta",
"cyan",
"white",
"bright_black",
"bright_red",
"bright_green",
"bright_yellow",
"bright_blue",
"bright_magenta",
"bright_cyan",
"bright_white",
}
def test_there_are_sixteen_palettes_of_ansi_names() -> None:
assert len(PALETTES) == 16
assert len(PALETTE_ORDER) == 16
assert len(set(PALETTE_ORDER)) == 16
assert DEFAULT_PALETTE in PALETTES
for name, stops in PALETTES.items():
assert len(stops) >= 3, name
assert set(stops) <= ANSI_NAMES, (name, set(stops) - ANSI_NAMES)
# a ladder, not a list: the quiet end must differ from the hot end
assert stops[0] != stops[-1], name
def test_a_broken_palette_name_falls_back_to_the_default() -> None:
assert palette_stops("mono") == PALETTES["mono"]
assert palette_stops(" MONO ") == PALETTES["mono"]
assert palette_stops("no-such-palette") == PALETTES[DEFAULT_PALETTE]
assert palette_stops(None) == PALETTES[DEFAULT_PALETTE]
def test_the_config_remembers_the_palette() -> None:
assert config.Config.load().palette == DEFAULT_PALETTE
config.Config(palette="ember").save()
assert config.Config.load().palette == "ember"
def test_an_unknown_palette_in_the_config_is_ignored() -> None:
config.CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
config.CONFIG_PATH.write_text('{"palette": "chartreuse"}')
assert config.Config.load().palette == DEFAULT_PALETTE
@pytest.mark.asyncio
async def test_the_c_key_cycles_and_remembers_the_palette() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
start = app.palette
assert start == DEFAULT_PALETTE
await pilot.press("c")
await pilot.pause()
expected = PALETTE_ORDER[(PALETTE_ORDER.index(start) + 1) % len(PALETTE_ORDER)]
assert app.palette == expected
assert app.config.palette == expected
assert "palette" in app.notice
# the choice survives a restart
config.Config(palette=app.palette).save()
assert config.Config.load().palette == expected
@pytest.mark.asyncio
async def test_the_pane_corners_follow_the_palette() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
pane = app.query_one(StationList)
app.palette = "mono"
mono = [str(span.style) for span in pane.render().renderables[0].spans]
app.palette = "ember"
ember = [str(span.style) for span in pane.render().renderables[0].spans]
assert mono != ember
assert any("bright_white" in style for style in mono)
# ember's hot end is white too, but its quiet end is not
assert any(PALETTES["ember"][-2] in style for style in ember)
@pytest.mark.asyncio
async def test_the_spectrum_rows_use_the_active_palette() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(100, 30)) as pilot:
await pilot.pause()
spectrum = app.query_one("SpectrumPanel")
def colours(styles: list[str]) -> set[str]:
return {
style.replace("dim ", "").replace("bold ", "").strip()
for style in styles
}
app.palette = "mono"
spectrum.refresh()
await pilot.pause()
assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["mono"])
app.palette = "neon"
spectrum.refresh()
await pilot.pause()
assert colours(spectrum._row_palette(12, 0, False)) <= set(PALETTES["neon"])
def test_the_corner_fade_starts_at_the_brightest_stop() -> None:
stops = PALETTES["spectrum"]
ramp = frame.fade_ramp(stops)
assert frame.corner_fade(0, stops) == ramp[0]
assert frame.corner_fade(1, stops) == ramp[1]
# the fade is short: past the ramp it is just the border grey
assert frame.corner_fade(len(ramp), stops) == frame.EDGE
# spectrum's hot end is red, which is dimmer than the yellow before it;
# the corner still gets the bright colour
assert ramp[0] != stops[-1]
assert ramp[-1] == stops[-1]
def test_every_palette_fades_from_bright_to_dim() -> None:
"""No palette may put a brighter cell further from the corner."""
for name, stops in PALETTES.items():
ramp = frame.fade_ramp(stops)
assert ramp, name
ranks = [brightness(colour) for colour in ramp]
assert ranks == sorted(ranks, reverse=True), (name, ramp)
assert set(ramp) <= set(stops), name
@pytest.mark.asyncio
async def test_the_help_scrolls_when_the_terminal_is_short() -> None:
"""The help page is ~56 rows: it must be readable on a 24-row screen."""
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(80, 24)) as pilot:
await pilot.pause()
await pilot.press("question_mark")
await pilot.pause()
body = app.screen.query_one("#help")
assert body.size.height < body.virtual_size.height
assert body.max_scroll_y > 0
assert body.scroll_y == 0
await pilot.press("pagedown")
await pilot.pause()
assert body.scroll_y > 0
await pilot.press("end")
await pilot.pause()
assert body.scroll_y == body.max_scroll_y
await pilot.press("home")
await pilot.pause()
assert body.scroll_y == 0
await pilot.press("x")
await pilot.pause()
assert not isinstance(app.screen, HelpScreen)
@pytest.mark.asyncio
async def test_a_tall_terminal_needs_no_scrolling() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(120, 70)) as pilot:
await pilot.pause()
await pilot.press("question_mark")
await pilot.pause()
body = app.screen.query_one("#help")
assert body.max_scroll_y == 0
@pytest.mark.asyncio
async def test_the_help_lists_every_palette_and_marks_the_active_one() -> None:
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(120, 64)) as pilot:
await pilot.pause()
app.palette = "ocean"
await pilot.press("question_mark")
await pilot.pause()
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
text = re.sub(r"<[^>]+>", "", text)
assert "PALETTES" in text
assert "c cycles them" in text
for name in PALETTE_ORDER:
assert name in text, name
assert "● ocean" in text
# each palette shows a swatch of eight blocks
assert "████████" in text
@pytest.mark.asyncio
async def test_the_help_footer_attributes_the_build() -> None:
"""One line: the version and the project URL — no credits essay."""
from beefy_player import __version__
app = BeefyPlayerApp(autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 70)) as pilot:
await pilot.pause()
await pilot.press("question_mark")
await pilot.pause()
body = app.screen.query_one("#help")
body.scroll_end(animate=False)
await pilot.pause()
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
text = re.sub(r"<[^>]+>", "", text)
assert f"beefy player {__version__}" in text
assert "https://tildeforge.dev/beefcarver/beefy-player" in text
# the old explanatory blurb is gone
assert "playerctl" not in text
assert "parec" not in text
assert "Stations and song history come from" not in text
@pytest.mark.parametrize("slug", ["bassdrive", "somafm"])
@pytest.mark.asyncio
async def test_the_help_has_no_pane_explainer(slug: str) -> None:
"""No catalogue gets a paragraph about its left pane."""
app = BeefyPlayerApp(provider=slug, autoplay=False, offline=True, audio=False)
async with app.run_test(size=(110, 60)) as pilot:
await pilot.pause()
await pilot.press("question_mark")
await pilot.pause()
text = html.unescape(app.export_screenshot()).replace("\xa0", " ")
text = re.sub(r"<[^>]+>", "", text)
assert "is a single stream" not in text
assert "multi-station catalogues" not in text
assert "CATALOGUES" in text