diff --git a/Manifest.in b/Manifest.in new file mode 100644 index 0000000..b5034bd --- /dev/null +++ b/Manifest.in @@ -0,0 +1 @@ +include habapp_rules/energy/monthly_report_template.html \ No newline at end of file diff --git a/changelog.md b/changelog.md index c53c0cf..89d8ac1 100644 --- a/changelog.md +++ b/changelog.md @@ -1,3 +1,11 @@ +# Version 5.6.0 - 24.03.2024 + +# Features + +- added ``habapp_rules.energy.montly_report.MonthlyReport`` for generating a monthly energy report mail +- bumped holidays to 0.45 +- bumped multi-notifier to 0.4.0 + # Version 5.5.1 - 07.03.2024 # Bugfix @@ -19,9 +27,9 @@ # Bugfix -- fixed bug in ``habapp_rules.actors.state_observer.StateObserverNumber`` which triggered the manual-detected-callback if the received number deviates only a little bit because of data types. (e.g.: 1.000001 != 1.0) +- fixed bug in ``habapp_rules.actors.state_observer.StateObserverNumber`` which triggered the manual-detected-callback if the received number deviates only a little bit because of data types. (e.g.: 1.000001 != 1.0) - fixed bug for dimmer lights in ``habapp_rules.actors.light`` which did not set the correct brightness if light was switched on. -- fixed bug in ``habapp_rules.common.hysteresis.HysteresisSwitch.get_output`` resulted in a wrong switch state if the value was 0. +- fixed bug in ``habapp_rules.common.hysteresis.HysteresisSwitch.get_output`` resulted in a wrong switch state if the value was 0. - added missing state transition to ``habapp_rules.sensors.motion.Motion``. When state was ``PostSleepLocked`` and sleep started there was no change to ``SleepLocked`` - fixed strange behavior of ``habapp_rules.system.presence.Presence`` which did not abort leaving when the first phone appeared. This let to absence state if someone returned when leaving was active. diff --git a/custom_pylint_dictionary.txt b/custom_pylint_dictionary.txt index bcab0e8..ccebff6 100644 --- a/custom_pylint_dictionary.txt +++ b/custom_pylint_dictionary.txt @@ -3,11 +3,13 @@ autoupdate bool config dataclass +donut dwd DWD enums ga HCL +html init KNX kwargs diff --git a/habapp_rules/__version__.py b/habapp_rules/__version__.py index 5a62936..50cf21e 100644 --- a/habapp_rules/__version__.py +++ b/habapp_rules/__version__.py @@ -1,2 +1,2 @@ """Set version for the package.""" -__version__ = "5.5.1" +__version__ = "5.6.0" diff --git a/habapp_rules/energy/__init__.py b/habapp_rules/energy/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/habapp_rules/energy/donut_chart.py b/habapp_rules/energy/donut_chart.py new file mode 100644 index 0000000..f59230c --- /dev/null +++ b/habapp_rules/energy/donut_chart.py @@ -0,0 +1,39 @@ +"""Module to create donut charts.""" +import collections.abc +import pathlib + +import matplotlib.pyplot + + +def _auto_percent_format(values: list[float]) -> collections.abc.Callable: + """Get labels for representing the absolute value. + + :param values: list of all values + :return: function which returns the formatted string if called + """ + + def my_format(pct: float) -> str: + """get formatted value. + + :param pct: percent value + :return: formatted value + """ + total = sum(values) + return f"{(pct * total / 100.0):.1f} kWh" + + return my_format + + +def create_chart(labels: list[str], values: list[float], chart_path: pathlib.Path) -> None: + """Create the donut chart. + + :param labels: labels for the donut chart + :param values: values of the donut chart + :param chart_path: target path for the chart + """ + _, ax = matplotlib.pyplot.subplots() + _, texts, _ = ax.pie(values, labels=labels, autopct=_auto_percent_format(values), pctdistance=0.7, textprops={"fontsize": 10}) + for text in texts: + text.set_backgroundcolor("white") + + matplotlib.pyplot.savefig(str(chart_path), bbox_inches="tight", transparent=True) diff --git a/habapp_rules/energy/monthly_report.py b/habapp_rules/energy/monthly_report.py new file mode 100644 index 0000000..778ee06 --- /dev/null +++ b/habapp_rules/energy/monthly_report.py @@ -0,0 +1,231 @@ +"""Module for sending the monthly energy consumption.""" +import dataclasses +import datetime +import logging +import pathlib +import tempfile + +import HABApp +import HABApp.core.internals +import dateutil.relativedelta +import jinja2 +import multi_notifier.connectors.connector_mail + +import habapp_rules.__version__ +import habapp_rules.core.exceptions +import habapp_rules.core.logger +import habapp_rules.energy.donut_chart + +LOGGER = logging.getLogger(__name__) + +MONTH_MAPPING = { + 1: "Januar", + 2: "Februar", + 3: "März", + 4: "April", + 5: "Mai", + 6: "Juni", + 7: "Juli", + 8: "August", + 9: "September", + 10: "Oktober", + 11: "November", + 12: "Dezember" +} + + +def _get_previous_month_name() -> str: + """Get name of the previous month + + :return: name of current month + + if other languages are required, the global dict must be replaced + """ + today = datetime.date.today() + last_month = today.replace(day=1) - datetime.timedelta(days=1) + + return MONTH_MAPPING[last_month.month] + + +def _get_next_trigger() -> datetime.datetime: + """Get next trigger time (always first day of month at midnight) + + :return: next trigger time + """ + return datetime.datetime.now().replace(day=1, hour=0, minute=0, second=0, microsecond=0) + dateutil.relativedelta.relativedelta(months=1) + + +@dataclasses.dataclass +class EnergyShare: + """Dataclass for defining energy share objects.""" + openhab_name: str + chart_name: str + monthly_power: float = 0 + + _openhab_item = None + + def __post_init__(self) -> None: + """This is triggered after init. + + :raises habapp_rules.core.exceptions.HabAppRulesConfigurationException: if the number item could not be found + """ + try: + self._openhab_item = HABApp.openhab.items.NumberItem.get_item(self.openhab_name) + except AssertionError: + raise habapp_rules.core.exceptions.HabAppRulesConfigurationException(f"The given item name is not a number item. '{self.openhab_name}'") + except HABApp.core.errors.ItemNotFoundException: + raise habapp_rules.core.exceptions.HabAppRulesConfigurationException(f"Could not find any item for given name '{self.openhab_name}'") + + @property + def openhab_item(self) -> HABApp.openhab.items.NumberItem: + """Get OpenHAB item. + + :return: OpenHAB item + """ + return self._openhab_item + + +class MonthlyReport(HABApp.Rule): + """Rule for sending the monthly energy consumption. + + Example: + known_energy_share = [ + habapp_rules.energy.monthly_report.EnergyShare("Dishwasher_Energy", "Dishwasher"), + habapp_rules.energy.monthly_report.EnergyShare("Light", "Light") + ] + + config_mail = multi_notifier.connectors.connector_mail.MailConfig( + user="sender@test.de", + password="fancy_password", + smtp_host="smtp.test.de", + smtp_port=587, + ) + + habapp_rules.energy.monthly_report.MonthlyReport("Total_Energy", known_energy_share, "Group_RRD4J", config_mail, "test@test.de") + """ + + def __init__( + self, + name_energy_sum: str, + known_energy_share: list[EnergyShare], + persistence_group_name: str | None, + config_mail: multi_notifier.connectors.connector_mail.MailConfig | None, + recipients: str | list[str]) -> None: + """Initialize the rule. + + :param name_energy_sum: name of OpenHAB Number item, which holds the total energy consumption (NumberItem) + :param known_energy_share: list of EnergyShare objects + :param persistence_group_name: OpenHAB group name which holds all items which are persisted. If the group name is given it will be checked if all energy items are in the group + :param config_mail: config for sending mails + :param recipients: list of recipients who get the mail + :raises habapp_rules.core.exceptions.HabAppRulesConfigurationException: if config is not valid + """ + HABApp.Rule.__init__(self) + self._instance_logger = habapp_rules.core.logger.InstanceLogger(LOGGER, name_energy_sum) + self._recipients = recipients + + self._item_energy_sum = HABApp.openhab.items.NumberItem.get_item(name_energy_sum) + self._known_energy_share = known_energy_share + self._mail = multi_notifier.connectors.connector_mail.Mail(config_mail) + + if persistence_group_name is not None: + # check if all energy items are in the given persistence group + items_to_check = [self._item_energy_sum] + [share.openhab_item for share in self._known_energy_share] + not_in_persistence_group = [item.name for item in items_to_check if persistence_group_name not in item.groups] + if not_in_persistence_group: + raise habapp_rules.core.exceptions.HabAppRulesConfigurationException(f"The following OpenHAB items are not in the persistence group '{persistence_group_name}': {not_in_persistence_group}") + + self.run.at(next_trigger_time := _get_next_trigger(), self._cb_send_energy) + self._instance_logger.info(f"Successfully initiated monthly consumption rule for {name_energy_sum}. Triggered first execution to {next_trigger_time.isoformat()}") + + def _get_historic_value(self, item: HABApp.openhab.items.NumberItem, start_time: datetime.datetime) -> float: + """Get historic value of given Number item + + :param item: item instance + :param start_time: start time to search for the interested value + :return: historic value of the item + """ + historic = item.get_persistence_data(start_time=start_time, end_time=start_time + datetime.timedelta(hours=1)).data + if not historic: + self._instance_logger.warning(f"Could not get value of item '{item.name}' of time = {start_time}") + return 0 + + return next(iter(historic.values())) + + # pylint: disable=wrong-spelling-in-docstring + def _create_html(self, energy_sum_month: float) -> str: + """Create html which will be sent by the mail + + :param energy_sum_month: sum value for the current month + :return: html with replaced values + + The template was created by https://app.bootstrapemail.com/editor/documents with the following input: + + + + + + + +
+
+
+

