Skip to content
Snippets Groups Projects
  • Thomas Holene Løkkeborg's avatar
    f2d79a6e
    #70 Støtte for vedlegg i InlineEditableSOIView & i soi.py · f2d79a6e
    Thomas Holene Løkkeborg authored
    - Fikset ModuleList fortsatt referer til 'self.parent.soi' noen steder
    - Reorganisering av SOI vil nå også reorganisere vedlegg. Vedlegg plasseres etter rekkefølge i SOI.attachments listen, uavhengig av sorteringsalgoritmen som er valgt (siden denne bare gjelder for rectpack-sortering).
    - implementert skikkelig deepcopy for modul-lister i SOI test, siden det ble trøbbel med at modules and attachments i realiteten refererte til samme objekt...
    f2d79a6e
    History
    #70 Støtte for vedlegg i InlineEditableSOIView & i soi.py
    Thomas Holene Løkkeborg authored
    - Fikset ModuleList fortsatt referer til 'self.parent.soi' noen steder
    - Reorganisering av SOI vil nå også reorganisere vedlegg. Vedlegg plasseres etter rekkefølge i SOI.attachments listen, uavhengig av sorteringsalgoritmen som er valgt (siden denne bare gjelder for rectpack-sortering).
    - implementert skikkelig deepcopy for modul-lister i SOI test, siden det ble trøbbel med at modules and attachments i realiteten refererte til samme objekt...
test_soi.py 15.13 KiB
"""Test the soi module.

To view the SOI during manual testing the following snippet is useful:

```python
from soitool.inline_editable_soi_view import InlineEditableSOIView
self.soi.reorganize()
view = InlineEditableSOIView(self.soi)
view.show()
app.exec_()
```
"""

import unittest
from PySide2.QtWidgets import QApplication, QWidget
from PySide2 import QtGui
from PySide2.QtGui import QPalette, QColor
from soitool.soi import SOI, ModuleLargerThanBinError, ModuleNameTaken
from soitool.modules.module_base import ModuleBase

if isinstance(QtGui.qApp, type(None)):
    app = QApplication([])
else:
    app = QtGui.qApp


class Meta(type(ModuleBase), type(QWidget)):
    """Used as a metaclass to enable multiple inheritance."""


class TestModule(ModuleBase, QWidget, metaclass=Meta):
    """A simple module of given width, height and color for testing."""

    def __init__(self, color, width, height, *args, **kwargs):
        self.type = "TestModule"
        QWidget.__init__(self, *args, **kwargs)
        ModuleBase.__init__(self)

        self.color = color
        self.width = width
        self.height = height

        self.setAutoFillBackground(True)
        palette = self.palette()
        palette.setColor(QPalette.Window, QColor(self.color))
        self.setPalette(palette)
        self.setGeometry(0, 0, self.width, self.height)

    def get_size(self):
        """Override."""
        size = self.size()
        return size.width(), size.height()

    def set_pos(self, pos):
        """Override."""
        self.move(pos)

    def render_onto_pdf(self):
        """Not used."""
        raise NotImplementedError()

    def get_as_dict(self):
        """Not used."""
        raise NotImplementedError()


def testmodule_cloner(module):
    """Return new TestModule that is a clone of parameter module.

    Paramters
    ---------
    module : TestModule
        Module to clone.

    Returns
    -------
    TestModule
        New TestModule that is a clone of parameter module.
    """
    return TestModule(module.color, module.width, module.height)


