135 lines
5.6 KiB
Python
135 lines
5.6 KiB
Python
from __future__ import annotations
|
|
|
|
from io import BytesIO
|
|
|
|
import httpx
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from beefy_player import artwork
|
|
|
|
|
|
def png_bytes(size=(400, 200), alpha: int = 180) -> bytes:
|
|
buffer = BytesIO()
|
|
Image.new("RGBA", size, (20, 180, 220, alpha)).save(buffer, format="PNG")
|
|
return buffer.getvalue()
|
|
|
|
|
|
def ink_bbox(image: Image.Image):
|
|
"""Where the artwork itself is, ignoring transparent or black padding."""
|
|
return image.convert("RGB").getbbox()
|
|
|
|
|
|
def opaque_png_bytes(size=(400, 200)) -> bytes:
|
|
buffer = BytesIO()
|
|
Image.new("RGB", size, (20, 180, 220)).save(buffer, format="PNG")
|
|
return buffer.getvalue()
|
|
|
|
|
|
def test_protocol_detection() -> None:
|
|
assert artwork.detect_protocol({"KITTY_WINDOW_ID": "1"}) == "tgp"
|
|
assert artwork.detect_protocol({"TERM": "xterm-kitty"}) == "tgp"
|
|
assert artwork.detect_protocol({"TERM_PROGRAM": "WezTerm"}) == "iterm2"
|
|
assert artwork.detect_protocol({"WEZTERM_EXECUTABLE": "/bin/wezterm"}) == "iterm2"
|
|
assert artwork.detect_protocol({"TERM_PROGRAM": "iTerm.app"}) == "iterm2"
|
|
assert artwork.detect_protocol({"ITERM_SESSION_ID": "x"}) == "iterm2"
|
|
assert artwork.detect_protocol({"TERM_PROGRAM": "Apple_Terminal"}) is None
|
|
assert artwork.detect_protocol({"TMUX": "/tmp/tmux", "KITTY_WINDOW_ID": "1"}) == "tgp"
|
|
assert artwork.detect_protocol({"TMUX": "/tmp/tmux", "TERM_PROGRAM": "WezTerm"}) is None
|
|
assert artwork.detect_protocol({"ZELLIJ": "1", "TERM_PROGRAM": "WezTerm"}) is None
|
|
assert artwork.detect_protocol({"KITTY_PID": "1"}) == "tgp"
|
|
assert artwork.detect_protocol({"NO_COLOR": "", "KITTY_WINDOW_ID": "1"}) is None
|
|
assert artwork.detect_protocol({"BEEFY_IMAGES": "0", "KITTY_WINDOW_ID": "1"}) is None
|
|
assert artwork.detect_protocol({"BEEFY_IMAGES": "tgp"}) == "tgp"
|
|
assert artwork.detect_protocol({"BEEFY_IMAGES": "iterm2"}) == "iterm2"
|
|
# the pre-rename variable still works
|
|
assert artwork.detect_protocol({"SOMAFM_IMAGES": "0"}) is None
|
|
|
|
|
|
def test_fit_image_letterboxes_inside_the_cell_box() -> None:
|
|
fitted = artwork.fit_image(png_bytes((400, 200)), 13, 8)
|
|
assert fitted is not None
|
|
cell_width, cell_height = artwork.cell_size()
|
|
assert (fitted.width, fitted.height) == (13 * cell_width, 8 * cell_height)
|
|
|
|
# the ink keeps its own 2:1 aspect instead of being cropped to the box
|
|
ink = ink_bbox(fitted)
|
|
assert ink is not None
|
|
left, top, right, bottom = ink
|
|
assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02)
|
|
# a wide image is padded above and below, and spans the full width
|
|
assert left == 0 and right == fitted.width
|
|
assert top > 0 and bottom < fitted.height
|
|
|
|
|
|
def test_an_image_with_alpha_gets_a_black_backing() -> None:
|
|
"""Transparent artwork is drawn over the mosaic; black keeps it readable."""
|
|
fitted = artwork.fit_image(png_bytes((400, 200)), 13, 8)
|
|
assert fitted is not None
|
|
# every pixel is opaque now, and the padding is black rather than mosaic
|
|
assert fitted.getchannel("A").getextrema() == (255, 255)
|
|
assert fitted.getpixel((0, 0)) == (0, 0, 0, 255)
|
|
# the ink is still letterboxed, not cropped: its own 2:1 aspect survives
|
|
ink = ink_bbox(fitted)
|
|
assert ink is not None
|
|
left, top, right, bottom = ink
|
|
assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02)
|
|
|
|
|
|
def test_an_opaque_image_keeps_the_transparent_padding() -> None:
|
|
"""No alpha channel: nothing to hide, so the mosaic shows through."""
|
|
fitted = artwork.fit_image(opaque_png_bytes((400, 200)), 13, 8)
|
|
assert fitted is not None
|
|
assert fitted.getpixel((0, 0))[3] == 0
|
|
ink = ink_bbox(fitted)
|
|
assert ink is not None
|
|
left, top, right, bottom = ink
|
|
assert (right - left) / (bottom - top) == pytest.approx(2.0, rel=0.02)
|
|
|
|
|
|
def test_fit_image_centres_a_tall_image() -> None:
|
|
fitted = artwork.fit_image(png_bytes((100, 400)), 13, 8)
|
|
assert fitted is not None
|
|
ink = ink_bbox(fitted)
|
|
assert ink is not None
|
|
left, _, right, _ = ink
|
|
assert left > 0 and right < fitted.width
|
|
assert right - left == pytest.approx(fitted.height / 4, rel=0.05)
|
|
|
|
|
|
def test_fit_image_rejects_garbage() -> None:
|
|
assert artwork.fit_image(b"not an image", 13, 8) is None
|
|
|
|
|
|
def test_image_cache_roundtrip() -> None:
|
|
data = png_bytes()
|
|
url = "https://example.invalid/art.png"
|
|
artwork.save_cached(url, data)
|
|
assert artwork.load_cached(url) == data
|
|
assert artwork.load_cached("https://example.invalid/other.png") is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_fetch_validates_and_caches() -> None:
|
|
data = png_bytes((120, 120))
|
|
url = "https://example.invalid/logo.png"
|
|
|
|
def handler(request: httpx.Request) -> httpx.Response:
|
|
return httpx.Response(200, content=data, headers={"content-type": "image/png"})
|
|
|
|
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as client:
|
|
fetched = await artwork.fetch(client, url)
|
|
assert fetched == data
|
|
assert artwork.load_cached(url) == data
|
|
|
|
|
|
def test_multiplexer_env_values_that_mean_no() -> None:
|
|
# Some wrappers export ZELLIJ=0 / TMUX=0 outside a multiplexer; that must
|
|
# not disable iTerm2 or Kitty graphics.
|
|
assert artwork.detect_protocol({"ZELLIJ": "0", "TERM_PROGRAM": "iTerm.app"}) == "iterm2"
|
|
assert artwork.detect_protocol({"ZELLIJ": "", "TERM_PROGRAM": "iTerm.app"}) == "iterm2"
|
|
assert artwork.detect_protocol({"TMUX": "0", "KITTY_WINDOW_ID": "1"}) == "tgp"
|
|
# ...while a real multiplexer still downgrades to the mosaic.
|
|
assert artwork.detect_protocol({"TMUX": "/tmp/tmux-501/default,1,0", "TERM_PROGRAM": "iTerm.app"}) is None
|
|
assert artwork.detect_protocol({"STY": "1234.pts-0.host", "TERM_PROGRAM": "iTerm.app"}) is None
|