"""Test writer module functionality.""" from collections.abc import Callable from pathlib import Path from typing import Any from unittest.mock import MagicMock, patch import pytest from esphome.core import EsphomeError from esphome.storage_json import StorageJSON from esphome.writer import ( CPP_AUTO_GENERATE_BEGIN, CPP_AUTO_GENERATE_END, CPP_INCLUDE_BEGIN, CPP_INCLUDE_END, GITIGNORE_CONTENT, clean_build, clean_cmake_cache, storage_should_clean, update_storage_json, write_cpp, write_gitignore, ) @pytest.fixture def mock_copy_src_tree(): """Mock copy_src_tree to avoid side effects during tests.""" with patch("esphome.writer.copy_src_tree"): yield @pytest.fixture def create_storage() -> Callable[..., StorageJSON]: """Factory fixture to create StorageJSON instances.""" def _create( loaded_integrations: list[str] | None = None, **kwargs: Any ) -> StorageJSON: return StorageJSON( storage_version=kwargs.get("storage_version", 1), name=kwargs.get("name", "test"), friendly_name=kwargs.get("friendly_name", "Test Device"), comment=kwargs.get("comment"), esphome_version=kwargs.get("esphome_version", "2025.1.0"), src_version=kwargs.get("src_version", 1), address=kwargs.get("address", "test.local"), web_port=kwargs.get("web_port", 80), target_platform=kwargs.get("target_platform", "ESP32"), build_path=kwargs.get("build_path", "/build"), firmware_bin_path=kwargs.get("firmware_bin_path", "/firmware.bin"), loaded_integrations=set(loaded_integrations or []), loaded_platforms=kwargs.get("loaded_platforms", set()), no_mdns=kwargs.get("no_mdns", False), framework=kwargs.get("framework", "arduino"), core_platform=kwargs.get("core_platform", "esp32"), ) return _create def test_storage_should_clean_when_old_is_none( create_storage: Callable[..., StorageJSON], ) -> None: """Test that clean is triggered when old storage is None.""" new = create_storage(loaded_integrations=["api", "wifi"]) assert storage_should_clean(None, new) is True def test_storage_should_clean_when_src_version_changes( create_storage: Callable[..., StorageJSON], ) -> None: """Test that clean is triggered when src_version changes.""" old = create_storage(loaded_integrations=["api", "wifi"], src_version=1) new = create_storage(loaded_integrations=["api", "wifi"], src_version=2) assert storage_should_clean(old, new) is True def test_storage_should_clean_when_build_path_changes( create_storage: Callable[..., StorageJSON], ) -> None: """Test that clean is triggered when build_path changes.""" old = create_storage(loaded_integrations=["api", "wifi"], build_path="/build1") new = create_storage(loaded_integrations=["api", "wifi"], build_path="/build2") assert storage_should_clean(old, new) is True def test_storage_should_clean_when_component_removed( create_storage: Callable[..., StorageJSON], ) -> None: """Test that clean is triggered when a component is removed.""" old = create_storage( loaded_integrations=["api", "wifi", "bluetooth_proxy", "esp32_ble_tracker"] ) new = create_storage(loaded_integrations=["api", "wifi", "esp32_ble_tracker"]) assert storage_should_clean(old, new) is True def test_storage_should_clean_when_multiple_components_removed( create_storage: Callable[..., StorageJSON], ) -> None: """Test that clean is triggered when multiple components are removed.""" old = create_storage( loaded_integrations=["api", "wifi", "ota", "web_server", "logger"] ) new = create_storage(loaded_integrations=["api", "wifi", "logger"]) assert storage_should_clean(old, new) is True def test_storage_should_not_clean_when_nothing_changes( create_storage: Callable[..., StorageJSON], ) -> None: """Test that clean is not triggered when nothing changes.""" old = create_storage(loaded_integrations=["api", "wifi", "logger"]) new = create_storage(loaded_integrations=["api", "wifi", "logger"]) assert storage_should_clean(old, new) is False def test_storage_should_not_clean_when_component_added( create_storage: Callable[..., StorageJSON], ) -> None: """Test that clean is not triggered when a component is only added.""" old = create_storage(loaded_integrations=["api", "wifi"]) new = create_storage(loaded_integrations=["api", "wifi", "ota"]) assert storage_should_clean(old, new) is False def test_storage_should_not_clean_when_other_fields_change( create_storage: Callable[..., StorageJSON], ) -> None: """Test that clean is not triggered when non-relevant fields change.""" old = create_storage( loaded_integrations=["api", "wifi"], friendly_name="Old Name", esphome_version="2024.12.0", ) new = create_storage( loaded_integrations=["api", "wifi"], friendly_name="New Name", esphome_version="2025.1.0", ) assert storage_should_clean(old, new) is False def test_storage_edge_case_empty_integrations( create_storage: Callable[..., StorageJSON], ) -> None: """Test edge case when old has integrations but new has none.""" old = create_storage(loaded_integrations=["api", "wifi"]) new = create_storage(loaded_integrations=[]) assert storage_should_clean(old, new) is True def test_storage_edge_case_from_empty_integrations( create_storage: Callable[..., StorageJSON], ) -> None: """Test edge case when old has no integrations but new has some.""" old = create_storage(loaded_integrations=[]) new = create_storage(loaded_integrations=["api", "wifi"]) assert storage_should_clean(old, new) is False @patch("esphome.writer.clean_build") @patch("esphome.writer.StorageJSON") @patch("esphome.writer.storage_path") @patch("esphome.writer.CORE") def test_update_storage_json_logging_when_old_is_none( mock_core: MagicMock, mock_storage_path: MagicMock, mock_storage_json_class: MagicMock, mock_clean_build: MagicMock, create_storage: Callable[..., StorageJSON], caplog: pytest.LogCaptureFixture, ) -> None: """Test that update_storage_json doesn't crash when old storage is None. This is a regression test for the AttributeError that occurred when old was None and we tried to access old.loaded_integrations. """ # Setup mocks mock_storage_path.return_value = "/test/path" mock_storage_json_class.load.return_value = None # Old storage is None new_storage = create_storage(loaded_integrations=["api", "wifi"]) new_storage.save = MagicMock() # Mock the save method mock_storage_json_class.from_esphome_core.return_value = new_storage # Call the function - should not raise AttributeError with caplog.at_level("INFO"): update_storage_json() # Verify clean_build was called mock_clean_build.assert_called_once() # Verify the correct log message was used (not the component removal message) assert "Core config or version changed, cleaning build files..." in caplog.text assert "Components removed" not in caplog.text # Verify save was called new_storage.save.assert_called_once_with("/test/path") @patch("esphome.writer.clean_build") @patch("esphome.writer.StorageJSON") @patch("esphome.writer.storage_path") @patch("esphome.writer.CORE") def test_update_storage_json_logging_components_removed( mock_core: MagicMock, mock_storage_path: MagicMock, mock_storage_json_class: MagicMock, mock_clean_build: MagicMock, create_storage: Callable[..., StorageJSON], caplog: pytest.LogCaptureFixture, ) -> None: """Test that update_storage_json logs removed components correctly.""" # Setup mocks mock_storage_path.return_value = "/test/path" old_storage = create_storage(loaded_integrations=["api", "wifi", "bluetooth_proxy"]) new_storage = create_storage(loaded_integrations=["api", "wifi"]) new_storage.save = MagicMock() # Mock the save method mock_storage_json_class.load.return_value = old_storage mock_storage_json_class.from_esphome_core.return_value = new_storage # Call the function with caplog.at_level("INFO"): update_storage_json() # Verify clean_build was called mock_clean_build.assert_called_once() # Verify the correct log message was used with component names assert ( "Components removed (bluetooth_proxy), cleaning build files..." in caplog.text ) assert "Core config or version changed" not in caplog.text # Verify save was called new_storage.save.assert_called_once_with("/test/path") @patch("esphome.writer.CORE") def test_clean_cmake_cache( mock_core: MagicMock, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: """Test clean_cmake_cache removes CMakeCache.txt file.""" # Create directory structure pioenvs_dir = tmp_path / ".pioenvs" pioenvs_dir.mkdir() device_dir = pioenvs_dir / "test_device" device_dir.mkdir() cmake_cache_file = device_dir / "CMakeCache.txt" cmake_cache_file.write_text("# CMake cache file") # Setup mocks mock_core.relative_pioenvs_path.side_effect = [ str(pioenvs_dir), # First call for directory check str(cmake_cache_file), # Second call for file path ] mock_core.name = "test_device" # Verify file exists before assert cmake_cache_file.exists() # Call the function with caplog.at_level("INFO"): clean_cmake_cache() # Verify file was removed assert not cmake_cache_file.exists() # Verify logging assert "Deleting" in caplog.text assert "CMakeCache.txt" in caplog.text @patch("esphome.writer.CORE") def test_clean_cmake_cache_no_pioenvs_dir( mock_core: MagicMock, tmp_path: Path, ) -> None: """Test clean_cmake_cache when pioenvs directory doesn't exist.""" # Setup non-existent directory path pioenvs_dir = tmp_path / ".pioenvs" # Setup mocks mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) # Verify directory doesn't exist assert not pioenvs_dir.exists() # Call the function - should not crash clean_cmake_cache() # Verify directory still doesn't exist assert not pioenvs_dir.exists() @patch("esphome.writer.CORE") def test_clean_cmake_cache_no_cmake_file( mock_core: MagicMock, tmp_path: Path, ) -> None: """Test clean_cmake_cache when CMakeCache.txt doesn't exist.""" # Create directory structure without CMakeCache.txt pioenvs_dir = tmp_path / ".pioenvs" pioenvs_dir.mkdir() device_dir = pioenvs_dir / "test_device" device_dir.mkdir() cmake_cache_file = device_dir / "CMakeCache.txt" # Setup mocks mock_core.relative_pioenvs_path.side_effect = [ str(pioenvs_dir), # First call for directory check str(cmake_cache_file), # Second call for file path ] mock_core.name = "test_device" # Verify file doesn't exist assert not cmake_cache_file.exists() # Call the function - should not crash clean_cmake_cache() # Verify file still doesn't exist assert not cmake_cache_file.exists() @patch("esphome.writer.CORE") def test_clean_build( mock_core: MagicMock, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: """Test clean_build removes all build artifacts.""" # Create directory structure and files pioenvs_dir = tmp_path / ".pioenvs" pioenvs_dir.mkdir() (pioenvs_dir / "test_file.o").write_text("object file") piolibdeps_dir = tmp_path / ".piolibdeps" piolibdeps_dir.mkdir() (piolibdeps_dir / "library").mkdir() dependencies_lock = tmp_path / "dependencies.lock" dependencies_lock.write_text("lock file") # Setup mocks mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) mock_core.relative_build_path.return_value = str(dependencies_lock) # Verify all exist before assert pioenvs_dir.exists() assert piolibdeps_dir.exists() assert dependencies_lock.exists() # Call the function with caplog.at_level("INFO"): clean_build() # Verify all were removed assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() # Verify logging assert "Deleting" in caplog.text assert ".pioenvs" in caplog.text assert ".piolibdeps" in caplog.text assert "dependencies.lock" in caplog.text @patch("esphome.writer.CORE") def test_clean_build_partial_exists( mock_core: MagicMock, tmp_path: Path, caplog: pytest.LogCaptureFixture, ) -> None: """Test clean_build when only some paths exist.""" # Create only pioenvs directory pioenvs_dir = tmp_path / ".pioenvs" pioenvs_dir.mkdir() (pioenvs_dir / "test_file.o").write_text("object file") piolibdeps_dir = tmp_path / ".piolibdeps" dependencies_lock = tmp_path / "dependencies.lock" # Setup mocks mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) mock_core.relative_build_path.return_value = str(dependencies_lock) # Verify only pioenvs exists assert pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() # Call the function with caplog.at_level("INFO"): clean_build() # Verify only existing path was removed assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() # Verify logging - only pioenvs should be logged assert "Deleting" in caplog.text assert ".pioenvs" in caplog.text assert ".piolibdeps" not in caplog.text assert "dependencies.lock" not in caplog.text @patch("esphome.writer.CORE") def test_clean_build_nothing_exists( mock_core: MagicMock, tmp_path: Path, ) -> None: """Test clean_build when no build artifacts exist.""" # Setup paths that don't exist pioenvs_dir = tmp_path / ".pioenvs" piolibdeps_dir = tmp_path / ".piolibdeps" dependencies_lock = tmp_path / "dependencies.lock" # Setup mocks mock_core.relative_pioenvs_path.return_value = str(pioenvs_dir) mock_core.relative_piolibdeps_path.return_value = str(piolibdeps_dir) mock_core.relative_build_path.return_value = str(dependencies_lock) # Verify nothing exists assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() # Call the function - should not crash clean_build() # Verify nothing was created assert not pioenvs_dir.exists() assert not piolibdeps_dir.exists() assert not dependencies_lock.exists() @patch("esphome.writer.CORE") def test_write_gitignore_creates_new_file( mock_core: MagicMock, tmp_path: Path, ) -> None: """Test write_gitignore creates a new .gitignore file when it doesn't exist.""" gitignore_path = tmp_path / ".gitignore" # Setup mocks mock_core.relative_config_path.return_value = str(gitignore_path) # Verify file doesn't exist assert not gitignore_path.exists() # Call the function write_gitignore() # Verify file was created with correct content assert gitignore_path.exists() assert gitignore_path.read_text() == GITIGNORE_CONTENT @patch("esphome.writer.CORE") def test_write_gitignore_skips_existing_file( mock_core: MagicMock, tmp_path: Path, ) -> None: """Test write_gitignore doesn't overwrite existing .gitignore file.""" gitignore_path = tmp_path / ".gitignore" existing_content = "# Custom gitignore\n/custom_dir/\n" gitignore_path.write_text(existing_content) # Setup mocks mock_core.relative_config_path.return_value = str(gitignore_path) # Verify file exists with custom content assert gitignore_path.exists() assert gitignore_path.read_text() == existing_content # Call the function write_gitignore() # Verify file was not modified assert gitignore_path.exists() assert gitignore_path.read_text() == existing_content @patch("esphome.writer.write_file_if_changed") # Mock to capture output @patch("esphome.writer.copy_src_tree") # Keep this mock as it's complex @patch("esphome.writer.CORE") def test_write_cpp_with_existing_file( mock_core: MagicMock, mock_copy_src_tree: MagicMock, mock_write_file: MagicMock, tmp_path: Path, ) -> None: """Test write_cpp when main.cpp already exists.""" # Create a real file with markers main_cpp = tmp_path / "main.cpp" existing_content = f"""#include "esphome.h" {CPP_INCLUDE_BEGIN} // Old includes {CPP_INCLUDE_END} void setup() {{ {CPP_AUTO_GENERATE_BEGIN} // Old code {CPP_AUTO_GENERATE_END} }} void loop() {{}}""" main_cpp.write_text(existing_content) # Setup mocks mock_core.relative_src_path.return_value = str(main_cpp) mock_core.cpp_global_section = "// Global section" # Call the function test_code = " // New generated code" write_cpp(test_code) # Verify copy_src_tree was called mock_copy_src_tree.assert_called_once() # Get the content that would be written mock_write_file.assert_called_once() written_path, written_content = mock_write_file.call_args[0] # Check that markers are preserved and content is updated assert CPP_INCLUDE_BEGIN in written_content assert CPP_INCLUDE_END in written_content assert CPP_AUTO_GENERATE_BEGIN in written_content assert CPP_AUTO_GENERATE_END in written_content assert test_code in written_content assert "// Global section" in written_content @patch("esphome.writer.write_file_if_changed") # Mock to capture output @patch("esphome.writer.copy_src_tree") # Keep this mock as it's complex @patch("esphome.writer.CORE") def test_write_cpp_creates_new_file( mock_core: MagicMock, mock_copy_src_tree: MagicMock, mock_write_file: MagicMock, tmp_path: Path, ) -> None: """Test write_cpp when main.cpp doesn't exist.""" # Setup path for new file main_cpp = tmp_path / "main.cpp" # Setup mocks mock_core.relative_src_path.return_value = str(main_cpp) mock_core.cpp_global_section = "// Global section" # Verify file doesn't exist assert not main_cpp.exists() # Call the function test_code = " // Generated code" write_cpp(test_code) # Verify copy_src_tree was called mock_copy_src_tree.assert_called_once() # Get the content that would be written mock_write_file.assert_called_once() written_path, written_content = mock_write_file.call_args[0] assert written_path == str(main_cpp) # Check that all necessary parts are in the new file assert '#include "esphome.h"' in written_content assert CPP_INCLUDE_BEGIN in written_content assert CPP_INCLUDE_END in written_content assert CPP_AUTO_GENERATE_BEGIN in written_content assert CPP_AUTO_GENERATE_END in written_content assert test_code in written_content assert "void setup()" in written_content assert "void loop()" in written_content assert "App.setup();" in written_content assert "App.loop();" in written_content @pytest.mark.usefixtures("mock_copy_src_tree") @patch("esphome.writer.CORE") def test_write_cpp_with_missing_end_marker( mock_core: MagicMock, tmp_path: Path, ) -> None: """Test write_cpp raises error when end marker is missing.""" # Create a file with begin marker but no end marker main_cpp = tmp_path / "main.cpp" existing_content = f"""#include "esphome.h" {CPP_AUTO_GENERATE_BEGIN} // Code without end marker""" main_cpp.write_text(existing_content) # Setup mocks mock_core.relative_src_path.return_value = str(main_cpp) # Call should raise an error with pytest.raises(EsphomeError, match="Could not find auto generated code end"): write_cpp("// New code") @pytest.mark.usefixtures("mock_copy_src_tree") @patch("esphome.writer.CORE") def test_write_cpp_with_duplicate_markers( mock_core: MagicMock, tmp_path: Path, ) -> None: """Test write_cpp raises error when duplicate markers exist.""" # Create a file with duplicate begin markers main_cpp = tmp_path / "main.cpp" existing_content = f"""#include "esphome.h" {CPP_AUTO_GENERATE_BEGIN} // First section {CPP_AUTO_GENERATE_END} {CPP_AUTO_GENERATE_BEGIN} // Duplicate section {CPP_AUTO_GENERATE_END}""" main_cpp.write_text(existing_content) # Setup mocks mock_core.relative_src_path.return_value = str(main_cpp) # Call should raise an error with pytest.raises(EsphomeError, match="Found multiple auto generate code begins"): write_cpp("// New code")