# The modules below have sizes that make the ideal for testing.
# Sorting them by width should yield
#  1. wide_module
#  2. big_module
#  3. tall_module
# Sorting them by height should yield
#  1. tall_module
#  2. big_module
#  3. wide_module
# And sorting them by area should yield
#  1. big_module
#  2. tall_module
#  3. wide_module
# Note that when sorting by area the two modules 'tall_module' and
# 'wide_module' have the same area, but 'tall_module' should come first
# because sorting should be stable
# https://en.wikipedia.org/wiki/Category:Stable_sorts
# NOTE the only meta information necessary here is "name", if we assume
#      automatic sorting..
TEST_MODULES = [
    {
        "widget": TestModule("red", 100, 400),
        "meta": {"x": 0, "y": 0, "page": 1, "name": "tall_module"},
    },
    {
        "widget": TestModule("blue", 400, 100),
        "meta": {"x": 0, "y": 0, "page": 1, "name": "wide_module"},
    },
    {
        "widget": TestModule("orange", 300, 300),
        "meta": {"x": 0, "y": 0, "page": 1, "name": "big_module"},
    },
]


class TestSOI(unittest.TestCase):
    """TestCase for the SOI class."""

    def setUp(self):
        """Prepare SOI to test on.

        Note that the SOI can be modified by individual tests before use, this
        simply sets the default.
        """
        self.soi = SOI(
            modules=deep_copy_of_modules_list(TEST_MODULES, testmodule_cloner),
            attachments=deep_copy_of_modules_list(
                TEST_MODULES, testmodule_cloner
            ),
            algorithm_sort="none",
            placement_strategy="auto",
        )

    def sort_modules_and_assert_order(self, sorting_algorithm, expected_order):
        """Sort the SOI modules using algorithm and expect order.

        This function was created to reduce code-duplication only.

        Parameters
        ----------
        sorting_algorithm : str
            name of sorting algorithm to pass to the SOI
        expected_order : list of string names
            names of modules in the expected order
        """
        self.soi.algorithm_sort = sorting_algorithm
        self.soi.sort_modules()

        self.assertEqual(
            expected_order,
            [module["meta"]["name"] for module in self.soi.modules],
            f"Modules should have expected order when sorting algorithm is "
            "set to '{sorting_algorithm}'",
        )

    def test_sort_modules_none(self):
        """Test sort algorithm 'none'."""
        self.sort_modules_and_assert_order(
            "none", ["tall_module", "wide_module", "big_module"]
        )

    def test_sort_modules_width(self):
        """Test sort algorithm 'width'."""
        self.sort_modules_and_assert_order(
            "width", ["wide_module", "big_module", "tall_module"]
        )

    def test_sort_modules_height(self):
        """Test sort algorithm 'height'."""
        self.sort_modules_and_assert_order(
            "height", ["tall_module", "big_module", "wide_module"]
        )

    def test_sort_modules_area(self):
        """Test sort algorithm 'area'."""
        self.sort_modules_and_assert_order(
            "area", ["big_module", "tall_module", "wide_module"]
        )

    def test_reorganization_listener(self):
        """Test reorganization listener functionality of SOI."""
        listener_was_called = False

        def listener():
            # Nonlocal is required to access variable in nesting function's
            # scope
            nonlocal listener_was_called
            listener_was_called = True

        self.soi.add_reorganization_listener(listener)
        self.soi.reorganize()
        self.assertTrue(listener_was_called)

    def test_reorganize_of_too_big_module(self):
        """Test exception thrown by reorganize in case of too large modules."""
        maximum_width = self.soi.CONTENT_WIDTH
        maximum_height = self.soi.CONTENT_HEIGHT

        module_too_wide = {
            "widget": TestModule("red", maximum_width + 1, 100),
            "meta": {"x": 0, "y": 0, "page": 1, "name": "too_wide"},
        }
        module_too_tall = {
            "widget": TestModule("red", 100, maximum_height + 1),
            "meta": {"x": 0, "y": 0, "page": 1, "name": "too_tall"},
        }
        module_maximum_size = {
            "widget": TestModule("red", maximum_width, maximum_height),
            "meta": {"x": 0, "y": 0, "page": 1, "name": "maximum_size"},
        }

        # Too wide module should raise exception
        self.soi.modules = [module_too_wide]
        self.assertRaises(
            ModuleLargerThanBinError, self.soi.reorganize,
        )

        # Too tall module should raise exception
        self.soi.modules = [module_too_tall]
        self.assertRaises(
            ModuleLargerThanBinError, self.soi.reorganize,
        )

        # Module with maximum size shouldn't cause any exception
        self.soi.modules = [module_maximum_size]
        try:
            self.soi.reorganize()
        except ModuleLargerThanBinError:
            self.fail(
                "ModuleLargerThanBinError was raised even though module was "
                "not too large"
            )

    def test_packing(self):
        """Test that 4 test modules are packed as we expect them to be.

        Only the algorithms "MaxRectsBl" and "GuillotineBssfSas" are tested.
        This is because if these two work, we can assume other choices of
        algorithm will work as well. The modules in the SOI have been chosen
        such that these two algorithms should yield different packings.

        This test was constructed by first running the algorithms on our
        modules and inspecting the resulting packing, and then this packing is
        used for testing. This way if reorganize gives a different packing in
        the future this test should break. It is required that reorganize
        yields the same packing given the same input.

        Note that this test does not check the actual module widget positions,
        only the meta positions.
        """
        # Append module of maximum size
        maximum_width = self.soi.CONTENT_WIDTH
        maximum_height = self.soi.CONTENT_HEIGHT
        module_maximum_size = {
            "widget": TestModule("red", maximum_width, maximum_height),
            "meta": {"x": 0, "y": 0, "page": 1, "name": "maximum_size"},
        }
        self.soi.modules.append(module_maximum_size)

        # Store modules so we can use the exact same input for both algorithms
        input_modules = deep_copy_of_modules_list(
            self.soi.modules, testmodule_cloner
        )

        # Test packing with Guillotine algorithm
        expected_module_packing_metadata_guillotinebssfsas = [
            {"x": 0, "y": 0, "page": 1, "name": "tall_module"},
            {"x": 100, "y": 0, "page": 1, "name": "wide_module"},
            {"x": 100, "y": 100, "page": 1, "name": "big_module"},
            {"x": 0, "y": 0, "page": 2, "name": "maximum_size"},
        ]
        self.soi.algorithm_pack = "GuillotineBssfSas"
        self.soi.reorganize()
        self.assertEqual(
            expected_module_packing_metadata_guillotinebssfsas,
            [module["meta"] for module in self.soi.modules],
        )

        # Restore modules and test packing with MaxRects
        self.soi.modules = deep_copy_of_modules_list(
            input_modules, testmodule_cloner
        )
        expected_module_packing_metadata_maxrectsbl = [
            {"x": 0, "y": 0, "page": 1, "name": "tall_module"},
            {"x": 100, "y": 0, "page": 1, "name": "wide_module"},
            {"x": 500, "y": 0, "page": 1, "name": "big_module"},
            {"x": 0, "y": 0, "page": 2, "name": "maximum_size"},
        ]
        self.soi.algorithm_pack = "MaxRectsBl"
        self.soi.reorganize()
        self.assertEqual(
            expected_module_packing_metadata_maxrectsbl,
            [module["meta"] for module in self.soi.modules],
        )

    def test_module_widget_update_position(self):
        """Test update of real widget position to match meta position."""
        page_2_module = {
            "widget": TestModule("red", 100, 100),
            "meta": {"x": 100, "y": 100, "page": 2, "name": "maximum_size"},
        }

        self.soi.modules = [page_2_module]

        # Using [0] below because we're guaranteed there is only one
        # module, and it's the one we want to test

        self.soi.update_module_widget_position(self.soi.modules[0])

        # Calculate expected widget positions. See SOI class docstring for an
        # explanation of the calculation needed for y
        expected_x = (
            page_2_module["meta"]["x"]
            + self.soi.PADDING
            + self.soi.HEADER_WIDTH
        )
        expected_y = (
            page_2_module["meta"]["y"] + self.soi.PADDING * 2 + self.soi.HEIGHT
        )

        self.assertEqual(
            expected_x, self.soi.modules[0]["widget"].pos().x(),
        )

        self.assertEqual(
            expected_y, self.soi.modules[0]["widget"].pos().y(),
        )

    def test_add_module(self):
        """Test add module and reorganize and add module listeners."""
        reorganize_listener_called = False
        new_module_listener_called = False

        def listener_reorganize():
            # Nonlocal is required to access variable in nesting function's
            # scope
            nonlocal reorganize_listener_called
            reorganize_listener_called = True

        def listener_new_module():
            # Nonlocal is required to access variable in nesting function's
            # scope
            nonlocal new_module_listener_called
            new_module_listener_called = True

        self.soi.add_reorganization_listener(listener_reorganize)
        self.soi.add_reorganization_listener(listener_new_module)

        module_name = "add module test name"
        self.soi.add_module(module_name, TestModule("purple", 100, 100), True)
        self.assertTrue(self.soi.module_name_taken(module_name))

        self.assertTrue(reorganize_listener_called)
        self.assertTrue(new_module_listener_called)

    def test_add_module_duplicate_name(self):
        """Test adding module with duplicate name raises exception."""
        # Using already added module TEST_MODULES[0] to guarantee name exists
        self.assertRaises(
            ModuleNameTaken,
            lambda: self.soi.add_module(
                TEST_MODULES[0]["meta"]["name"],
                TestModule("purple", 100, 100),
                False,
            ),
        )
        self.assertRaises(
            ModuleNameTaken,
            lambda: self.soi.add_module(
                TEST_MODULES[0]["meta"]["name"],
                TestModule("purple", 100, 100),
                True,
            ),
        )

        # Test that unique name doesn't raise exception
        try:
            self.soi.add_module(
                "unique name", TestModule("purple", 100, 100), True
            )
        except ModuleNameTaken:
            self.fail("Exception should not be raised when name is unique")
        try:
            self.soi.add_module(
                "another unique name", TestModule("purple", 100, 100), False
            )
        except ModuleNameTaken:
            self.fail("Exception should not be raised when name is unique")

    def test_update_properties(self):
        """Test updating properties and corresponding listener."""
        test_title = "a test title"

        listener_called = False

        def listener_update_properties():
            # Nonlocal is required to access variable in nesting function's
            # scope
            nonlocal listener_called
            listener_called = True

        self.soi.add_update_property_listener(listener_update_properties)
        self.soi.update_properties(title=test_title)
        self.assertTrue(listener_called)

        self.assertEqual(self.soi.title, test_title)

    def test_update_properties_invalid_key(self):
        """Test update_properties properly throws exception if invalid key."""
        self.assertRaises(
            ValueError,
            lambda: self.soi.update_properties(invalid_key="garbage"),
        )


def deep_copy_of_modules_list(modules, widget_cloner):
    """Get deep copy of modules list. Works for both modules and attachments.

    This is necessary because copying lists in Python only works on one level
    of nesting, such that:

    ```python
    a = [[1,2,3]
    b = a.copy()
    b[0][0] = 9
    print(
        "nested list in 'a' now updated "
        "to '{}'!! Not what we want! ".format(a[0][0])
    )
    ```

    There is a package copy that contains deepcopy for this, but we can't use
    it because some of our objects "Can't be pickled", quoting the error.

    Parameters
    ----------
    modules : list
        List of modules.
    widget_cloner : function
        Function that given a Qt widget as a parameter can return a clone of
        that widget. Example usage: `widget_cloner(mywidget)` should return a
        widget that is a clone of mywidget.

    Returns
    -------
    list
        List of modules, deep copied from input list.
    """
    return [
        {
            "meta": module["meta"].copy(),
            "widget": widget_cloner(module["widget"]),
        }
        for module in modules
    ]