diff options
Diffstat (limited to 'share/extensions/inkex/tester')
16 files changed, 1240 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..f33f695 --- /dev/null +++ b/share/extensions/inkex/tester/__init__.py @@ -0,0 +1,384 @@ +# 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. +# +""" +All Inkscape extensions should come with tests. This package provides you with +the tools needed to create tests and thus ensure that your extension continues +to work with future versions of Inkscape, the "inkex" python modules, and other +python and non-python tools you may use. + +Make sure your extension is a python extension and is using the `inkex.generic` +base classes. These provide the greatest amount of functionality for testing. + +You should start by creating a folder in your repository called `tests` with +an empty file inside called `__init__.py` to turn it into a module folder. + +For each of your extensions, you should create a file called +`test_{extension_name}.py` where the name reflects the name of your extension. + +There are two types of tests: + + 1. Full-process Comparison tests - These are tests which invoke your + extension with various arguments and attempt to compare the + output to a known good reference. These are useful for testing + that your extension would work if it was used in Inkscape. + + Good example of writing comparison tests can be found in the + Inkscape core repository, each test which inherits from + the ComparisonMixin class is running comparison tests. + + 2. Unit tests - These are individual test functions which call out to + specific functions within your extension. These are typical + python unit tests and many good python documents exist + to describe how to write them well. For examples here you + can find the tests that test the inkex modules themselves + to be the most instructive. + +When running a test, it will cause a certain fraction of the code within the +extension to execute. This fraction called it's **coverage** and a higher +coverage score indicates that your test is better at exercising the various +options, features, and branches within your code. + +Generating comparison output can be done using the EXPORT_COMPARE environment +variable when calling pytest. For example: + + EXPORT_COMPARE=1 pytest tests/test_my_specific_test.py + +This will create files in `tests/data/refs/*.out.export` and these files should +be manually checked to make sure they are correct before being renamed and stripped +of the `.export` suffix. pytest should then be re-run to confirm before +committing to the repository. +""" + +from __future__ import absolute_import, print_function, unicode_literals + +import os +import re +import sys +import shutil +import tempfile +import hashlib +import random +import uuid + +from io import BytesIO, StringIO +import xml.etree.ElementTree as xml + +from unittest import TestCase as BaseCase +from inkex.base import InkscapeExtension + +from ..utils import PY3, to_bytes +from .xmldiff import xmldiff +from .mock import MockCommandMixin, Capture + +if False: # pylint: disable=using-constant-test + from typing import Type, List + from .filters import Compare + + +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""" + pass + + +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 + python3_only = False + + def __init__(self, *args, **kw): + super(TestCase, self).__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(TestCase, self).setUp() + if self.python3_only and not PY3: + self.skipTest("No available in python2") + try: + # python3, with version 1 to get the same numbers + # as in python2 during tests. + random.seed(0x35f, version=1) + except TypeError: + # But of course this kwarg doesn't exist in python2 + random.seed(0x35f) + + def tearDown(self): + super(TestCase, self).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 = 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): + """Provide a data file from a filename, can accept directories as arguments.""" + 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): + raise IOError("Can't find test data file: {}".format(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): # 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)) + for fon, exp in zip(found, expected): + self.assertAlmostEqual(fon, exp, precision) + + 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 + """ + effect = kwargs.pop('effect', self.effect_class)() + + args = [self.data_file(*filename)] if filename else [self.empty_svg] # pylint: disable=no-value-for-parameter + args += kwargs.pop('args', []) + args += ['--{}={}'.format(*kw) for kw in kwargs.items()] + + # 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 + + def assertDeepAlmostEqual(self, first, second, places=None, msg=None, delta=None): + 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) + + @property + def effect(self): + """Generate an effect object""" + if self._effect is None: + self._effect = self.effect_class() + return self._effect + +class InkscapeExtensionTestMixin(object): + """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(InkscapeExtensionTestMixin, self).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(object): + """ + Add comparison tests to any existing test suite. + """ + # This input svg file sent to the extension (if any) + compare_file = 'svg/shapes.svg' + # The ways in which the output is filtered for comparision (see filters.py) + compare_filters = [] # type: List[Compare] + # If true, the filtered output will be saved and only applied to the + # extension output (and not to the reference file) + compare_filter_save = False + # A list of comparison runs, each entry will cause the extension to be run. + comparisons = [ + (), + ('--id=p1', '--id=r3'), + ] + + 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_outfile(args, addout), + args, + ) + + def assertCompare(self, infile, outfile, args): #pylint: disable=invalid-name + """ + Compare the output of a previous run against this one. + + - infile: The filename of the pre-processed svg (or other type of file) + - outfile: 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 + + """ + effect = self.assertEffect(infile, args=args) + + if outfile is None: + outfile = self.get_compare_outfile(args) + + if not os.path.isfile(outfile): + raise IOError("Comparison file {} not found".format(outfile)) + + data_a = effect.test_output.getvalue() + if os.environ.get('EXPORT_COMPARE', False): + with open(outfile + '.export', 'wb') as fhl: + if sys.version_info[0] == 3 and isinstance(data_a, str): + data_a = data_a.encode('utf-8') + fhl.write(self._apply_compare_filters(data_a, True)) + print("Written output: {}.export".format(outfile)) + + data_a = self._apply_compare_filters(data_a) + + with open(outfile, 'rb') as fhl: + data_b = self._apply_compare_filters(fhl.read(), False) + + 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 not os.environ.get('EXPORT_COMPARE', False): + print('The XML is different, you can save the output using the EXPORT_COMPARE=1'\ + ' envionment variable. This will save the compared file as a ".output" file'\ + ' next to the reference file used in the test.\n') + diff = 'SVG Differences: {}\n\n'.format(outfile) + 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 += " {}. {}\n".format(x, str(err)) + 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_outfile(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", "{}{}.out".format(self.effect_name, opstr)) diff --git a/share/extensions/inkex/tester/__pycache__/__init__.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/__init__.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..7d6722e --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/__init__.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..6c98af5 --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/decorators.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/filters.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/filters.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..b460938 --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/filters.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/inx.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/inx.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..7f8204a --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/inx.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/mock.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/mock.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..82c6234 --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/mock.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/svg.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/svg.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..439198f --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/svg.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/word.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/word.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..cd16936 --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/word.cpython-39.pyc diff --git a/share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pyc b/share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pyc Binary files differnew file mode 100644 index 0000000..5e61017 --- /dev/null +++ b/share/extensions/inkex/tester/__pycache__/xmldiff.cpython-39.pyc diff --git a/share/extensions/inkex/tester/decorators.py b/share/extensions/inkex/tester/decorators.py new file mode 100644 index 0000000..92d6b4d --- /dev/null +++ b/share/extensions/inkex/tester/decorators.py @@ -0,0 +1,8 @@ +""" +Useful decorators for tests. +""" +import pytest +from inkex.command import is_inkscape_available + +requires_inkscape = pytest.mark.skipif( # pylint: disable=invalid-name + not is_inkscape_available(), reason="Test requires inkscape, but it's not available") diff --git a/share/extensions/inkex/tester/filters.py b/share/extensions/inkex/tester/filters.py new file mode 100644 index 0000000..f23d124 --- /dev/null +++ b/share/extensions/inkex/tester/filters.py @@ -0,0 +1,139 @@ +# +# Copyright (C) 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-1301, USA. +# +# pylint: disable=too-few-public-methods +# +""" +Comparison filters for use with the ComparisonMixin. + +Each filter should be initialised in the list of +filters that are being used. + +compare_filters = [ + CompareNumericFuzzy(), + CompareOrderIndependentLines(option=yes), +] +""" + +import re +from ..utils import to_bytes + +class Compare(object): + """ + Comparison base class, this acts as a passthrough unless + the filter staticmethod is overwritten. + """ + def __init__(self, **options): + self.options = options + + def __call__(self, content): + return self.filter(content) + + @staticmethod + def filter(contents): + """Replace this filter method with your own filtering""" + return contents + +class CompareNumericFuzzy(Compare): + """ + Turn all numbers into shorter standard formats + + 1.2345678 -> 1.2346 + 1.2300 -> 1.23, 50.0000 -> 50.0 + 50.0 -> 50 + """ + @staticmethod + def filter(contents): + func = lambda m: b'%.3f' % (float(m.group(0))) + contents = re.sub(br'\d+\.\d+', func, contents) + contents = re.sub(br'(\d\.\d+?)0+\b', br'\1', contents) + contents = re.sub(br'(\d)\.0+(?=\D|\b)', br'\1', contents) + return contents + +class CompareWithoutIds(Compare): + """Remove all ids from the svg""" + @staticmethod + def filter(contents): + return re.sub(br' id="([^"]*)"', b'', contents) + +class CompareWithPathSpace(Compare): + """Make sure that path segment commands have spaces around them""" + @staticmethod + def filter(contents): + def func(match): + """We've found a path command, process it""" + new = re.sub(br'\s*([LZMHVCSQTAatqscvhmzl])\s*', br' \1 ', match.group(1)) + return b' d="' + new.replace(b',', b' ') + b'"' + return re.sub(br' d="([^"]*)"', func, contents) + +class CompareSize(Compare): + """Compare the length of the contents instead of the contents""" + @staticmethod + def filter(contents): + return len(contents) + +class CompareOrderIndependentBytes(Compare): + """Take all the bytes and sort them""" + @staticmethod + def filter(contents): + return b"".join([bytes(i) for i in sorted(contents)]) + +class CompareOrderIndependentLines(Compare): + """Take all the lines and sort them""" + @staticmethod + def filter(contents): + return b"\n".join(sorted(contents.splitlines())) + +class CompareOrderIndependentStyle(Compare): + """Take all styles and sort the results""" + @staticmethod + def filter(contents): + contents = CompareNumericFuzzy.filter(contents) + def func(match): + """Search and replace function for sorting""" + sty = b';'.join(sorted(match.group(1).split(b';'))) + return b'style="%s"' % (sty,) + return re.sub(br'style="([^"]*)"', func, contents) + +class CompareOrderIndependentStyleAndPath(Compare): + """Take all styles and paths and sort them both""" + @staticmethod + def filter(contents): + contents = CompareOrderIndependentStyle.filter(contents) + def func(match): + """Search and replace function for sorting""" + path = b'X'.join(sorted(re.split(br'[A-Z]', match.group(1)))) + return b'd="%s"' % (path,) + return re.sub(br'\bd="([^"]*)"', func, contents) + +class CompareOrderIndependentTags(Compare): + """Sorts all the XML tags""" + @staticmethod + def filter(contents): + return b"\n".join(sorted(re.split(br'>\s*<', contents))) + +class CompareReplacement(Compare): + """Replace pieces to make output more comparable""" + def __init__(self, *replacements): + self.deltas = replacements + super(CompareReplacement, self).__init__() + + def filter(self, contents): + contents = to_bytes(contents) + for _from, _to in self.deltas: + contents = contents.replace(to_bytes(_from), to_bytes(_to)) + return contents diff --git a/share/extensions/inkex/tester/inx.py b/share/extensions/inkex/tester/inx.py new file mode 100644 index 0000000..a30eb7e --- /dev/null +++ b/share/extensions/inkex/tester/inx.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python +# coding=utf-8 +""" +Test elements extra logic from svg xml lxml custom classes. +""" + +from ..utils import PY3 +from ..inx import InxFile + +INTERNAL_ARGS = ('help', 'output', 'id', 'selected-nodes') +ARG_TYPES = { + 'Boolean': 'bool', + 'Color': 'color', + 'str': 'string', + 'int': 'int', + 'float': 'float', +} + +class InxMixin(object): + """Tools for Testing INX files, use as a mixin class: + + class MyTests(InxMixin, TestCase): + def test_inx_file(self): + self.assertInxIsGood("some_inx_file.inx") + """ + def assertInxIsGood(self, inx_file): # pylint: disable=invalid-name + """Test the inx file for consistancy and correctness""" + self.assertTrue(PY3, "INX files can only be tested in python3") + + inx = InxFile(inx_file) + if 'help' in inx.ident or inx.script.get('interpreter', None) != 'python': + return + cls = inx.extension_class + # Check class can be matched in python file + self.assertTrue(cls, 'Can not find class for {}'.format(inx.filename)) + # Check name is reasonable for the class + if not cls.multi_inx: + self.assertEqual( + cls.__name__, inx.slug, + "Name of extension class {}.{} is different from ident {}".format( + cls.__module__, cls.__name__, inx.slug)) + self.assertParams(inx, cls) + + def assertParams(self, inx, cls): # pylint: disable=invalid-name + """Confirm the params in the inx match the python script""" + params = dict([(param.name, self.parse_param(param)) for param in inx.params]) + args = dict(self.introspect_arg_parser(cls().arg_parser)) + mismatch_a = list(set(params) ^ set(args) & set(params)) + mismatch_b = list(set(args) ^ set(params) & set(args)) + self.assertFalse(mismatch_a, "{}: Inx params missing from arg parser".format(inx.filename)) + self.assertFalse(mismatch_b, "{}: Script args missing from inx xml".format(inx.filename)) + + for param in args: + if params[param]['type'] and args[param]['type']: + self.assertEqual( + params[param]['type'], + args[param]['type'], + "Type is not the same for {}:param:{}".format(inx.filename, param)) + + def introspect_arg_parser(self, arg_parser): + """Pull apart the arg parser to find out what we have in it""" + for action in arg_parser._optionals._actions: # pylint: disable=protected-access + for opt in action.option_strings: + # Ignore params internal to inkscape (thus not in the inx) + if opt.startswith('--') and opt[2:] not in INTERNAL_ARGS: + yield (opt[2:], self.introspect_action(action)) + + @staticmethod + def introspect_action(action): + """Pull apart a single action to get at the juicy insides""" + return { + 'type': ARG_TYPES.get((action.type or str).__name__, 'string'), + 'default': action.default, + 'choices': action.choices, + 'help': action.help, + } + + @staticmethod + def parse_param(param): + """Pull apart the param element in the inx file""" + if param.param_type in ('optiongroup', 'notebook'): + options = param.options + return { + 'type': None, + 'choices': options, + 'default': options and options[0] or None, + } + param_type = param.param_type + if param.param_type in ('path',): + param_type = 'string' + return { + 'type': param_type, + 'default': param.text, + 'choices': None, + } diff --git a/share/extensions/inkex/tester/mock.py b/share/extensions/inkex/tester/mock.py new file mode 100644 index 0000000..2df8791 --- /dev/null +++ b/share/extensions/inkex/tester/mock.py @@ -0,0 +1,414 @@ +# coding=utf-8 +# +# Copyright (C) 2018 Martin Owens +# +# 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. +# +# pylint: disable=protected-access,too-few-public-methods +""" +Any mocking utilities required by testing. Mocking is when you need the test +to exercise a piece of code, but that code may or does call on something +outside of the target code that either takes too long to run, isn't available +during the test running process or simply shouldn't be running at all. +""" + +import io +import os +import sys +import logging +import hashlib +import tempfile + +from email.mime.application import MIMEApplication +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText +from email.parser import Parser as EmailParser + +import inkex.command + +if False: # pylint: disable=using-constant-test + from typing import List, Tuple, Callable, Any # pylint: disable=unused-import + +FIXED_BOUNDARY = '--CALLDATA--//--CALLDATA--' + +class Capture(object): + """Capture stdout or stderr. Used as `with Capture('stdout') as stream:`""" + def __init__(self, io_name='stdout', swap=True): + self.io_name = io_name + self.original = getattr(sys, io_name) + self.stream = io.StringIO() + self.swap = swap + + def __enter__(self): + # We can't control python2 correctly (unicode vs. bytes-like) but + # we don't need it, so we're ignore python2 as if it doesn't exist. + if self.swap: + setattr(sys, self.io_name, self.stream) + return self.stream + + def __exit__(self, exc, value, traceback): + if exc is not None and self.swap: + # Dump content back to original if there was an error. + self.original.write(self.stream.getvalue()) + setattr(sys, self.io_name, self.original) + +class ManualVerbosity(object): + """Change the verbosity of the test suite manually""" + result = property(lambda self: self.test._current_result) + + def __init__(self, test, okay=True, dots=False): + self.test = test + self.okay = okay + self.dots = dots + + def flip(self, exc_type=None, exc_val=None, exc_tb=None): # pylint: disable=unused-argument + """Swap the stored verbosity with the original""" + self.okay, self.result.showAll = self.result.showAll, self.okay + self.dots, self.result.dots = self.result.dots, self.okay + + __enter__ = flip + __exit__ = flip + + +class MockMixin(object): + """ + Add mocking ability to any test base class, will set up mock on setUp + and remove it on tearDown. + + Mocks are stored in an array attached to the test class (not instance!) which + ensures that mocks can only ever be setUp once and can never be reset over + themselves. (just in case this looks weird at first glance) + + class SomeTest(MockingMixin, TestBase): + mocks = [(sys, 'exit', NoSystemExit("Nope!")] + """ + mocks = [] # type: List[Tuple[Any, str, Any]] + + def setUpMock(self, owner, name, new): # pylint: disable=invalid-name + """Setup the mock here, taking name and function and returning (name, old)""" + old = getattr(owner, name) + if isinstance(new, str): + if hasattr(self, new): + new = getattr(self, new) + if isinstance(new, Exception): + def _error_function(*args2, **kw2): # pylint: disable=unused-argument + raise type(new)(str(new)) + setattr(owner, name, _error_function) + elif new is None or isinstance(new, (str, int, float, list, tuple)): + def _value_function(*args, **kw): # pylint: disable=unused-argument + return new + setattr(owner, name, _value_function) + else: + setattr(owner, name, new) + # When we start, mocks contains length 3 tuples, when we're finished, it contains + # length 4, this stops remocking and reunmocking from taking place. + return (owner, name, old, False) + + def setUp(self): # pylint: disable=invalid-name + """For each mock instruction, set it up and store the return""" + super(MockMixin, self).setUp() + for x, mock in enumerate(self.mocks): + if len(mock) == 4: + logging.error("Mock was already set up, so it wasn't cleared previously!") + continue + self.mocks[x] = self.setUpMock(*mock) + + def tearDown(self): # pylint: disable=invalid-name + """For each returned stored, tear it down and restore mock instruction""" + super(MockMixin, self).tearDown() + try: + for x, (owner, name, old, _) in enumerate(self.mocks): + self.mocks[x] = (owner, name, getattr(owner, name)) + setattr(owner, name, old) + except ValueError: + logging.warning("Was never mocked, did something go wrong?") + + def old_call(self, name): + """Get the original caller""" + for arg in self.mocks: + if arg[1] == name: + return arg[2] + return lambda: None + +class MockCommandMixin(MockMixin): + """ + Replace all the command functions with testable replacements. + + This stops the pipeline and people without the programs, running into problems. + """ + mocks = [ + (inkex.command, '_call', 'mock_call'), + (tempfile, 'mkdtemp', 'record_tempdir'), + ] + recorded_tempdirs = [] # type:List[str] + + def setUp(self): # pylint: disable=invalid-name + super(MockCommandMixin, self).setUp() + # This is a the daftest thing I've ever seen, when in the middle + # of a mock, the 'self' variable magically turns from a FooTest + # into a TestCase, this makes it impossible to find the datadir. + from . import TestCase + TestCase._mockdatadir = self.datadir() + + @classmethod + def cmddir(cls): + """Returns the location of all the mocked command results""" + from . import TestCase + return os.path.join(TestCase._mockdatadir, 'cmd') + + def record_tempdir(self, *args, **kwargs): + """Record any attempts to make tempdirs""" + newdir = self.old_call('mkdtemp')(*args, **kwargs) + self.recorded_tempdirs.append(newdir) + return newdir + + def clean_paths(self, data, files): + """Clean a string of any files or tempdirs""" + try: + for fdir in self.recorded_tempdirs: + data = data.replace(fdir, '.') + files = [fname.replace(fdir, '.') for fname in files] + for fname in files: + data = data.replace(fname, os.path.basename(fname)) + except (UnicodeDecodeError, TypeError): + pass + return data + + def get_all_tempfiles(self): + """Returns a set() of all files currently in any of the tempdirs""" + ret = set([]) + for fdir in self.recorded_tempdirs: + if not os.path.isdir(fdir): + continue + for fname in os.listdir(fdir): + if fname in ('.', '..'): + continue + path = os.path.join(fdir, fname) + # We store the modified time so if a program modifies + # the input file in-place, it will look different. + ret.add(path + ';{}'.format(os.path.getmtime(path))) + + return ret + + def ignore_command_mock(self, program, arglst): + """Return true if the mock is ignored""" + if self and program and arglst: + return os.environ.get('NO_MOCK_COMMANDS') + return False + + def mock_call(self, program, *args, **kwargs): + """ + Replacement for the inkex.command.call() function, instead of calling + an external program, will compile all arguments into a hash and use the + hash to find a command result. + """ + # Remove stdin first because it needs to NOT be in the Arguments list. + stdin = kwargs.pop('stdin', None) + args = list(args) + + # We use email + msg = MIMEMultipart(boundary=FIXED_BOUNDARY) + msg['Program'] = self.get_program_name(program) + + # Gather any output files and add any input files to msg, args and kwargs + # may be modified to strip out filename directories (which change) + inputs, outputs = self.add_call_files(msg, args, kwargs) + + arglst = inkex.command.to_args(program, *args, **kwargs)[1:] + arglst.sort() + argstr = ' '.join(arglst) + argstr = self.clean_paths(argstr, inputs + outputs) + msg['Arguments'] = argstr.strip() + + if stdin is not None: + # The stdin is counted as the msg body + cleanin = self.clean_paths(stdin, inputs + outputs) + msg.attach(MIMEText(cleanin, 'plain', 'utf-8')) + + keystr = msg.as_string() + # There is a difference between python2 and python3 output + keystr = keystr.replace('\n\n', '\n') + keystr = keystr.replace('\n ', ' ') + if 'verb' in keystr: + # Verbs seperated by colons cause diff in py2/3 + keystr = keystr.replace('; ', ';') + # Generate a unique key for this call based on _all_ it's inputs + key = hashlib.md5(keystr.encode('utf-8')).hexdigest() + + if self.ignore_command_mock(program, arglst): + # Call original code. This is so programmers can run the test suite + # against the external programs too, to see how their fair. + if stdin is not None: + kwargs['stdin'] = stdin + + before = self.get_all_tempfiles() + stdout = self.old_call('_call')(program, *args, **kwargs) + outputs += list(self.get_all_tempfiles() - before) + # Remove the modified time from the call + outputs = [out.rsplit(';', 1)[0] for out in outputs] + + # After the program has run, we collect any file outputs and store + # them, then store any stdout or stderr created during the run. + # A developer can then use this to build new test cases. + reply = MIMEMultipart(boundary=FIXED_BOUNDARY) + reply['Program'] = self.get_program_name(program) + reply['Arguments'] = argstr + self.save_call(program, key, stdout, outputs, reply) + self.save_key(program, key, keystr, 'key') + return stdout + + try: + return self.load_call(program, key, outputs) + except IOError: + self.save_key(program, key, keystr, 'bad-key') + raise IOError("Problem loading call: {}/{} use the environment variable "\ + "NO_MOCK_COMMANDS=1 to call out to the external program and generate "\ + "the mock call file.".format(program, key)) + + def add_call_files(self, msg, args, kwargs): + """ + Gather all files, adding input files to the msg (for hashing) and + output files to the returned files list (for outputting in debug) + """ + # Gather all possible string arguments together. + loargs = sorted(kwargs.items(), key=lambda i: i[0]) + values = [] + for arg in args: + if isinstance(arg, (tuple, list)): + loargs.append(arg) + else: + values.append(str(arg)) + + for (_, value) in loargs: + if isinstance(value, (tuple, list)): + for val in value: + if val is not True: + values.append(str(val)) + elif value is not True: + values.append(str(value)) + + # See if any of the strings could be filenames, either going to be + # or are existing files on the disk. + files = [[], []] + for value in values: + if os.path.isfile(value): # Input file + files[0].append(value) + self.add_call_file(msg, value) + elif os.path.isdir(os.path.dirname(value)): # Output file + files[1].append(value) + return files + + def add_call_file(self, msg, filename): + """Add a single file to the given mime message""" + fname = os.path.basename(filename) + with open(filename, "rb") as fhl: + if filename.endswith('.svg'): + value = self.clean_paths(fhl.read().decode('utf8'), []) + else: + value = fhl.read() + part = MIMEApplication(value, Name=fname) + # After the file is closed + part['Content-Disposition'] = 'attachment' + part['Filename'] = fname + msg.attach(part) + + def get_call_filename(self, program, key, create=False): + """ + Get the filename for the call testing information. + """ + path = self.get_call_path(program, create=create) + fname = os.path.join(path, key + '.msg') + if not create and not os.path.isfile(fname): + raise IOError("Attempted to find call test data {}".format(key)) + return fname + + def get_program_name(self, program): + """Takes a program and returns a program name""" + if program == inkex.command.INKSCAPE_EXECUTABLE_NAME: + return 'inkscape' + return program + + def get_call_path(self, program, create=True): + """Get where this program would store it's test data""" + command_dir = os.path.join(self.cmddir(), self.get_program_name(program)) + if not os.path.isdir(command_dir): + if create: + os.makedirs(command_dir) + else: + raise IOError("A test is attempting to use an external program in a test:"\ + " {}; but there is not a command data directory which should"\ + " contain the results of the command here: {}"\ + .format(program, command_dir)) + return command_dir + + def load_call(self, program, key, files): + """ + Load the given call + """ + fname = self.get_call_filename(program, key, create=False) + with open(fname, 'rb') as fhl: + msg = EmailParser().parsestr(fhl.read().decode('utf-8')) + + stdout = None + for part in msg.walk(): + if 'attachment' in part.get("Content-Disposition", ''): + base_name = part['Filename'] + for out_file in files: + if out_file.endswith(base_name): + with open(out_file, 'wb') as fhl: + fhl.write(part.get_payload(decode=True)) + part = None + if part is not None: + # Was not caught by any normal outputs, so we will + # save the file to EVERY tempdir in the hopes of + # hitting on of them. + for fdir in self.recorded_tempdirs: + if os.path.isdir(fdir): + with open(os.path.join(fdir, base_name), 'wb') as fhl: + fhl.write(part.get_payload(decode=True)) + elif part.get_content_type() == "text/plain": + stdout = part.get_payload(decode=True) + + return stdout + + def save_call(self, program, key, stdout, files, msg, ext='output'): # pylint: disable=too-many-arguments + """ + Saves the results from the call into a debug output file, the resulting files + should be a Mime msg file format with each attachment being one of the input + files as well as any stdin and arguments used in the call. + """ + if stdout is not None and stdout.strip(): + # The stdout is counted as the msg body here + msg.attach(MIMEText(stdout.decode('utf-8'), 'plain', 'utf-8')) + + for fname in set(files): + if os.path.isfile(fname): + #print("SAVING FILE INTO MSG: {}".format(fname)) + self.add_call_file(msg, fname) + else: + part = MIMEText("Missing File", 'plain', 'utf-8') + part.add_header('Filename', os.path.basename(fname)) + msg.attach(part) + + fname = self.get_call_filename(program, key, create=True) + '.' + ext + with open(fname, 'wb') as fhl: + fhl.write(msg.as_string().encode('utf-8')) + + def save_key(self, program, key, keystr, ext='key'): + """Save the key file if we are debugging the key data""" + if os.environ.get('DEBUG_KEY'): + fname = self.get_call_filename(program, key, create=True) + '.' + ext + with open(fname, 'wb') as fhl: + fhl.write(keystr.encode('utf-8')) diff --git a/share/extensions/inkex/tester/svg.py b/share/extensions/inkex/tester/svg.py new file mode 100644 index 0000000..d7ef569 --- /dev/null +++ b/share/extensions/inkex/tester/svg.py @@ -0,0 +1,49 @@ +# coding=utf-8 +# +# Copyright (C) 2018 Martin Owens +# +# 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. +# +""" +SVG specific utilities for tests. +""" + +from lxml import etree + +from inkex import SVG_PARSER + +def svg(svg_attrs=''): + """Returns xml etree based on a simple SVG element. + + svg_attrs: A string containing attributes to add to the + root <svg> element of a minimal SVG document. + """ + return etree.fromstring(str.encode( + '<?xml version="1.0" encoding="UTF-8" standalone="no"?>' + '<svg {}></svg>'.format(svg_attrs)), parser=SVG_PARSER) + + +def uu_svg(user_unit): + """Same as svg, but takes a user unit for the new document. + + It's based on the ratio between the SVG width and the viewBox width. + """ + return svg('width="1{}" viewBox="0 0 1 1"'.format(user_unit)) + +def svg_file(filename): + """Parse an svg file and return it's document root""" + with open(filename, 'r') as fhl: + doc = etree.parse(fhl, parser=SVG_PARSER) + return doc.getroot() diff --git a/share/extensions/inkex/tester/word.py b/share/extensions/inkex/tester/word.py new file mode 100644 index 0000000..cab7d04 --- /dev/null +++ b/share/extensions/inkex/tester/word.py @@ -0,0 +1,38 @@ +# coding=utf-8 +# +# Unknown author +# +""" +Generate words for testing. +""" + +import string +import random + +def word_generator(text_length): + """ + Generate a word of text_length size + """ + word = "" + + for _ in range(0, text_length): + word += random.choice(string.ascii_lowercase + \ + string.ascii_uppercase + \ + string.digits + \ + string.punctuation) + + return word + +def sentencecase(word): + """Make a word standace case""" + word_new = "" + lower_letters = list(string.ascii_lowercase) + first = True + for letter in word: + if letter in lower_letters and first is True: + word_new += letter.upper() + first = False + else: + word_new += letter + + return word_new diff --git a/share/extensions/inkex/tester/xmldiff.py b/share/extensions/inkex/tester/xmldiff.py new file mode 100644 index 0000000..17f43a4 --- /dev/null +++ b/share/extensions/inkex/tester/xmldiff.py @@ -0,0 +1,113 @@ +# +# Copyright 2011 (c) Ian Bicking <ianb@colorstudy.com> +# 2019 (c) Martin Owens <doctormo@gmail.com> +# +# Taken from http://formencode.org under the GPL compatible PSF License. +# Modified to produce more output as a diff. +# +""" +Allow two xml files/lxml etrees to be compared, returning their differences. +""" +import xml.etree.ElementTree as xml +from io import BytesIO + +def text_compare(test1, test2): + """ + Compare two text strings while allowing for '*' to match + anything on either lhs or rhs. + """ + if not test1 and not test2: + return True + if test1 == '*' or test2 == '*': + return True + return (test1 or '').strip() == (test2 or '').strip() + +class DeltaLogger(list): + """A record keeper of the delta between two svg files""" + def append_tag(self, tag_a, tag_b): + """Record a tag difference""" + if tag_a: + tag_a = "<{}.../>".format(tag_a) + if tag_b: + tag_b = "<{}.../>".format(tag_b) + self.append((tag_a, tag_b)) + + def append_attr(self, attr, value_a, value_b): + """Record an attribute difference""" + def _prep(val): + if val: + if attr == 'd': + from inkex.paths import Path + return [attr] + Path(val).to_arrays() + return (attr, val) + return val + self.append((_prep(value_a), _prep(value_b))) + + def append_text(self, text_a, text_b): + """Record a text difference""" + self.append((text_a, text_b)) + + def __bool__(self): + """Returns True if there's no log, i.e. the delta is clean""" + return not self.__len__() + __nonzero__ = __bool__ + + def __repr__(self): + if self: + return "No differences detected" + return "{} xml differences".format(len(self)) + +def to_xml(data): + """Convert string or bytes to xml parsed root node""" + if isinstance(data, str): + data = data.encode('utf8') + if isinstance(data, bytes): + return xml.parse(BytesIO(data)).getroot() + return data + +def xmldiff(data1, data2): + """Create an xml difference, will modify the first xml structure with a diff""" + xml1, xml2 = to_xml(data1), to_xml(data2) + delta = DeltaLogger() + _xmldiff(xml1, xml2, delta) + return xml.tostring(xml1).decode('utf-8'), delta + +def _xmldiff(xml1, xml2, delta): + if xml1.tag != xml2.tag: + xml1.tag = '{}XXX{}'.format(xml1.tag, xml2.tag) + delta.append_tag(xml1.tag, xml2.tag) + for name, value in xml1.attrib.items(): + if name not in xml2.attrib: + delta.append_attr(name, xml1.attrib[name], None) + xml1.attrib[name] += "XXX" + elif xml2.attrib.get(name) != value: + delta.append_attr(name, xml1.attrib.get(name), xml2.attrib.get(name)) + xml1.attrib[name] = "{}XXX{}".format(xml1.attrib.get(name), xml2.attrib.get(name)) + for name, value in xml2.attrib.items(): + if name not in xml1.attrib: + delta.append_attr(name, None, value) + xml1.attrib[name] = "XXX" + value + if not text_compare(xml1.text, xml2.text): + delta.append_text(xml1.text, xml2.text) + xml1.text = "{}XXX{}".format(xml1.text, xml2.text) + if not text_compare(xml1.tail, xml2.tail): + delta.append_text(xml1.tail, xml2.tail) + xml1.tail = "{}XXX{}".format(xml1.tail, xml2.tail) + + # Get children and pad with nulls + children_a = list(xml1) + children_b = list(xml2) + children_a += [None] * (len(children_a) - len(children_b)) + children_b += [None] * (len(children_b) - len(children_a)) + + for child_a, child_b in zip(children_a, children_b): + if child_a is None: # child_b exists + child_c = child_b.clone() + delta.append_tag(child_c.tag, None) + child_c.tag = 'XXX' + child_c.tag + xml1.append(child_c) + elif child_b is None: # child_a exists + delta.append_tag(None, child_a.tag) + child_a.tag += 'XXX' + else: + _xmldiff(child_a, child_b, delta) |