From e784d6a724f99f2d4d3b46a01a5a3558cfe19ba4 Mon Sep 17 00:00:00 2001 From: Rodolfo Olivieri Date: Mon, 6 Jan 2025 11:43:32 -0300 Subject: [PATCH] Small code refactor for outputting text --- command_line_assistant/commands/history.py | 127 ++++++++------------- command_line_assistant/commands/query.py | 82 ++++--------- command_line_assistant/initialize.py | 10 +- command_line_assistant/rendering/base.py | 10 +- command_line_assistant/utils/renderers.py | 86 ++++++++++++++ docs/source/utils/index.rst | 1 + docs/source/utils/renderers.rst | 8 ++ tests/commands/test_history.py | 18 +-- tests/conftest.py | 6 + tests/helpers.py | 17 +++ tests/rendering/renders/test_spinner.py | 28 +---- tests/rendering/renders/test_text.py | 31 +++-- tests/test_initialize.py | 2 +- tests/utils/test_renderers.py | 39 +++++++ 14 files changed, 266 insertions(+), 199 deletions(-) create mode 100644 command_line_assistant/utils/renderers.py create mode 100644 docs/source/utils/renderers.rst create mode 100644 tests/helpers.py create mode 100644 tests/utils/test_renderers.py diff --git a/command_line_assistant/commands/history.py b/command_line_assistant/commands/history.py index 6bb56b5..eb91598 100644 --- a/command_line_assistant/commands/history.py +++ b/command_line_assistant/commands/history.py @@ -1,6 +1,5 @@ """Module to handle the history command.""" -import logging from argparse import Namespace from command_line_assistant.dbus.constants import HISTORY_IDENTIFIER @@ -8,56 +7,19 @@ CorruptedHistoryError, MissingHistoryFileError, ) -from command_line_assistant.dbus.structures import HistoryEntry +from command_line_assistant.dbus.structures import HistoryEntry, HistoryItem from command_line_assistant.rendering.decorators.colors import ColorDecorator from command_line_assistant.rendering.decorators.text import ( EmojiDecorator, - TextWrapDecorator, ) from command_line_assistant.rendering.renders.spinner import SpinnerRenderer from command_line_assistant.rendering.renders.text import TextRenderer -from command_line_assistant.rendering.stream import StdoutStream from command_line_assistant.utils.cli import BaseCLICommand, SubParsersAction - -logger = logging.getLogger(__name__) - - -def _initialize_spinner_renderer() -> SpinnerRenderer: - """Initialize a new text renderer class - - Returns: - SpinnerRenderer: Instance of a text renderer class with decorators. - """ - spinner = SpinnerRenderer(message="Loading history", stream=StdoutStream(end="")) - spinner.update(TextWrapDecorator()) - - return spinner - - -def _initialize_qa_renderer(is_assistant: bool = False) -> TextRenderer: - """Initialize a new text renderer class. - - Args: - is_assistant (bool): Apply different decorators if it is assistant. - - Returns: - TextRenderer: Instance of a text renderer class with decorators. - """ - text = TextRenderer(stream=StdoutStream(end="\n")) - foreground = "lightblue" if is_assistant else "lightgreen" - text.update(ColorDecorator(foreground=foreground)) - text.update(EmojiDecorator("šŸ¤–")) - return text - - -def _initialize_text_renderer() -> TextRenderer: - """Initialize a new text renderer class - - Returns: - TextRenderer: Instance of a text renderer class with decorators. - """ - text = TextRenderer(stream=StdoutStream(end="\n")) - return text +from command_line_assistant.utils.renderers import ( + create_error_renderer, + create_spinner_renderer, + create_text_renderer, +) class HistoryCommand(BaseCLICommand): @@ -80,10 +42,19 @@ def __init__(self, clear: bool, first: bool, last: bool) -> None: self._last = last self._proxy = HISTORY_IDENTIFIER.get_proxy() - self._user_renderer = _initialize_qa_renderer() - self._assistant_renderer = _initialize_qa_renderer(is_assistant=True) - self._text_renderer = _initialize_text_renderer() - self._spinner_renderer = _initialize_spinner_renderer() + + self._spinner_renderer: SpinnerRenderer = create_spinner_renderer( + message="Loading history", + decorators=[EmojiDecorator(emoji="U+1F916")], + ) + self._q_renderer: TextRenderer = create_text_renderer( + decorators=[ColorDecorator("lightgreen")] + ) + self._a_renderer: TextRenderer = create_text_renderer( + decorators=[ColorDecorator("lightblue")] + ) + self._text_renderer: TextRenderer = create_text_renderer() + self._error_renderer: TextRenderer = create_error_renderer() super().__init__() @@ -108,9 +79,7 @@ def run(self) -> int: return 0 except (MissingHistoryFileError, CorruptedHistoryError) as e: - self._text_renderer.update(ColorDecorator(foreground="red")) - self._text_renderer.update(EmojiDecorator(emoji="U+1F641")) - self._text_renderer.render(str(e)) + self._error_renderer.render(str(e)) return 1 def _retrieve_all_conversations(self) -> None: @@ -119,54 +88,56 @@ def _retrieve_all_conversations(self) -> None: response = self._proxy.GetHistory() history = HistoryEntry.from_structure(response) - if not history.entries: - self._text_renderer.render("No history found.") - return - - for entry in history.entries: - self._user_renderer.render(f"Query: {entry.query}") - self._assistant_renderer.render(f"Answer: {entry.response}") - self._text_renderer.render(f"Time: {entry.timestamp}") - self._text_renderer.render("-" * 50) # Separator between conversations + # Display the conversation + self._show_history(history.entries) def _retrieve_first_conversation(self) -> None: """Retrieve the first conversation in the conversation cache.""" - logger.info("Getting first conversation from history.") + self._text_renderer.render("Getting first conversation from history.") response = self._proxy.GetFirstConversation() history = HistoryEntry.from_structure(response) - if not history.entries: - self._text_renderer.render("No history found.") - return - - entry = history.entries[0] - self._user_renderer.render(f"Query: {entry.query}") - self._assistant_renderer.render(f"Answer: {entry.response}") - self._text_renderer.render(f"Time: {entry.timestamp}") + # Display the conversation + self._show_history(history.entries) def _retrieve_last_conversation(self): """Retrieve the last conversation in the conversation cache.""" - logger.info("Getting last conversation from history.") + self._text_renderer.render("Getting last conversation from history.") response = self._proxy.GetLastConversation() # Handle and display the response history = HistoryEntry.from_structure(response) - if not history.entries: - self._text_renderer.render("No history found.") - return - # Display the conversation - entry = history.entries[-1] - self._user_renderer.render(f"Query: {entry.query}") - self._assistant_renderer.render(f"Answer: {entry.response}") - self._text_renderer.render(f"Time: {entry.timestamp}") + self._show_history(history.entries) def _clear_history(self) -> None: """Clear the user history""" self._text_renderer.render("Cleaning the history.") self._proxy.ClearHistory() + def _show_history(self, entries: list[HistoryItem]) -> None: + """Internal method to show the history in a standarized way + + Args: + entries (list[HistoryItem]): The list of entries in the history + """ + if not entries: + self._text_renderer.render("No history found.") + return + + is_separator_needed = len(entries) > 1 + for entry in entries: + self._q_renderer.render(f"Query: {entry.query}") + self._a_renderer.render(f"Answer: {entry.response}") + + timestamp = f"Time: {entry.timestamp}" + self._text_renderer.render(timestamp) + + if is_separator_needed: + # Separator between conversations + self._text_renderer.render("-" * len(timestamp)) + def register_subcommand(parser: SubParsersAction): """ diff --git a/command_line_assistant/commands/query.py b/command_line_assistant/commands/query.py index ebd5183..94dbf27 100644 --- a/command_line_assistant/commands/query.py +++ b/command_line_assistant/commands/query.py @@ -13,13 +13,16 @@ from command_line_assistant.rendering.decorators.colors import ColorDecorator from command_line_assistant.rendering.decorators.text import ( EmojiDecorator, - TextWrapDecorator, WriteOnceDecorator, ) from command_line_assistant.rendering.renders.spinner import SpinnerRenderer from command_line_assistant.rendering.renders.text import TextRenderer -from command_line_assistant.rendering.stream import StderrStream, StdoutStream from command_line_assistant.utils.cli import BaseCLICommand, SubParsersAction +from command_line_assistant.utils.renderers import ( + create_error_renderer, + create_spinner_renderer, + create_text_renderer, +) LEGAL_NOTICE = ( "RHEL Lightspeed Command Line Assistant can answer questions related to RHEL." @@ -34,54 +37,6 @@ #: Always good to have legal message. -def _initialize_spinner_renderer() -> SpinnerRenderer: - """Initialize a new spinner renderer class - - Returns: - SpinnerRenderer: Instance of a spinner renderer class with decorators. - """ - spinner = SpinnerRenderer( - message="Requesting knowledge from AI", stream=StdoutStream(end="") - ) - - spinner.update(EmojiDecorator(emoji="U+1F916")) # Robot emoji - spinner.update(TextWrapDecorator()) - - return spinner - - -def _initialize_text_renderer() -> TextRenderer: - """Initialize a new text renderer class - - Returns: - TextRenderer: Instance of a text renderer class with decorators. - """ - text = TextRenderer(stream=StdoutStream(end="\n")) - text.update(ColorDecorator(foreground="green")) # Robot emoji - text.update(TextWrapDecorator()) - - return text - - -def _initialize_legal_renderer(write_once: bool = False) -> TextRenderer: - """Initialize a new text renderer class - - Args: - write_once (bool): If it should add the `py:WriteOnceDecorator` or not. - - Returns: - SpinnerRenderer: Instance of a text renderer class with decorators. - """ - text = TextRenderer(stream=StderrStream()) - text.update(ColorDecorator(foreground="lightyellow")) - text.update(TextWrapDecorator()) - - if write_once: - text.update(WriteOnceDecorator(state_filename="legal")) - - return text - - class QueryCommand(BaseCLICommand): """Class that represents the query command.""" @@ -95,10 +50,23 @@ def __init__(self, query_string: str, stdin: Optional[str]) -> None: self._query = query_string self._stdin = stdin - self._spinner_renderer: SpinnerRenderer = _initialize_spinner_renderer() - self._text_renderer: TextRenderer = _initialize_text_renderer() - self._legal_renderer: TextRenderer = _initialize_legal_renderer(write_once=True) - self._warning_renderer: TextRenderer = _initialize_legal_renderer() + self._spinner_renderer: SpinnerRenderer = create_spinner_renderer( + message="Requesting knowledge from AI", + decorators=[EmojiDecorator(emoji="U+1F916")], + ) + self._text_renderer: TextRenderer = create_text_renderer( + decorators=[ColorDecorator(foreground="green")] + ) + self._legal_renderer: TextRenderer = create_text_renderer( + decorators=[ + ColorDecorator(foreground="lightyellow"), + WriteOnceDecorator(state_filename="legal"), + ] + ) + self._warning_renderer: TextRenderer = create_text_renderer( + decorators=[ColorDecorator(foreground="lightyellow")] + ) + self._error_renderer: TextRenderer = create_error_renderer() super().__init__() @@ -130,9 +98,7 @@ def run(self) -> int: MissingHistoryFileError, CorruptedHistoryError, ) as e: - self._text_renderer.update(ColorDecorator(foreground="red")) - self._text_renderer.update(EmojiDecorator(emoji="U+1F641")) - self._text_renderer.render(str(e)) + self._error_renderer.render(str(e)) return 1 self._legal_renderer.render(LEGAL_NOTICE) @@ -143,7 +109,7 @@ def run(self) -> int: def register_subcommand(parser: SubParsersAction) -> None: """ - Register this command to argparse so it's available for the root parser. + Register this command to argparse so it's available for the root parserself._. Args: parser (SubParsersAction): Root parser to register command-specific arguments diff --git a/command_line_assistant/initialize.py b/command_line_assistant/initialize.py index 35adce1..befc485 100644 --- a/command_line_assistant/initialize.py +++ b/command_line_assistant/initialize.py @@ -4,14 +4,12 @@ from argparse import ArgumentParser, Namespace from command_line_assistant.commands import history, query, record -from command_line_assistant.rendering.decorators.colors import ColorDecorator -from command_line_assistant.rendering.decorators.text import EmojiDecorator -from command_line_assistant.rendering.renders.text import TextRenderer from command_line_assistant.utils.cli import ( add_default_command, create_argument_parser, read_stdin, ) +from command_line_assistant.utils.renderers import create_error_renderer def register_subcommands() -> ArgumentParser: @@ -45,10 +43,8 @@ def initialize() -> int: stdin = read_stdin() except UnicodeDecodeError: # Usually happens when the user try to cat a binary file and redirect that to us. - text_renderer = TextRenderer() - text_renderer.update(ColorDecorator(foreground="red")) - text_renderer.update(EmojiDecorator(emoji="U+1F641")) - text_renderer.render( + error_renderer = create_error_renderer() + error_renderer.render( "The stdin provided could not be decoded. Please, make sure it is in textual format." ) return 1 diff --git a/command_line_assistant/rendering/base.py b/command_line_assistant/rendering/base.py index d83be93..92eaf9c 100644 --- a/command_line_assistant/rendering/base.py +++ b/command_line_assistant/rendering/base.py @@ -74,14 +74,18 @@ def __init__(self, stream: BaseStream) -> None: self._stream = stream self._decorators: dict[type, BaseDecorator] = {} - def update(self, decorator: BaseDecorator) -> None: + def update(self, decorators: list[BaseDecorator]) -> None: """Update or add a decorator of the same type. Args: - decorator (RenderDecorator): An instance of a rendering decorator to be applied. + decorator (list[BaseDecorator]): An instance of a rendering + decorator to be applied. """ + if not isinstance(decorators, list): + raise TypeError(f"decorators must be a list, not '{type(decorators)}'") - self._decorators[type(decorator)] = decorator + for decorator in decorators: + self._decorators[type(decorator)] = decorator def _apply_decorators(self, text: str) -> str: """Apply all decorators to the text. diff --git a/command_line_assistant/utils/renderers.py b/command_line_assistant/utils/renderers.py new file mode 100644 index 0000000..ea413f7 --- /dev/null +++ b/command_line_assistant/utils/renderers.py @@ -0,0 +1,86 @@ +"""Utility module that provides standarized functions for rendering""" + +from typing import Optional + +from command_line_assistant.rendering.base import BaseDecorator, BaseStream +from command_line_assistant.rendering.decorators.colors import ColorDecorator +from command_line_assistant.rendering.decorators.text import ( + EmojiDecorator, + TextWrapDecorator, +) +from command_line_assistant.rendering.renders.spinner import SpinnerRenderer +from command_line_assistant.rendering.renders.text import TextRenderer +from command_line_assistant.rendering.stream import StdoutStream + + +def create_error_renderer() -> TextRenderer: + """Create a standarized instance of text rendering for error output + + Returns: + TextRenderer: Instance of a TextRenderer with correct decorators for + error output. + """ + renderer = TextRenderer() + renderer.update( + [ + EmojiDecorator(emoji="U+1F641"), + ColorDecorator(foreground="red"), + TextWrapDecorator(), + ] + ) + + return renderer + + +def create_spinner_renderer( + message: str, decorators: list[BaseDecorator] +) -> SpinnerRenderer: + """Create a new instance of a spinner renderer. + + Note: + `py:TextWrapDecorator` is applied automatically to the renderer. + + Args: + message (str): The message to show while spinning + decorators (list[BaseDecorator]): List of decorators that can be + applied to the spinner renderer. + + Returns: + SpinnerRenderer: Instance of a SpinnerRenderer with decorators applied. + """ + spinner = SpinnerRenderer(message, stream=StdoutStream(end="")) + decorators.append(TextWrapDecorator()) + spinner.update(decorators) + return spinner + + +def create_text_renderer( + decorators: Optional[list[BaseDecorator]] = None, + stream: Optional[BaseStream] = None, +) -> TextRenderer: + """Create a new instance of a text renderer. + + Note: + `py:TextWrapDecorator` is applied automatically to the renderer. + + Note: + If no `stream` is provided in the arguments, it will default to the + `py:StdoutStream()`. + + Args: + decorators (Optional[list[BaseDecorator]], optional): List of + decorators that can be applied to the text renderer. Defaults to None. + stream (Optional[BaseStream], optional): Apply a different stream other + than the StdoutStream. Defaults to None. + + Returns: + TextRenderer: Instance of a TextRenderer with decorators applied. + """ + # In case it is None, default it to an empty list. + decorators = decorators or [] + + text = TextRenderer(stream=stream) + decorators.append(TextWrapDecorator()) + text.update(decorators) + + return text diff --git a/docs/source/utils/index.rst b/docs/source/utils/index.rst index 669e79e..99326f9 100644 --- a/docs/source/utils/index.rst +++ b/docs/source/utils/index.rst @@ -6,3 +6,4 @@ Utils cli environment + renderers diff --git a/docs/source/utils/renderers.rst b/docs/source/utils/renderers.rst new file mode 100644 index 0000000..712706d --- /dev/null +++ b/docs/source/utils/renderers.rst @@ -0,0 +1,8 @@ +Renderers +========= + +.. automodule:: command_line_assistant.utils.renderers + :members: + :undoc-members: + :private-members: + :no-index: diff --git a/tests/commands/test_history.py b/tests/commands/test_history.py index 7ff366d..2546032 100644 --- a/tests/commands/test_history.py +++ b/tests/commands/test_history.py @@ -4,9 +4,6 @@ from command_line_assistant.commands.history import ( HistoryCommand, - _initialize_qa_renderer, - _initialize_spinner_renderer, - _initialize_text_renderer, ) from command_line_assistant.dbus.structures import HistoryEntry, HistoryItem @@ -42,17 +39,6 @@ def sample_history_entry(): return history_entry -def test_initialize_renderers(): - """Test initialization of various renderers.""" - spinner = _initialize_spinner_renderer() - qa = _initialize_qa_renderer() - text = _initialize_text_renderer() - - assert spinner is not None - assert qa is not None - assert text is not None - - def test_retrieve_all_conversations_success(mock_proxy, sample_history_entry, capsys): """Test retrieving all conversations successfully.""" mock_proxy.GetHistory.return_value = sample_history_entry.to_structure( @@ -86,7 +72,7 @@ def test_retrieve_first_conversation_success(mock_proxy, sample_history_entry, c captured = capsys.readouterr() mock_proxy.GetFirstConversation.assert_called_once() assert ( - "\x1b[92mQuery: test query\x1b[0m\nšŸ¤– \x1b[94mAnswer: test response\x1b[0m\n" + "\x1b[92mQuery: test query\x1b[0m\n\x1b[94mAnswer: test response\x1b[0m\n" in captured.out ) @@ -113,7 +99,7 @@ def test_retrieve_last_conversation_success(mock_proxy, sample_history_entry, ca captured = capsys.readouterr() mock_proxy.GetLastConversation.assert_called_once() assert ( - "\x1b[92mQuery: test final query\x1b[0m\nšŸ¤– \x1b[94mAnswer: test final response\x1b[0m\n" + "\x1b[92mQuery: test query\x1b[0m\n\x1b[94mAnswer: test response\x1b[0m\n" in captured.out ) diff --git a/tests/conftest.py b/tests/conftest.py index c979a14..7332320 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,6 +13,7 @@ OutputSchema, ) from command_line_assistant.config.schemas import AuthSchema +from tests.helpers import MockStream @pytest.fixture(autouse=True) @@ -64,3 +65,8 @@ def mock_proxy(): """Create a mock proxy for testing.""" proxy = MagicMock() return proxy + + +@pytest.fixture +def mock_stream(): + return MockStream() diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..2941fb7 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,17 @@ +from unittest.mock import MagicMock + +from command_line_assistant.rendering.base import BaseStream + + +class MockStream(BaseStream): + """Mock stream class for testing""" + + def __init__(self): + self.written = [] + super().__init__(stream=MagicMock()) + + def write(self, text: str) -> None: + self.written.append(text) + + def flush(self) -> None: + pass diff --git a/tests/rendering/renders/test_spinner.py b/tests/rendering/renders/test_spinner.py index d2ece25..d61aa3e 100644 --- a/tests/rendering/renders/test_spinner.py +++ b/tests/rendering/renders/test_spinner.py @@ -1,10 +1,8 @@ import threading import time -from unittest.mock import MagicMock import pytest -from command_line_assistant.rendering.base import BaseStream from command_line_assistant.rendering.decorators.colors import ColorDecorator from command_line_assistant.rendering.decorators.text import ( EmojiDecorator, @@ -13,25 +11,6 @@ from command_line_assistant.rendering.renders.spinner import Frames, SpinnerRenderer -class MockStream(BaseStream): - """Mock stream class for testing""" - - def __init__(self): - self.written = [] - super().__init__(stream=MagicMock()) - - def write(self, text: str) -> None: - self.written.append(text) - - def flush(self) -> None: - pass - - -@pytest.fixture -def mock_stream(): - return MockStream() - - @pytest.fixture def spinner(mock_stream): return SpinnerRenderer("Loading...", stream=mock_stream) @@ -82,7 +61,7 @@ def test_spinner_context_manager(spinner): def test_spinner_with_colored_text(mock_stream): """Test spinner with colored text""" spinner = SpinnerRenderer("Loading...", stream=mock_stream) - spinner.update(ColorDecorator(foreground="cyan")) + spinner.update([ColorDecorator(foreground="cyan")]) with spinner: time.sleep(0.2) @@ -95,8 +74,7 @@ def test_spinner_with_colored_text(mock_stream): def test_spinner_with_emoji_and_color(mock_stream): """Test spinner with both emoji and color decorators""" spinner = SpinnerRenderer("Processing...", stream=mock_stream) - spinner.update(ColorDecorator(foreground="yellow")) - spinner.update(EmojiDecorator("āš”")) + spinner.update([EmojiDecorator("āš”"), ColorDecorator(foreground="yellow")]) with spinner: time.sleep(0.2) @@ -110,7 +88,7 @@ def test_spinner_with_text_wrap(mock_stream): """Test spinner with text wrapping""" long_message = "This is a very long message that should be wrapped" spinner = SpinnerRenderer(long_message, stream=mock_stream) - spinner.update(TextWrapDecorator(width=20)) + spinner.update([TextWrapDecorator(width=20)]) with spinner: time.sleep(0.2) diff --git a/tests/rendering/renders/test_text.py b/tests/rendering/renders/test_text.py index af13c20..1f2932b 100644 --- a/tests/rendering/renders/test_text.py +++ b/tests/rendering/renders/test_text.py @@ -6,9 +6,13 @@ def test_text_renderer_multiple_decorators(): renderer = TextRenderer() - renderer.update(ColorDecorator(foreground="red")) - renderer.update(StyleDecorator("bright")) - renderer.update(TextWrapDecorator(width=50)) + renderer.update( + [ + ColorDecorator(foreground="red"), + StyleDecorator("bright"), + TextWrapDecorator(width=50), + ] + ) # Verify renderer has all decorators assert len(renderer._decorators) == 3 @@ -16,8 +20,9 @@ def test_text_renderer_multiple_decorators(): def test_text_renderer_decorator_override(): renderer = TextRenderer() - renderer.update(ColorDecorator(foreground="red")) - renderer.update(ColorDecorator(foreground="blue")) + renderer.update( + [ColorDecorator(foreground="red"), ColorDecorator(foreground="blue")] + ) # Verify last decorator of same type overrides previous assert len(renderer._decorators) == 1 @@ -25,7 +30,7 @@ def test_text_renderer_decorator_override(): def test_text_renderer_render_single_decorator(capsys): renderer = TextRenderer() - renderer.update(ColorDecorator(foreground="red")) + renderer.update([ColorDecorator(foreground="red")]) test_text = "Test message" renderer.render(test_text) @@ -36,9 +41,13 @@ def test_text_renderer_render_single_decorator(capsys): def test_text_renderer_render_multiple_decorators(capsys): renderer = TextRenderer() - renderer.update(ColorDecorator(foreground="blue")) - renderer.update(StyleDecorator("bright")) - renderer.update(TextWrapDecorator(width=20)) + renderer.update( + [ + ColorDecorator(foreground="blue"), + StyleDecorator("bright"), + TextWrapDecorator(width=20), + ] + ) test_text = "This is a test message that should be wrapped" renderer.render(test_text) @@ -54,7 +63,7 @@ def test_text_renderer_render_multiple_decorators(capsys): def test_text_renderer_render_empty_text(capsys): renderer = TextRenderer() - renderer.update(ColorDecorator(foreground="green")) + renderer.update([ColorDecorator(foreground="green")]) renderer.render("") @@ -66,7 +75,7 @@ def test_text_renderer_render_empty_text(capsys): def test_text_renderer_render_multiline(capsys): renderer = TextRenderer() - renderer.update(ColorDecorator(foreground="yellow")) + renderer.update([ColorDecorator(foreground="yellow")]) test_text = "Line 1\nLine 2\nLine 3" renderer.render(test_text) diff --git a/tests/test_initialize.py b/tests/test_initialize.py index 781635e..b82b812 100644 --- a/tests/test_initialize.py +++ b/tests/test_initialize.py @@ -125,7 +125,7 @@ def test_initialize_bad_stdin(capsys): captured = capsys.readouterr() assert ( - "The stdin provided could not be decoded. Please, make sure it is in textual format." + "The stdin provided could not be decoded. Please, make sure it is in\ntextual format." in captured.out ) diff --git a/tests/utils/test_renderers.py b/tests/utils/test_renderers.py new file mode 100644 index 0000000..aa0309f --- /dev/null +++ b/tests/utils/test_renderers.py @@ -0,0 +1,39 @@ +import threading +from unittest.mock import patch + +import pytest + +from command_line_assistant.utils import renderers + + +def test_create_error_renderer(capsys: pytest.CaptureFixture[str]): + renderer = renderers.create_error_renderer() + renderer.render("errored out") + + captured = capsys.readouterr() + print(captured) + assert "\x1b[31mšŸ™ errored out\x1b[0m\n" in captured.out + + +def test_create_spinner_renderer(capsys, mock_stream): + with patch("command_line_assistant.rendering.stream.StdoutStream", mock_stream): + spinner = renderers.create_spinner_renderer(message="Loading...", decorators=[]) + spinner.start() + assert isinstance(spinner._spinner_thread, threading.Thread) + assert spinner._spinner_thread.is_alive() + + spinner.stop() + assert not spinner._spinner_thread.is_alive() + assert spinner._done.is_set() + + captured = capsys.readouterr().out + assert "Loading..." in captured + + +def test_create_text_renderer(capsys: pytest.CaptureFixture[str]): + renderer = renderers.create_text_renderer() + renderer.render("errored out") + + captured = capsys.readouterr() + print(captured) + assert "rrored out\n" in captured.out