diff --git a/.gitignore b/.gitignore index 2de9dd40e5..11a80a647c 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,7 @@ htmlcov/ .esphome nosetests.xml coverage.xml +cov.xml *.cover .hypothesis/ .pytest_cache/ diff --git a/esphome/wizard.py b/esphome/wizard.py index b00f6d2b01..b1a0b17072 100644 --- a/esphome/wizard.py +++ b/esphome/wizard.py @@ -142,7 +142,7 @@ def wizard(path): if os.path.exists(path): safe_print("Uh oh, it seems like {} already exists, please delete that file first " "or chose another configuration file.".format(color('cyan', path))) - return 1 + return 2 safe_print("Hi there!") sleep(1.5) safe_print("I'm the wizard of ESPHome :)") @@ -162,14 +162,15 @@ def wizard(path): safe_print() sleep(1) name = input(color("bold_white", "(name): ")) + while True: try: name = cv.valid_name(name) break except vol.Invalid: safe_print(color("red", "Oh noes, \"{}\" isn't a valid name. Names can only include " - "numbers, letters and underscores.".format(name))) - name = strip_accents(name).replace(' ', '_') + "numbers, lower-case letters and underscores.".format(name))) + name = strip_accents(name).lower().replace(' ', '_') name = ''.join(c for c in name if c in cv.ALLOWED_NAME_CHARS) safe_print("Shall I use \"{}\" as the name instead?".format(color('cyan', name))) sleep(0.5) diff --git a/tests/unit_tests/test_wizard.py b/tests/unit_tests/test_wizard.py new file mode 100644 index 0000000000..2baff80edd --- /dev/null +++ b/tests/unit_tests/test_wizard.py @@ -0,0 +1,389 @@ +""" Tests for the wizard.py file """ + +import esphome.wizard as wz +import pytest +from esphome.pins import ESP8266_BOARD_PINS +from mock import MagicMock + + +@pytest.fixture +def default_config(): + return { + "name": "test_name", + "platform": "test_platform", + "board": "test_board", + "ssid": "test_ssid", + "psk": "test_psk", + "password": "" + } + + +@pytest.fixture +def wizard_answers(): + return [ + "test_node", # Name of the node + "ESP8266", # platform + "nodemcuv2", # board + "SSID", # ssid + "psk", # wifi password + "ota_pass", # ota password + ] + + +def test_sanitize_quotes_replaces_with_escaped_char(): + """ + The sanitize_quotes function should replace double quotes with their escaped equivalents + """ + # Given + input_str = "\"key\": \"value\"" + + # When + output_str = wz.sanitize_double_quotes(input_str) + + # Then + assert output_str == "\\\"key\\\": \\\"value\\\"" + + +def test_config_file_fallback_ap_includes_descriptive_name(default_config): + """ + The fallback AP should include the node and a descriptive name + """ + # Given + default_config["name"] = "test_node" + + # When + config = wz.wizard_file(**default_config) + + # Then + assert f"ssid: \"Test Node Fallback Hotspot\"" in config + + +def test_config_file_fallback_ap_name_less_than_32_chars(default_config): + """ + The fallback AP name must be less than 32 chars. + Since it is composed of the node name and "Fallback Hotspot" this can be too long and needs truncating + """ + # Given + default_config["name"] = "a_very_long_name_for_this_node" + + # When + config = wz.wizard_file(**default_config) + + # Then + assert f"ssid: \"A Very Long Name For This Node\"" in config + + +def test_config_file_should_include_ota(default_config): + """ + The Over-The-Air update should be enabled by default + """ + # Given + + # When + config = wz.wizard_file(**default_config) + + # Then + assert "ota:" in config + + +def test_config_file_should_include_ota_when_password_set(default_config): + """ + The Over-The-Air update should be enabled when a password is set + """ + # Given + default_config["password"] = "foo" + + # When + config = wz.wizard_file(**default_config) + + # Then + assert "ota:" in config + + +def test_wizard_write_sets_platform(default_config, tmp_path, monkeypatch): + """ + If the platform is not explicitly set, use "ESP8266" if the board is one of the ESP8266 boards + """ + # Given + monkeypatch.setattr(wz, "write_file", MagicMock()) + + # When + wz.wizard_write(tmp_path, **default_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert f"platform: {default_config['platform']}" in generated_config + + +def test_wizard_write_defaults_platform_from_board_esp8266(default_config, tmp_path, monkeypatch): + """ + If the platform is not explicitly set, use "ESP8266" if the board is one of the ESP8266 boards + """ + # Given + del default_config["platform"] + default_config["board"] = [*ESP8266_BOARD_PINS][0] + + monkeypatch.setattr(wz, "write_file", MagicMock()) + + # When + wz.wizard_write(tmp_path, **default_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert "platform: ESP8266" in generated_config + + +def test_wizard_write_defaults_platform_from_board_esp32(default_config, tmp_path, monkeypatch): + """ + If the platform is not explicitly set, use "ESP32" if the board is not one of the ESP8266 boards + """ + # Given + del default_config["platform"] + default_config["board"] = "foo" + + monkeypatch.setattr(wz, "write_file", MagicMock()) + + # When + wz.wizard_write(tmp_path, **default_config) + + # Then + generated_config = wz.write_file.call_args.args[1] + assert "platform: ESP32" in generated_config + + +def test_safe_print_step_prints_step_number_and_description(monkeypatch): + """ + The safe_print_step function prints the step number and the passed description + """ + # Given + monkeypatch.setattr(wz, "safe_print", MagicMock()) + monkeypatch.setattr(wz, "sleep", lambda time: 0) + + step_num = 22 + step_desc = "foobartest" + + # When + wz.safe_print_step(step_num, step_desc) + + # Then + # Collect arguments to all safe_print() calls (substituting "" for any empty ones) + all_args = [call.args[0] if len(call.args) else "" for call in wz.safe_print.call_args_list] + + assert any(step_desc == arg for arg in all_args) + assert any(f"STEP {step_num}" in arg for arg in all_args) + + +def test_default_input_uses_default_if_no_input_supplied(monkeypatch): + """ + The default_input() function should return the supplied default value if the user doesn't enter anything + """ + + # Given + monkeypatch.setattr("builtins.input", lambda _: "") + default_string = "foobar" + + # When + retval = wz.default_input("", default_string) + + # Then + assert retval == default_string + + +def test_default_input_uses_user_supplied_value(monkeypatch): + """ + The default_input() function should return the value that the user enters + """ + + # Given + user_input = "A value" + monkeypatch.setattr("builtins.input", lambda _: user_input) + default_string = "foobar" + + # When + retval = wz.default_input("", default_string) + + # Then + assert retval == user_input + + +def test_strip_accents_removes_diacritics(): + """ + The strip_accents() function should remove diacritics (umlauts) + """ + + # Given + input_str = u"Kühne" + expected_str = "Kuhne" + + # When + output_str = wz.strip_accents(input_str) + + # Then + assert output_str == expected_str + + +def test_wizard_rejects_path_with_invalid_extension(): + """ + The wizard should reject config files that are not yaml + """ + + # Given + config_file = "test.json" + + # When + retval = wz.wizard(config_file) + + # Then + assert retval == 1 + + +def test_wizard_rejects_existing_files(tmpdir): + """ + The wizard should reject any configuration file that already exists + """ + + # Given + config_file = tmpdir.join("test.yaml") + config_file.write("") + + # When + retval = wz.wizard(str(config_file)) + + # Then + assert retval == 2 + + +def test_wizard_accepts_default_answers_esp8266(tmpdir, monkeypatch, wizard_answers): + """ + The wizard should accept the given default answers for esp8266 + """ + + # Given + config_file = tmpdir.join("test.yaml") + input_mock = MagicMock(side_effect=wizard_answers) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + monkeypatch.setattr(wz, "wizard_write", MagicMock()) + + # When + retval = wz.wizard(str(config_file)) + + # Then + assert retval == 0 + + +def test_wizard_accepts_default_answers_esp32(tmpdir, monkeypatch, wizard_answers): + """ + The wizard should accept the given default answers for esp32 + """ + + # Given + wizard_answers[1] = "ESP32" + wizard_answers[2] = "nodemcu-32s" + config_file = tmpdir.join("test.yaml") + input_mock = MagicMock(side_effect=wizard_answers) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + monkeypatch.setattr(wz, "wizard_write", MagicMock()) + + # When + retval = wz.wizard(str(config_file)) + + # Then + assert retval == 0 + + +def test_wizard_offers_better_node_name(tmpdir, monkeypatch, wizard_answers): + """ + When the node name does not conform, a better alternative is offered + * Removes special chars + * Replaces spaces with underscores + * Converts all uppercase letters to lowercase + """ + + # Given + wizard_answers[0] = "Küche #2" + expected_name = "kuche_2" + monkeypatch.setattr(wz, "default_input", MagicMock(side_effect=lambda _, default: default)) + + config_file = tmpdir.join("test.yaml") + input_mock = MagicMock(side_effect=wizard_answers) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + monkeypatch.setattr(wz, "wizard_write", MagicMock()) + + # When + retval = wz.wizard(str(config_file)) + + # Then + assert retval == 0 + assert wz.default_input.call_args.args[1] == expected_name + + +def test_wizard_requires_correct_platform(tmpdir, monkeypatch, wizard_answers): + """ + When the platform is not either esp32 or esp8266, the wizard should reject it + """ + + # Given + wizard_answers.insert(1, "foobar") # add invalid entry for platform + + config_file = tmpdir.join("test.yaml") + input_mock = MagicMock(side_effect=wizard_answers) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + monkeypatch.setattr(wz, "wizard_write", MagicMock()) + + # When + retval = wz.wizard(str(config_file)) + + # Then + assert retval == 0 + + +def test_wizard_requires_correct_board(tmpdir, monkeypatch, wizard_answers): + """ + When the board is not a valid esp8266 board, the wizard should reject it + """ + + # Given + wizard_answers.insert(2, "foobar") # add an invalid entry for board + + config_file = tmpdir.join("test.yaml") + input_mock = MagicMock(side_effect=wizard_answers) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + monkeypatch.setattr(wz, "wizard_write", MagicMock()) + + # When + retval = wz.wizard(str(config_file)) + + # Then + assert retval == 0 + + +def test_wizard_requires_valid_ssid(tmpdir, monkeypatch, wizard_answers): + """ + When the board is not a valid esp8266 board, the wizard should reject it + """ + + # Given + wizard_answers.insert(3, "") # add an invalid entry for ssid + + config_file = tmpdir.join("test.yaml") + input_mock = MagicMock(side_effect=wizard_answers) + monkeypatch.setattr("builtins.input", input_mock) + monkeypatch.setattr(wz, "safe_print", lambda t=None: 0) + monkeypatch.setattr(wz, "sleep", lambda _: 0) + monkeypatch.setattr(wz, "wizard_write", MagicMock()) + + # When + retval = wz.wizard(str(config_file)) + + # Then + assert retval == 0