Strom Verbrauch

+
von Februar
+
+
+

Aktueller Zählerstand: 7000 kWh.

+

Hier die Details:

+

Italian Trulli +

+
+
+

Generated with habapp_rules version = 20.0.3

+
+
+
+ + + """ + html_template_path = pathlib.Path(__file__).parent / "monthly_report_template.html" + + with html_template_path.open() as html_template_file: + html_template = html_template_file.read() + + return jinja2.Template(html_template).render( + month=_get_previous_month_name(), + energy_now=f"{self._item_energy_sum.value:.1f}", + energy_last_month=f"{energy_sum_month:.1f}", + habapp_version=habapp_rules.__version__.__version__, + chart="{{ chart }}" # this is needed to not replace the chart from the mail-template + ) + + def _cb_send_energy(self) -> None: + """Send the mail with the energy consumption of the last month""" + self._instance_logger.debug("Send energy consumption was triggered.") + # get values + now = datetime.datetime.now() + last_month = now - dateutil.relativedelta.relativedelta(months=1) + + energy_sum_month = self._item_energy_sum.value - self._get_historic_value(self._item_energy_sum, last_month) + for share in self._known_energy_share: + share.monthly_power = share.openhab_item.value - self._get_historic_value(share.openhab_item, last_month) + + energy_unknown = energy_sum_month - sum(share.monthly_power for share in self._known_energy_share) + + with tempfile.TemporaryDirectory() as temp_dir_name: + # create plot + labels = [share.chart_name for share in self._known_energy_share] + ["Rest"] + values = [share.monthly_power for share in self._known_energy_share] + [energy_unknown] + chart_path = pathlib.Path(temp_dir_name) / "chart.png" + habapp_rules.energy.donut_chart.create_chart(labels, values, chart_path) + + # get html + html = self._create_html(energy_sum_month) + + # send mail + self._mail.send_message(self._recipients, html, f"Stromverbrauch {_get_previous_month_name()}", images={"chart": str(chart_path)}) + + self.run.at(next_trigger_time := _get_next_trigger(), self._cb_send_energy) + self._instance_logger.info(f"Successfully sent energy consumption mail to {self._recipients}. Scheduled the next trigger time to {next_trigger_time.isoformat()}") diff --git a/habapp_rules/energy/monthly_report_template.html b/habapp_rules/energy/monthly_report_template.html new file mode 100644 index 0000000..5719a2c --- /dev/null +++ b/habapp_rules/energy/monthly_report_template.html @@ -0,0 +1,179 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/requirements.txt b/requirements.txt index d7e0a64..d8d95f0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,5 @@ HABApp==24.02.0 -multi-notifier~=0.2.2 +matplotlib~=3.8.3 +multi-notifier~=0.4.0 transitions~=0.9 -holidays==0.44 \ No newline at end of file +holidays==0.45 \ No newline at end of file diff --git a/requirements_dev.txt b/requirements_dev.txt index a35a742..e89da9f 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -1,2 +1,2 @@ graphviz~=0.20 -nose_helper~=1.2.0 \ No newline at end of file +nose_helper~=1.3.0 \ No newline at end of file diff --git a/setup.py b/setup.py index 19ef258..6a445a7 100644 --- a/setup.py +++ b/setup.py @@ -21,5 +21,6 @@ def load_req() -> typing.List[str]: packages=setuptools.find_packages(exclude=["tests*", "rules*"]), install_requires=load_req(), python_requires=">=3.10", - license="Apache License 2.0" + license="Apache License 2.0", + include_package_data=True ) diff --git a/tests/energy/donut_chart.py b/tests/energy/donut_chart.py new file mode 100644 index 0000000..c5a9303 --- /dev/null +++ b/tests/energy/donut_chart.py @@ -0,0 +1,43 @@ +"""Tests for donut chart.""" +import unittest +import unittest.mock + +import habapp_rules.energy.donut_chart + + +# pylint: disable=protected-access +class TestDonutFunctions(unittest.TestCase): + """Test all donut plot functions.""" + + def test_auto_percent_format(self): + """Test _auto_percent_format.""" + values = [100, 20, 80] + percent_values = [val / sum(values) * 100 for val in values] + + label_function = habapp_rules.energy.donut_chart._auto_percent_format(values) + + for idx, percent_value in enumerate(percent_values): + self.assertEqual(f"{values[idx]:.1f} kWh", label_function(percent_value)) + + def test_create_chart(self): + """Test create_chart.""" + labels = ["one", "two", "three"] + values = [1, 2, 3.0] + path = unittest.mock.MagicMock() + + with unittest.mock.patch("matplotlib.pyplot") as pyplot_mock: + ax_mock = unittest.mock.MagicMock() + pyplot_mock.subplots.return_value = None, ax_mock + text_mock_1 = unittest.mock.MagicMock() + text_mock_2 = unittest.mock.MagicMock() + ax_mock.pie.return_value = None, [text_mock_1, text_mock_2], None + + habapp_rules.energy.donut_chart.create_chart(labels, values, path) + + pyplot_mock.subplots.assert_called_once() + ax_mock.pie.assert_called_once_with(values, labels=labels, autopct=unittest.mock.ANY, pctdistance=0.7, textprops={"fontsize": 10}) + + text_mock_1.set_backgroundcolor.assert_called_once_with("white") + text_mock_2.set_backgroundcolor.assert_called_once_with("white") + + pyplot_mock.savefig.assert_called_once_with(str(path), bbox_inches="tight", transparent=True) diff --git a/tests/energy/monthly_report.py b/tests/energy/monthly_report.py new file mode 100644 index 0000000..046c20b --- /dev/null +++ b/tests/energy/monthly_report.py @@ -0,0 +1,188 @@ +"""Tests for monthly energy report.""" +import collections +import datetime +import unittest +import unittest.mock + +import HABApp.openhab.definitions.helpers.persistence_data +import HABApp.openhab.items + +import habapp_rules.__version__ +import habapp_rules.core.exceptions +import habapp_rules.energy.monthly_report +import tests.helper.oh_item +import tests.helper.test_case_base + +# pylint: disable=protected-access +class TestFunctions(unittest.TestCase): + """Test all global functions.""" + + def test_get_last_month_name(self): + """Test _get_last_month_name.""" + TestCase = collections.namedtuple("TestCase", ["month_number", "expected_name"]) + + test_cases = [ + TestCase(1, "Dezember"), + TestCase(2, "Januar"), + TestCase(3, "Februar"), + TestCase(4, "März"), + TestCase(5, "April"), + TestCase(6, "Mai"), + TestCase(7, "Juni"), + TestCase(8, "Juli"), + TestCase(9, "August"), + TestCase(10, "September"), + TestCase(11, "Oktober"), + TestCase(12, "November"), + ] + + today = datetime.datetime.today() + with unittest.mock.patch("datetime.date") as mock_date: + for test_case in test_cases: + with self.subTest(test_case=test_case): + mock_date.today.return_value = today.replace(month=test_case.month_number) + self.assertEqual(test_case.expected_name, habapp_rules.energy.monthly_report._get_previous_month_name()) + + def test_get_next_trigger(self): + """Test _get_next_trigger.""" + TestCase = collections.namedtuple("TestCase", ["current_datetime", "expected_trigger"]) + + test_cases = [ + TestCase(datetime.datetime(2022, 1, 1, 0, 0, 0), datetime.datetime(2022, 2, 1, 0, 0, 0)), + TestCase(datetime.datetime(2023, 12, 17, 2, 42, 55), datetime.datetime(2024, 1, 1, 0, 0, 0)), + TestCase(datetime.datetime(2024, 2, 29, 23, 59, 59), datetime.datetime(2024, 3, 1, 0, 0, 0)) + ] + + with unittest.mock.patch("datetime.datetime") as mock_datetime: + for test_case in test_cases: + with self.subTest(test_case=test_case): + mock_datetime.now.return_value = test_case.current_datetime + self.assertEqual(test_case.expected_trigger, habapp_rules.energy.monthly_report._get_next_trigger()) + + +class TestEnergyShare(tests.helper.test_case_base.TestCaseBase): + """Test EnergyShare dataclass.""" + + def setUp(self) -> None: + """Setup test case.""" + tests.helper.test_case_base.TestCaseBase.setUp(self) + + tests.helper.oh_item.add_mock_item(HABApp.openhab.items.NumberItem, "Number_1", None) + tests.helper.oh_item.add_mock_item(HABApp.openhab.items.SwitchItem, "Switch_1", None) + + def test_init(self): + """Test init.""" + # valid init + energy_share = habapp_rules.energy.monthly_report.EnergyShare("Number_1", "First Number") + self.assertEqual("Number_1", energy_share.openhab_name) + self.assertEqual("First Number", energy_share.chart_name) + self.assertEqual(0, energy_share.monthly_power) + self.assertEqual("Number_1", energy_share.openhab_item.name) + + expected_item = HABApp.openhab.items.NumberItem("Number_1") + self.assertEqual(expected_item, energy_share.openhab_item) + + # invalid init (Item not found) + with self.assertRaises(habapp_rules.core.exceptions.HabAppRulesConfigurationException): + habapp_rules.energy.monthly_report.EnergyShare("Number_2", "Second Number") + + # invalid init (Item is not a number) + with self.assertRaises(habapp_rules.core.exceptions.HabAppRulesConfigurationException): + habapp_rules.energy.monthly_report.EnergyShare("Switch_1", "Second Number") + + +class TestMonthlyReport(tests.helper.test_case_base.TestCaseBase): + """Test MonthlyReport rule.""" + + def setUp(self) -> None: + """Setup test case.""" + tests.helper.test_case_base.TestCaseBase.setUp(self) + + tests.helper.oh_item.add_mock_item(HABApp.openhab.items.NumberItem, "Energy_Sum", None) + tests.helper.oh_item.add_mock_item(HABApp.openhab.items.NumberItem, "Energy_1", None) + tests.helper.oh_item.add_mock_item(HABApp.openhab.items.NumberItem, "Energy_2", None) + + self._energy_1 = habapp_rules.energy.monthly_report.EnergyShare("Energy_1", "Energy 1") + self._energy_2 = habapp_rules.energy.monthly_report.EnergyShare("Energy_2", "Energy 2") + self._mail_config = unittest.mock.MagicMock() + + self._rule = habapp_rules.energy.monthly_report.MonthlyReport("Energy_Sum", [self._energy_1, self._energy_2], None, self._mail_config, "test@test.de") + + def test_init(self): + """Test init.""" + TestCase = collections.namedtuple("TestCase", ["sum_in_group", "item_1_in_group", "item_2_in_group", "raises_exception"]) + + test_cases = [ + TestCase(True, True, True, False), + TestCase(True, True, False, True), + TestCase(True, False, True, True), + TestCase(True, False, False, True), + TestCase(False, True, True, True), + TestCase(False, True, False, True), + TestCase(False, False, True, True), + TestCase(False, False, False, True), + ] + + for test_case in test_cases: + with self.subTest(test_case=test_case): + self._energy_1.openhab_item.groups = {"PersistenceGroup"} if test_case.item_1_in_group else set() + self._energy_2.openhab_item.groups = {"PersistenceGroup"} if test_case.item_2_in_group else set() + self._rule._item_energy_sum.groups = {"PersistenceGroup"} if test_case.sum_in_group else set() + + if test_case.raises_exception: + with self.assertRaises(habapp_rules.core.exceptions.HabAppRulesConfigurationException): + habapp_rules.energy.monthly_report.MonthlyReport("Energy_Sum", [self._energy_1, self._energy_2], "PersistenceGroup", self._mail_config, "test@test.de") + else: + habapp_rules.energy.monthly_report.MonthlyReport("Energy_Sum", [self._energy_1, self._energy_2], "PersistenceGroup", self._mail_config, "test@test.de") + + def test_get_historic_value(self): + """Test _get_historic_value.""" + mock_item = unittest.mock.MagicMock() + fake_persistence_data = HABApp.openhab.definitions.helpers.persistence_data.OpenhabPersistenceData() + mock_item.get_persistence_data.return_value = fake_persistence_data + + start_time = datetime.datetime.now() + end_time = start_time + datetime.timedelta(hours=1) + + # no data + self.assertEqual(0, self._rule._get_historic_value(mock_item, start_time)) + mock_item.get_persistence_data.assert_called_once_with(start_time=start_time, end_time=end_time) + + # data + fake_persistence_data.data = {"0.0": 42, "1.0": 1337} + self.assertEqual(42, self._rule._get_historic_value(mock_item, start_time)) + + def test_create_html(self): + """Test create_html.""" + self._rule._item_energy_sum.value = 20_123.5489135 + + template_mock = unittest.mock.MagicMock() + with (unittest.mock.patch("pathlib.Path.open"), + unittest.mock.patch("jinja2.Template", return_value=template_mock), + unittest.mock.patch("habapp_rules.energy.monthly_report._get_previous_month_name", return_value="MonthName")): + self._rule._create_html(10_042.123456) + + template_mock.render.assert_called_once_with( + month="MonthName", + energy_now="20123.5", + energy_last_month="10042.1", + habapp_version=habapp_rules.__version__.__version__, + chart="{{ chart }}" + ) + + def test_cb_send_energy(self): + """Test cb_send_energy.""" + self._rule._item_energy_sum.value = 1000 + self._energy_1.openhab_item.value = 100 + self._energy_2.openhab_item.value = 50 + + with (unittest.mock.patch.object(self._rule, "_get_historic_value", side_effect=[800, 90, 45]), + unittest.mock.patch("habapp_rules.energy.donut_chart.create_chart", return_value="html text result") as create_chart_mock, + unittest.mock.patch.object(self._rule, "_create_html") as create_html_mock, + unittest.mock.patch("habapp_rules.energy.monthly_report._get_previous_month_name", return_value="MonthName"), + unittest.mock.patch.object(self._rule, "_mail") as mail_mock): + self._rule._cb_send_energy() + + create_chart_mock.assert_called_once_with(["Energy 1", "Energy 2", "Rest"], [10, 5, 185], unittest.mock.ANY) + create_html_mock.assert_called_once_with(200) + mail_mock.send_message("test@test.de", "html text result", "Stromverbrauch MonthName", images={"chart": unittest.mock.ANY}) diff --git a/tests/run_unittest.py b/tests/run_unittest.py index 655594f..33a488c 100644 --- a/tests/run_unittest.py +++ b/tests/run_unittest.py @@ -9,5 +9,8 @@ EXCLUDED_PY_FILES = ["run_unittest.py", "__init__.py", "rule_runner.py"] INPUT_MODULES = [f"tests.{'.'.join(f.parts)[:-3]}" for f in pathlib.Path(".").rglob("*.py") if f.name not in EXCLUDED_PY_FILES] -with unittest.mock.patch("logging.getLogger", spec=logging.getLogger): +logger_mock = unittest.mock.MagicMock() +logger_mock.level = logging.WARNING + +with unittest.mock.patch("logging.getLogger", return_value=logger_mock): result = unittest.TextTestRunner().run(unittest.TestLoader().loadTestsFromNames(INPUT_MODULES))