summaryrefslogtreecommitdiffstats
path: root/share/extensions/inkex/tester/__init__.py
diff options
context:
space:
mode:
Diffstat (limited to 'share/extensions/inkex/tester/__init__.py')
-rw-r--r--share/extensions/inkex/tester/__init__.py437
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
+ )