# SPDX-License-Identifier: LGPL-2.1-or-later # *************************************************************************** # * * # * Copyright (c) 2022-2023 FreeCAD Project Association * # * * # * This file is part of FreeCAD. * # * * # * FreeCAD is free software: you can redistribute it and/or modify it * # * under the terms of the GNU Lesser General Public License as * # * published by the Free Software Foundation, either version 2.1 of the * # * License, or (at your option) any later version. * # * * # * FreeCAD is distributed in the hope that it will be useful, but * # * WITHOUT ANY WARRANTY; without even the implied warranty of * # * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * # * Lesser General Public License for more details. * # * * # * You should have received a copy of the GNU Lesser General Public * # * License along with FreeCAD. If not, see * # * . * # * * # *************************************************************************** """Tests for the MacroParser class""" import io import os import sys import unittest sys.path.append("../../") # So the IDE can find the classes to run with from addonmanager_macro_parser import MacroParser from AddonManagerTest.app.mocks import MockConsole, CallCatcher, MockThread # pylint: disable=protected-access, too-many-public-methods class TestMacroParser(unittest.TestCase): """Test the MacroParser class""" def setUp(self) -> None: self.test_object = MacroParser("UnitTestMacro") self.test_object.console = MockConsole() self.test_object.current_thread = MockThread() def tearDown(self) -> None: pass def test_fill_details_from_code_normal(self): """Test to make sure _process_line gets called as expected""" catcher = CallCatcher() self.test_object._process_line = catcher.catch_call fake_macro_data = self.given_some_lines(20, 10) self.test_object.fill_details_from_code(fake_macro_data) self.assertEqual(catcher.call_count, 10) def test_fill_details_from_code_too_many_lines(self): """Test to make sure _process_line gets limited as expected""" catcher = CallCatcher() self.test_object._process_line = catcher.catch_call self.test_object.MAX_LINES_TO_SEARCH = 5 fake_macro_data = self.given_some_lines(20, 10) self.test_object.fill_details_from_code(fake_macro_data) self.assertEqual(catcher.call_count, 5) def test_fill_details_from_code_thread_interrupted(self): """Test to make sure _process_line gets stopped as expected""" catcher = CallCatcher() self.test_object._process_line = catcher.catch_call self.test_object.current_thread.interrupt_after_n_calls = 6 # Stop on the 6th fake_macro_data = self.given_some_lines(20, 10) self.test_object.fill_details_from_code(fake_macro_data) self.assertEqual(catcher.call_count, 5) @staticmethod def given_some_lines(num_lines, num_dunder_lines) -> str: """Generate fake macro header data with the given number of lines and number of lines beginning with a double-underscore.""" result = "" for i in range(num_lines): if i < num_dunder_lines: result += f"__something_{i}__ = 'Test{i}' # A line to be scanned\n" else: result += f"# Nothing to see on line {i}\n" return result def test_process_line_known_lines(self): """Lines starting with keys are processed""" test_lines = ["__known_key__ = 'Test'", "__another_known_key__ = 'Test'"] for line in test_lines: with self.subTest(line=line): self.test_object.remaining_item_map = { "__known_key__": "known_key", "__another_known_key__": "another_known_key", } content_lines = io.StringIO(line) read_in_line = content_lines.readline() catcher = CallCatcher() self.test_object._process_key = catcher.catch_call self.test_object._process_line(read_in_line, content_lines) self.assertTrue(catcher.called, "_process_key was not called for a known key") def test_process_line_unknown_lines(self): """Lines starting with non-keys are not processed""" test_lines = [ "# Just a line with a comment", "\n", "__dont_know_this_one__ = 'Who cares?'", "# __known_key__ = 'Aha, but it is commented out!'", ] for line in test_lines: with self.subTest(line=line): self.test_object.remaining_item_map = { "__known_key__": "known_key", "__another_known_key__": "another_known_key", } content_lines = io.StringIO(line) read_in_line = content_lines.readline() catcher = CallCatcher() self.test_object._process_key = catcher.catch_call self.test_object._process_line(read_in_line, content_lines) self.assertFalse(catcher.called, "_process_key was called for an unknown key") def test_process_key_standard(self): """Normal expected data is processed""" self.test_object._reset_map() in_memory_data = '__comment__ = "Test"' content_lines = io.StringIO(in_memory_data) line = content_lines.readline() self.test_object._process_key("__comment__", line, content_lines) self.assertTrue(self.test_object.parse_results["comment"], "Test") def test_process_key_special(self): """Special handling for version = date is processed""" self.test_object._reset_map() self.test_object.parse_results["date"] = "2001-01-01" in_memory_data = "__version__ = __date__" content_lines = io.StringIO(in_memory_data) line = content_lines.readline() self.test_object._process_key("__version__", line, content_lines) self.assertTrue(self.test_object.parse_results["version"], "2001-01-01") def test_handle_backslash_continuation_no_backslashes(self): """The backslash handling code doesn't change a line with no backslashes""" in_memory_data = '"Not a backslash in sight"' content_lines = io.StringIO(in_memory_data) line = content_lines.readline() result = self.test_object._handle_backslash_continuation(line, content_lines) self.assertEqual(result, in_memory_data) def test_handle_backslash_continuation(self): """Lines ending in a backslash get stripped and concatenated""" in_memory_data = '"Line1\\\nLine2\\\nLine3\\\nLine4"' content_lines = io.StringIO(in_memory_data) line = content_lines.readline() result = self.test_object._handle_backslash_continuation(line, content_lines) self.assertEqual(result, '"Line1Line2Line3Line4"') def test_handle_triple_quoted_string_no_triple_quotes(self): """The triple-quote handler leaves alone lines without triple-quotes""" in_memory_data = '"Line1"' content_lines = io.StringIO(in_memory_data) line = content_lines.readline() result, was_triple_quoted = self.test_object._handle_triple_quoted_string( line, content_lines ) self.assertEqual(result, in_memory_data) self.assertFalse(was_triple_quoted) def test_handle_triple_quoted_string(self): """Data is extracted across multiple lines for a triple-quoted string""" in_memory_data = '"""Line1\nLine2\nLine3\nLine4"""\nLine5\n' content_lines = io.StringIO(in_memory_data) line = content_lines.readline() result, was_triple_quoted = self.test_object._handle_triple_quoted_string( line, content_lines ) self.assertEqual(result, '"""Line1\nLine2\nLine3\nLine4"""') self.assertTrue(was_triple_quoted) def test_strip_quotes_single(self): """Single quotes are stripped from the final string""" expected = "test" quoted = f"'{expected}'" actual = self.test_object._strip_quotes(quoted) self.assertEqual(actual, expected) def test_strip_quotes_double(self): """Double quotes are stripped from the final string""" expected = "test" quoted = f'"{expected}"' actual = self.test_object._strip_quotes(quoted) self.assertEqual(actual, expected) def test_strip_quotes_triple(self): """Triple quotes are stripped from the final string""" expected = "test" quoted = f'"""{expected}"""' actual = self.test_object._strip_quotes(quoted) self.assertEqual(actual, expected) def test_strip_quotes_unquoted(self): """Unquoted data results in None""" unquoted = "This has no quotation marks of any kind" actual = self.test_object._strip_quotes(unquoted) self.assertIsNone(actual) def test_standard_extraction_string(self): """String variables are extracted and stored""" string_keys = [ "comment", "url", "wiki", "version", "author", "date", "icon", "xpm", ] for key in string_keys: with self.subTest(key=key): self.test_object._standard_extraction(key, "test") self.assertEqual(self.test_object.parse_results[key], "test") def test_standard_extraction_list(self): """List variable is extracted and stored""" key = "other_files" self.test_object._standard_extraction(key, "test1, test2, test3") self.assertIn("test1", self.test_object.parse_results[key]) self.assertIn("test2", self.test_object.parse_results[key]) self.assertIn("test3", self.test_object.parse_results[key]) def test_apply_special_handling_version(self): """If the tag is __version__, apply our special handling""" self.test_object._reset_map() self.test_object._apply_special_handling("__version__", 42) self.assertNotIn("__version__", self.test_object.remaining_item_map) self.assertEqual(self.test_object.parse_results["version"], "42") def test_apply_special_handling_not_version(self): """If the tag is not __version__, raise an error""" self.test_object._reset_map() with self.assertRaises(SyntaxError): self.test_object._apply_special_handling("__not_version__", 42) self.assertIn("__version__", self.test_object.remaining_item_map) def test_process_noncompliant_version_date(self): """Detect and allow __date__ for the __version__""" self.test_object.parse_results["date"] = "1/2/3" self.test_object._process_noncompliant_version("__date__") self.assertEqual( self.test_object.parse_results["version"], self.test_object.parse_results["date"], ) def test_process_noncompliant_version_float(self): """Detect and allow floats for the __version__""" self.test_object._process_noncompliant_version(1.2) self.assertEqual(self.test_object.parse_results["version"], "1.2") def test_process_noncompliant_version_int(self): """Detect and allow integers for the __version__""" self.test_object._process_noncompliant_version(42) self.assertEqual(self.test_object.parse_results["version"], "42") def test_detect_illegal_content_prefixed_string(self): """Detect and raise an error for various kinds of prefixed strings""" illegal_strings = [ "f'Some fancy {thing}'", 'f"Some fancy {thing}"', "r'Some fancy {thing}'", 'r"Some fancy {thing}"', "u'Some fancy {thing}'", 'u"Some fancy {thing}"', "fr'Some fancy {thing}'", 'fr"Some fancy {thing}"', "rf'Some fancy {thing}'", 'rf"Some fancy {thing}"', ] for test_string in illegal_strings: with self.subTest(test_string=test_string): with self.assertRaises(SyntaxError): MacroParser._detect_illegal_content(test_string) def test_detect_illegal_content_not_a_string(self): """Detect and raise an error for (some) non-strings""" illegal_strings = [ "no quotes", "do_stuff()", 'print("A function call sporting quotes!")', "__name__", "__version__", "1.2.3", ] for test_string in illegal_strings: with self.subTest(test_string=test_string): with self.assertRaises(SyntaxError): MacroParser._detect_illegal_content(test_string) def test_detect_illegal_content_no_failure(self): """Recognize strings of various kinds, plus ints, and floats""" legal_strings = [ '"Some legal value in double quotes"', "'Some legal value in single quotes'", '"""Some legal value in triple quotes"""', "__date__", "42", "4.2", ] for test_string in legal_strings: with self.subTest(test_string=test_string): MacroParser._detect_illegal_content(test_string) ##################### # INTEGRATION TESTS # ##################### def test_macro_parser(self): """INTEGRATION TEST: Given "real" data, ensure the parsing yields the expected results.""" data_dir = os.path.join(os.path.dirname(__file__), "../data") macro_file = os.path.join(data_dir, "DoNothing.FCMacro") with open(macro_file, "r", encoding="utf-8") as f: code = f.read() self.test_object.fill_details_from_code(code) self.assertEqual(len(self.test_object.console.errors), 0) self.assertEqual(len(self.test_object.console.warnings), 0) self.assertEqual(self.test_object.parse_results["author"], "Chris Hennes") self.assertEqual(self.test_object.parse_results["version"], "1.0") self.assertEqual(self.test_object.parse_results["date"], "2022-02-28") self.assertEqual( self.test_object.parse_results["comment"], "Do absolutely nothing. For Addon Manager integration tests.", ) self.assertEqual( self.test_object.parse_results["url"], "https://github.com/FreeCAD/FreeCAD" ) self.assertEqual(self.test_object.parse_results["icon"], "not_real.png") self.assertListEqual( self.test_object.parse_results["other_files"], ["file1.py", "file2.py", "file3.py"], ) self.assertNotEqual(self.test_object.parse_results["xpm"], "")