- simple-icons als kompakter Index im Repository (3.459 Marken, 2 MB gzip),
erzeugt von scripts/vendor_simple_icons.py bzw. `make vendor-icons`
- logo.dev und Brandfetch als optionale Adapter, ohne Schlüssel übersprungen
- Favicon-Fallback und generierter Buchstaben-Avatar als Garantie
- Bei eindeutigem Offline-Treffer unterbleiben Anfragen nach außen komplett
- Cache im Dateisystem nach SHA-256, Auslieferung nur über /api/logos/{id}
mit immutable-Header und ETag
- Markenfarbe aus SVG-Fills bzw. per k-Means (k=4) über 64x64 Pixel, dazu eine
aufgehellte Variante mit mindestens 4,5:1 Kontrast auf dunklem Grund
- Kandidatensuche mit Vorauswahl, Auswahl, Upload und Zurücksetzen
- Bildtyp wird nur noch am Inhalt bestimmt, nicht an der gemeldeten Kopfzeile
- 60 neue Tests, insgesamt 208 grün
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
159 lines
4.8 KiB
Python
159 lines
4.8 KiB
Python
"""Tests der Markenfarben-Ermittlung."""
|
|
|
|
import io
|
|
|
|
import pytest
|
|
from PIL import Image
|
|
|
|
from app.services.colors import (
|
|
DARK_BACKGROUND,
|
|
MIN_CONTRAST,
|
|
brand_colors,
|
|
color_from_raster,
|
|
color_from_svg,
|
|
contrast_ratio,
|
|
deterministic_color,
|
|
hex_to_rgb,
|
|
is_grayscale,
|
|
lighten_for_dark_background,
|
|
normalize_hex,
|
|
)
|
|
|
|
|
|
def png(pixels: list[tuple[int, int, int, int]], size: tuple[int, int] = (8, 8)) -> bytes:
|
|
"""Baut ein kleines PNG aus einer Pixelliste."""
|
|
image = Image.new("RGBA", size)
|
|
image.putdata([pixels[index % len(pixels)] for index in range(size[0] * size[1])])
|
|
buffer = io.BytesIO()
|
|
image.save(buffer, format="PNG")
|
|
return buffer.getvalue()
|
|
|
|
|
|
# --- Umrechnungen --------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("eingabe", "erwartet"),
|
|
[
|
|
("#E50914", "#e50914"),
|
|
("E50914", "#e50914"),
|
|
("#f00", "#ff0000"),
|
|
("#E50914FF", "#e50914"),
|
|
("kein-hex", None),
|
|
("#12345", None),
|
|
],
|
|
)
|
|
def test_hexwerte_werden_normalisiert(eingabe: str, erwartet: str | None) -> None:
|
|
assert normalize_hex(eingabe) == erwartet
|
|
|
|
|
|
def test_kontrastverhaeltnis_nach_wcag() -> None:
|
|
# Schwarz auf Weiß ist das Maximum von 21:1.
|
|
assert round(contrast_ratio("#000000", "#ffffff"), 1) == 21.0
|
|
assert round(contrast_ratio("#ffffff", "#ffffff"), 1) == 1.0
|
|
|
|
|
|
def test_graustufen_werden_erkannt() -> None:
|
|
assert is_grayscale(hex_to_rgb("#808080")) is True
|
|
assert is_grayscale(hex_to_rgb("#ffffff")) is True
|
|
assert is_grayscale(hex_to_rgb("#e50914")) is False
|
|
|
|
|
|
# --- Aufhellung ----------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"farbe",
|
|
["#e50914", "#1d4ed8", "#166534", "#7c2d12", "#0058a3", "#e20074", "#312e81"],
|
|
)
|
|
def test_aufgehellte_farbe_erreicht_den_mindestkontrast(farbe: str) -> None:
|
|
aufgehellt = lighten_for_dark_background(farbe)
|
|
|
|
assert contrast_ratio(aufgehellt, DARK_BACKGROUND) >= MIN_CONTRAST
|
|
|
|
|
|
def test_bereits_helle_farbe_bleibt_unveraendert() -> None:
|
|
# Ein helles Grün erfüllt den Kontrast bereits.
|
|
assert lighten_for_dark_background("#1ed760") == "#1ed760"
|
|
|
|
|
|
def test_farbpaar_faellt_auf_den_namenshash_zurueck() -> None:
|
|
ohne = brand_colors(None, fallback_name="Knuspertronic")
|
|
nochmal = brand_colors(None, fallback_name="Knuspertronic")
|
|
|
|
assert ohne.color == nochmal.color # deterministisch
|
|
assert ohne.color == deterministic_color("Knuspertronic")
|
|
assert contrast_ratio(ohne.color_dark, DARK_BACKGROUND) >= MIN_CONTRAST
|
|
|
|
|
|
def test_namensfarbe_ignoriert_gross_schreibung_und_leerzeichen() -> None:
|
|
assert deterministic_color(" Netflix ") == deterministic_color("netflix")
|
|
|
|
|
|
# --- SVG -----------------------------------------------------------------------
|
|
|
|
|
|
def test_svg_haeufigste_nicht_graustufe_gewinnt() -> None:
|
|
svg = (
|
|
b'<svg><path fill="#ffffff" d="a"/><path fill="#e50914" d="b"/>'
|
|
b'<path fill="#e50914" d="c"/><path fill="#111111" d="d"/></svg>'
|
|
)
|
|
|
|
assert color_from_svg(svg) == "#e50914"
|
|
|
|
|
|
def test_svg_versteht_style_und_rgb_schreibweise() -> None:
|
|
svg = b'<svg><path style="fill:rgb(29, 185, 84)" d="a"/></svg>'
|
|
|
|
assert color_from_svg(svg) == "#1db954"
|
|
|
|
|
|
def test_svg_beruecksichtigt_verlaufsstopps() -> None:
|
|
svg = b'<svg><linearGradient><stop stop-color="#833ab4"/></linearGradient></svg>'
|
|
|
|
assert color_from_svg(svg) == "#833ab4"
|
|
|
|
|
|
def test_svg_ohne_farbe_liefert_nichts() -> None:
|
|
assert color_from_svg(b'<svg><path d="M0 0"/></svg>') is None
|
|
assert color_from_svg(b'<svg><path fill="#333333" d="M0 0"/></svg>') is None
|
|
|
|
|
|
# --- Rastergrafiken ------------------------------------------------------------
|
|
|
|
|
|
def test_raster_findet_die_dominante_farbe() -> None:
|
|
# Überwiegend Rot mit weißem Rand.
|
|
bild = png([(229, 9, 20, 255)] * 12 + [(255, 255, 255, 255)] * 4)
|
|
|
|
ergebnis = color_from_raster(bild)
|
|
|
|
assert ergebnis is not None
|
|
rot, gruen, blau = hex_to_rgb(ergebnis)
|
|
assert rot > 200 and gruen < 60 and blau < 60
|
|
|
|
|
|
def test_raster_ignoriert_durchsichtige_pixel() -> None:
|
|
"""Ein grüner Bildinhalt auf vollständig durchsichtigem Blau bleibt grün."""
|
|
bild = png([(30, 215, 96, 255)] * 8 + [(0, 0, 255, 0)] * 8)
|
|
|
|
ergebnis = color_from_raster(bild)
|
|
|
|
assert ergebnis is not None
|
|
_, gruen, blau = hex_to_rgb(ergebnis)
|
|
assert gruen > 150 and blau < 150
|
|
|
|
|
|
def test_reines_graustufenbild_liefert_keine_markenfarbe() -> None:
|
|
bild = png([(20, 20, 20, 255)] * 8 + [(240, 240, 240, 255)] * 8)
|
|
|
|
assert color_from_raster(bild) is None
|
|
|
|
|
|
def test_vollstaendig_durchsichtiges_bild_liefert_nichts() -> None:
|
|
assert color_from_raster(png([(255, 0, 0, 0)])) is None
|
|
|
|
|
|
def test_kaputte_datei_wirft_nicht() -> None:
|
|
assert color_from_raster(b"kein bild") is None
|