diff --git a/tests/unit_tests/conftest.py b/tests/unit_tests/conftest.py index aac5a642f6..a1e438b577 100644 --- a/tests/unit_tests/conftest.py +++ b/tests/unit_tests/conftest.py @@ -36,3 +36,10 @@ def fixture_path() -> Path: Location of all fixture files. """ return here / "fixtures" + + +@pytest.fixture +def setup_core(tmp_path: Path) -> Path: + """Set up CORE with test paths.""" + CORE.config_path = str(tmp_path / "test.yaml") + return tmp_path diff --git a/tests/unit_tests/test_config_validation_paths.py b/tests/unit_tests/test_config_validation_paths.py new file mode 100644 index 0000000000..f8f038390e --- /dev/null +++ b/tests/unit_tests/test_config_validation_paths.py @@ -0,0 +1,187 @@ +"""Tests for config_validation.py path-related functions.""" + +from pathlib import Path + +import pytest +import voluptuous as vol + +from esphome import config_validation as cv + + +def test_directory_valid_path(setup_core: Path) -> None: + """Test directory validator with valid directory.""" + test_dir = setup_core / "test_directory" + test_dir.mkdir() + + result = cv.directory("test_directory") + + assert result == "test_directory" + + +def test_directory_absolute_path(setup_core: Path) -> None: + """Test directory validator with absolute path.""" + test_dir = setup_core / "test_directory" + test_dir.mkdir() + + result = cv.directory(str(test_dir)) + + assert result == str(test_dir) + + +def test_directory_nonexistent_path(setup_core: Path) -> None: + """Test directory validator raises error for non-existent directory.""" + with pytest.raises( + vol.Invalid, match="Could not find directory.*nonexistent_directory" + ): + cv.directory("nonexistent_directory") + + +def test_directory_file_instead_of_directory(setup_core: Path) -> None: + """Test directory validator raises error when path is a file.""" + test_file = setup_core / "test_file.txt" + test_file.write_text("content") + + with pytest.raises(vol.Invalid, match="is not a directory"): + cv.directory("test_file.txt") + + +def test_directory_with_parent_directory(setup_core: Path) -> None: + """Test directory validator with nested directory structure.""" + nested_dir = setup_core / "parent" / "child" / "grandchild" + nested_dir.mkdir(parents=True) + + result = cv.directory("parent/child/grandchild") + + assert result == "parent/child/grandchild" + + +def test_file_valid_path(setup_core: Path) -> None: + """Test file_ validator with valid file.""" + test_file = setup_core / "test_file.yaml" + test_file.write_text("test content") + + result = cv.file_("test_file.yaml") + + assert result == "test_file.yaml" + + +def test_file_absolute_path(setup_core: Path) -> None: + """Test file_ validator with absolute path.""" + test_file = setup_core / "test_file.yaml" + test_file.write_text("test content") + + result = cv.file_(str(test_file)) + + assert result == str(test_file) + + +def test_file_nonexistent_path(setup_core: Path) -> None: + """Test file_ validator raises error for non-existent file.""" + with pytest.raises(vol.Invalid, match="Could not find file.*nonexistent_file.yaml"): + cv.file_("nonexistent_file.yaml") + + +def test_file_directory_instead_of_file(setup_core: Path) -> None: + """Test file_ validator raises error when path is a directory.""" + test_dir = setup_core / "test_directory" + test_dir.mkdir() + + with pytest.raises(vol.Invalid, match="is not a file"): + cv.file_("test_directory") + + +def test_file_with_parent_directory(setup_core: Path) -> None: + """Test file_ validator with file in nested directory.""" + nested_dir = setup_core / "configs" / "sensors" + nested_dir.mkdir(parents=True) + test_file = nested_dir / "temperature.yaml" + test_file.write_text("sensor config") + + result = cv.file_("configs/sensors/temperature.yaml") + + assert result == "configs/sensors/temperature.yaml" + + +def test_directory_handles_trailing_slash(setup_core: Path) -> None: + """Test directory validator handles trailing slashes correctly.""" + test_dir = setup_core / "test_dir" + test_dir.mkdir() + + result = cv.directory("test_dir/") + assert result == "test_dir/" + + result = cv.directory("test_dir") + assert result == "test_dir" + + +def test_file_handles_various_extensions(setup_core: Path) -> None: + """Test file_ validator works with different file extensions.""" + yaml_file = setup_core / "config.yaml" + yaml_file.write_text("yaml content") + assert cv.file_("config.yaml") == "config.yaml" + + yml_file = setup_core / "config.yml" + yml_file.write_text("yml content") + assert cv.file_("config.yml") == "config.yml" + + txt_file = setup_core / "readme.txt" + txt_file.write_text("text content") + assert cv.file_("readme.txt") == "readme.txt" + + no_ext_file = setup_core / "LICENSE" + no_ext_file.write_text("license content") + assert cv.file_("LICENSE") == "LICENSE" + + +def test_directory_with_symlink(setup_core: Path) -> None: + """Test directory validator follows symlinks.""" + actual_dir = setup_core / "actual_directory" + actual_dir.mkdir() + + symlink_dir = setup_core / "symlink_directory" + symlink_dir.symlink_to(actual_dir) + + result = cv.directory("symlink_directory") + assert result == "symlink_directory" + + +def test_file_with_symlink(setup_core: Path) -> None: + """Test file_ validator follows symlinks.""" + actual_file = setup_core / "actual_file.txt" + actual_file.write_text("content") + + symlink_file = setup_core / "symlink_file.txt" + symlink_file.symlink_to(actual_file) + + result = cv.file_("symlink_file.txt") + assert result == "symlink_file.txt" + + +def test_directory_error_shows_full_path(setup_core: Path) -> None: + """Test directory validator error message includes full path.""" + with pytest.raises(vol.Invalid, match=".*missing_dir.*full path:.*"): + cv.directory("missing_dir") + + +def test_file_error_shows_full_path(setup_core: Path) -> None: + """Test file_ validator error message includes full path.""" + with pytest.raises(vol.Invalid, match=".*missing_file.yaml.*full path:.*"): + cv.file_("missing_file.yaml") + + +def test_directory_with_spaces_in_name(setup_core: Path) -> None: + """Test directory validator handles spaces in directory names.""" + dir_with_spaces = setup_core / "my test directory" + dir_with_spaces.mkdir() + + result = cv.directory("my test directory") + assert result == "my test directory" + + +def test_file_with_spaces_in_name(setup_core: Path) -> None: + """Test file_ validator handles spaces in file names.""" + file_with_spaces = setup_core / "my test file.yaml" + file_with_spaces.write_text("content") + + result = cv.file_("my test file.yaml") + assert result == "my test file.yaml" diff --git a/tests/unit_tests/test_external_files.py b/tests/unit_tests/test_external_files.py new file mode 100644 index 0000000000..3fa7de2f64 --- /dev/null +++ b/tests/unit_tests/test_external_files.py @@ -0,0 +1,196 @@ +"""Tests for external_files.py functions.""" + +from pathlib import Path +import time +from unittest.mock import MagicMock, patch + +import pytest +import requests + +from esphome import external_files +from esphome.config_validation import Invalid +from esphome.core import CORE, TimePeriod + + +def test_compute_local_file_dir(setup_core: Path) -> None: + """Test compute_local_file_dir creates and returns correct path.""" + domain = "font" + + result = external_files.compute_local_file_dir(domain) + + assert isinstance(result, Path) + assert result == Path(CORE.data_dir) / domain + assert result.exists() + assert result.is_dir() + + +def test_compute_local_file_dir_nested(setup_core: Path) -> None: + """Test compute_local_file_dir works with nested domains.""" + domain = "images/icons" + + result = external_files.compute_local_file_dir(domain) + + assert result == Path(CORE.data_dir) / "images" / "icons" + assert result.exists() + assert result.is_dir() + + +def test_is_file_recent_with_recent_file(setup_core: Path) -> None: + """Test is_file_recent returns True for recently created file.""" + test_file = setup_core / "recent.txt" + test_file.write_text("content") + + refresh = TimePeriod(seconds=3600) + + result = external_files.is_file_recent(str(test_file), refresh) + + assert result is True + + +def test_is_file_recent_with_old_file(setup_core: Path) -> None: + """Test is_file_recent returns False for old file.""" + test_file = setup_core / "old.txt" + test_file.write_text("content") + + old_time = time.time() - 7200 + + with patch("os.path.getctime", return_value=old_time): + refresh = TimePeriod(seconds=3600) + + result = external_files.is_file_recent(str(test_file), refresh) + + assert result is False + + +def test_is_file_recent_nonexistent_file(setup_core: Path) -> None: + """Test is_file_recent returns False for non-existent file.""" + test_file = setup_core / "nonexistent.txt" + refresh = TimePeriod(seconds=3600) + + result = external_files.is_file_recent(str(test_file), refresh) + + assert result is False + + +def test_is_file_recent_with_zero_refresh(setup_core: Path) -> None: + """Test is_file_recent with zero refresh period returns False.""" + test_file = setup_core / "test.txt" + test_file.write_text("content") + + # Mock getctime to return a time 10 seconds ago + with patch("os.path.getctime", return_value=time.time() - 10): + refresh = TimePeriod(seconds=0) + result = external_files.is_file_recent(str(test_file), refresh) + assert result is False + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_not_modified( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed returns False when file not modified.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_head.return_value = mock_response + + url = "https://example.com/file.txt" + result = external_files.has_remote_file_changed(url, str(test_file)) + + assert result is False + mock_head.assert_called_once() + + call_args = mock_head.call_args + headers = call_args[1]["headers"] + assert external_files.IF_MODIFIED_SINCE in headers + assert external_files.CACHE_CONTROL in headers + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_modified( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed returns True when file modified.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_response = MagicMock() + mock_response.status_code = 200 + mock_head.return_value = mock_response + + url = "https://example.com/file.txt" + result = external_files.has_remote_file_changed(url, str(test_file)) + + assert result is True + + +def test_has_remote_file_changed_no_local_file(setup_core: Path) -> None: + """Test has_remote_file_changed returns True when local file doesn't exist.""" + test_file = setup_core / "nonexistent.txt" + + url = "https://example.com/file.txt" + result = external_files.has_remote_file_changed(url, str(test_file)) + + assert result is True + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_network_error( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed handles network errors gracefully.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_head.side_effect = requests.exceptions.RequestException("Network error") + + url = "https://example.com/file.txt" + + with pytest.raises(Invalid, match="Could not check if.*Network error"): + external_files.has_remote_file_changed(url, str(test_file)) + + +@patch("esphome.external_files.requests.head") +def test_has_remote_file_changed_timeout( + mock_head: MagicMock, setup_core: Path +) -> None: + """Test has_remote_file_changed respects timeout.""" + test_file = setup_core / "cached.txt" + test_file.write_text("cached content") + + mock_response = MagicMock() + mock_response.status_code = 304 + mock_head.return_value = mock_response + + url = "https://example.com/file.txt" + external_files.has_remote_file_changed(url, str(test_file)) + + call_args = mock_head.call_args + assert call_args[1]["timeout"] == external_files.NETWORK_TIMEOUT + + +def test_compute_local_file_dir_creates_parent_dirs(setup_core: Path) -> None: + """Test compute_local_file_dir creates parent directories.""" + domain = "level1/level2/level3/level4" + + result = external_files.compute_local_file_dir(domain) + + assert result.exists() + assert result.is_dir() + assert result.parent.name == "level3" + assert result.parent.parent.name == "level2" + assert result.parent.parent.parent.name == "level1" + + +def test_is_file_recent_handles_float_seconds(setup_core: Path) -> None: + """Test is_file_recent works with float seconds in TimePeriod.""" + test_file = setup_core / "test.txt" + test_file.write_text("content") + + refresh = TimePeriod(seconds=3600.5) + + result = external_files.is_file_recent(str(test_file), refresh) + + assert result is True diff --git a/tests/unit_tests/test_platformio_api.py b/tests/unit_tests/test_platformio_api.py new file mode 100644 index 0000000000..a1fa963e51 --- /dev/null +++ b/tests/unit_tests/test_platformio_api.py @@ -0,0 +1,129 @@ +"""Tests for platformio_api.py path functions.""" + +from pathlib import Path +from unittest.mock import patch + +from esphome import platformio_api +from esphome.core import CORE + + +def test_idedata_firmware_elf_path(setup_core: Path) -> None: + """Test IDEData.firmware_elf_path returns correct path.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + raw_data = {"prog_path": "/path/to/firmware.elf"} + idedata = platformio_api.IDEData(raw_data) + + assert idedata.firmware_elf_path == "/path/to/firmware.elf" + + +def test_idedata_firmware_bin_path(setup_core: Path) -> None: + """Test IDEData.firmware_bin_path returns Path with .bin extension.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + prog_path = str(Path("/path/to/firmware.elf")) + raw_data = {"prog_path": prog_path} + idedata = platformio_api.IDEData(raw_data) + + result = idedata.firmware_bin_path + assert isinstance(result, str) + expected = str(Path("/path/to/firmware.bin")) + assert result == expected + assert result.endswith(".bin") + + +def test_idedata_firmware_bin_path_preserves_directory(setup_core: Path) -> None: + """Test firmware_bin_path preserves the directory structure.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + prog_path = str(Path("/complex/path/to/build/firmware.elf")) + raw_data = {"prog_path": prog_path} + idedata = platformio_api.IDEData(raw_data) + + result = idedata.firmware_bin_path + expected = str(Path("/complex/path/to/build/firmware.bin")) + assert result == expected + + +def test_idedata_extra_flash_images(setup_core: Path) -> None: + """Test IDEData.extra_flash_images returns list of FlashImage objects.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + raw_data = { + "prog_path": "/path/to/firmware.elf", + "extra": { + "flash_images": [ + {"path": "/path/to/bootloader.bin", "offset": "0x1000"}, + {"path": "/path/to/partition.bin", "offset": "0x8000"}, + ] + }, + } + idedata = platformio_api.IDEData(raw_data) + + images = idedata.extra_flash_images + assert len(images) == 2 + assert all(isinstance(img, platformio_api.FlashImage) for img in images) + assert images[0].path == "/path/to/bootloader.bin" + assert images[0].offset == "0x1000" + assert images[1].path == "/path/to/partition.bin" + assert images[1].offset == "0x8000" + + +def test_idedata_extra_flash_images_empty(setup_core: Path) -> None: + """Test extra_flash_images returns empty list when no extra images.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + raw_data = {"prog_path": "/path/to/firmware.elf", "extra": {"flash_images": []}} + idedata = platformio_api.IDEData(raw_data) + + images = idedata.extra_flash_images + assert images == [] + + +def test_idedata_cc_path(setup_core: Path) -> None: + """Test IDEData.cc_path returns compiler path.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + raw_data = { + "prog_path": "/path/to/firmware.elf", + "cc_path": "/Users/test/.platformio/packages/toolchain-xtensa32/bin/xtensa-esp32-elf-gcc", + } + idedata = platformio_api.IDEData(raw_data) + + assert ( + idedata.cc_path + == "/Users/test/.platformio/packages/toolchain-xtensa32/bin/xtensa-esp32-elf-gcc" + ) + + +def test_flash_image_dataclass() -> None: + """Test FlashImage dataclass stores path and offset correctly.""" + image = platformio_api.FlashImage(path="/path/to/image.bin", offset="0x10000") + + assert image.path == "/path/to/image.bin" + assert image.offset == "0x10000" + + +def test_load_idedata_returns_dict(setup_core: Path) -> None: + """Test _load_idedata returns parsed idedata dict when successful.""" + CORE.build_path = str(setup_core / "build" / "test") + CORE.name = "test" + + # Create required files + platformio_ini = setup_core / "build" / "test" / "platformio.ini" + platformio_ini.parent.mkdir(parents=True, exist_ok=True) + platformio_ini.touch() + + idedata_path = setup_core / ".esphome" / "idedata" / "test.json" + idedata_path.parent.mkdir(parents=True, exist_ok=True) + idedata_path.write_text('{"prog_path": "/test/firmware.elf"}') + + with patch("esphome.platformio_api.run_platformio_cli_run") as mock_run: + mock_run.return_value = '{"prog_path": "/test/firmware.elf"}' + + config = {"name": "test"} + result = platformio_api._load_idedata(config) + + assert result is not None + assert isinstance(result, dict) + assert result["prog_path"] == "/test/firmware.elf" diff --git a/tests/unit_tests/test_storage_json.py b/tests/unit_tests/test_storage_json.py new file mode 100644 index 0000000000..52de327bbc --- /dev/null +++ b/tests/unit_tests/test_storage_json.py @@ -0,0 +1,182 @@ +"""Tests for storage_json.py path functions.""" + +from pathlib import Path +import sys +from unittest.mock import patch + +import pytest + +from esphome import storage_json +from esphome.core import CORE + + +def test_storage_path(setup_core: Path) -> None: + """Test storage_path returns correct path for current config.""" + CORE.config_path = str(setup_core / "my_device.yaml") + + result = storage_json.storage_path() + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "storage" / "my_device.yaml.json") + assert result == expected + + +def test_ext_storage_path(setup_core: Path) -> None: + """Test ext_storage_path returns correct path for given filename.""" + result = storage_json.ext_storage_path("other_device.yaml") + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "storage" / "other_device.yaml.json") + assert result == expected + + +def test_ext_storage_path_handles_various_extensions(setup_core: Path) -> None: + """Test ext_storage_path works with different file extensions.""" + result_yml = storage_json.ext_storage_path("device.yml") + assert result_yml.endswith("device.yml.json") + + result_no_ext = storage_json.ext_storage_path("device") + assert result_no_ext.endswith("device.json") + + result_path = storage_json.ext_storage_path("my/device.yaml") + assert result_path.endswith("device.yaml.json") + + +def test_esphome_storage_path(setup_core: Path) -> None: + """Test esphome_storage_path returns correct path.""" + result = storage_json.esphome_storage_path() + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "esphome.json") + assert result == expected + + +def test_ignored_devices_storage_path(setup_core: Path) -> None: + """Test ignored_devices_storage_path returns correct path.""" + result = storage_json.ignored_devices_storage_path() + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "ignored-devices.json") + assert result == expected + + +def test_trash_storage_path(setup_core: Path) -> None: + """Test trash_storage_path returns correct path.""" + CORE.config_path = str(setup_core / "configs" / "device.yaml") + + result = storage_json.trash_storage_path() + + expected = str(setup_core / "configs" / "trash") + assert result == expected + + +def test_archive_storage_path(setup_core: Path) -> None: + """Test archive_storage_path returns correct path.""" + CORE.config_path = str(setup_core / "configs" / "device.yaml") + + result = storage_json.archive_storage_path() + + expected = str(setup_core / "configs" / "archive") + assert result == expected + + +def test_storage_path_with_subdirectory(setup_core: Path) -> None: + """Test storage paths work correctly when config is in subdirectory.""" + subdir = setup_core / "configs" / "basement" + subdir.mkdir(parents=True, exist_ok=True) + CORE.config_path = str(subdir / "sensor.yaml") + + result = storage_json.storage_path() + + data_dir = Path(CORE.data_dir) + expected = str(data_dir / "storage" / "sensor.yaml.json") + assert result == expected + + +def test_storage_json_firmware_bin_path_property(setup_core: Path) -> None: + """Test StorageJSON firmware_bin_path property.""" + storage = storage_json.StorageJSON( + storage_version=1, + name="test_device", + friendly_name="Test Device", + comment=None, + esphome_version="2024.1.0", + src_version=None, + address="192.168.1.100", + web_port=80, + target_platform="ESP32", + build_path="build/test_device", + firmware_bin_path="/path/to/firmware.bin", + loaded_integrations={"wifi", "api"}, + loaded_platforms=set(), + no_mdns=False, + ) + + assert storage.firmware_bin_path == "/path/to/firmware.bin" + + +def test_storage_json_save_creates_directory(setup_core: Path, tmp_path: Path) -> None: + """Test StorageJSON.save creates storage directory if it doesn't exist.""" + storage_dir = tmp_path / "new_data" / "storage" + storage_file = storage_dir / "test.json" + + assert not storage_dir.exists() + + storage = storage_json.StorageJSON( + storage_version=1, + name="test", + friendly_name="Test", + comment=None, + esphome_version="2024.1.0", + src_version=None, + address="test.local", + web_port=None, + target_platform="ESP8266", + build_path=None, + firmware_bin_path=None, + loaded_integrations=set(), + loaded_platforms=set(), + no_mdns=False, + ) + + with patch("esphome.storage_json.write_file_if_changed") as mock_write: + storage.save(str(storage_file)) + mock_write.assert_called_once() + call_args = mock_write.call_args[0] + assert call_args[0] == str(storage_file) + + +def test_storage_json_from_wizard(setup_core: Path) -> None: + """Test StorageJSON.from_wizard creates correct storage object.""" + storage = storage_json.StorageJSON.from_wizard( + name="my_device", + friendly_name="My Device", + address="my_device.local", + platform="ESP32", + ) + + assert storage.name == "my_device" + assert storage.friendly_name == "My Device" + assert storage.address == "my_device.local" + assert storage.target_platform == "ESP32" + assert storage.build_path is None + assert storage.firmware_bin_path is None + + +@pytest.mark.skipif(sys.platform == "win32", reason="HA addons don't run on Windows") +@patch("esphome.core.is_ha_addon") +def test_storage_paths_with_ha_addon(mock_is_ha_addon: bool, tmp_path: Path) -> None: + """Test storage paths when running as Home Assistant addon.""" + mock_is_ha_addon.return_value = True + + CORE.config_path = str(tmp_path / "test.yaml") + + result = storage_json.storage_path() + # When is_ha_addon is True, CORE.data_dir returns "/data" + # This is the standard mount point for HA addon containers + expected = str(Path("/data") / "storage" / "test.yaml.json") + assert result == expected + + result = storage_json.esphome_storage_path() + expected = str(Path("/data") / "esphome.json") + assert result == expected