"""Extensive tests for the custom SVG exporter."""
from __future__ import annotations
from textual_webterm.svg_exporter import (
ANSI_COLORS,
DEFAULT_BG,
DEFAULT_FG,
CharData,
_build_row_spans,
_color_to_hex,
_escape_xml,
_is_all_horizontal_box_drawing,
_is_box_drawing_vertical_or_corner,
_should_break_span,
render_terminal_svg,
)
class TestColorToHex:
"""Tests for _color_to_hex function."""
def test_default_foreground(self) -> None:
"""Default color returns DEFAULT_FG for foreground."""
assert _color_to_hex("default", is_foreground=True) == DEFAULT_FG
def test_default_background(self) -> None:
"""Default color returns DEFAULT_BG for background."""
assert _color_to_hex("default", is_foreground=False) == DEFAULT_BG
def test_hex_color_passthrough(self) -> None:
"""Hex colors pass through unchanged."""
assert _color_to_hex("#ff0000") == "#ff0000"
assert _color_to_hex("#123456") == "#123456"
assert _color_to_hex("#AABBCC") == "#AABBCC"
def test_hex_color_without_hash(self) -> None:
"""Hex colors without # prefix (pyte's 256-color/truecolor) get # added."""
assert _color_to_hex("ff0000") == "#ff0000"
assert _color_to_hex("123456") == "#123456"
assert _color_to_hex("AABBCC") == "#AABBCC"
assert _color_to_hex("ff8700") == "#ff8700" # Common 256-color orange
def test_named_colors(self) -> None:
"""Named ANSI colors map correctly."""
assert _color_to_hex("red") == ANSI_COLORS["red"]
assert _color_to_hex("green") == ANSI_COLORS["green"]
assert _color_to_hex("blue") == ANSI_COLORS["blue"]
assert _color_to_hex("white") == ANSI_COLORS["white"]
assert _color_to_hex("black") == ANSI_COLORS["black"]
def test_bright_colors(self) -> None:
"""Bright color variants map correctly."""
assert _color_to_hex("brightred") == ANSI_COLORS["brightred"]
assert _color_to_hex("brightgreen") == ANSI_COLORS["brightgreen"]
assert _color_to_hex("brightblue") == ANSI_COLORS["brightblue"]
def test_case_insensitive(self) -> None:
"""Color names are case-insensitive."""
assert _color_to_hex("RED") == ANSI_COLORS["red"]
assert _color_to_hex("Green") == ANSI_COLORS["green"]
assert _color_to_hex("BRIGHTBLUE") == ANSI_COLORS["brightblue"]
def test_unknown_color_returns_default(self) -> None:
"""Unknown color names return default."""
assert _color_to_hex("unknowncolor", is_foreground=True) == DEFAULT_FG
assert _color_to_hex("unknowncolor", is_foreground=False) == DEFAULT_BG
def test_rgb_format_returns_default(self) -> None:
"""RGB format falls back to default (not commonly used in terminals)."""
assert _color_to_hex("rgb(255,0,0)", is_foreground=True) == DEFAULT_FG
assert _color_to_hex("rgb(0,255,0)", is_foreground=False) == DEFAULT_BG
def test_gray_aliases(self) -> None:
"""Gray/grey aliases work."""
assert _color_to_hex("gray") == ANSI_COLORS["gray"]
assert _color_to_hex("grey") == ANSI_COLORS["grey"]
assert _color_to_hex("lightgray") == ANSI_COLORS["lightgray"]
assert _color_to_hex("lightgrey") == ANSI_COLORS["lightgrey"]
class TestEscapeXml:
"""Tests for XML escaping."""
def test_no_special_chars(self) -> None:
"""Plain text passes through unchanged."""
assert _escape_xml("hello world") == "hello world"
def test_less_than(self) -> None:
"""Less than is escaped."""
assert _escape_xml("<") == "<"
assert _escape_xml("a < b") == "a < b"
def test_greater_than(self) -> None:
"""Greater than is escaped."""
assert _escape_xml(">") == ">"
assert _escape_xml("a > b") == "a > b"
def test_ampersand(self) -> None:
"""Ampersand is escaped."""
assert _escape_xml("&") == "&"
assert _escape_xml("a & b") == "a & b"
def test_quotes(self) -> None:
"""Quotes are escaped."""
assert _escape_xml('"') == """
assert _escape_xml("'") == "'"
def test_mixed_special_chars(self) -> None:
"""Multiple special chars are all escaped."""
assert _escape_xml('') == (
"<script>"alert"</script>"
)
def test_unicode_preserved(self) -> None:
"""Unicode characters are preserved."""
assert _escape_xml("你好世界") == "你好世界"
assert _escape_xml("🎉🚀") == "🎉🚀"
class TestBuildRowSpans:
"""Tests for _build_row_spans function."""
def _char(
self,
data: str,
fg: str = "default",
bg: str = "default",
bold: bool = False,
italics: bool = False,
underscore: bool = False,
reverse: bool = False,
) -> CharData:
"""Helper to create CharData."""
return {
"data": data,
"fg": fg,
"bg": bg,
"bold": bold,
"italics": italics,
"underscore": underscore,
"reverse": reverse,
}
def test_empty_row(self) -> None:
"""Empty row returns no spans."""
assert _build_row_spans([], DEFAULT_FG, DEFAULT_BG) == []
def test_single_char(self) -> None:
"""Single character produces one span."""
row = [self._char("A")]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 1
assert spans[0]["text"] == "A"
def test_consecutive_same_style_merged(self) -> None:
"""Consecutive chars with same style are merged."""
row = [self._char("H"), self._char("e"), self._char("l"), self._char("l"), self._char("o")]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 1
assert spans[0]["text"] == "Hello"
def test_different_colors_split(self) -> None:
"""Different colors create separate spans."""
row = [
self._char("R", fg="red"),
self._char("G", fg="green"),
self._char("B", fg="blue"),
]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 3
assert spans[0]["text"] == "R"
assert spans[0]["fg"] == ANSI_COLORS["red"]
assert spans[1]["text"] == "G"
assert spans[1]["fg"] == ANSI_COLORS["green"]
assert spans[2]["text"] == "B"
assert spans[2]["fg"] == ANSI_COLORS["blue"]
def test_same_color_merged(self) -> None:
"""Same color chars are merged."""
row = [
self._char("A", fg="red"),
self._char("B", fg="red"),
self._char("C", fg="red"),
]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 1
assert spans[0]["text"] == "ABC"
assert spans[0]["fg"] == ANSI_COLORS["red"]
def test_bold_creates_new_span(self) -> None:
"""Bold attribute creates new span."""
row = [
self._char("N"),
self._char("B", bold=True),
self._char("N"),
]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 3
assert spans[0]["bold"] is False
assert spans[1]["bold"] is True
assert spans[1]["text"] == "B"
assert spans[2]["bold"] is False
def test_italic_creates_new_span(self) -> None:
"""Italic attribute creates new span."""
row = [
self._char("N"),
self._char("I", italics=True),
self._char("N"),
]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 3
assert spans[1]["italic"] is True
def test_underline_creates_new_span(self) -> None:
"""Underline attribute creates new span."""
row = [
self._char("N"),
self._char("U", underscore=True),
self._char("N"),
]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 3
assert spans[1]["underline"] is True
def test_reverse_swaps_colors(self) -> None:
"""Reverse video swaps foreground and background."""
row = [self._char("R", fg="red", bg="blue", reverse=True)]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 1
# Colors should be swapped
assert spans[0]["fg"] == ANSI_COLORS["blue"]
assert spans[0]["bg"] == ANSI_COLORS["red"]
def test_background_color_tracked(self) -> None:
"""Background color is tracked in has_bg flag."""
row = [
self._char("N"),
self._char("B", bg="red"),
self._char("N"),
]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert spans[0]["has_bg"] is False
assert spans[1]["has_bg"] is True
assert spans[2]["has_bg"] is False
def test_wide_char_placeholder_skipped(self) -> None:
"""Empty placeholder cells (after wide chars) are skipped but counted in columns."""
row = [
self._char("A"),
self._char("中"), # Wide char
self._char(""), # Placeholder - should be skipped but counted
self._char("B"),
]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
# Should merge into single span since all default style
assert len(spans) == 1
assert spans[0]["text"] == "A中B"
assert spans[0]["columns"] == 4 # 1 + 1 + 1(placeholder) + 1
def test_multiple_wide_chars(self) -> None:
"""Multiple wide characters handled correctly."""
row = [
self._char("日"),
self._char(""),
self._char("本"),
self._char(""),
self._char("語"),
self._char(""),
]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 1
assert spans[0]["text"] == "日本語"
assert spans[0]["columns"] == 6 # Each wide char + placeholder = 2 columns
def test_emoji_with_placeholder(self) -> None:
"""Emoji characters with placeholders handled."""
row = [
self._char("🎉"),
self._char(""),
self._char(" "),
self._char("🚀"),
self._char(""),
]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 1
assert spans[0]["text"] == "🎉 🚀"
assert spans[0]["columns"] == 5 # 2 + 1 + 2
def test_mixed_styles_complex(self) -> None:
"""Complex mix of styles produces correct spans."""
row = [
self._char("H", fg="red", bold=True),
self._char("e", fg="red", bold=True),
self._char("l", fg="green"),
self._char("l", fg="green"),
self._char("o", fg="blue", italics=True),
]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 3
assert spans[0]["text"] == "He"
assert spans[0]["bold"] is True
assert spans[1]["text"] == "ll"
assert spans[1]["bold"] is False
assert spans[2]["text"] == "o"
assert spans[2]["italic"] is True
def test_placeholder_at_start_ignored(self) -> None:
"""Empty placeholder at start of row is ignored."""
row = [
self._char(""), # Orphan placeholder at start
self._char("A"),
self._char("B"),
]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 1
assert spans[0]["text"] == "AB"
assert spans[0]["columns"] == 2 # Placeholder not counted (no prior span)
def test_style_change_after_wide_char(self) -> None:
"""Style change right after a wide character placeholder works."""
row = [
self._char("中", fg="red"),
self._char(""), # Placeholder
self._char("A", fg="blue"),
]
spans = _build_row_spans(row, DEFAULT_FG, DEFAULT_BG)
assert len(spans) == 2
assert spans[0]["text"] == "中"
assert spans[0]["columns"] == 2 # Wide char + placeholder
assert spans[1]["text"] == "A"
assert spans[1]["columns"] == 1
class TestBoxDrawingHelpers:
"""Tests for box drawing character detection helpers."""
def test_is_box_drawing_empty_char(self) -> None:
"""Empty string returns False."""
assert _is_box_drawing_vertical_or_corner("") is False
def test_is_box_drawing_regular_char(self) -> None:
"""Regular ASCII characters return False."""
assert _is_box_drawing_vertical_or_corner("A") is False
assert _is_box_drawing_vertical_or_corner(" ") is False
assert _is_box_drawing_vertical_or_corner("1") is False
def test_is_box_drawing_horizontal_lines(self) -> None:
"""Horizontal box drawing lines return False (can merge)."""
assert _is_box_drawing_vertical_or_corner("─") is False # U+2500
assert _is_box_drawing_vertical_or_corner("━") is False # U+2501
assert _is_box_drawing_vertical_or_corner("═") is False # U+2550
def test_is_box_drawing_vertical_lines(self) -> None:
"""Vertical box drawing lines return True (need precise positioning)."""
assert _is_box_drawing_vertical_or_corner("│") is True # U+2502
assert _is_box_drawing_vertical_or_corner("┃") is True # U+2503
assert _is_box_drawing_vertical_or_corner("║") is True # U+2551
def test_is_box_drawing_corners(self) -> None:
"""Corner box drawing characters return True."""
assert _is_box_drawing_vertical_or_corner("┌") is True
assert _is_box_drawing_vertical_or_corner("┐") is True
assert _is_box_drawing_vertical_or_corner("└") is True
assert _is_box_drawing_vertical_or_corner("┘") is True
assert _is_box_drawing_vertical_or_corner("╭") is True
assert _is_box_drawing_vertical_or_corner("╮") is True
assert _is_box_drawing_vertical_or_corner("╯") is True
assert _is_box_drawing_vertical_or_corner("╰") is True
def test_should_break_span_empty_current(self) -> None:
"""Empty current text never breaks."""
assert _should_break_span("", "A") is False
assert _should_break_span("", "│") is False
def test_should_break_span_normal_chars(self) -> None:
"""Normal characters don't break spans."""
assert _should_break_span("A", "B") is False
assert _should_break_span("Hello", "!") is False
def test_should_break_span_vertical_line(self) -> None:
"""Vertical lines cause breaks."""
assert _should_break_span("A", "│") is True
assert _should_break_span("│", "A") is True
def test_should_break_span_horizontal_lines_merge(self) -> None:
"""Horizontal lines can merge with each other."""
assert _should_break_span("─", "─") is False
assert _should_break_span("━", "━") is False
def test_is_all_horizontal_box_drawing_empty(self) -> None:
"""Empty string returns False."""
assert _is_all_horizontal_box_drawing("") is False
def test_is_all_horizontal_box_drawing_normal_text(self) -> None:
"""Normal text returns False."""
assert _is_all_horizontal_box_drawing("Hello") is False
assert _is_all_horizontal_box_drawing("ABC") is False
def test_is_all_horizontal_box_drawing_horizontal_lines(self) -> None:
"""Horizontal box chars return True."""
assert _is_all_horizontal_box_drawing("─") is True
assert _is_all_horizontal_box_drawing("───") is True
assert _is_all_horizontal_box_drawing("━━━") is True
assert _is_all_horizontal_box_drawing("═══") is True
def test_is_all_horizontal_box_drawing_mixed(self) -> None:
"""Mixed content returns False."""
assert _is_all_horizontal_box_drawing("─A─") is False
assert _is_all_horizontal_box_drawing("│──") is False # vertical at start
class TestRenderTerminalSvg:
"""Tests for render_terminal_svg function."""
def _char(
self,
data: str,
fg: str = "default",
bg: str = "default",
bold: bool = False,
italics: bool = False,
underscore: bool = False,
reverse: bool = False,
) -> CharData:
"""Helper to create CharData."""
return {
"data": data,
"fg": fg,
"bg": bg,
"bold": bold,
"italics": italics,
"underscore": underscore,
"reverse": reverse,
}
def _make_buffer(self, rows: list[str]) -> list[list[CharData]]:
"""Create simple buffer from strings."""
return [[self._char(c) for c in row] for row in rows]
def test_empty_buffer(self) -> None:
"""Empty buffer produces valid SVG."""
svg = render_terminal_svg([], width=80, height=24)
assert svg.startswith("