"""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, 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_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.""" row = [ self._char("A"), self._char("中"), # Wide char self._char(""), # Placeholder - should be skipped 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" 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"] == "日本語" 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"] == "🎉 🚀" 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 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("") assert 'xmlns="http://www.w3.org/2000/svg"' in svg def test_basic_text_output(self) -> None: """Basic text is included in SVG.""" buffer = self._make_buffer(["Hello, World!"]) svg = render_terminal_svg(buffer, width=80, height=24) assert "Hello, World!" in svg def test_multiline_output(self) -> None: """Multiple lines render correctly.""" buffer = self._make_buffer(["Line 1", "Line 2", "Line 3"]) svg = render_terminal_svg(buffer, width=80, height=24) assert "Line 1" in svg assert "Line 2" in svg assert "Line 3" in svg # Should have 3 text elements assert svg.count("&test"]) svg = render_terminal_svg(buffer, width=80, height=24) assert "<script>" in svg assert "&test" in svg assert "