# Copyright Kevin Deldycke <kevin@deldycke.com> and contributors.
#
# This program is Free Software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation; either version 2
# of the License, or (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA.
"""Tests for click_extra.styling.Style extras."""
from __future__ import annotations
import cloup
import pytest
from click_extra import Style
from click_extra.styling import _nearest_256, supports_truecolor
# --- 1. Hex string color shorthand ------------------------------------------
[docs]
@pytest.mark.parametrize(
("hex_str", "expected_rgb"),
[
("#ff0000", (0xFF, 0x00, 0x00)),
("#f1fa8c", (0xF1, 0xFA, 0x8C)),
("#FFFFFF", (0xFF, 0xFF, 0xFF)), # uppercase
("#000", (0x00, 0x00, 0x00)), # 3-digit shorthand expands
("#abc", (0xAA, 0xBB, 0xCC)),
],
)
def test_hex_fg_converts_to_rgb_tuple(hex_str, expected_rgb):
s = Style(fg=hex_str)
assert s.fg == expected_rgb
[docs]
def test_hex_bg_converts_to_rgb_tuple():
s = Style(bg="#282a36")
assert s.bg == (0x28, 0x2A, 0x36)
[docs]
def test_hex_invalid_raises():
with pytest.raises(ValueError, match="Not a valid hex color"):
Style(fg="#xyz")
with pytest.raises(ValueError, match="Not a valid hex color"):
Style(fg="#1234") # 4 chars: not a recognized form
[docs]
def test_named_color_string_passes_through():
"""Plain named-color strings must not be touched by the hex shorthand."""
s = Style(fg="cyan")
assert s.fg == "cyan"
# --- 2. Composition operator ------------------------------------------------
[docs]
def test_or_right_operand_wins_on_conflicts():
a = Style(fg="red", bold=True)
b = Style(fg="blue", italic=True)
merged = a | b
assert merged.fg == "blue" # b wins
assert merged.bold is True # only a sets it
assert merged.italic is True # only b sets it
[docs]
def test_or_returns_subclass_instance():
merged = Style(fg="red") | Style(bold=True)
assert isinstance(merged, Style)
assert type(merged) is Style
[docs]
def test_ror_with_cloup_left_operand():
"""``cloup_style | my_style`` is reached via ``__ror__``."""
merged = cloup.Style(fg="red") | Style(bold=True)
assert isinstance(merged, Style)
assert merged.fg == "red"
assert merged.bold is True
[docs]
def test_or_with_non_style_returns_notimplemented():
"""Style | int falls through to ``int.__ror__`` and raises ``TypeError``."""
with pytest.raises(TypeError):
Style(fg="red") | 42
# --- 3. cascade() -----------------------------------------------------------
[docs]
def test_cascade_fills_unset_fields_from_base():
base = Style(fg="cyan", bold=True, italic=True)
derived = Style(fg="red")
merged = derived.cascade(base)
assert merged.fg == "red" # derived wins
assert merged.bold is True # filled from base
assert merged.italic is True # filled from base
[docs]
def test_cascade_keeps_subclass_identity():
merged = Style(fg="red").cascade(Style(bold=True))
assert isinstance(merged, Style)
[docs]
def test_cascade_with_non_style_raises():
with pytest.raises(TypeError, match="not a Style"):
Style(fg="red").cascade("not a style") # type: ignore[arg-type]
# --- 4. to_dict() / from_dict() ---------------------------------------------
[docs]
def test_to_dict_omits_unset_fields():
s = Style(fg="#f1fa8c", bold=True)
d = s.to_dict()
assert d == {"fg": "#f1fa8c", "bold": True}
[docs]
def test_to_dict_serializes_rgb_as_hex():
s = Style(fg=(0xF1, 0xFA, 0x8C))
assert s.to_dict() == {"fg": "#f1fa8c"}
[docs]
def test_to_dict_omits_none_only():
"""``False`` boolean attributes are kept; only ``None`` is filtered out."""
# Actually our to_dict treats only None as "unset". False is preserved.
s = Style(fg="red", bold=False)
d = s.to_dict()
assert d == {"fg": "red", "bold": False}
[docs]
def test_from_dict_round_trip():
original = Style(fg="#bd93f9", bold=True, underline=True)
restored = Style.from_dict(original.to_dict())
assert restored == original
[docs]
def test_from_dict_accepts_hex_or_rgb():
"""Both hex strings and RGB tuples work as input."""
via_hex = Style.from_dict({"fg": "#ff5555"})
via_rgb = Style.from_dict({"fg": (0xFF, 0x55, 0x55)})
assert via_hex == via_rgb
# --- 5. __str__ -------------------------------------------------------------
[docs]
def test_str_returns_styled_sample():
s = Style(fg="red", bold=True)
rendered = str(s)
assert rendered.endswith("sample\x1b[0m")
assert "\x1b[" in rendered # ANSI code present
[docs]
def test_str_no_styling_has_no_color_codes():
"""Style with no fields set produces only click's bare reset suffix."""
rendered = str(Style())
# click.style always appends ``\x1b[0m`` even when no SGR codes precede it.
assert "sample" in rendered
assert "\x1b[31" not in rendered # no actual color escape
# --- 6. __repr__ ------------------------------------------------------------
[docs]
def test_repr_compact_named_color():
assert repr(Style(fg="cyan", dim=True)) == "Style(fg='cyan', dim)"
[docs]
def test_repr_compact_rgb_to_hex():
assert repr(Style(fg=(0xF1, 0xFA, 0x8C), bold=True)) == "Style(fg=#f1fa8c, bold)"
[docs]
def test_repr_empty_style():
assert repr(Style()) == "Style()"
[docs]
def test_repr_multiple_attrs():
rendered = repr(
Style(fg="red", bg="black", bold=True, underline=True, italic=True),
)
assert rendered == "Style(fg='red', bg='black', bold, italic, underline)"
# --- 7. to_css() ------------------------------------------------------------
[docs]
def test_to_css_basic():
css = Style(fg="#f1fa8c", bold=True).to_css()
assert css == "color: #f1fa8c; font-weight: bold"
[docs]
def test_to_css_named_color_passes_through():
css = Style(fg="red").to_css()
assert css == "color: red"
[docs]
def test_to_css_bright_named_color_resolves_to_rgb():
"""Bright ANSI colors aren't valid CSS keywords: convert to RGB."""
css = Style(fg="bright_red").to_css()
assert css.startswith("color: #")
[docs]
def test_to_css_text_decorations_combine():
css = Style(underline=True, strikethrough=True).to_css()
assert "text-decoration: underline line-through" in css
[docs]
def test_to_css_dim_emits_opacity():
assert "opacity: 0.6" in Style(dim=True).to_css()
[docs]
def test_to_css_empty_style_returns_empty_string():
assert Style().to_css() == ""
# --- 8. from_ansi() ---------------------------------------------------------
[docs]
@pytest.mark.parametrize(
("escape", "expected"),
[
("\x1b[31m", Style(fg="red")),
("\x1b[1m", Style(bold=True)),
("\x1b[31;1m", Style(fg="red", bold=True)),
("\x1b[91m", Style(fg="bright_red")),
("\x1b[42m", Style(bg="green")),
("\x1b[102m", Style(bg="bright_green")),
("\x1b[3;4m", Style(italic=True, underline=True)),
("\x1b[2m", Style(dim=True)),
("\x1b[7m", Style(reverse=True)),
("\x1b[9m", Style(strikethrough=True)),
# 256-color extension.
("\x1b[38;5;42m", Style(fg=42)),
("\x1b[48;5;200m", Style(bg=200)),
# 24-bit RGB extension.
("\x1b[38;2;241;250;140m", Style(fg=(241, 250, 140))),
("\x1b[38;2;241;250;140;1m", Style(fg=(241, 250, 140), bold=True)),
# Reset code is ignored.
("\x1b[0;31m", Style(fg="red")),
],
)
def test_from_ansi_parses_codes(escape, expected):
assert Style.from_ansi(escape) == expected
[docs]
def test_from_ansi_round_trip_through_call():
"""A Style can be parsed back from its own ANSI output."""
original = Style(fg="red", bold=True)
rendered = original("text")
# Extract just the leading SGR escape (before the text).
leading = rendered.split("text", 1)[0]
parsed = Style.from_ansi(leading)
assert parsed == original
[docs]
def test_from_ansi_invalid_raises():
with pytest.raises(ValueError, match="Not an ANSI SGR escape"):
Style.from_ansi("not an escape")
# --- 9. contrast_ratio() ----------------------------------------------------
[docs]
def test_contrast_ratio_white_on_black_is_max():
ratio = Style(fg="#ffffff").contrast_ratio(Style(fg="#000000"))
assert ratio == pytest.approx(21.0, abs=0.01)
[docs]
def test_contrast_ratio_identical_colors_is_one():
ratio = Style(fg="#aaaaaa").contrast_ratio(Style(fg="#aaaaaa"))
assert ratio == pytest.approx(1.0, abs=0.001)
[docs]
def test_contrast_ratio_is_symmetric():
a = Style(fg="#ff5555")
b = Style(fg="#282a36")
assert a.contrast_ratio(b) == b.contrast_ratio(a)
[docs]
def test_contrast_ratio_meets_wcag_aa_for_dracula_default():
"""Dracula's default fg/bg pair clears WCAG AA (4.5)."""
fg = Style(fg="#f8f8f2")
bg = Style(fg="#282a36")
assert fg.contrast_ratio(bg) >= 4.5
[docs]
def test_contrast_ratio_requires_both_fgs():
with pytest.raises(ValueError, match="foreground color"):
Style().contrast_ratio(Style(fg="red"))
# --- 10. __eq__ / __hash__ --------------------------------------------------
[docs]
def test_eq_ignores_style_kwargs_cache():
"""Equality must not depend on cloup's lazy ``_style_kwargs`` cache."""
a = Style(fg="red")
b = Style(fg="red")
a("trigger") # primes a's _style_kwargs
assert b._style_kwargs is None
assert a._style_kwargs is not None
assert a == b
assert hash(a) == hash(b)
[docs]
def test_eq_with_cloup_style():
"""A click-extra Style equals an equivalent cloup.Style."""
assert Style(fg="red") == cloup.Style(fg="red")
# --- 11. Truecolor detection and quantization -------------------------------
[docs]
@pytest.mark.parametrize(
("colorterm", "term", "expected"),
(
# Unset: optimistic default keeps 24-bit.
(None, None, True),
# COLORTERM positively advertises truecolor (case-insensitive, stripped).
("truecolor", None, True),
("24bit", None, True),
("TrueColor", None, True),
(" truecolor ", None, True),
# COLORTERM set to any other value is a deliberate non-truecolor advert.
("256", None, False),
("8bit", None, False),
# A 256color TERM is NOT a downgrade: truecolor terminals report it too.
(None, "xterm-256color", True),
# A 16color TERM is an unambiguous sub-256 terminal.
(None, "xterm-16color", False),
# COLORTERM outranks TERM.
("truecolor", "xterm-16color", True),
),
)
def test_supports_truecolor(monkeypatch, colorterm, term, expected):
monkeypatch.delenv("COLORTERM", raising=False)
monkeypatch.delenv("TERM", raising=False)
if colorterm is not None:
monkeypatch.setenv("COLORTERM", colorterm)
if term is not None:
monkeypatch.setenv("TERM", term)
assert supports_truecolor() is expected
[docs]
def test_style_call_keeps_24bit_on_truecolor(monkeypatch):
"""An RGB color emits a 24-bit sequence when the terminal supports truecolor."""
monkeypatch.setenv("COLORTERM", "truecolor")
assert Style(fg="#ff0000")("X") == "\x1b[38;2;255;0;0mX\x1b[0m"
[docs]
def test_style_call_quantizes_without_truecolor(monkeypatch):
"""An RGB color downsamples to the nearest 256-index without truecolor."""
monkeypatch.delenv("COLORTERM", raising=False)
monkeypatch.setenv("TERM", "xterm-16color")
index = _nearest_256(255, 0, 0) # 196
assert Style(fg="#ff0000")("X") == f"\x1b[38;5;{index}mX\x1b[0m"
[docs]
@pytest.mark.parametrize("colorterm", ("truecolor", "256"))
def test_style_call_leaves_named_and_indexed_colors(monkeypatch, colorterm):
"""Named and palette-index colors never quantize, regardless of depth."""
monkeypatch.setenv("COLORTERM", colorterm)
assert Style(fg="red")("X") == "\x1b[31mX\x1b[0m"
assert Style(fg=200)("X") == "\x1b[38;5;200mX\x1b[0m"
[docs]
def test_style_call_cache_survives_depth_flip(monkeypatch):
"""The same style renders correctly when truecolor flips between calls.
Quantizing on a transient copy must not poison cloup's lazy
``_style_kwargs`` cache on the shared (often singleton) style instance.
"""
style = Style(fg="#ff0000")
index = _nearest_256(255, 0, 0)
monkeypatch.setenv("COLORTERM", "truecolor")
assert style("X") == "\x1b[38;2;255;0;0mX\x1b[0m"
monkeypatch.setenv("COLORTERM", "256")
assert style("X") == f"\x1b[38;5;{index}mX\x1b[0m"
monkeypatch.setenv("COLORTERM", "truecolor")
assert style("X") == "\x1b[38;2;255;0;0mX\x1b[0m"