diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 000000000..723242b28 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,2 @@ +[run] +omit = esphome/components/* diff --git a/esphome/core.py b/esphome/core.py index b4bea49db..7734636a7 100644 --- a/esphome/core.py +++ b/esphome/core.py @@ -143,6 +143,9 @@ class TimePeriod: return f'{self.total_days}d' return '0s' + def __repr__(self): + return f"TimePeriod<{self.total_microseconds}>" + @property def total_microseconds(self): return self.total_milliseconds * 1000 + (self.microseconds or 0) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 000000000..a91a2ea20 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +addopts = + --cov=esphome + --cov-branch diff --git a/requirements_test.txt b/requirements_test.txt index 7711b3867..85f7d511a 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -16,3 +16,9 @@ pylint==2.4.4 ; python_version>"3" flake8==3.7.9 pillow pexpect + +# Unit tests +pytest==5.3.2 +pytest-cov==2.8.1 +pytest-mock==1.13.0 +hypothesis==4.57.0 diff --git a/script/fulltest b/script/fulltest index 0fa88516c..795482281 100755 --- a/script/fulltest +++ b/script/fulltest @@ -9,4 +9,5 @@ set -x script/ci-custom.py script/lint-python script/lint-cpp +script/unit_test script/test diff --git a/script/unit_test b/script/unit_test new file mode 100755 index 000000000..1e5c28863 --- /dev/null +++ b/script/unit_test @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -e + +cd "$(dirname "$0")/.." + +set -x + +pytest tests/unit_tests diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py new file mode 100644 index 000000000..adef39a0b --- /dev/null +++ b/tests/unit_tests/conftest.py @@ -0,0 +1,30 @@ +""" +ESPHome Unittests +~~~~~~~~~~~~~~~~~ + +Configuration file for unit tests. + +If adding unit tests ensure that they are fast. Slower integration tests should +not be part of a unit test suite. + +""" +import sys +import pytest + +from pathlib import Path + + +here = Path(__file__).parent + +# Configure location of package root +package_root = here.parent.parent +sys.path.insert(0, package_root.as_posix()) + + +@pytest.fixture +def fixture_path() -> Path: + """ + Location of all fixture files. + """ + return here / "fixtures" + diff --git a/tests/unit_tests/fixtures/helpers/file-a.txt b/tests/unit_tests/fixtures/helpers/file-a.txt new file mode 100644 index 000000000..a9d1060fb --- /dev/null +++ b/tests/unit_tests/fixtures/helpers/file-a.txt @@ -0,0 +1 @@ +A files are unique. diff --git a/tests/unit_tests/fixtures/helpers/file-b_1.txt b/tests/unit_tests/fixtures/helpers/file-b_1.txt new file mode 100644 index 000000000..907b98d93 --- /dev/null +++ b/tests/unit_tests/fixtures/helpers/file-b_1.txt @@ -0,0 +1 @@ +All b files match. diff --git a/tests/unit_tests/fixtures/helpers/file-b_2.txt b/tests/unit_tests/fixtures/helpers/file-b_2.txt new file mode 100644 index 000000000..907b98d93 --- /dev/null +++ b/tests/unit_tests/fixtures/helpers/file-b_2.txt @@ -0,0 +1 @@ +All b files match. diff --git a/tests/unit_tests/fixtures/helpers/file-c.txt b/tests/unit_tests/fixtures/helpers/file-c.txt new file mode 100644 index 000000000..558763720 --- /dev/null +++ b/tests/unit_tests/fixtures/helpers/file-c.txt @@ -0,0 +1 @@ +C files are unique. diff --git a/tests/unit_tests/strategies.py b/tests/unit_tests/strategies.py new file mode 100644 index 000000000..f4763f047 --- /dev/null +++ b/tests/unit_tests/strategies.py @@ -0,0 +1,15 @@ +from typing import Text + +import hypothesis.strategies._internal.core as st +from hypothesis.strategies._internal.strategies import SearchStrategy + + +@st.defines_strategy_with_reusable_values +def mac_addr_strings(): + # type: () -> SearchStrategy[Text] + """A strategy for MAC address strings. + + This consists of six strings representing integers [0..255], + without zero-padding, joined by dots. + """ + return st.builds("{:02X}:{:02X}:{:02X}:{:02X}:{:02X}:{:02X}".format, *(6 * [st.integers(0, 255)])) diff --git a/tests/unit_tests/test_config_validation.py b/tests/unit_tests/test_config_validation.py new file mode 100644 index 000000000..a19330062 --- /dev/null +++ b/tests/unit_tests/test_config_validation.py @@ -0,0 +1,113 @@ +import pytest +import string + +from hypothesis import given, example +from hypothesis.strategies import one_of, text, integers, booleans, builds + +from esphome import config_validation +from esphome.config_validation import Invalid +from esphome.core import Lambda, HexInt + + +def test_check_not_tamplatable__invalid(): + with pytest.raises(Invalid, match="This option is not templatable!"): + config_validation.check_not_templatable(Lambda("")) + + +@given(one_of( + booleans(), + integers(), + text(alphabet=string.ascii_letters + string.digits)), +) +def test_alphanumeric__valid(value): + actual = config_validation.alphanumeric(value) + + assert actual == str(value) + + +@given(value=text(alphabet=string.ascii_lowercase + string.digits + "_")) +def test_valid_name__valid(value): + actual = config_validation.valid_name(value) + + assert actual == value + + +@pytest.mark.parametrize("value", ( + "foo bar", "FooBar", "foo::bar" +)) +def test_valid_name__invalid(value): + with pytest.raises(Invalid): + config_validation.valid_name(value) + + +@given(one_of(integers(), text())) +def test_string__valid(value): + actual = config_validation.string(value) + + assert actual == str(value) + + +@pytest.mark.parametrize("value", ( + {}, [], True, False, None +)) +def test_string__invalid(value): + with pytest.raises(Invalid): + config_validation.string(value) + + +@given(text()) +def test_strict_string__valid(value): + actual = config_validation.string_strict(value) + + assert actual == value + + +@pytest.mark.parametrize("value", (None, 123)) +def test_string_string__invalid(value): + with pytest.raises(Invalid, match="Must be string, got"): + config_validation.string_strict(value) + + +@given(builds(lambda v: "mdi:" + v, text())) +@example("") +def test_icon__valid(value): + actual = config_validation.icon(value) + + assert actual == value + + +def test_icon__invalid(): + with pytest.raises(Invalid, match="Icons should start with prefix"): + config_validation.icon("foo") + + +@pytest.mark.parametrize("value", ( + "True", "YES", "on", "enAblE", True +)) +def test_boolean__valid_true(value): + assert config_validation.boolean(value) is True + + +@pytest.mark.parametrize("value", ( + "False", "NO", "off", "disAblE", False +)) +def test_boolean__valid_false(value): + assert config_validation.boolean(value) is False + + +@pytest.mark.parametrize("value", ( + None, 1, 0, "foo" +)) +def test_boolean__invalid(value): + with pytest.raises(Invalid, match="Expected boolean value"): + config_validation.boolean(value) + + +# TODO: ensure_list +@given(integers()) +def hex_int__valid(value): + actual = config_validation.hex_int(value) + + assert isinstance(actual, HexInt) + assert actual == value + diff --git a/tests/unit_tests/test_core.py b/tests/unit_tests/test_core.py new file mode 100644 index 000000000..c02aed644 --- /dev/null +++ b/tests/unit_tests/test_core.py @@ -0,0 +1,491 @@ +import pytest + +from hypothesis import given +from hypothesis.provisional import ip4_addr_strings +from strategies import mac_addr_strings + +from esphome import core, const + + +class TestHexInt: + @pytest.mark.parametrize("value, expected", ( + (1, "0x01"), + (255, "0xFF"), + (128, "0x80"), + (256, "0x100"), + (-1, "-0x01"), # TODO: this currently fails + )) + def test_str(self, value, expected): + target = core.HexInt(value) + + actual = str(target) + + assert actual == expected + + +class TestIPAddress: + @given(value=ip4_addr_strings()) + def test_init__valid(self, value): + core.IPAddress(*value.split(".")) + + @pytest.mark.parametrize("value", ("127.0.0", "localhost", "")) + def test_init__invalid(self, value): + with pytest.raises(ValueError, match="IPAddress must consist of 4 items"): + core.IPAddress(*value.split(".")) + + @given(value=ip4_addr_strings()) + def test_str(self, value): + target = core.IPAddress(*value.split(".")) + + actual = str(target) + + assert actual == value + + +class TestMACAddress: + @given(value=mac_addr_strings()) + def test_init__valid(self, value): + core.MACAddress(*value.split(":")) + + @pytest.mark.parametrize("value", ("1:2:3:4:5", "localhost", "")) + def test_init__invalid(self, value): + with pytest.raises(ValueError, match="MAC Address must consist of 6 items"): + core.MACAddress(*value.split(":")) + + @given(value=mac_addr_strings()) + def test_str(self, value): + target = core.MACAddress(*(int(v, 16) for v in value.split(":"))) + + actual = str(target) + + assert actual == value + + def test_as_hex(self): + target = core.MACAddress(0xDE, 0xAD, 0xBE, 0xEF, 0x00, 0xFF) + + actual = target.as_hex + + assert actual.text == "0xDEADBEEF00FFULL" + + +@pytest.mark.parametrize("value", ( + 1, 2, -1, 0, 1.0, -1.0, 42.0009, -42.0009 +)) +def test_is_approximately_integer__in_range(value): + actual = core.is_approximately_integer(value) + + assert actual is True + + +@pytest.mark.parametrize("value", ( + 42.01, -42.01, 1.5 +)) +def test_is_approximately_integer__not_in_range(value): + actual = core.is_approximately_integer(value) + + assert actual is False + + +class TestTimePeriod: + @pytest.mark.parametrize("kwargs, expected", ( + ({}, {}), + ({"microseconds": 1}, {"microseconds": 1}), + ({"microseconds": 1.0001}, {"microseconds": 1}), + ({"milliseconds": 2}, {"milliseconds": 2}), + ({"milliseconds": 2.0001}, {"milliseconds": 2}), + ({"milliseconds": 2.01}, {"milliseconds": 2, "microseconds": 10}), + ({"seconds": 3}, {"seconds": 3}), + ({"seconds": 3.0001}, {"seconds": 3}), + ({"seconds": 3.01}, {"seconds": 3, "milliseconds": 10}), + ({"minutes": 4}, {"minutes": 4}), + ({"minutes": 4.0001}, {"minutes": 4}), + ({"minutes": 4.1}, {"minutes": 4, "seconds": 6}), + ({"hours": 5}, {"hours": 5}), + ({"hours": 5.0001}, {"hours": 5}), + ({"hours": 5.1}, {"hours": 5, "minutes": 6}), + ({"days": 6}, {"days": 6}), + ({"days": 6.0001}, {"days": 6}), + ({"days": 6.1}, {"days": 6, "hours": 2, "minutes": 24}), + )) + def test_init(self, kwargs, expected): + target = core.TimePeriod(**kwargs) + + actual = target.as_dict() + + assert actual == expected + + def test_init__microseconds_with_fraction(self): + with pytest.raises(ValueError, match="Maximum precision is microseconds"): + core.TimePeriod(microseconds=1.1) + + @pytest.mark.parametrize("kwargs, expected", ( + ({}, "0s"), + ({"microseconds": 1}, "1us"), + ({"microseconds": 1.0001}, "1us"), + ({"milliseconds": 2}, "2ms"), + ({"milliseconds": 2.0001}, "2ms"), + ({"milliseconds": 2.01}, "2010us"), + ({"seconds": 3}, "3s"), + ({"seconds": 3.0001}, "3s"), + ({"seconds": 3.01}, "3010ms"), + ({"minutes": 4}, "4min"), + ({"minutes": 4.0001}, "4min"), + ({"minutes": 4.1}, "246s"), + ({"hours": 5}, "5h"), + ({"hours": 5.0001}, "5h"), + ({"hours": 5.1}, "306min"), + ({"days": 6}, "6d"), + ({"days": 6.0001}, "6d"), + ({"days": 6.1}, "8784min"), + )) + def test_str(self, kwargs, expected): + target = core.TimePeriod(**kwargs) + + actual = str(target) + + assert actual == expected + + @pytest.mark.parametrize("comparison, other, expected", ( + ("__eq__", core.TimePeriod(microseconds=900), False), + ("__eq__", core.TimePeriod(milliseconds=1), True), + ("__eq__", core.TimePeriod(microseconds=1100), False), + ("__eq__", 1000, NotImplemented), + ("__eq__", "1000", NotImplemented), + ("__eq__", True, NotImplemented), + ("__eq__", object(), NotImplemented), + ("__eq__", None, NotImplemented), + + ("__ne__", core.TimePeriod(microseconds=900), True), + ("__ne__", core.TimePeriod(milliseconds=1), False), + ("__ne__", core.TimePeriod(microseconds=1100), True), + ("__ne__", 1000, NotImplemented), + ("__ne__", "1000", NotImplemented), + ("__ne__", True, NotImplemented), + ("__ne__", object(), NotImplemented), + ("__ne__", None, NotImplemented), + + ("__lt__", core.TimePeriod(microseconds=900), False), + ("__lt__", core.TimePeriod(milliseconds=1), False), + ("__lt__", core.TimePeriod(microseconds=1100), True), + ("__lt__", 1000, NotImplemented), + ("__lt__", "1000", NotImplemented), + ("__lt__", True, NotImplemented), + ("__lt__", object(), NotImplemented), + ("__lt__", None, NotImplemented), + + ("__gt__", core.TimePeriod(microseconds=900), True), + ("__gt__", core.TimePeriod(milliseconds=1), False), + ("__gt__", core.TimePeriod(microseconds=1100), False), + ("__gt__", 1000, NotImplemented), + ("__gt__", "1000", NotImplemented), + ("__gt__", True, NotImplemented), + ("__gt__", object(), NotImplemented), + ("__gt__", None, NotImplemented), + + ("__le__", core.TimePeriod(microseconds=900), False), + ("__le__", core.TimePeriod(milliseconds=1), True), + ("__le__", core.TimePeriod(microseconds=1100), True), + ("__le__", 1000, NotImplemented), + ("__le__", "1000", NotImplemented), + ("__le__", True, NotImplemented), + ("__le__", object(), NotImplemented), + ("__le__", None, NotImplemented), + + ("__ge__", core.TimePeriod(microseconds=900), True), + ("__ge__", core.TimePeriod(milliseconds=1), True), + ("__ge__", core.TimePeriod(microseconds=1100), False), + ("__ge__", 1000, NotImplemented), + ("__ge__", "1000", NotImplemented), + ("__ge__", True, NotImplemented), + ("__ge__", object(), NotImplemented), + ("__ge__", None, NotImplemented), + )) + def test_comparison(self, comparison, other, expected): + target = core.TimePeriod(microseconds=1000) + + actual = getattr(target, comparison)(other) + + assert actual == expected + + +SAMPLE_LAMBDA = """ +it.strftime(64, 0, id(my_font), TextAlign::TOP_CENTER, "%H:%M:%S", id(esptime).now()); +it.printf(64, 16, id(my_font2), TextAlign::TOP_CENTER, "%.1f°C (%.1f%%)", id( office_tmp ).state, id(office_hmd).state); +""" + + +class TestLambda: + def test_init__copy_initializer(self): + value = core.Lambda("foo") + target = core.Lambda(value) + + assert str(target) is value.value + + def test_parts(self): + target = core.Lambda(SAMPLE_LAMBDA.strip()) + + # Check cache + assert target._parts is None + actual = target.parts + assert target._parts is actual + assert target.parts is actual + + assert actual == [ + "it.strftime(64, 0, ", + "my_font", + "", + ", TextAlign::TOP_CENTER, \"%H:%M:%S\", ", + "esptime", + ".", + "now());\nit.printf(64, 16, ", + "my_font2", + "", + ", TextAlign::TOP_CENTER, \"%.1f°C (%.1f%%)\", ", + "office_tmp", + ".", + "state, ", + "office_hmd", + ".", + "state);" + ] + + def test_requires_ids(self): + target = core.Lambda(SAMPLE_LAMBDA.strip()) + + # Check cache + assert target._requires_ids is None + actual = target.requires_ids + assert target._requires_ids is actual + assert target.requires_ids is actual + + assert actual == [ + core.ID("my_font"), + core.ID("esptime"), + core.ID("my_font2"), + core.ID("office_tmp"), + core.ID("office_hmd"), + ] + + def test_value_setter(self): + target = core.Lambda("") + + # Populate cache + _ = target.parts + _ = target.requires_ids + + target.value = SAMPLE_LAMBDA + + # Check cache has been cleared + assert target._parts is None + assert target._requires_ids is None + + assert target.value == SAMPLE_LAMBDA + + def test_repr(self): + target = core.Lambda("id(var).value == 1") + + assert repr(target) == "Lambda" + + +class TestID: + @pytest.fixture + def target(self): + return core.ID(None, is_declaration=True, type="binary_sensor::Example") + + @pytest.mark.parametrize("id, is_manual, expected", ( + ("foo", None, True), + (None, None, False), + ("foo", True, True), + ("foo", False, False), + (None, True, True), + )) + def test_init__resolve_is_manual(self, id, is_manual, expected): + target = core.ID(id, is_manual=is_manual) + + assert target.is_manual == expected + + @pytest.mark.parametrize("registered_ids, expected", ( + ([], "binary_sensor_example"), + (["binary_sensor_example"], "binary_sensor_example_2"), + (["foo"], "binary_sensor_example"), + (["binary_sensor_example", "foo", "binary_sensor_example_2"], "binary_sensor_example_3"), + )) + def test_resolve(self, target, registered_ids, expected): + actual = target.resolve(registered_ids) + + assert actual == expected + assert str(target) == expected + + def test_copy(self, target): + target.resolve([]) + + actual = target.copy() + + assert actual is not target + assert all(getattr(actual, n) == getattr(target, n) + for n in ("id", "is_declaration", "type", "is_manual")) + + @pytest.mark.parametrize("comparison, other, expected", ( + ("__eq__", core.ID(id="foo"), True), + ("__eq__", core.ID(id="bar"), False), + ("__eq__", 1000, NotImplemented), + ("__eq__", "1000", NotImplemented), + ("__eq__", True, NotImplemented), + ("__eq__", object(), NotImplemented), + ("__eq__", None, NotImplemented), + )) + def test_comparison(self, comparison, other, expected): + target = core.ID(id="foo") + + actual = getattr(target, comparison)(other) + + assert actual == expected + + +class TestDocumentLocation: + @pytest.fixture + def target(self): + return core.DocumentLocation( + document="foo.txt", + line=10, + column=20, + ) + + def test_str(self, target): + actual = str(target) + + assert actual == "foo.txt 10:20" + + +class TestDocumentRange: + @pytest.fixture + def target(self): + return core.DocumentRange( + core.DocumentLocation( + document="foo.txt", + line=10, + column=20, + ), + core.DocumentLocation( + document="foo.txt", + line=15, + column=12, + ), + ) + + def test_str(self, target): + actual = str(target) + + assert actual == "[foo.txt 10:20 - foo.txt 15:12]" + + +class TestDefine: + @pytest.mark.parametrize("name, value, prop, expected", ( + ("ANSWER", None, "as_build_flag", "-DANSWER"), + ("ANSWER", None, "as_macro", "#define ANSWER"), + ("ANSWER", None, "as_tuple", ("ANSWER", None)), + ("ANSWER", 42, "as_build_flag", "-DANSWER=42"), + ("ANSWER", 42, "as_macro", "#define ANSWER 42"), + ("ANSWER", 42, "as_tuple", ("ANSWER", 42)), + )) + def test_properties(self, name, value, prop, expected): + target = core.Define(name, value) + + actual = getattr(target, prop) + + assert actual == expected + + @pytest.mark.parametrize("comparison, other, expected", ( + ("__eq__", core.Define(name="FOO", value=42), True), + ("__eq__", core.Define(name="FOO", value=13), False), + ("__eq__", core.Define(name="FOO"), False), + ("__eq__", core.Define(name="BAR", value=42), False), + ("__eq__", core.Define(name="BAR"), False), + ("__eq__", 1000, NotImplemented), + ("__eq__", "1000", NotImplemented), + ("__eq__", True, NotImplemented), + ("__eq__", object(), NotImplemented), + ("__eq__", None, NotImplemented), + )) + def test_comparison(self, comparison, other, expected): + target = core.Define(name="FOO", value=42) + + actual = getattr(target, comparison)(other) + + assert actual == expected + + +class TestLibrary: + @pytest.mark.parametrize("name, value, prop, expected", ( + ("mylib", None, "as_lib_dep", "mylib"), + ("mylib", None, "as_tuple", ("mylib", None)), + ("mylib", "1.2.3", "as_lib_dep", "mylib@1.2.3"), + ("mylib", "1.2.3", "as_tuple", ("mylib", "1.2.3")), + )) + def test_properties(self, name, value, prop, expected): + target = core.Library(name, value) + + actual = getattr(target, prop) + + assert actual == expected + + @pytest.mark.parametrize("comparison, other, expected", ( + ("__eq__", core.Library(name="libfoo", version="1.2.3"), True), + ("__eq__", core.Library(name="libfoo", version="1.2.4"), False), + ("__eq__", core.Library(name="libbar", version="1.2.3"), False), + ("__eq__", 1000, NotImplemented), + ("__eq__", "1000", NotImplemented), + ("__eq__", True, NotImplemented), + ("__eq__", object(), NotImplemented), + ("__eq__", None, NotImplemented), + )) + def test_comparison(self, comparison, other, expected): + target = core.Library(name="libfoo", version="1.2.3") + + actual = getattr(target, comparison)(other) + + assert actual == expected + + +class TestEsphomeCore: + @pytest.fixture + def target(self, fixture_path): + target = core.EsphomeCore() + target.build_path = "foo/build" + target.config_path = "foo/config" + return target + + def test_reset(self, target): + """Call reset on target and compare to new instance""" + other = core.EsphomeCore() + + target.reset() + + # TODO: raw_config and config differ, should they? + assert target.__dict__ == other.__dict__ + + def test_address__none(self, target): + assert target.address is None + + def test_address__wifi(self, target): + target.config[const.CONF_WIFI] = {const.CONF_USE_ADDRESS: "1.2.3.4"} + target.config["ethernet"] = {const.CONF_USE_ADDRESS: "4.3.2.1"} + + assert target.address == "1.2.3.4" + + def test_address__ethernet(self, target): + target.config["ethernet"] = {const.CONF_USE_ADDRESS: "4.3.2.1"} + + assert target.address == "4.3.2.1" + + def test_is_esp32(self, target): + target.esp_platform = "ESP32" + + assert target.is_esp32 is True + assert target.is_esp8266 is False + + def test_is_esp8266(self, target): + target.esp_platform = "ESP8266" + + assert target.is_esp32 is False + assert target.is_esp8266 is True diff --git a/tests/unit_tests/test_helpers.py b/tests/unit_tests/test_helpers.py new file mode 100644 index 000000000..e48286ae5 --- /dev/null +++ b/tests/unit_tests/test_helpers.py @@ -0,0 +1,208 @@ +import pytest + +from hypothesis import given +from hypothesis.provisional import ip4_addr_strings + +from esphome import helpers + + +@pytest.mark.parametrize("preferred_string, current_strings, expected", ( + ("foo", [], "foo"), + # TODO: Should this actually start at 1? + ("foo", ["foo"], "foo_2"), + ("foo", ("foo",), "foo_2"), + ("foo", ("foo", "foo_2"), "foo_3"), + ("foo", ("foo", "foo_2", "foo_2"), "foo_3"), +)) +def test_ensure_unique_string(preferred_string, current_strings, expected): + actual = helpers.ensure_unique_string(preferred_string, current_strings) + + assert actual == expected + + +@pytest.mark.parametrize("text, expected", ( + ("foo", "foo"), + ("foo\nbar", "foo\nbar"), + ("foo\nbar\neek", "foo\n bar\neek"), +)) +def test_indent_all_but_first_and_last(text, expected): + actual = helpers.indent_all_but_first_and_last(text) + + assert actual == expected + + +@pytest.mark.parametrize("text, expected", ( + ("foo", [" foo"]), + ("foo\nbar", [" foo", " bar"]), + ("foo\nbar\neek", [" foo", " bar", " eek"]), +)) +def test_indent_list(text, expected): + actual = helpers.indent_list(text) + + assert actual == expected + + +@pytest.mark.parametrize("text, expected", ( + ("foo", " foo"), + ("foo\nbar", " foo\n bar"), + ("foo\nbar\neek", " foo\n bar\n eek"), +)) +def test_indent(text, expected): + actual = helpers.indent(text) + + assert actual == expected + + +@pytest.mark.parametrize("string, expected", ( + ("foo", '"foo"'), + ("foo\nbar", '"foo\\012bar"'), + ("foo\\bar", '"foo\\134bar"'), + ('foo "bar"', '"foo \\042bar\\042"'), + ('foo 🐍', '"foo \\360\\237\\220\\215"'), +)) +def test_cpp_string_escape(string, expected): + actual = helpers.cpp_string_escape(string) + + assert actual == expected + + +@pytest.mark.parametrize("host", ( + "127.0.0", "localhost", "127.0.0.b", +)) +def test_is_ip_address__invalid(host): + actual = helpers.is_ip_address(host) + + assert actual is False + + +@given(value=ip4_addr_strings()) +def test_is_ip_address__valid(value): + actual = helpers.is_ip_address(value) + + assert actual is True + + +@pytest.mark.parametrize("var, value, default, expected", ( + ("FOO", None, False, False), + ("FOO", None, True, True), + ("FOO", "", False, False), + ("FOO", "Yes", False, True), + ("FOO", "123", False, True), +)) +def test_get_bool_env(monkeypatch, var, value, default, expected): + if value is None: + monkeypatch.delenv(var, raising=False) + else: + monkeypatch.setenv(var, value) + + actual = helpers.get_bool_env(var, default) + + assert actual == expected + + +@pytest.mark.parametrize("value, expected", ( + (None, False), + ("Yes", True) +)) +def test_is_hassio(monkeypatch, value, expected): + if value is None: + monkeypatch.delenv("ESPHOME_IS_HASSIO", raising=False) + else: + monkeypatch.setenv("ESPHOME_IS_HASSIO", value) + + actual = helpers.is_hassio() + + assert actual == expected + + +def test_walk_files(fixture_path): + path = fixture_path / "helpers" + + actual = list(helpers.walk_files(path)) + + # Ensure paths start with the root + assert all(p.startswith(path.as_posix()) for p in actual) + + +class Test_write_file_if_changed: + def test_src_and_dst_match(self, tmp_path): + text = "A files are unique.\n" + initial = text + dst = tmp_path / "file-a.txt" + dst.write_text(initial) + + helpers.write_file_if_changed(dst, text) + + assert dst.read_text() == text + + def test_src_and_dst_do_not_match(self, tmp_path): + text = "A files are unique.\n" + initial = "B files are unique.\n" + dst = tmp_path / "file-a.txt" + dst.write_text(initial) + + helpers.write_file_if_changed(dst, text) + + assert dst.read_text() == text + + def test_dst_does_not_exist(self, tmp_path): + text = "A files are unique.\n" + dst = tmp_path / "file-a.txt" + + helpers.write_file_if_changed(dst, text) + + assert dst.read_text() == text + + +class Test_copy_file_if_changed: + def test_src_and_dst_match(self, tmp_path, fixture_path): + src = fixture_path / "helpers" / "file-a.txt" + initial = fixture_path / "helpers" / "file-a.txt" + dst = tmp_path / "file-a.txt" + + dst.write_text(initial.read_text()) + + helpers.copy_file_if_changed(src, dst) + + def test_src_and_dst_do_not_match(self, tmp_path, fixture_path): + src = fixture_path / "helpers" / "file-a.txt" + initial = fixture_path / "helpers" / "file-c.txt" + dst = tmp_path / "file-a.txt" + + dst.write_text(initial.read_text()) + + helpers.copy_file_if_changed(src, dst) + + assert src.read_text() == dst.read_text() + + def test_dst_does_not_exist(self, tmp_path, fixture_path): + src = fixture_path / "helpers" / "file-a.txt" + dst = tmp_path / "file-a.txt" + + helpers.copy_file_if_changed(src, dst) + + assert dst.exists() + assert src.read_text() == dst.read_text() + + +@pytest.mark.parametrize("file1, file2, expected", ( + # Same file + ("file-a.txt", "file-a.txt", True), + # Different files, different size + ("file-a.txt", "file-b_1.txt", False), + # Different files, same size + ("file-a.txt", "file-c.txt", False), + # Same files + ("file-b_1.txt", "file-b_2.txt", True), + # Not a file + ("file-a.txt", "", False), + # File doesn't exist + ("file-a.txt", "file-d.txt", False), +)) +def test_file_compare(fixture_path, file1, file2, expected): + path1 = fixture_path / "helpers" / file1 + path2 = fixture_path / "helpers" / file2 + + actual = helpers.file_compare(path1, path2) + + assert actual == expected diff --git a/tests/unit_tests/test_pins.py b/tests/unit_tests/test_pins.py new file mode 100644 index 000000000..606c20eea --- /dev/null +++ b/tests/unit_tests/test_pins.py @@ -0,0 +1,326 @@ +""" +Please Note: + +These tests cover the process of identifying information about pins, they do not +check if the definition of MCUs and pins is correct. + +""" +import logging + +import pytest + +from esphome.config_validation import Invalid +from esphome.core import EsphomeCore +from esphome import pins + + +MOCK_ESP8266_BOARD_ID = "_mock_esp8266" +MOCK_ESP8266_PINS = {'X0': 16, 'X1': 5, 'X2': 4, 'LED': 2} +MOCK_ESP8266_BOARD_ALIAS_ID = "_mock_esp8266_alias" +MOCK_ESP8266_FLASH_SIZE = pins.FLASH_SIZE_2_MB + +MOCK_ESP32_BOARD_ID = "_mock_esp32" +MOCK_ESP32_PINS = {'Y0': 12, 'Y1': 8, 'Y2': 3, 'LED': 9, "A0": 8} +MOCK_ESP32_BOARD_ALIAS_ID = "_mock_esp32_alias" + +UNKNOWN_PLATFORM = "STM32" + + +@pytest.fixture +def mock_mcu(monkeypatch): + """ + Add a mock MCU into the lists as a stable fixture + """ + pins.ESP8266_BOARD_PINS[MOCK_ESP8266_BOARD_ID] = MOCK_ESP8266_PINS + pins.ESP8266_FLASH_SIZES[MOCK_ESP8266_BOARD_ID] = MOCK_ESP8266_FLASH_SIZE + pins.ESP8266_BOARD_PINS[MOCK_ESP8266_BOARD_ALIAS_ID] = MOCK_ESP8266_BOARD_ID + pins.ESP8266_FLASH_SIZES[MOCK_ESP8266_BOARD_ALIAS_ID] = MOCK_ESP8266_FLASH_SIZE + pins.ESP32_BOARD_PINS[MOCK_ESP32_BOARD_ID] = MOCK_ESP32_PINS + pins.ESP32_BOARD_PINS[MOCK_ESP32_BOARD_ALIAS_ID] = MOCK_ESP32_BOARD_ID + yield + del pins.ESP8266_BOARD_PINS[MOCK_ESP8266_BOARD_ID] + del pins.ESP8266_FLASH_SIZES[MOCK_ESP8266_BOARD_ID] + del pins.ESP8266_BOARD_PINS[MOCK_ESP8266_BOARD_ALIAS_ID] + del pins.ESP8266_FLASH_SIZES[MOCK_ESP8266_BOARD_ALIAS_ID] + del pins.ESP32_BOARD_PINS[MOCK_ESP32_BOARD_ID] + del pins.ESP32_BOARD_PINS[MOCK_ESP32_BOARD_ALIAS_ID] + + +@pytest.fixture +def core(monkeypatch, mock_mcu): + core = EsphomeCore() + monkeypatch.setattr(pins, "CORE", core) + return core + + +@pytest.fixture +def core_esp8266(core): + core.esp_platform = "ESP8266" + core.board = MOCK_ESP8266_BOARD_ID + return core + + +@pytest.fixture +def core_esp32(core): + core.esp_platform = "ESP32" + core.board = MOCK_ESP32_BOARD_ID + return core + + +class Test_lookup_pin: + @pytest.mark.parametrize("value, expected", ( + ("X1", 5), + ("MOSI", 13), + )) + def test_valid_esp8266_pin(self, core_esp8266, value, expected): + actual = pins._lookup_pin(value) + + assert actual == expected + + def test_valid_esp8266_pin_alias(self, core_esp8266): + core_esp8266.board = MOCK_ESP8266_BOARD_ALIAS_ID + + actual = pins._lookup_pin("X2") + + assert actual == 4 + + @pytest.mark.parametrize("value, expected", ( + ("Y1", 8), + ("A0", 8), + ("MOSI", 23), + )) + def test_valid_esp32_pin(self, core_esp32, value, expected): + actual = pins._lookup_pin(value) + + assert actual == expected + + @pytest.mark.xfail(reason="This may be expected") + def test_valid_32_pin_alias(self, core_esp32): + core_esp32.board = MOCK_ESP32_BOARD_ALIAS_ID + + actual = pins._lookup_pin("Y2") + + assert actual == 3 + + def test_invalid_pin(self, core_esp8266): + with pytest.raises(Invalid, match="Cannot resolve pin name 'X42' for board _mock_esp8266."): + pins._lookup_pin("X42") + + def test_unsupported_platform(self, core): + core.esp_platform = UNKNOWN_PLATFORM + + with pytest.raises(NotImplementedError): + pins._lookup_pin("TX") + + +class Test_translate_pin: + @pytest.mark.parametrize("value, expected", ( + (2, 2), + ("3", 3), + ("GPIO4", 4), + ("TX", 1), + ("Y0", 12), + )) + def test_valid_values(self, core_esp32, value, expected): + actual = pins._translate_pin(value) + + assert actual == expected + + @pytest.mark.parametrize("value", ({}, None)) + def test_invalid_values(self, core_esp32, value): + with pytest.raises(Invalid, match="This variable only supports"): + pins._translate_pin(value) + + +class Test_validate_gpio_pin: + def test_esp32_valid(self, core_esp32): + actual = pins.validate_gpio_pin("GPIO22") + + assert actual == 22 + + @pytest.mark.parametrize("value, match", ( + (-1, "ESP32: Invalid pin number: -1"), + (40, "ESP32: Invalid pin number: 40"), + (6, "This pin cannot be used on ESP32s and"), + (7, "This pin cannot be used on ESP32s and"), + (8, "This pin cannot be used on ESP32s and"), + (11, "This pin cannot be used on ESP32s and"), + (20, "The pin GPIO20 is not usable on ESP32s"), + (24, "The pin GPIO24 is not usable on ESP32s"), + (28, "The pin GPIO28 is not usable on ESP32s"), + (29, "The pin GPIO29 is not usable on ESP32s"), + (30, "The pin GPIO30 is not usable on ESP32s"), + (31, "The pin GPIO31 is not usable on ESP32s"), + )) + def test_esp32_invalid_pin(self, core_esp32, value, match): + with pytest.raises(Invalid, match=match): + pins.validate_gpio_pin(value) + + @pytest.mark.parametrize("value", (9, 10)) + def test_esp32_warning(self, core_esp32, caplog, value): + caplog.at_level(logging.WARNING) + pins.validate_gpio_pin(value) + + assert len(caplog.messages) == 1 + assert caplog.messages[0].endswith("flash interface in QUAD IO flash mode.") + + def test_esp8266_valid(self, core_esp8266): + actual = pins.validate_gpio_pin("GPIO12") + + assert actual == 12 + + @pytest.mark.parametrize("value, match", ( + (-1, "ESP8266: Invalid pin number: -1"), + (18, "ESP8266: Invalid pin number: 18"), + (6, "This pin cannot be used on ESP8266s and"), + (7, "This pin cannot be used on ESP8266s and"), + (8, "This pin cannot be used on ESP8266s and"), + (11, "This pin cannot be used on ESP8266s and"), + )) + def test_esp8266_invalid_pin(self, core_esp8266, value, match): + with pytest.raises(Invalid, match=match): + pins.validate_gpio_pin(value) + + @pytest.mark.parametrize("value", (9, 10)) + def test_esp8266_warning(self, core_esp8266, caplog, value): + caplog.at_level(logging.WARNING) + pins.validate_gpio_pin(value) + + assert len(caplog.messages) == 1 + assert caplog.messages[0].endswith("flash interface in QUAD IO flash mode.") + + def test_unknown_device(self, core): + core.esp_platform = UNKNOWN_PLATFORM + + with pytest.raises(NotImplementedError): + pins.validate_gpio_pin("0") + + +class Test_input_pin: + @pytest.mark.parametrize("value, expected", ( + ("X0", 16), + )) + def test_valid_esp8266_values(self, core_esp8266, value, expected): + actual = pins.input_pin(value) + + assert actual == expected + + @pytest.mark.parametrize("value, expected", ( + ("Y0", 12), + (17, 17), + )) + def test_valid_esp32_values(self, core_esp32, value, expected): + actual = pins.input_pin(value) + + assert actual == expected + + @pytest.mark.parametrize("value", (17,)) + def test_invalid_esp8266_values(self, core_esp8266, value): + with pytest.raises(Invalid): + pins.input_pin(value) + + def test_unknown_platform(self, core): + core.esp_platform = UNKNOWN_PLATFORM + + with pytest.raises(NotImplementedError): + pins.input_pin(2) + + +class Test_input_pullup_pin: + @pytest.mark.parametrize("value, expected", ( + ("X0", 16), + )) + def test_valid_esp8266_values(self, core_esp8266, value, expected): + actual = pins.input_pullup_pin(value) + + assert actual == expected + + @pytest.mark.parametrize("value, expected", ( + ("Y0", 12), + (17, 17), + )) + def test_valid_esp32_values(self, core_esp32, value, expected): + actual = pins.input_pullup_pin(value) + + assert actual == expected + + @pytest.mark.parametrize("value", (0,)) + def test_invalid_esp8266_values(self, core_esp8266, value): + with pytest.raises(Invalid): + pins.input_pullup_pin(value) + + def test_unknown_platform(self, core): + core.esp_platform = UNKNOWN_PLATFORM + + with pytest.raises(NotImplementedError): + pins.input_pullup_pin(2) + + +class Test_output_pin: + @pytest.mark.parametrize("value, expected", ( + ("X0", 16), + )) + def test_valid_esp8266_values(self, core_esp8266, value, expected): + actual = pins.output_pin(value) + + assert actual == expected + + @pytest.mark.parametrize("value, expected", ( + ("Y0", 12), + (17, 17), + )) + def test_valid_esp32_values(self, core_esp32, value, expected): + actual = pins.output_pin(value) + + assert actual == expected + + @pytest.mark.parametrize("value", (17,)) + def test_invalid_esp8266_values(self, core_esp8266, value): + with pytest.raises(Invalid): + pins.output_pin(value) + + @pytest.mark.parametrize("value", range(34, 40)) + def test_invalid_esp32_values(self, core_esp32, value): + with pytest.raises(Invalid): + pins.output_pin(value) + + def test_unknown_platform(self, core): + core.esp_platform = UNKNOWN_PLATFORM + + with pytest.raises(NotImplementedError): + pins.output_pin(2) + + +class Test_analog_pin: + @pytest.mark.parametrize("value, expected", ( + (17, 17), + )) + def test_valid_esp8266_values(self, core_esp8266, value, expected): + actual = pins.analog_pin(value) + + assert actual == expected + + @pytest.mark.parametrize("value, expected", ( + (32, 32), + (39, 39), + )) + def test_valid_esp32_values(self, core_esp32, value, expected): + actual = pins.analog_pin(value) + + assert actual == expected + + @pytest.mark.parametrize("value", ("X0",)) + def test_invalid_esp8266_values(self, core_esp8266, value): + with pytest.raises(Invalid): + pins.analog_pin(value) + + @pytest.mark.parametrize("value", ("Y0",)) + def test_invalid_esp32_values(self, core_esp32, value): + with pytest.raises(Invalid): + pins.analog_pin(value) + + def test_unknown_platform(self, core): + core.esp_platform = UNKNOWN_PLATFORM + + with pytest.raises(NotImplementedError): + pins.analog_pin(2)