diff options
Diffstat (limited to 'share/extensions/inkex/tester/__init__.py')
-rw-r--r-- | share/extensions/inkex/tester/__init__.py | 437 |
1 files changed, 437 insertions, 0 deletions
diff --git a/share/extensions/inkex/tester/__init__.py b/share/extensions/inkex/tester/__init__.py new file mode 100644 index 0000000..f7e1d4b --- /dev/null +++ b/share/extensions/inkex/tester/__init__.py @@ -0,0 +1,437 @@ +# coding=utf-8 +# +# Copyright (C) 2018-2019 Martin Owens +# 2019 Thomas Holder +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program 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 General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110, USA. +# +""" +Testing module. See :ref:`unittests` for details. +""" + +import os +import re +import sys +import shutil +import tempfile +import hashlib +import random +import uuid +from typing import List, Union, Tuple, Type, TYPE_CHECKING + +from io import BytesIO, StringIO +import xml.etree.ElementTree as xml + +from unittest import TestCase as BaseCase +from inkex.base import InkscapeExtension + +from .. import Transform +from ..utils import to_bytes +from .xmldiff import xmldiff +from .mock import MockCommandMixin, Capture + +if TYPE_CHECKING: + from .filters import Compare + +COMPARE_DELETE, COMPARE_CHECK, COMPARE_WRITE, COMPARE_OVERWRITE = range(4) + + +class NoExtension(InkscapeExtension): # pylint: disable=too-few-public-methods + """Test case must specify 'self.effect_class' to assertEffect.""" + + def __init__(self, *args, **kwargs): # pylint: disable=super-init-not-called + raise NotImplementedError(self.__doc__) + + def run(self, args=None, output=None): + """Fake run""" + + +class TestCase(MockCommandMixin, BaseCase): + """ + Base class for all effects tests, provides access to data_files and + test_without_parameters + """ + + effect_class = NoExtension # type: Type[InkscapeExtension] + effect_name = property(lambda self: self.effect_class.__module__) + + # If set to true, the output is not expected to be the stdout SVG document, but + # rather text or a message sent to the stderr, this is highly weird. But sometimes + # happens. + stderr_output = False + stdout_protect = True + stderr_protect = True + + def __init__(self, *args, **kw): + super().__init__(*args, **kw) + self._temp_dir = None + self._effect = None + + def setUp(self): # pylint: disable=invalid-name + """Make sure every test is seeded the same way""" + self._effect = None + super().setUp() + random.seed(0x35F) + + def tearDown(self): + super().tearDown() + if self._temp_dir and os.path.isdir(self._temp_dir): + shutil.rmtree(self._temp_dir) + + @classmethod + def __file__(cls): + """Create a __file__ property which acts much like the module version""" + return os.path.abspath(sys.modules[cls.__module__].__file__) + + @classmethod + def _testdir(cls): + """Get's the folder where the test exists (so data can be found)""" + return os.path.dirname(cls.__file__()) + + @classmethod + def rootdir(cls): + """Return the full path to the extensions directory""" + return os.path.dirname(cls._testdir()) + + @classmethod + def datadir(cls): + """Get the data directory (can be over-ridden if needed)""" + return os.path.join(cls._testdir(), "data") + + @property + def tempdir(self): + """Generate a temporary location to store files""" + if self._temp_dir is None: + self._temp_dir = os.path.realpath(tempfile.mkdtemp(prefix="inkex-tests-")) + if not os.path.isdir(self._temp_dir): + raise IOError("The temporary directory has disappeared!") + return self._temp_dir + + def temp_file( + self, prefix="file-", template="{prefix}{name}{suffix}", suffix=".tmp" + ): + """Generate the filename of a temporary file""" + filename = template.format(prefix=prefix, suffix=suffix, name=uuid.uuid4().hex) + return os.path.join(self.tempdir, filename) + + @classmethod + def data_file(cls, filename, *parts, check_exists=True): + """Provide a data file from a filename, can accept directories as arguments. + + .. versionchanged:: 1.2 + ``check_exists`` parameter added""" + if os.path.isabs(filename): + # Absolute root was passed in, so we trust that (it might be a tempdir) + full_path = os.path.join(filename, *parts) + else: + # Otherwise we assume it's relative to the test data dir. + full_path = os.path.join(cls.datadir(), filename, *parts) + + if not os.path.isfile(full_path) and check_exists: + raise IOError(f"Can't find test data file: {full_path}") + return full_path + + @property + def empty_svg(self): + """Returns a common minimal svg file""" + return self.data_file("svg", "default-inkscape-SVG.svg") + + def assertAlmostTuple( + self, found, expected, precision=8, msg="" + ): # pylint: disable=invalid-name + """ + Floating point results may vary with computer architecture; use + assertAlmostEqual to allow a tolerance in the result. + """ + self.assertEqual(len(found), len(expected), msg) + for fon, exp in zip(found, expected): + self.assertAlmostEqual(fon, exp, precision, msg) + + def assertEffectEmpty(self, effect, **kwargs): # pylint: disable=invalid-name + """Assert calling effect without any arguments""" + self.assertEffect(effect=effect, **kwargs) + + def assertEffect(self, *filename, **kwargs): # pylint: disable=invalid-name + """Assert an effect, capturing the output to stdout. + + filename should point to a starting svg document, default is empty_svg + """ + if filename: + data_file = self.data_file(*filename) + else: + data_file = self.empty_svg + + os.environ["DOCUMENT_PATH"] = data_file + args = [data_file] + list(kwargs.pop("args", [])) + args += [f"--{kw[0]}={kw[1]}" for kw in kwargs.items()] + + effect = kwargs.pop("effect", self.effect_class)() + + # Output is redirected to this string io buffer + if self.stderr_output: + with Capture("stderr") as stderr: + effect.run(args, output=BytesIO()) + effect.test_output = stderr + else: + output = BytesIO() + with Capture( + "stdout", kwargs.get("stdout_protect", self.stdout_protect) + ) as stdout: + with Capture( + "stderr", kwargs.get("stderr_protect", self.stderr_protect) + ) as stderr: + effect.run(args, output=output) + self.assertEqual( + "", stdout.getvalue(), "Extra print statements detected" + ) + self.assertEqual( + "", stderr.getvalue(), "Extra error or warnings detected" + ) + effect.test_output = output + + if os.environ.get("FAIL_ON_DEPRECATION", False): + warnings = getattr(effect, "warned_about", set()) + effect.warned_about = set() # reset for next test + self.assertFalse(warnings, "Deprecated API is still being used!") + + return effect + + # pylint: disable=invalid-name + def assertDeepAlmostEqual(self, first, second, places=None, msg=None, delta=None): + """Asserts that two objects, possible nested lists, are almost equal.""" + if delta is None and places is None: + places = 7 + if isinstance(first, (list, tuple)): + assert len(first) == len(second) + for (f, s) in zip(first, second): + self.assertDeepAlmostEqual(f, s, places, msg, delta) + else: + self.assertAlmostEqual(first, second, places, msg, delta) + + def assertTransformEqual(self, lhs, rhs, places=7): + """Assert that two transform expressions evaluate to the same + transformation matrix. + + .. versionadded:: 1.1 + """ + self.assertAlmostTuple( + tuple(Transform(lhs).to_hexad()), tuple(Transform(rhs).to_hexad()), places + ) + + # pylint: enable=invalid-name + + @property + def effect(self): + """Generate an effect object""" + if self._effect is None: + self._effect = self.effect_class() + return self._effect + + +class InkscapeExtensionTestMixin: + """Automatically setup self.effect for each test and test with an empty svg""" + + def setUp(self): # pylint: disable=invalid-name + """Check if there is an effect_class set and create self.effect if it is""" + super().setUp() + if self.effect_class is None: + self.skipTest("self.effect_class is not defined for this this test") + + def test_default_settings(self): + """Extension works with empty svg file""" + self.effect.run([self.empty_svg]) + + +class ComparisonMixin: + """ + Add comparison tests to any existing test suite. + """ + + compare_file: Union[List[str], Tuple[str], str] = "svg/shapes.svg" + """This input svg file sent to the extension (if any)""" + + compare_filters = [] # type: List[Compare] + """The ways in which the output is filtered for comparision (see filters.py)""" + + compare_filter_save = False + """If true, the filtered output will be saved and only applied to the + extension output (and not to the reference file)""" + + comparisons = [ + (), + ("--id=p1", "--id=r3"), + ] + """A list of comparison runs, each entry will cause the extension to be run.""" + + compare_file_extension = "svg" + + @property + def _compare_file_extension(self): + """The default extension to use when outputting check files in COMPARE_CHECK + mode.""" + if self.stderr_output: + return "txt" + return self.compare_file_extension + + def test_all_comparisons(self): + """Testing all comparisons""" + if not isinstance(self.compare_file, (list, tuple)): + self._test_comparisons(self.compare_file) + else: + for compare_file in self.compare_file: + self._test_comparisons( + compare_file, addout=os.path.basename(compare_file) + ) + + def _test_comparisons(self, compare_file, addout=None): + for args in self.comparisons: + self.assertCompare( + compare_file, + self.get_compare_cmpfile(args, addout), + args, + ) + + def assertCompare( + self, infile, cmpfile, args, outfile=None + ): # pylint: disable=invalid-name + """ + Compare the output of a previous run against this one. + + Args: + infile: The filename of the pre-processed svg (or other type of file) + cmpfile: The filename of the data we expect to get, if not set + the filename will be generated from the effect name and kwargs. + args: All the arguments to be passed to the effect run + outfile: Optional, instead of returning a regular output, this extension + dumps it's output to this filename instead. + + """ + compare_mode = int(os.environ.get("EXPORT_COMPARE", COMPARE_DELETE)) + + effect = self.assertEffect(infile, args=args) + + if cmpfile is None: + cmpfile = self.get_compare_cmpfile(args) + + if not os.path.isfile(cmpfile) and compare_mode == COMPARE_DELETE: + raise IOError( + f"Comparison file {cmpfile} not found, set EXPORT_COMPARE=1 to create " + "it." + ) + + if outfile: + if not os.path.isabs(outfile): + outfile = os.path.join(self.tempdir, outfile) + self.assertTrue( + os.path.isfile(outfile), f"No output file created! {outfile}" + ) + with open(outfile, "rb") as fhl: + data_a = fhl.read() + else: + data_a = effect.test_output.getvalue() + + write_output = None + if compare_mode == COMPARE_CHECK: + _file = cmpfile[:-4] if cmpfile.endswith(".out") else cmpfile + write_output = f"{_file}.{self._compare_file_extension}" + elif ( + compare_mode == COMPARE_WRITE and not os.path.isfile(cmpfile) + ) or compare_mode == COMPARE_OVERWRITE: + write_output = cmpfile + + try: + if write_output and not os.path.isfile(cmpfile): + raise AssertionError(f"Check the output: {write_output}") + with open(cmpfile, "rb") as fhl: + data_b = self._apply_compare_filters(fhl.read(), False) + self._base_compare(data_a, data_b, compare_mode) + except AssertionError: + if write_output: + if isinstance(data_a, str): + data_a = data_a.encode("utf-8") + with open(write_output, "wb") as fhl: + fhl.write(self._apply_compare_filters(data_a, True)) + print(f"Written output: {write_output}") + # This only reruns if the original test failed. + # The idea here is to make sure the new output file is "stable" + # Because some tests can produce random changes and we don't + # want test authors to be too reassured by a simple write. + if write_output == cmpfile: + effect = self.assertEffect(infile, args=args) + self._base_compare(data_a, cmpfile, COMPARE_CHECK) + if not write_output == cmpfile: + raise + + def _base_compare(self, data_a, data_b, compare_mode): + data_a = self._apply_compare_filters(data_a) + + if ( + isinstance(data_a, bytes) + and isinstance(data_b, bytes) + and data_a.startswith(b"<") + and data_b.startswith(b"<") + ): + # Late importing + diff_xml, delta = xmldiff(data_a, data_b) + if not delta and compare_mode == COMPARE_DELETE: + print( + "The XML is different, you can save the output using the " + "EXPORT_COMPARE envionment variable. Set it to 1 to save a file " + "you can check, set it to 3 to overwrite this comparison, setting " + "the new data as the correct one.\n" + ) + diff = "SVG Differences\n\n" + if os.environ.get("XML_DIFF", False): + diff = "<- " + diff_xml + else: + for x, (value_a, value_b) in enumerate(delta): + try: + # Take advantage of better text diff in testcase's own asserts. + self.assertEqual(value_a, value_b) + except AssertionError as err: + diff += f" {x}. {str(err)}\n" + self.assertTrue(delta, diff) + else: + # compare any content (non svg) + self.assertEqual(data_a, data_b) + + def _apply_compare_filters(self, data, is_saving=None): + data = to_bytes(data) + # Applying filters flips depending if we are saving the filtered content + # to disk, or filtering during the test run. This is because some filters + # are destructive others are useful for diagnostics. + if is_saving is self.compare_filter_save or is_saving is None: + for cfilter in self.compare_filters: + data = cfilter(data) + return data + + def get_compare_cmpfile(self, args, addout=None): + """Generate an output file for the arguments given""" + if addout is not None: + args = list(args) + [str(addout)] + opstr = ( + "__".join(args) + .replace(self.tempdir, "TMP_DIR") + .replace(self.datadir(), "DAT_DIR") + ) + opstr = re.sub(r"[^\w-]", "__", opstr) + if opstr: + if len(opstr) > 127: + # avoid filename-too-long error + opstr = hashlib.md5(opstr.encode("latin1")).hexdigest() + opstr = "__" + opstr + return self.data_file( + "refs", f"{self.effect_name}{opstr}.out", check_exists=False + ) |