"""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