# 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
import io
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, load_svg, SvgDocumentElement
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)
[docs]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__)
[docs] def run(self, args=None, output=None):
"""Fake run"""
[docs]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
[docs] def setUp(self): # pylint: disable=invalid-name
"""Make sure every test is seeded the same way"""
self._effect = None
super().setUp()
random.seed(0x35F)
[docs] 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__)
[docs] @classmethod
def _testdir(cls):
"""Get's the folder where the test exists (so data can be found)"""
return os.path.dirname(cls.__file__())
[docs] @classmethod
def rootdir(cls):
"""Return the full path to the extensions directory"""
return os.path.dirname(cls._testdir())
[docs] @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
[docs] 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)
[docs] @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")
[docs] 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)
[docs] def assertEffectEmpty(self, effect, **kwargs): # pylint: disable=invalid-name
"""Assert calling effect without any arguments"""
self.assertEffect(effect=effect, **kwargs)
[docs] 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
[docs] 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)
# 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
[docs] def import_string(self, string, *args) -> SvgDocumentElement:
"""Runs a string through an import extension, with optional arguments
provided as "--arg=value" arguments"""
stream = io.BytesIO(string.encode())
reader = self.effect_class()
out = io.BytesIO()
reader.parse_arguments([*args])
reader.options.input_file = stream
reader.options.output = out
reader.load_raw()
reader.save_raw(reader.effect())
out.seek(0)
decoded = out.read().decode("utf-8")
document = load_svg(decoded)
return document
[docs]class InkscapeExtensionTestMixin:
"""Automatically setup self.effect for each test and test with an empty svg"""
[docs] 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")
[docs] def test_default_settings(self):
"""Extension works with empty svg file"""
self.effect.run([self.empty_svg])
[docs]class ComparisonMixin(metaclass=ComparisonMeta):
"""
This mixin allows to easily specify a set of run-through unit tests for an
extension, which is specified in :attr:`inkex.tester.TestCase.effect_class`.
The commandline parameters are passed in :attr:`comparisons`, the input file
in :attr:`compare_file` (either a list of files, or a single file).
The :class:`ComparisonMeta` metaclass creates a set of independent unit tests
out of this data. Behavior notest:
- The unit tests created are the cross product of :attr:`comparisons` and
:attr:`compare_file`. If :attr:`compare_file` is a list, the comparison file name
is suffixed with the current ``compare_file`` name.
- Optionally, :attr:`comparisons_cmpfile_dict` may be specified as
``Dict[Tuple[str], str]`` where the keys are sets of command line parameters and
the values are the filenames of the output file. This takes precedence over
:attr:`comparisons`.
- If any of those values are properties, their values cannot be accessed at test
collection time and there will only be a single test, ``test_all_comparisons``
with otherwise identical behavior.
- If the class overrides ``test_all_comparisons``, no additional tests are
generated to allow for custom comparison logic.
To create the comparison files for the unit tests, use the ``EXPORT_COMPARE``
environment variable.
"""
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
[docs] def _test_comparisons(self, compare_file, addout=None):
for args in self.comparisons:
self._test_comparison(args, compare_file=compare_file, addout=addout)
[docs] def _test_comparison(self, args, compare_file, addout=None):
self.assertCompare(
compare_file,
self.get_compare_cmpfile(args, addout),
args,
)
[docs] 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")
self.write_compare_data(infile, write_output, data_a)
# 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
[docs] def write_compare_data(self, infile, outfile, data):
"""Write output"""
with open(outfile, "wb") as fhl:
fhl.write(self._apply_compare_filters(data, True))
print(f"Written output: {outfile}")
[docs] 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)
[docs] 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
[docs] 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
)