diff --git a/elementary/messages/formats/adaptive_cards.py b/elementary/messages/formats/adaptive_cards.py index a526e1184..7b228f1e9 100644 --- a/elementary/messages/formats/adaptive_cards.py +++ b/elementary/messages/formats/adaptive_cards.py @@ -16,23 +16,9 @@ TextBlock, TextStyle, ) +from elementary.messages.formats.html import ICON_TO_HTML from elementary.messages.message_body import Color, MessageBlock, MessageBody -ICON_TO_HTML = { - Icon.RED_TRIANGLE: "đŸ”ē", - Icon.X: "❌", - Icon.WARNING: "âš ī¸", - Icon.EXCLAMATION: "❗", - Icon.CHECK: "✅", - Icon.MAGNIFYING_GLASS: "🔎", - Icon.HAMMER_AND_WRENCH: "đŸ› ī¸", - Icon.POLICE_LIGHT: "🚨", - Icon.INFO: "â„šī¸", - Icon.EYE: "đŸ‘ī¸", - Icon.GEAR: "âš™ī¸", - Icon.BELL: "🔔", -} - COLOR_TO_STYLE = { Color.RED: "Attention", Color.YELLOW: "Warning", diff --git a/elementary/messages/formats/block_kit.py b/elementary/messages/formats/block_kit.py new file mode 100644 index 000000000..7a1bae456 --- /dev/null +++ b/elementary/messages/formats/block_kit.py @@ -0,0 +1,231 @@ +from typing import Any, Dict, List, Optional, Tuple + +from slack_sdk.models import blocks as slack_blocks + +from elementary.messages.blocks import ( + CodeBlock, + DividerBlock, + ExpandableBlock, + FactBlock, + FactListBlock, + HeaderBlock, + Icon, + IconBlock, + InlineBlock, + LineBlock, + LinesBlock, + LinkBlock, + TextBlock, + TextStyle, +) +from elementary.messages.formats.html import ICON_TO_HTML +from elementary.messages.message_body import Color, MessageBlock, MessageBody + +COLOR_MAP = { + Color.RED: "#ff0000", + Color.YELLOW: "#ffcc00", + Color.GREEN: "#33b989", +} + + +class BlockKitBuilder: + _SECONDARY_FACT_CHUNK_SIZE = 2 + _LONGEST_MARKDOWN_SUFFIX_LEN = 3 # length of markdown's code suffix (```) + + def __init__(self) -> None: + self._blocks: List[dict] = [] + self._attachment_blocks: List[dict] = [] + self._is_divided = False + + def _format_icon(self, icon: Icon) -> str: + return ICON_TO_HTML[icon] + + def _format_text_block(self, block: TextBlock) -> str: + if block.style == TextStyle.BOLD: + return f"*{block.text}*" + elif block.style == TextStyle.ITALIC: + return f"_{block.text}_" + else: + return block.text + + def _format_inline_block(self, block: InlineBlock) -> str: + if isinstance(block, IconBlock): + return self._format_icon(block.icon) + elif isinstance(block, TextBlock): + return self._format_text_block(block) + elif isinstance(block, LinkBlock): + return f"<{block.url}|{block.text}>" + else: + raise ValueError(f"Unsupported inline block type: {type(block)}") + + def _format_line_block_text(self, block: LineBlock) -> str: + return block.sep.join( + [self._format_inline_block(inline) for inline in block.inlines] + ) + + def _format_markdown_section_text(self, text: str) -> dict: + if len(text) > slack_blocks.SectionBlock.text_max_length: + text = ( + text[ + : slack_blocks.SectionBlock.text_max_length + - len("...") + - self._LONGEST_MARKDOWN_SUFFIX_LEN + ] + + "..." + + text[-self._LONGEST_MARKDOWN_SUFFIX_LEN :] + ) + return { + "type": "mrkdwn", + "text": text, + } + + def _format_markdown_section(self, text: str) -> dict: + return { + "type": "section", + "text": self._format_markdown_section_text(text), + } + + def _add_block(self, block: dict) -> None: + if not self._is_divided: + self._blocks.append(block) + else: + self._attachment_blocks.append(block) + + def _add_lines_block(self, block: LinesBlock) -> None: + formatted_lines = [ + self._format_line_block_text(line_block) for line_block in block.lines + ] + self._add_block(self._format_markdown_section("\n".join(formatted_lines))) + + def _add_header_block(self, block: HeaderBlock) -> None: + if len(block.text) > slack_blocks.HeaderBlock.text_max_length: + text = block.text[: slack_blocks.HeaderBlock.text_max_length - 3] + "..." + else: + text = block.text + self._add_block( + { + "type": "header", + "text": { + "type": "plain_text", + "text": text, + }, + } + ) + + def _add_code_block(self, block: CodeBlock) -> None: + self._add_block(self._format_markdown_section(f"```{block.text}```")) + + def _add_primary_fact(self, fact: FactBlock) -> None: + self._add_block( + self._format_markdown_section( + f"*{self._format_line_block_text(fact.title)}*\n{self._format_line_block_text(fact.value)}" + ) + ) + + def _add_secondary_facts(self, facts: List[FactBlock]) -> None: + if not facts: + return + self._add_block( + { + "type": "section", + "fields": [ + self._format_markdown_section_text( + f"*{self._format_line_block_text(fact.title)}*\n{self._format_line_block_text(fact.value)}" + ) + for fact in facts + ], + } + ) + + def _add_fact_list_block(self, block: FactListBlock) -> None: + remaining_facts = block.facts[:] + secondary_facts: List[FactBlock] = [] + while remaining_facts: + current_fact = remaining_facts.pop(0) + if current_fact.primary: + self._add_secondary_facts(secondary_facts) + secondary_facts = [] + self._add_primary_fact(current_fact) + else: + if len(secondary_facts) >= self._SECONDARY_FACT_CHUNK_SIZE: + self._add_secondary_facts(secondary_facts) + secondary_facts = [] + secondary_facts.append(current_fact) + self._add_secondary_facts(secondary_facts) + + def _add_divider_block(self, block: DividerBlock) -> None: + self._add_block({"type": "divider"}) + self._is_divided = True + + def _add_expandable_block(self, block: ExpandableBlock) -> None: + """ + Expandable blocks are not supported in Slack Block Kit. + However, slack automatically collapses a large section block into an expandable block. + """ + self._add_block( + { + "type": "section", + "text": self._format_markdown_section_text(f"*{block.title}*"), + } + ) + self._add_message_blocks(block.body) + + def _add_message_block(self, block: MessageBlock) -> None: + if isinstance(block, HeaderBlock): + self._add_header_block(block) + elif isinstance(block, CodeBlock): + self._add_code_block(block) + elif isinstance(block, LinesBlock): + self._add_lines_block(block) + elif isinstance(block, FactListBlock): + self._add_fact_list_block(block) + elif isinstance(block, DividerBlock): + self._add_divider_block(block) + elif isinstance(block, ExpandableBlock): + self._add_expandable_block(block) + else: + raise ValueError(f"Unsupported message block type: {type(block)}") + + def _add_message_blocks(self, blocks: List[MessageBlock]) -> None: + for block in blocks: + self._add_message_block(block) + + def _get_final_blocks( + self, color: Optional[Color] + ) -> Tuple[List[dict], List[dict]]: + """ + Slack does not support coloring regular messages, only attachments. + Also, regular messages are always displayed in full, while attachments show the first 5 blocks (with a "show more" button). + The way we handle this is as follows: + - If we have a divider block, everything up to it and including it is a regular message, and everything after it is an attachment. + - If we don't have a divider block: + - If we have a color, everything is an attachment (in order to always display colored messages). + - If we don't have a color, everything is a regular message. + """ + if self._is_divided or not color: + return self._blocks, self._attachment_blocks + else: + return [], self._blocks + + def build(self, message: MessageBody) -> Dict[str, Any]: + self._blocks = [] + self._attachment_blocks = [] + self._add_message_blocks(message.blocks) + color_code = COLOR_MAP.get(message.color) if message.color else None + blocks, attachment_blocks = self._get_final_blocks(message.color) + built_message = { + "blocks": blocks, + "attachments": [ + { + "blocks": attachment_blocks, + } + ], + } + if color_code: + built_message["attachments"][0]["color"] = color_code + return built_message + + +def format_block_kit(message: MessageBody) -> Dict[str, Any]: + builder = BlockKitBuilder() + return builder.build(message) diff --git a/elementary/messages/formats/html.py b/elementary/messages/formats/html.py new file mode 100644 index 000000000..a9ac0674b --- /dev/null +++ b/elementary/messages/formats/html.py @@ -0,0 +1,20 @@ +from elementary.messages.blocks import Icon + +ICON_TO_HTML = { + Icon.RED_TRIANGLE: "đŸ”ē", + Icon.X: "❌", + Icon.WARNING: "âš ī¸", + Icon.EXCLAMATION: "❗", + Icon.CHECK: "✅", + Icon.MAGNIFYING_GLASS: "🔎", + Icon.HAMMER_AND_WRENCH: "đŸ› ī¸", + Icon.POLICE_LIGHT: "🚨", + Icon.INFO: "â„šī¸", + Icon.EYE: "đŸ‘ī¸", + Icon.GEAR: "âš™ī¸", + Icon.BELL: "🔔", +} + +for icon in Icon: + if icon not in ICON_TO_HTML: + raise RuntimeError(f"No HTML representation for icon {icon}") diff --git a/tests/unit/messages/formats/adaptive_cards/test_adaptive_cards.py b/tests/unit/messages/formats/adaptive_cards/test_adaptive_cards.py index d4faca6ae..3d661c691 100644 --- a/tests/unit/messages/formats/adaptive_cards/test_adaptive_cards.py +++ b/tests/unit/messages/formats/adaptive_cards/test_adaptive_cards.py @@ -10,28 +10,13 @@ import uuid from pathlib import Path -from typing import List, Union import pytest -from elementary.messages.block_builders import BulletListBlock -from elementary.messages.blocks import ( - CodeBlock, - DividerBlock, - ExpandableBlock, - FactBlock, - FactListBlock, - HeaderBlock, - Icon, - IconBlock, - LineBlock, - LinesBlock, - LinkBlock, - TextBlock, - TextStyle, -) +from elementary.messages.blocks import HeaderBlock from elementary.messages.formats.adaptive_cards import format_adaptive_card -from elementary.messages.message_body import Color, MessageBody +from elementary.messages.message_body import MessageBody +from tests.unit.messages.formats.base_test_format import BaseTestFormat from tests.unit.messages.utils import assert_expected_json, get_expected_json_path FIXTURES_DIR = Path(__file__).resolve().parent / "fixtures" @@ -54,320 +39,34 @@ def __call__(self): return mock -def test_format_message_body_simple_header(): - message_body = MessageBody(blocks=[HeaderBlock(text="Test Header")], color=None) - expected_json_path = get_expected_json_path(FIXTURES_DIR, "simple_header.json") - result = format_adaptive_card(message_body) - assert_expected_json(result, expected_json_path) +class TestAdaptiveCards(BaseTestFormat[dict]): + def format(self, message_body: MessageBody) -> dict: + return format_adaptive_card(message_body) + def get_expected_file_path(self, name: str) -> str: + return get_expected_json_path(FIXTURES_DIR, f"{name}.json") -def test_format_message_body_colored_header(): - message_body = MessageBody( - blocks=[HeaderBlock(text="Test Header")], color=Color.GREEN - ) - expected_json_path = get_expected_json_path(FIXTURES_DIR, "colored_header.json") - result = format_adaptive_card(message_body) - assert_expected_json(result, expected_json_path) - - -def test_format_message_body_all_icons(): - icon_blocks: List[Union[TextBlock, IconBlock]] = [] - for icon in Icon: - icon_blocks.append(TextBlock(text=icon.name)) - icon_blocks.append(IconBlock(icon=icon)) - message_body = MessageBody( - blocks=[LinesBlock(lines=[LineBlock(inlines=icon_blocks)])] - ) - result = format_adaptive_card(message_body) - expected_json_path = get_expected_json_path(FIXTURES_DIR, "all_icons.json") - assert_expected_json(result, expected_json_path) - + def assert_expected_value(self, result: dict, expected_file_path: Path) -> None: + assert_expected_json(result, expected_file_path) -def test_format_message_body_text_styles(): - message_body = MessageBody( - blocks=[ - LinesBlock( - lines=[ - LineBlock( - inlines=[ - TextBlock(text="Normal text"), - TextBlock(text="Bold text", style=TextStyle.BOLD), - TextBlock(text="Italic text", style=TextStyle.ITALIC), - ] - ) - ] - ) - ] - ) - expected_json_path = get_expected_json_path(FIXTURES_DIR, "text_styles.json") - result = format_adaptive_card(message_body) - assert_expected_json(result, expected_json_path) - - -def test_format_message_body_fact_list(): - message_body = MessageBody( - blocks=[ - FactListBlock( - facts=[ - FactBlock( - title=LineBlock(inlines=[TextBlock(text="Status")]), - value=LineBlock(inlines=[TextBlock(text="Passed")]), - ), - ] - ) - ] - ) - expected_json_path = get_expected_json_path(FIXTURES_DIR, "fact_list.json") - result = format_adaptive_card(message_body) - assert_expected_json(result, expected_json_path) - - -def test_format_message_body_expandable_block(): - message_body = MessageBody( - blocks=[ - ExpandableBlock( - title="Show More", - body=[ - LinesBlock( - lines=[LineBlock(inlines=[TextBlock(text="Hidden content")])] - ) - ], - expanded=False, - ) - ] - ) - expected_json_path = get_expected_json_path(FIXTURES_DIR, "expandable_block.json") - result = format_adaptive_card(message_body) - assert_expected_json(result, expected_json_path) - - -def test_format_message_body_divider_blocks(): - message_body = MessageBody( - blocks=[ - HeaderBlock(text="First Section"), - DividerBlock(), - HeaderBlock(text="Second Section"), - ] - ) - expected_json_path = get_expected_json_path(FIXTURES_DIR, "divider_blocks.json") - result = format_adaptive_card(message_body) - assert_expected_json(result, expected_json_path) - - -def test_format_message_body_bullet_lists(): - message_body = MessageBody( - blocks=[ - BulletListBlock( - icon="-", - lines=[ - LineBlock(inlines=[TextBlock(text="First bullet")]), - LineBlock(inlines=[TextBlock(text="Second bullet")]), - ], - ), - BulletListBlock( - icon=Icon.CHECK, - lines=[ - LineBlock(inlines=[TextBlock(text="Check item 1")]), - LineBlock(inlines=[TextBlock(text="Check item 2")]), - ], - ), - ] - ) - expected_json_path = get_expected_json_path(FIXTURES_DIR, "bullet_list.json") - result = format_adaptive_card(message_body) - assert_expected_json(result, expected_json_path) - - -def test_format_message_body_nested_expandable(): - message_body = MessageBody( - blocks=[ - ExpandableBlock( - title="Outer Block", - body=[ - LinesBlock( - lines=[ - LineBlock( - inlines=[ - IconBlock(icon=Icon.MAGNIFYING_GLASS), - TextBlock( - text="Title with Icon", style=TextStyle.BOLD - ), - ] - ), - LineBlock( - inlines=[ - TextBlock(text="Some content with a"), - LinkBlock(text="link", url="https://example.com"), - ] - ), - ] - ), - ExpandableBlock( - title="Inner Block", - body=[ - LinesBlock( - lines=[ - LineBlock(inlines=[TextBlock(text="Inner content")]) - ] - ) - ], - expanded=True, - ), - ], - expanded=False, - ) - ] - ) - expected_json_path = get_expected_json_path(FIXTURES_DIR, "nested_expandable.json") - result = format_adaptive_card(message_body) - assert_expected_json(result, expected_json_path) - - -@pytest.mark.parametrize( - "color,expected_file", - [ - pytest.param(None, "all_blocks_no_color.json", id="no_color"), - pytest.param(Color.RED, "all_blocks_red.json", id="red"), - pytest.param(Color.YELLOW, "all_blocks_yellow.json", id="yellow"), - pytest.param(Color.GREEN, "all_blocks_green.json", id="green"), - ], -) -def test_format_message_body_all_blocks(color, expected_file): - """Test a comprehensive message that includes all block types with different colors.""" - message_body = MessageBody( - blocks=[ - HeaderBlock(text="Main Header"), - LinesBlock( - lines=[ - LineBlock( - inlines=[ - TextBlock(text="Normal text"), - TextBlock(text="Bold text", style=TextStyle.BOLD), - TextBlock(text="Italic text", style=TextStyle.ITALIC), - ] - ) - ] - ), - BulletListBlock( - icon="-", - lines=[ - LineBlock(inlines=[TextBlock(text="First bullet point")]), - LineBlock(inlines=[TextBlock(text="Second bullet point")]), - ], - ), - BulletListBlock( - icon=Icon.CHECK, - lines=[LineBlock(inlines=[TextBlock(text="Check item")])], - ), - FactListBlock( - facts=[ - FactBlock( - title=LineBlock(inlines=[TextBlock(text="Status")]), - value=LineBlock(inlines=[TextBlock(text="Passed")]), - ), - FactBlock( - title=LineBlock(inlines=[TextBlock(text="Tags")]), - value=LineBlock(inlines=[TextBlock(text="test, example")]), - ), - ] - ), - ExpandableBlock( - title="Show Details", - body=[ - LinesBlock( - lines=[ - LineBlock( - inlines=[ - IconBlock(icon=Icon.MAGNIFYING_GLASS), - TextBlock( - text="Details Section", style=TextStyle.BOLD - ), - ] - ), - LineBlock( - inlines=[ - TextBlock(text="Here's some content with a"), - LinkBlock(text="link", url="https://example.com"), - ] - ), - ] - ) - ], - expanded=False, - ), + @pytest.mark.parametrize( + "version,should_raise", + [ + pytest.param("1.6", False, id="supported_version"), + pytest.param("1.1", True, id="unsupported_version_low"), + pytest.param("1.7", True, id="unsupported_version_high"), ], - color=color, - ) - expected_json_path = get_expected_json_path(FIXTURES_DIR, expected_file) - result = format_adaptive_card(message_body) - assert_expected_json(result, expected_json_path) - - -@pytest.mark.parametrize( - "version,should_raise", - [ - pytest.param("1.6", False, id="supported_version"), - pytest.param("1.1", True, id="unsupported_version_low"), - pytest.param("1.7", True, id="unsupported_version_high"), - ], -) -def test_format_version_validation(version, should_raise): - message_body = MessageBody(blocks=[HeaderBlock(text="Test")]) - - if should_raise: - try: - format_adaptive_card(message_body, version=version) - assert False, f"Expected ValueError for version {version}" - except ValueError: - pass - else: - result = format_adaptive_card(message_body, version=version) - assert result["version"] == version - assert result["type"] == "AdaptiveCard" - - -@pytest.mark.parametrize( - "text_length", - [ - pytest.param(50, id="short_code"), - pytest.param(200, id="medium_code"), - pytest.param(500, id="long_code"), - ], -) -def test_format_message_body_code_block(text_length: int): - lorem_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * ( - (text_length + 49) // 50 - ) - lorem_text = lorem_text[:text_length] - - message_body = MessageBody(blocks=[CodeBlock(text=lorem_text)]) - expected_json_path = get_expected_json_path( - FIXTURES_DIR, f"code_block_{text_length}.json" - ) - result = format_adaptive_card(message_body) - assert_expected_json(result, expected_json_path) - - -@pytest.mark.parametrize( - "text_length", - [ - pytest.param(50, id="short_text"), - pytest.param(200, id="medium_text"), - pytest.param(500, id="long_text"), - pytest.param(1000, id="very_long_text"), - ], -) -def test_format_message_body_text_length(text_length: int): - lorem_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * ( - (text_length + 49) // 50 - ) - lorem_text = lorem_text[:text_length] - - message_body = MessageBody( - blocks=[LinesBlock(lines=[LineBlock(inlines=[TextBlock(text=lorem_text)])])] - ) - expected_json_path = get_expected_json_path( - FIXTURES_DIR, f"text_length_{text_length}.json" ) - result = format_adaptive_card(message_body) - assert_expected_json(result, expected_json_path) + def test_format_version_validation(self, version, should_raise): + message_body = MessageBody(blocks=[HeaderBlock(text="Test")]) + + if should_raise: + try: + format_adaptive_card(message_body, version=version) + assert False, f"Expected ValueError for version {version}" + except ValueError: + pass + else: + result = format_adaptive_card(message_body, version=version) + assert result["version"] == version + assert result["type"] == "AdaptiveCard" diff --git a/tests/unit/messages/formats/base_test_format.py b/tests/unit/messages/formats/base_test_format.py new file mode 100644 index 000000000..ccc3eeb3f --- /dev/null +++ b/tests/unit/messages/formats/base_test_format.py @@ -0,0 +1,327 @@ +from abc import abstractmethod +from pathlib import Path +from typing import Generic, List, TypeVar, Union + +import pytest + +from elementary.messages.block_builders import BulletListBlock +from elementary.messages.blocks import ( + CodeBlock, + DividerBlock, + ExpandableBlock, + FactBlock, + FactListBlock, + HeaderBlock, + Icon, + IconBlock, + LineBlock, + LinesBlock, + LinkBlock, + TextBlock, + TextStyle, +) +from elementary.messages.message_body import Color, MessageBody + +T = TypeVar("T") + + +class BaseTestFormat(Generic[T]): + @abstractmethod + def format(self, message_body: MessageBody) -> T: + raise NotImplementedError + + @abstractmethod + def get_expected_file_path(self, name: str) -> Path: + raise NotImplementedError + + @abstractmethod + def assert_expected_value(self, result: T, expected_file_path: Path) -> None: + raise NotImplementedError + + def test_format_message_body_simple_header(self): + message_body = MessageBody(blocks=[HeaderBlock(text="Test Header")], color=None) + expected_file_path = self.get_expected_file_path("simple_header") + result = self.format(message_body) + self.assert_expected_value(result, expected_file_path) + + def test_format_message_body_colored_header(self): + message_body = MessageBody( + blocks=[HeaderBlock(text="Test Header")], color=Color.GREEN + ) + expected_file_path = self.get_expected_file_path("colored_header") + result = self.format(message_body) + self.assert_expected_value(result, expected_file_path) + + def test_format_message_body_all_icons(self): + icon_blocks: List[Union[TextBlock, IconBlock]] = [] + for icon in Icon: + icon_blocks.append(TextBlock(text=icon.name)) + icon_blocks.append(IconBlock(icon=icon)) + message_body = MessageBody( + blocks=[LinesBlock(lines=[LineBlock(inlines=icon_blocks)])] + ) + result = self.format(message_body) + expected_file_path = self.get_expected_file_path("all_icons") + self.assert_expected_value(result, expected_file_path) + + def test_format_message_body_text_styles(self): + message_body = MessageBody( + blocks=[ + LinesBlock( + lines=[ + LineBlock( + inlines=[ + TextBlock(text="Normal text"), + TextBlock(text="Bold text", style=TextStyle.BOLD), + TextBlock(text="Italic text", style=TextStyle.ITALIC), + ] + ) + ] + ) + ] + ) + expected_file_path = self.get_expected_file_path("text_styles") + result = self.format(message_body) + self.assert_expected_value(result, expected_file_path) + + def test_format_message_body_fact_list(self): + message_body = MessageBody( + blocks=[ + FactListBlock( + facts=[ + FactBlock( + title=LineBlock(inlines=[TextBlock(text="Status")]), + value=LineBlock(inlines=[TextBlock(text="Passed")]), + ), + ] + ) + ] + ) + expected_file_path = self.get_expected_file_path("fact_list") + result = self.format(message_body) + self.assert_expected_value(result, expected_file_path) + + def test_format_message_body_expandable_block(self): + message_body = MessageBody( + blocks=[ + ExpandableBlock( + title="Show More", + body=[ + LinesBlock( + lines=[ + LineBlock(inlines=[TextBlock(text="Hidden content")]) + ] + ) + ], + expanded=False, + ) + ] + ) + expected_file_path = self.get_expected_file_path("expandable_block") + result = self.format(message_body) + self.assert_expected_value(result, expected_file_path) + + def test_format_message_body_divider_blocks(self): + message_body = MessageBody( + blocks=[ + HeaderBlock(text="First Section"), + DividerBlock(), + HeaderBlock(text="Second Section"), + ] + ) + expected_file_path = self.get_expected_file_path("divider_blocks") + result = self.format(message_body) + self.assert_expected_value(result, expected_file_path) + + def test_format_message_body_bullet_lists(self): + message_body = MessageBody( + blocks=[ + BulletListBlock( + icon="-", + lines=[ + LineBlock(inlines=[TextBlock(text="First bullet")]), + LineBlock(inlines=[TextBlock(text="Second bullet")]), + ], + ), + BulletListBlock( + icon=Icon.CHECK, + lines=[ + LineBlock(inlines=[TextBlock(text="Check item 1")]), + LineBlock(inlines=[TextBlock(text="Check item 2")]), + ], + ), + ] + ) + expected_file_path = self.get_expected_file_path("bullet_list") + result = self.format(message_body) + self.assert_expected_value(result, expected_file_path) + + def test_format_message_body_nested_expandable(self): + message_body = MessageBody( + blocks=[ + ExpandableBlock( + title="Outer Block", + body=[ + LinesBlock( + lines=[ + LineBlock( + inlines=[ + IconBlock(icon=Icon.MAGNIFYING_GLASS), + TextBlock( + text="Title with Icon", style=TextStyle.BOLD + ), + ] + ), + LineBlock( + inlines=[ + TextBlock(text="Some content with a"), + LinkBlock( + text="link", url="https://example.com" + ), + ] + ), + ] + ), + ExpandableBlock( + title="Inner Block", + body=[ + LinesBlock( + lines=[ + LineBlock( + inlines=[TextBlock(text="Inner content")] + ) + ] + ) + ], + expanded=True, + ), + ], + expanded=False, + ) + ] + ) + expected_file_path = self.get_expected_file_path("nested_expandable") + result = self.format(message_body) + self.assert_expected_value(result, expected_file_path) + + @pytest.mark.parametrize( + "color,expected_file", + [ + pytest.param(None, "all_blocks_no_color", id="no_color"), + pytest.param(Color.RED, "all_blocks_red", id="red"), + pytest.param(Color.YELLOW, "all_blocks_yellow", id="yellow"), + pytest.param(Color.GREEN, "all_blocks_green", id="green"), + ], + ) + def test_format_message_body_all_blocks(self, color, expected_file): + """Test a comprehensive message that includes all block types with different colors.""" + message_body = MessageBody( + blocks=[ + HeaderBlock(text="Main Header"), + LinesBlock( + lines=[ + LineBlock( + inlines=[ + TextBlock(text="Normal text"), + TextBlock(text="Bold text", style=TextStyle.BOLD), + TextBlock(text="Italic text", style=TextStyle.ITALIC), + ] + ) + ] + ), + BulletListBlock( + icon="-", + lines=[ + LineBlock(inlines=[TextBlock(text="First bullet point")]), + LineBlock(inlines=[TextBlock(text="Second bullet point")]), + ], + ), + BulletListBlock( + icon=Icon.CHECK, + lines=[LineBlock(inlines=[TextBlock(text="Check item")])], + ), + FactListBlock( + facts=[ + FactBlock( + title=LineBlock(inlines=[TextBlock(text="Status")]), + value=LineBlock(inlines=[TextBlock(text="Passed")]), + ), + FactBlock( + title=LineBlock(inlines=[TextBlock(text="Tags")]), + value=LineBlock(inlines=[TextBlock(text="test, example")]), + ), + ] + ), + ExpandableBlock( + title="Show Details", + body=[ + LinesBlock( + lines=[ + LineBlock( + inlines=[ + IconBlock(icon=Icon.MAGNIFYING_GLASS), + TextBlock( + text="Details Section", style=TextStyle.BOLD + ), + ] + ), + LineBlock( + inlines=[ + TextBlock(text="Here's some content with a"), + LinkBlock( + text="link", url="https://example.com" + ), + ] + ), + ] + ) + ], + expanded=False, + ), + ], + color=color, + ) + expected_file_path = self.get_expected_file_path(expected_file) + result = self.format(message_body) + self.assert_expected_value(result, expected_file_path) + + @pytest.mark.parametrize( + "text_length", + [ + pytest.param(50, id="short_code"), + pytest.param(200, id="medium_code"), + pytest.param(500, id="long_code"), + ], + ) + def test_format_message_body_code_block(self, text_length: int): + lorem_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * ( + (text_length + 49) // 50 + ) + lorem_text = lorem_text[:text_length] + + message_body = MessageBody(blocks=[CodeBlock(text=lorem_text)]) + expected_file_path = self.get_expected_file_path(f"code_block_{text_length}") + result = self.format(message_body) + self.assert_expected_value(result, expected_file_path) + + @pytest.mark.parametrize( + "text_length", + [ + pytest.param(50, id="short_text"), + pytest.param(200, id="medium_text"), + pytest.param(500, id="long_text"), + pytest.param(1000, id="very_long_text"), + ], + ) + def test_format_message_body_text_length(self, text_length: int): + lorem_text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * ( + (text_length + 49) // 50 + ) + lorem_text = lorem_text[:text_length] + + message_body = MessageBody( + blocks=[LinesBlock(lines=[LineBlock(inlines=[TextBlock(text=lorem_text)])])] + ) + expected_file_path = self.get_expected_file_path(f"text_length_{text_length}") + result = self.format(message_body) + self.assert_expected_value(result, expected_file_path) diff --git a/tests/unit/messages/formats/block_kit/__init__.py b/tests/unit/messages/formats/block_kit/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/messages/formats/block_kit/fixtures/all_blocks_green.json b/tests/unit/messages/formats/block_kit/fixtures/all_blocks_green.json new file mode 100644 index 000000000..38ccfb652 --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/all_blocks_green.json @@ -0,0 +1,65 @@ +{ + "blocks": [], + "attachments": [ + { + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Main Header" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Normal text *Bold text* _Italic text_" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "- First bullet point\n- Second bullet point" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "\u2705 Check item" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Status*\nPassed" + }, + { + "type": "mrkdwn", + "text": "*Tags*\ntest, example" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Show Details*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "\ud83d\udd0e *Details Section*\nHere's some content with a " + } + } + ], + "color": "#33b989" + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/all_blocks_no_color.json b/tests/unit/messages/formats/block_kit/fixtures/all_blocks_no_color.json new file mode 100644 index 000000000..6abd6c88f --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/all_blocks_no_color.json @@ -0,0 +1,64 @@ +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Main Header" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Normal text *Bold text* _Italic text_" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "- First bullet point\n- Second bullet point" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "\u2705 Check item" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Status*\nPassed" + }, + { + "type": "mrkdwn", + "text": "*Tags*\ntest, example" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Show Details*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "\ud83d\udd0e *Details Section*\nHere's some content with a " + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/all_blocks_red.json b/tests/unit/messages/formats/block_kit/fixtures/all_blocks_red.json new file mode 100644 index 000000000..662122826 --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/all_blocks_red.json @@ -0,0 +1,65 @@ +{ + "blocks": [], + "attachments": [ + { + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Main Header" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Normal text *Bold text* _Italic text_" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "- First bullet point\n- Second bullet point" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "\u2705 Check item" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Status*\nPassed" + }, + { + "type": "mrkdwn", + "text": "*Tags*\ntest, example" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Show Details*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "\ud83d\udd0e *Details Section*\nHere's some content with a " + } + } + ], + "color": "#ff0000" + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/all_blocks_yellow.json b/tests/unit/messages/formats/block_kit/fixtures/all_blocks_yellow.json new file mode 100644 index 000000000..b645c946f --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/all_blocks_yellow.json @@ -0,0 +1,65 @@ +{ + "blocks": [], + "attachments": [ + { + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Main Header" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Normal text *Bold text* _Italic text_" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "- First bullet point\n- Second bullet point" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "\u2705 Check item" + } + }, + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Status*\nPassed" + }, + { + "type": "mrkdwn", + "text": "*Tags*\ntest, example" + } + ] + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Show Details*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "\ud83d\udd0e *Details Section*\nHere's some content with a " + } + } + ], + "color": "#ffcc00" + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/all_icons.json b/tests/unit/messages/formats/block_kit/fixtures/all_icons.json new file mode 100644 index 000000000..c2b8a8941 --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/all_icons.json @@ -0,0 +1,16 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "RED_TRIANGLE \ud83d\udd3a X \u274c WARNING \u26a0\ufe0f EXCLAMATION \u2757 CHECK \u2705 MAGNIFYING_GLASS \ud83d\udd0e HAMMER_AND_WRENCH \ud83d\udee0\ufe0f POLICE_LIGHT \ud83d\udea8 INFO \u2139\ufe0f EYE \ud83d\udc41\ufe0f GEAR \u2699\ufe0f BELL \ud83d\udd14" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/bullet_list.json b/tests/unit/messages/formats/block_kit/fixtures/bullet_list.json new file mode 100644 index 000000000..756b9b619 --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/bullet_list.json @@ -0,0 +1,23 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "- First bullet\n- Second bullet" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "\u2705 Check item 1\n\u2705 Check item 2" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/code_block_200.json b/tests/unit/messages/formats/block_kit/fixtures/code_block_200.json new file mode 100644 index 000000000..3eee0b01b --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/code_block_200.json @@ -0,0 +1,16 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "```Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, c```" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/code_block_50.json b/tests/unit/messages/formats/block_kit/fixtures/code_block_50.json new file mode 100644 index 000000000..ba6ab738a --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/code_block_50.json @@ -0,0 +1,16 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "```Lorem ipsum dolor sit amet, consectetur adipiscing```" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/code_block_500.json b/tests/unit/messages/formats/block_kit/fixtures/code_block_500.json new file mode 100644 index 000000000..5bf0f80eb --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/code_block_500.json @@ -0,0 +1,16 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "```Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adip```" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/colored_header.json b/tests/unit/messages/formats/block_kit/fixtures/colored_header.json new file mode 100644 index 000000000..84b086a72 --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/colored_header.json @@ -0,0 +1,17 @@ +{ + "blocks": [], + "attachments": [ + { + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Test Header" + } + } + ], + "color": "#33b989" + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/divider_blocks.json b/tests/unit/messages/formats/block_kit/fixtures/divider_blocks.json new file mode 100644 index 000000000..39defa792 --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/divider_blocks.json @@ -0,0 +1,27 @@ +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "First Section" + } + }, + { + "type": "divider" + } + ], + "attachments": [ + { + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Second Section" + } + } + ] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/expandable_block.json b/tests/unit/messages/formats/block_kit/fixtures/expandable_block.json new file mode 100644 index 000000000..e1887d53a --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/expandable_block.json @@ -0,0 +1,23 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Show More*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Hidden content" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/fact_list.json b/tests/unit/messages/formats/block_kit/fixtures/fact_list.json new file mode 100644 index 000000000..d696aaa8b --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/fact_list.json @@ -0,0 +1,18 @@ +{ + "blocks": [ + { + "type": "section", + "fields": [ + { + "type": "mrkdwn", + "text": "*Status*\nPassed" + } + ] + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/nested_expandable.json b/tests/unit/messages/formats/block_kit/fixtures/nested_expandable.json new file mode 100644 index 000000000..6897b2de3 --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/nested_expandable.json @@ -0,0 +1,37 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Outer Block*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "\ud83d\udd0e *Title with Icon*\nSome content with a " + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "*Inner Block*" + } + }, + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Inner content" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/simple_header.json b/tests/unit/messages/formats/block_kit/fixtures/simple_header.json new file mode 100644 index 000000000..fc2e9242f --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/simple_header.json @@ -0,0 +1,16 @@ +{ + "blocks": [ + { + "type": "header", + "text": { + "type": "plain_text", + "text": "Test Header" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/text_length_1000.json b/tests/unit/messages/formats/block_kit/fixtures/text_length_1000.json new file mode 100644 index 000000000..4e612ae8f --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/text_length_1000.json @@ -0,0 +1,16 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, con" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/text_length_200.json b/tests/unit/messages/formats/block_kit/fixtures/text_length_200.json new file mode 100644 index 000000000..209a70faf --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/text_length_200.json @@ -0,0 +1,16 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, c" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/text_length_50.json b/tests/unit/messages/formats/block_kit/fixtures/text_length_50.json new file mode 100644 index 000000000..6613c9a5b --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/text_length_50.json @@ -0,0 +1,16 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/text_length_500.json b/tests/unit/messages/formats/block_kit/fixtures/text_length_500.json new file mode 100644 index 000000000..bbb0ac685 --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/text_length_500.json @@ -0,0 +1,16 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Lorem ipsum dolor sit amet, consectetur adip" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/fixtures/text_styles.json b/tests/unit/messages/formats/block_kit/fixtures/text_styles.json new file mode 100644 index 000000000..8567db378 --- /dev/null +++ b/tests/unit/messages/formats/block_kit/fixtures/text_styles.json @@ -0,0 +1,16 @@ +{ + "blocks": [ + { + "type": "section", + "text": { + "type": "mrkdwn", + "text": "Normal text *Bold text* _Italic text_" + } + } + ], + "attachments": [ + { + "blocks": [] + } + ] +} diff --git a/tests/unit/messages/formats/block_kit/test_block_kit.py b/tests/unit/messages/formats/block_kit/test_block_kit.py new file mode 100644 index 000000000..fd63d2f48 --- /dev/null +++ b/tests/unit/messages/formats/block_kit/test_block_kit.py @@ -0,0 +1,19 @@ +from pathlib import Path + +from elementary.messages.formats.block_kit import format_block_kit +from elementary.messages.message_body import MessageBody +from tests.unit.messages.formats.base_test_format import BaseTestFormat +from tests.unit.messages.utils import assert_expected_json, get_expected_json_path + +FIXTURES_DIR = Path(__file__).parent / "fixtures" + + +class TestBlockKit(BaseTestFormat[dict]): + def format(self, message_body: MessageBody) -> dict: + return format_block_kit(message_body) + + def get_expected_file_path(self, name: str) -> str: + return get_expected_json_path(FIXTURES_DIR, f"{name}.json") + + def assert_expected_value(self, result: dict, expected_file_path: Path) -> None: + assert_expected_json(result, expected_file_path)