diff --git a/vulcano/app/lexer_test.py b/vulcano/app/lexer_test.py index 3d3e833..fce5f11 100644 --- a/vulcano/app/lexer_test.py +++ b/vulcano/app/lexer_test.py @@ -3,8 +3,18 @@ from unittest import TestCase # Third-party imports +from pygments.token import Keyword, Name, Number, Operator, String, Text + # Local imports -from .lexer import create_lexer +from .lexer import ( + DraculaTheme, + MonokaiTheme, + NordTheme, + OneDarkTheme, + SolarizedDarkTheme, + VulcanoStyle, + create_lexer, +) class TestThemesModule(TestCase): @@ -44,3 +54,120 @@ def test_dot_path_commands_are_escaped_and_ordered(self): self.assertEqual(parts[0], r"text\.formal\.dear") self.assertEqual(parts[1], r"text\.hi") self.assertEqual(parts[2], "text") + + def test_create_lexer_with_no_commands_returns_base_token_count(self): + lexer_none = create_lexer(None) + lexer_empty = create_lexer([]) + self.assertEqual( + len(lexer_none.tokens["root"]), + len(lexer_empty.tokens["root"]), + ) + + def test_each_create_lexer_call_returns_fresh_subclass(self): + lexer_a = create_lexer(["foo"]) + lexer_b = create_lexer(["bar"]) + self.assertIsNot(lexer_a, lexer_b) + + def test_command_keyword_token_is_first_in_root(self): + lexer = create_lexer(["mycmd"]) + token_type = lexer.tokens["root"][0][1] + self.assertEqual(token_type, Keyword) + + def test_no_commands_does_not_prepend_keyword_token(self): + lexer = create_lexer(None) + first_token_type = lexer.tokens["root"][0][1] + self.assertNotEqual(first_token_type, Keyword) + + +class TestVulcanoLexerTokenization(TestCase): + def _lex(self, text, commands=None): + lexer_cls = create_lexer(commands or []) + lexer = lexer_cls() + return list(lexer.get_tokens(text)) + + def test_integer_tokenized_as_number(self): + tokens = self._lex("42") + token_types = [t for t, _ in tokens] + self.assertIn(Number.Integer, token_types) + + def test_boolean_true_tokenized_as_operator(self): + tokens = self._lex("True") + token_types = [t for t, _ in tokens] + self.assertIn(Operator, token_types) + + def test_boolean_false_tokenized_as_operator(self): + tokens = self._lex("False") + token_types = [t for t, _ in tokens] + self.assertIn(Operator, token_types) + + def test_lowercase_boolean_tokenized_as_operator(self): + tokens = self._lex("true false") + token_types = [t for t, _ in tokens] + self.assertIn(Operator, token_types) + + def test_double_quoted_string_tokenized_as_string(self): + tokens = self._lex('"hello world"') + token_types = [t for t, _ in tokens] + self.assertIn(String.Single, token_types) + + def test_single_quoted_string_tokenized_as_string(self): + tokens = self._lex("'hello'") + token_types = [t for t, _ in tokens] + self.assertIn(String.Single, token_types) + + def test_identifier_tokenized_as_name(self): + tokens = self._lex("myvar") + token_types = [t for t, _ in tokens] + self.assertIn(Name, token_types) + + def test_registered_command_tokenized_as_keyword(self): + tokens = self._lex("greet", commands=["greet"]) + token_types = [t for t, _ in tokens] + self.assertIn(Keyword, token_types) + + def test_unregistered_command_not_tokenized_as_keyword(self): + tokens = self._lex("unknown", commands=["greet"]) + token_types = [t for t, _ in tokens] + self.assertNotIn(Keyword, token_types) + + def test_whitespace_tokenized_as_text(self): + tokens = self._lex(" ") + token_types = [t for t, _ in tokens] + self.assertIn(Text, token_types) + + +class TestThemeStyles(TestCase): + def test_dracula_theme_has_styles(self): + self.assertTrue(len(DraculaTheme.styles) > 0) + + def test_nord_theme_has_styles(self): + self.assertTrue(len(NordTheme.styles) > 0) + + def test_solarized_dark_theme_has_styles(self): + self.assertTrue(len(SolarizedDarkTheme.styles) > 0) + + def test_one_dark_theme_has_styles(self): + self.assertTrue(len(OneDarkTheme.styles) > 0) + + def test_all_themes_are_subclasses_of_vulcano_style(self): + themes = [ + MonokaiTheme, DraculaTheme, NordTheme, SolarizedDarkTheme, OneDarkTheme + ] + for theme in themes: + self.assertTrue( + issubclass(theme, VulcanoStyle), + "{} not a VulcanoStyle".format(theme.__name__), + ) + + def test_pygments_style_returns_callable(self): + style = DraculaTheme.pygments_style() + self.assertIsNotNone(style) + + def test_keyword_style_defined_in_color_themes(self): + themes = [DraculaTheme, NordTheme, SolarizedDarkTheme, OneDarkTheme] + for theme in themes: + self.assertIn( + Keyword, + theme.styles, + "{} missing Keyword style".format(theme.__name__), + ) diff --git a/vulcano/command/builtin_test.py b/vulcano/command/builtin_test.py index 35072bf..a9d43aa 100644 --- a/vulcano/command/builtin_test.py +++ b/vulcano/command/builtin_test.py @@ -57,3 +57,48 @@ def test_help_without_command(self, console_mock): help_func = builtin.help(app) help_func() console_mock.print.assert_called_once() + + @patch(console_path) + def test_help_hides_invisible_commands(self, console_mock): + from rich.table import Table + + app = MagicMock() + visible_cmd = MagicMock() + visible_cmd.name = "visible" + visible_cmd.short_description = "I am visible" + visible_cmd.visible = True + hidden_cmd = MagicMock() + hidden_cmd.name = "hidden" + hidden_cmd.short_description = "I am hidden" + hidden_cmd.visible = False + app.manager._commands = {"visible": visible_cmd, "hidden": hidden_cmd} + help_func = builtin.help(app) + help_func() + console_mock.print.assert_called_once() + table_arg = console_mock.print.call_args[0][0] + self.assertIsInstance(table_arg, Table) + # Only the visible command should be added; row count must be 1. + self.assertEqual(table_arg.row_count, 1) + + @patch(console_path) + def test_help_empty_command_list(self, console_mock): + from rich.table import Table + + app = MagicMock() + app.manager._commands = {} + help_func = builtin.help(app) + help_func() + console_mock.print.assert_called_once() + table_arg = console_mock.print.call_args[0][0] + self.assertIsInstance(table_arg, Table) + self.assertEqual(table_arg.row_count, 0) + + @patch(console_path) + def test_exit_prints_goodbye_message(self, console_mock): + app = MagicMock() + app.do_repl = True + exit_func = builtin.exit(app) + exit_func() + console_mock.print.assert_called_once() + printed_text = str(console_mock.print.call_args) + self.assertTrue(len(printed_text) > 0) diff --git a/vulcano/command/docutils_test.py b/vulcano/command/docutils_test.py new file mode 100644 index 0000000..03194b2 --- /dev/null +++ b/vulcano/command/docutils_test.py @@ -0,0 +1,140 @@ +# -* coding: utf-8 *- +# System imports +from unittest import TestCase + +# Local imports +from .docutils import multi_doc_parser + + +class TestMultiDocParserNullInputs(TestCase): + def test_none_returns_empty_tuple(self): + result = multi_doc_parser(None) + self.assertEqual(result, (None, None, {}, None)) + + def test_empty_string_returns_empty_tuple(self): + result = multi_doc_parser("") + self.assertEqual(result, (None, None, {}, None)) + + +class TestMultiDocParserPlainProse(TestCase): + def test_plain_short_description_only(self): + short, long, params, returns = multi_doc_parser("Do something useful.") + self.assertEqual(short, "Do something useful.") + self.assertIsNone(long) + self.assertEqual(params, {}) + self.assertIsNone(returns) + + def test_short_and_long_description_no_params(self): + docstring = "Short line.\n\nLonger explanation here." + short, long, params, returns = multi_doc_parser(docstring) + self.assertEqual(short, "Short line.") + self.assertIn("Longer", long) + self.assertEqual(params, {}) + self.assertIsNone(returns) + + +class TestMultiDocParserGoogleStyle(TestCase): + def test_google_style_params(self): + docstring = """Compute the sum. + + Args: + x (int): First number. + y (int): Second number. + + Returns: + int: The sum. + """ + short, long, params, returns = multi_doc_parser(docstring) + self.assertEqual(short, "Compute the sum.") + self.assertIn("x", params) + self.assertIn("y", params) + self.assertEqual(params["x"]["doc"], "First number.") + self.assertEqual(params["x"]["type"], "int") + self.assertEqual(params["y"]["doc"], "Second number.") + self.assertIsNotNone(returns) + + def test_google_style_param_without_type(self): + docstring = """Do a thing. + + Args: + name: A name string. + """ + _, _, params, _ = multi_doc_parser(docstring) + self.assertIn("name", params) + self.assertIsNone(params["name"]["type"]) + self.assertEqual(params["name"]["doc"], "A name string.") + + def test_google_style_no_returns(self): + docstring = """Prints something. + + Args: + msg (str): The message. + """ + _, _, _, returns = multi_doc_parser(docstring) + self.assertIsNone(returns) + + +class TestMultiDocParserSphinxStyle(TestCase): + def test_sphinx_style_params(self): + docstring = """Fetch a resource. + + :param str url: The URL to fetch. + :param int timeout: Seconds before timeout. + :returns: The response body. + """ + short, _, params, returns = multi_doc_parser(docstring) + self.assertEqual(short, "Fetch a resource.") + self.assertIn("url", params) + self.assertEqual(params["url"]["type"], "str") + self.assertIn("timeout", params) + self.assertEqual(params["timeout"]["type"], "int") + self.assertIsNotNone(returns) + + def test_sphinx_style_type_via_type_directive(self): + docstring = """:param name: A name. + :type name: str + """ + _, _, params, _ = multi_doc_parser(docstring) + self.assertIn("name", params) + self.assertEqual(params["name"]["type"], "str") + + +class TestMultiDocParserNumpyStyle(TestCase): + def test_numpy_style_params(self): + docstring = """Add two numbers. + + Parameters + ---------- + a : int + First operand. + b : int + Second operand. + + Returns + ------- + int + The result. + """ + short, _, params, returns = multi_doc_parser(docstring) + self.assertEqual(short, "Add two numbers.") + self.assertIn("a", params) + self.assertEqual(params["a"]["type"], "int") + self.assertIn("b", params) + self.assertIsNotNone(returns) + + +class TestMultiDocParserReturnValues(TestCase): + def test_returns_description_is_string(self): + docstring = """Get a value. + + Returns: + str: The value string. + """ + _, _, _, returns = multi_doc_parser(docstring) + self.assertIsInstance(returns, str) + self.assertTrue(len(returns) > 0) + + def test_no_return_section_yields_none(self): + docstring = """Just do stuff.""" + _, _, _, returns = multi_doc_parser(docstring) + self.assertIsNone(returns) diff --git a/vulcano/command/parser_test.py b/vulcano/command/parser_test.py index 1d2138d..66097a9 100644 --- a/vulcano/command/parser_test.py +++ b/vulcano/command/parser_test.py @@ -4,7 +4,7 @@ # Third-party imports # Local imports -from .parser import CommandParseError, inline_parser +from .parser import CommandParseError, inline_parser, split_list_by_arg class TestInlineParser(TestCase): @@ -54,3 +54,81 @@ def test_should_parse_list_value(self): """List values exercise the _no_transform path in _parse_type.""" args, kwargs = inline_parser("items=[1, 2, 3]") self.assertEqual(kwargs["items"], [1, 2, 3]) + + def test_should_parse_float_value(self): + _, kwargs = inline_parser("rate=3.14") + self.assertAlmostEqual(kwargs["rate"], 3.14) + + def test_should_parse_negative_int(self): + args, _ = inline_parser("-5") + self.assertEqual(args, [-5]) + + def test_should_parse_negative_float(self): + args, _ = inline_parser("-1.5") + self.assertAlmostEqual(args[0], -1.5) + + def test_should_parse_boolean_false(self): + _, kwargs = inline_parser("flag=False") + self.assertFalse(kwargs["flag"]) + + def test_should_parse_lowercase_true_false(self): + _, kwargs = inline_parser("a=true b=false") + self.assertTrue(kwargs["a"]) + self.assertFalse(kwargs["b"]) + + def test_should_parse_empty_list(self): + _, kwargs = inline_parser("items=[]") + self.assertEqual(kwargs["items"], []) + + def test_should_parse_list_of_strings(self): + _, kwargs = inline_parser("tags=['foo', 'bar']") + self.assertEqual(kwargs["tags"], ["foo", "bar"]) + + def test_dict_syntax_is_not_currently_supported(self): + # The grammar defines dict_value but the rule has a known parse bug; + # dict-style kwargs raise CommandParseError instead of being parsed. + with self.assertRaises(CommandParseError): + inline_parser("opts={key: value}") + + def test_should_parse_quoted_string_with_spaces(self): + args, _ = inline_parser('"hello world"') + self.assertEqual(args, ["hello world"]) + + def test_should_parse_single_quoted_string(self): + args, _ = inline_parser("'single quoted'") + self.assertEqual(args, ["single quoted"]) + + +class TestSplitListByArg(TestCase): + def test_splits_on_separator(self): + result = split_list_by_arg(["cmd1", "and", "cmd2"], "and") + self.assertEqual(result, ["cmd1", "cmd2"]) + + def test_no_separator_returns_single_chunk(self): + result = split_list_by_arg(["cmd1", "arg=1"], "and") + self.assertEqual(result, ["cmd1 arg=1"]) + + def test_multiple_separators_split_into_multiple_chunks(self): + result = split_list_by_arg(["a", "and", "b", "and", "c"], "and") + self.assertEqual(result, ["a", "b", "c"]) + + def test_quoted_separator_is_preserved(self): + result = split_list_by_arg(['cmd', 'msg="hello and world"'], "and") + self.assertEqual(len(result), 1) + self.assertIn("and", result[0]) + + def test_single_quoted_separator_is_preserved(self): + result = split_list_by_arg(["cmd", "msg='one and two'"], "and") + self.assertEqual(len(result), 1) + + def test_separator_is_word_boundary_only(self): + result = split_list_by_arg(["android", "and", "band"], "and") + self.assertEqual(result, ["android", "band"]) + + def test_empty_list_returns_single_empty_string(self): + result = split_list_by_arg([], "and") + self.assertEqual(result, [""]) + + def test_custom_separator(self): + result = split_list_by_arg(["do", "then", "cleanup"], "then") + self.assertEqual(result, ["do", "cleanup"]) diff --git a/vulcano/exceptions_test.py b/vulcano/exceptions_test.py new file mode 100644 index 0000000..48354e2 --- /dev/null +++ b/vulcano/exceptions_test.py @@ -0,0 +1,63 @@ +# -* coding: utf-8 *- +# System imports +from unittest import TestCase + +# Local imports +from vulcano.exceptions import CommandNotFound, CommandParseError, VulcanoException + + +class TestVulcanoException(TestCase): + def test_is_subclass_of_exception(self): + self.assertTrue(issubclass(VulcanoException, Exception)) + + def test_can_be_raised_and_caught(self): + with self.assertRaises(VulcanoException): + raise VulcanoException("something went wrong") + + def test_carries_message(self): + msg = "boom" + exc = VulcanoException(msg) + self.assertEqual(str(exc), msg) + + def test_caught_as_base_exception(self): + with self.assertRaises(Exception): + raise VulcanoException("also an Exception") + + +class TestCommandNotFound(TestCase): + def test_is_subclass_of_vulcano_exception(self): + self.assertTrue(issubclass(CommandNotFound, VulcanoException)) + + def test_is_subclass_of_exception(self): + self.assertTrue(issubclass(CommandNotFound, Exception)) + + def test_can_be_raised_and_caught_as_vulcano_exception(self): + with self.assertRaises(VulcanoException): + raise CommandNotFound("unknown_cmd") + + def test_carries_command_name(self): + exc = CommandNotFound("my_command") + self.assertIn("my_command", str(exc)) + + +class TestCommandParseError(TestCase): + def test_is_subclass_of_vulcano_exception(self): + self.assertTrue(issubclass(CommandParseError, VulcanoException)) + + def test_is_subclass_of_exception(self): + self.assertTrue(issubclass(CommandParseError, Exception)) + + def test_can_be_raised_and_caught_as_vulcano_exception(self): + with self.assertRaises(VulcanoException): + raise CommandParseError("bad input") + + def test_carries_parse_error_message(self): + exc = CommandParseError("unexpected token !") + self.assertIn("unexpected token", str(exc)) + + def test_different_exception_types_are_distinct(self): + self.assertIsNot(CommandNotFound, CommandParseError) + with self.assertRaises(CommandNotFound): + raise CommandNotFound("cmd") + with self.assertRaises(CommandParseError): + raise CommandParseError("parse")