mirror of
https://github.com/esphome/esphome.git
synced 2026-02-08 00:31:58 +00:00
Add build info to image (#12425)
Co-authored-by: J. Nick Koston <nick+github@koston.org> Co-authored-by: J. Nick Koston <nick@home-assistant.io> Co-authored-by: J. Nick Koston <nick@koston.org> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
using namespace esphome;
|
||||
|
||||
void setup() {
|
||||
App.pre_setup("livingroom", "LivingRoom", "comment", __DATE__ ", " __TIME__, false);
|
||||
App.pre_setup("livingroom", "LivingRoom", "comment", false);
|
||||
auto *log = new logger::Logger(115200, 512); // NOLINT
|
||||
log->pre_setup();
|
||||
log->set_uart_selection(logger::UART_SELECTION_UART0);
|
||||
|
||||
31
tests/integration/fixtures/build_info.yaml
Normal file
31
tests/integration/fixtures/build_info.yaml
Normal file
@@ -0,0 +1,31 @@
|
||||
esphome:
|
||||
name: build-info-test
|
||||
host:
|
||||
api:
|
||||
logger:
|
||||
|
||||
text_sensor:
|
||||
- platform: template
|
||||
name: "Config Hash"
|
||||
id: config_hash_sensor
|
||||
update_interval: 100ms
|
||||
lambda: |-
|
||||
char buf[16];
|
||||
snprintf(buf, sizeof(buf), "0x%08x", App.get_config_hash());
|
||||
return std::string(buf);
|
||||
- platform: template
|
||||
name: "Build Time"
|
||||
id: build_time_sensor
|
||||
update_interval: 100ms
|
||||
lambda: |-
|
||||
char buf[32];
|
||||
snprintf(buf, sizeof(buf), "%ld", (long)App.get_build_time());
|
||||
return std::string(buf);
|
||||
- platform: template
|
||||
name: "Build Time String"
|
||||
id: build_time_str_sensor
|
||||
update_interval: 100ms
|
||||
lambda: |-
|
||||
char buf[Application::BUILD_TIME_STR_SIZE];
|
||||
App.get_build_time_string(buf);
|
||||
return std::string(buf);
|
||||
117
tests/integration/test_build_info.py
Normal file
117
tests/integration/test_build_info.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Integration test for build_info values."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import re
|
||||
import time
|
||||
|
||||
from aioesphomeapi import EntityState, TextSensorState
|
||||
import pytest
|
||||
|
||||
from .types import APIClientConnectedFactory, RunCompiledFunction
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_info(
|
||||
yaml_config: str,
|
||||
run_compiled: RunCompiledFunction,
|
||||
api_client_connected: APIClientConnectedFactory,
|
||||
) -> None:
|
||||
"""Test that build_info values are sane."""
|
||||
async with run_compiled(yaml_config), api_client_connected() as client:
|
||||
device_info = await client.device_info()
|
||||
assert device_info is not None
|
||||
assert device_info.name == "build-info-test"
|
||||
|
||||
# Verify compilation_time from device_info is present and parseable
|
||||
# The format is ISO 8601 with timezone: "YYYY-MM-DD HH:MM:SS +ZZZZ"
|
||||
compilation_time = device_info.compilation_time
|
||||
assert compilation_time is not None
|
||||
|
||||
# Validate the ISO format: "YYYY-MM-DD HH:MM:SS +ZZZZ"
|
||||
parsed = datetime.strptime(compilation_time, "%Y-%m-%d %H:%M:%S %z")
|
||||
assert parsed.year >= time.localtime().tm_year
|
||||
|
||||
# Get entities
|
||||
entities, _ = await client.list_entities_services()
|
||||
|
||||
# Find our text sensors by object_id
|
||||
config_hash_entity = next(
|
||||
(e for e in entities if e.object_id == "config_hash"), None
|
||||
)
|
||||
build_time_entity = next(
|
||||
(e for e in entities if e.object_id == "build_time"), None
|
||||
)
|
||||
build_time_str_entity = next(
|
||||
(e for e in entities if e.object_id == "build_time_string"), None
|
||||
)
|
||||
|
||||
assert config_hash_entity is not None, "Config Hash sensor not found"
|
||||
assert build_time_entity is not None, "Build Time sensor not found"
|
||||
assert build_time_str_entity is not None, "Build Time String sensor not found"
|
||||
|
||||
# Wait for all three text sensors to have valid states
|
||||
loop = asyncio.get_running_loop()
|
||||
states: dict[int, TextSensorState] = {}
|
||||
all_received = loop.create_future()
|
||||
expected_keys = {
|
||||
config_hash_entity.key,
|
||||
build_time_entity.key,
|
||||
build_time_str_entity.key,
|
||||
}
|
||||
|
||||
def on_state(state: EntityState) -> None:
|
||||
if isinstance(state, TextSensorState) and not state.missing_state:
|
||||
states[state.key] = state
|
||||
if expected_keys <= states.keys() and not all_received.done():
|
||||
all_received.set_result(True)
|
||||
|
||||
client.subscribe_states(on_state)
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(all_received, timeout=5.0)
|
||||
except TimeoutError:
|
||||
pytest.fail(
|
||||
f"Timeout waiting for text sensor states. Got: {list(states.keys())}"
|
||||
)
|
||||
|
||||
config_hash_state = states[config_hash_entity.key]
|
||||
build_time_state = states[build_time_entity.key]
|
||||
build_time_str_state = states[build_time_str_entity.key]
|
||||
|
||||
# Validate config_hash format (0x followed by 8 hex digits)
|
||||
config_hash = config_hash_state.state
|
||||
assert re.match(r"^0x[0-9a-f]{8}$", config_hash), (
|
||||
f"config_hash should be 0x followed by 8 hex digits, got: {config_hash}"
|
||||
)
|
||||
|
||||
# Validate build_time is a reasonable Unix timestamp
|
||||
build_time = int(build_time_state.state)
|
||||
current_time = int(time.time())
|
||||
# Build time should be within last hour and not in the future
|
||||
assert build_time <= current_time, (
|
||||
f"build_time {build_time} should not be in the future (current: {current_time})"
|
||||
)
|
||||
assert build_time > current_time - 3600, (
|
||||
f"build_time {build_time} should be within the last hour"
|
||||
)
|
||||
|
||||
# Validate build_time_str matches the new ISO format
|
||||
build_time_str = build_time_str_state.state
|
||||
# Format: "YYYY-MM-DD HH:MM:SS +ZZZZ"
|
||||
parsed_build_time = datetime.strptime(build_time_str, "%Y-%m-%d %H:%M:%S %z")
|
||||
assert parsed_build_time.year >= time.localtime().tm_year
|
||||
|
||||
# Verify build_time_str matches what we get from build_time timestamp
|
||||
expected_str = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime(build_time))
|
||||
assert build_time_str == expected_str, (
|
||||
f"build_time_str '{build_time_str}' should match timestamp '{expected_str}'"
|
||||
)
|
||||
|
||||
# Verify compilation_time matches build_time_str (they should be the same)
|
||||
assert compilation_time == build_time_str, (
|
||||
f"compilation_time '{compilation_time}' should match "
|
||||
f"build_time_str '{build_time_str}'"
|
||||
)
|
||||
@@ -58,6 +58,7 @@ def mock_write_file_if_changed() -> Generator[Mock, None, None]:
|
||||
def mock_copy_file_if_changed() -> Generator[Mock, None, None]:
|
||||
"""Mock copy_file_if_changed for core.config."""
|
||||
with patch("esphome.core.config.copy_file_if_changed") as mock:
|
||||
mock.return_value = True
|
||||
yield mock
|
||||
|
||||
|
||||
|
||||
@@ -892,3 +892,74 @@ async def test_add_includes_overwrites_existing_files(
|
||||
mock_copy_file_if_changed.assert_called_once_with(
|
||||
include_file, CORE.build_path / "src" / "header.h"
|
||||
)
|
||||
|
||||
|
||||
def test_config_hash_returns_int() -> None:
|
||||
"""Test that config_hash returns an integer."""
|
||||
CORE.reset()
|
||||
CORE.config = {"esphome": {"name": "test"}}
|
||||
assert isinstance(CORE.config_hash, int)
|
||||
|
||||
|
||||
def test_config_hash_is_cached() -> None:
|
||||
"""Test that config_hash is computed once and cached."""
|
||||
CORE.reset()
|
||||
CORE.config = {"esphome": {"name": "test"}}
|
||||
|
||||
# First access computes the hash
|
||||
hash1 = CORE.config_hash
|
||||
|
||||
# Modify config (without resetting cache)
|
||||
CORE.config = {"esphome": {"name": "different"}}
|
||||
|
||||
# Second access returns cached value
|
||||
hash2 = CORE.config_hash
|
||||
|
||||
assert hash1 == hash2
|
||||
|
||||
|
||||
def test_config_hash_reset_clears_cache() -> None:
|
||||
"""Test that reset() clears the cached config_hash."""
|
||||
CORE.reset()
|
||||
CORE.config = {"esphome": {"name": "test"}}
|
||||
hash1 = CORE.config_hash
|
||||
|
||||
# Reset clears the cache
|
||||
CORE.reset()
|
||||
CORE.config = {"esphome": {"name": "different"}}
|
||||
|
||||
hash2 = CORE.config_hash
|
||||
|
||||
# After reset, hash should be recomputed
|
||||
assert hash1 != hash2
|
||||
|
||||
|
||||
def test_config_hash_deterministic_key_order() -> None:
|
||||
"""Test that config_hash is deterministic regardless of key insertion order."""
|
||||
CORE.reset()
|
||||
# Create two configs with same content but different key order
|
||||
config1 = {"z_key": 1, "a_key": 2, "nested": {"z_nested": "z", "a_nested": "a"}}
|
||||
config2 = {"a_key": 2, "z_key": 1, "nested": {"a_nested": "a", "z_nested": "z"}}
|
||||
|
||||
CORE.config = config1
|
||||
hash1 = CORE.config_hash
|
||||
|
||||
CORE.reset()
|
||||
CORE.config = config2
|
||||
hash2 = CORE.config_hash
|
||||
|
||||
# Hashes should be equal because keys are sorted during serialization
|
||||
assert hash1 == hash2
|
||||
|
||||
|
||||
def test_config_hash_different_for_different_configs() -> None:
|
||||
"""Test that different configs produce different hashes."""
|
||||
CORE.reset()
|
||||
CORE.config = {"esphome": {"name": "test1"}}
|
||||
hash1 = CORE.config_hash
|
||||
|
||||
CORE.reset()
|
||||
CORE.config = {"esphome": {"name": "test2"}}
|
||||
hash2 = CORE.config_hash
|
||||
|
||||
assert hash1 != hash2
|
||||
|
||||
@@ -4,9 +4,11 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
@@ -22,6 +24,7 @@ from esphome.__main__ import (
|
||||
command_rename,
|
||||
command_update_all,
|
||||
command_wizard,
|
||||
compile_program,
|
||||
detect_external_components,
|
||||
get_port_type,
|
||||
has_ip_address,
|
||||
@@ -2605,3 +2608,197 @@ def test_command_analyze_memory_no_idedata(
|
||||
|
||||
assert result == 1
|
||||
assert "Failed to get IDE data for memory analysis" in caplog.text
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_compile_build_info_run_compile() -> Generator[Mock]:
|
||||
"""Mock platformio_api.run_compile for build_info tests."""
|
||||
with patch("esphome.platformio_api.run_compile", return_value=0) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_compile_build_info_get_idedata() -> Generator[Mock]:
|
||||
"""Mock platformio_api.get_idedata for build_info tests."""
|
||||
mock_idedata = MagicMock()
|
||||
with patch("esphome.platformio_api.get_idedata", return_value=mock_idedata) as mock:
|
||||
yield mock
|
||||
|
||||
|
||||
def _setup_build_info_test(
|
||||
tmp_path: Path,
|
||||
*,
|
||||
create_firmware: bool = True,
|
||||
create_build_info: bool = True,
|
||||
build_info_content: str | None = None,
|
||||
firmware_first: bool = False,
|
||||
) -> tuple[Path, Path]:
|
||||
"""Set up build directory structure for build_info tests.
|
||||
|
||||
Args:
|
||||
tmp_path: Temporary directory path.
|
||||
create_firmware: Whether to create firmware.bin file.
|
||||
create_build_info: Whether to create build_info.json file.
|
||||
build_info_content: Custom content for build_info.json, or None for default.
|
||||
firmware_first: If True, create firmware before build_info (makes firmware older).
|
||||
|
||||
Returns:
|
||||
Tuple of (build_info_path, firmware_path).
|
||||
"""
|
||||
setup_core(platform=PLATFORM_ESP32, tmp_path=tmp_path, name="test_device")
|
||||
|
||||
build_path = tmp_path / ".esphome" / "build" / "test_device"
|
||||
pioenvs_path = build_path / ".pioenvs" / "test_device"
|
||||
pioenvs_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
build_info_path = build_path / "build_info.json"
|
||||
firmware_path = pioenvs_path / "firmware.bin"
|
||||
|
||||
default_build_info = json.dumps(
|
||||
{
|
||||
"config_hash": 0x12345678,
|
||||
"build_time": int(time.time()),
|
||||
"build_time_str": "Dec 13 2025, 12:00:00",
|
||||
"esphome_version": "2025.1.0",
|
||||
}
|
||||
)
|
||||
|
||||
def create_build_info_file() -> None:
|
||||
if create_build_info:
|
||||
content = (
|
||||
build_info_content
|
||||
if build_info_content is not None
|
||||
else default_build_info
|
||||
)
|
||||
build_info_path.write_text(content)
|
||||
|
||||
def create_firmware_file() -> None:
|
||||
if create_firmware:
|
||||
firmware_path.write_bytes(b"fake firmware")
|
||||
|
||||
if firmware_first:
|
||||
create_firmware_file()
|
||||
time.sleep(0.01) # Ensure different timestamps
|
||||
create_build_info_file()
|
||||
else:
|
||||
create_build_info_file()
|
||||
time.sleep(0.01) # Ensure different timestamps
|
||||
create_firmware_file()
|
||||
|
||||
return build_info_path, firmware_path
|
||||
|
||||
|
||||
def test_compile_program_emits_build_info_when_firmware_rebuilt(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_compile_build_info_run_compile: Mock,
|
||||
mock_compile_build_info_get_idedata: Mock,
|
||||
) -> None:
|
||||
"""Test that compile_program logs build_info when firmware is rebuilt."""
|
||||
_setup_build_info_test(tmp_path, firmware_first=False)
|
||||
|
||||
config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
args = MockArgs()
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
result = compile_program(args, config)
|
||||
|
||||
assert result == 0
|
||||
assert "Build Info: config_hash=0x12345678" in caplog.text
|
||||
|
||||
|
||||
def test_compile_program_no_build_info_when_firmware_not_rebuilt(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_compile_build_info_run_compile: Mock,
|
||||
mock_compile_build_info_get_idedata: Mock,
|
||||
) -> None:
|
||||
"""Test that compile_program doesn't log build_info when firmware wasn't rebuilt."""
|
||||
_setup_build_info_test(tmp_path, firmware_first=True)
|
||||
|
||||
config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
args = MockArgs()
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
result = compile_program(args, config)
|
||||
|
||||
assert result == 0
|
||||
assert "Build Info:" not in caplog.text
|
||||
|
||||
|
||||
def test_compile_program_no_build_info_when_firmware_missing(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_compile_build_info_run_compile: Mock,
|
||||
mock_compile_build_info_get_idedata: Mock,
|
||||
) -> None:
|
||||
"""Test that compile_program doesn't log build_info when firmware.bin doesn't exist."""
|
||||
_setup_build_info_test(tmp_path, create_firmware=False)
|
||||
|
||||
config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
args = MockArgs()
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
result = compile_program(args, config)
|
||||
|
||||
assert result == 0
|
||||
assert "Build Info:" not in caplog.text
|
||||
|
||||
|
||||
def test_compile_program_no_build_info_when_json_missing(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_compile_build_info_run_compile: Mock,
|
||||
mock_compile_build_info_get_idedata: Mock,
|
||||
) -> None:
|
||||
"""Test that compile_program doesn't log build_info when build_info.json doesn't exist."""
|
||||
_setup_build_info_test(tmp_path, create_build_info=False)
|
||||
|
||||
config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
args = MockArgs()
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
result = compile_program(args, config)
|
||||
|
||||
assert result == 0
|
||||
assert "Build Info:" not in caplog.text
|
||||
|
||||
|
||||
def test_compile_program_no_build_info_when_json_invalid(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_compile_build_info_run_compile: Mock,
|
||||
mock_compile_build_info_get_idedata: Mock,
|
||||
) -> None:
|
||||
"""Test that compile_program doesn't log build_info when build_info.json is invalid."""
|
||||
_setup_build_info_test(tmp_path, build_info_content="not valid json {{{")
|
||||
|
||||
config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
args = MockArgs()
|
||||
|
||||
with caplog.at_level(logging.DEBUG):
|
||||
result = compile_program(args, config)
|
||||
|
||||
assert result == 0
|
||||
assert "Build Info:" not in caplog.text
|
||||
|
||||
|
||||
def test_compile_program_no_build_info_when_json_missing_keys(
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
mock_compile_build_info_run_compile: Mock,
|
||||
mock_compile_build_info_get_idedata: Mock,
|
||||
) -> None:
|
||||
"""Test that compile_program doesn't log build_info when build_info.json is missing required keys."""
|
||||
_setup_build_info_test(
|
||||
tmp_path, build_info_content=json.dumps({"build_time": 1234567890})
|
||||
)
|
||||
|
||||
config: dict[str, Any] = {CONF_ESPHOME: {CONF_NAME: "test_device"}}
|
||||
args = MockArgs()
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
result = compile_program(args, config)
|
||||
|
||||
assert result == 0
|
||||
assert "Build Info:" not in caplog.text
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"""Test writer module functionality."""
|
||||
|
||||
from collections.abc import Callable
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import stat
|
||||
@@ -20,6 +24,9 @@ from esphome.writer import (
|
||||
clean_all,
|
||||
clean_build,
|
||||
clean_cmake_cache,
|
||||
copy_src_tree,
|
||||
generate_build_info_data_h,
|
||||
get_build_info,
|
||||
storage_should_clean,
|
||||
update_storage_json,
|
||||
write_cpp,
|
||||
@@ -1165,3 +1172,721 @@ def test_clean_build_reraises_for_other_errors(
|
||||
finally:
|
||||
# Cleanup - restore write permission so tmp_path cleanup works
|
||||
os.chmod(subdir, stat.S_IRWXU)
|
||||
|
||||
|
||||
# Tests for get_build_info()
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_get_build_info_new_build(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test get_build_info returns new build_time when no existing build_info.json."""
|
||||
build_info_path = tmp_path / "build_info.json"
|
||||
mock_core.relative_build_path.return_value = build_info_path
|
||||
mock_core.config_hash = 0x12345678
|
||||
|
||||
config_hash, build_time, build_time_str = get_build_info()
|
||||
|
||||
assert config_hash == 0x12345678
|
||||
assert isinstance(build_time, int)
|
||||
assert build_time > 0
|
||||
assert isinstance(build_time_str, str)
|
||||
# Verify build_time_str format matches expected pattern
|
||||
assert len(build_time_str) >= 19 # e.g., "2025-12-15 16:27:44 +0000"
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_get_build_info_always_returns_current_time(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test get_build_info always returns current build_time."""
|
||||
build_info_path = tmp_path / "build_info.json"
|
||||
mock_core.relative_build_path.return_value = build_info_path
|
||||
mock_core.config_hash = 0x12345678
|
||||
|
||||
# Create existing build_info.json with matching config_hash and version
|
||||
existing_build_time = 1700000000
|
||||
existing_build_time_str = "2023-11-14 22:13:20 +0000"
|
||||
build_info_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config_hash": 0x12345678,
|
||||
"build_time": existing_build_time,
|
||||
"build_time_str": existing_build_time_str,
|
||||
"esphome_version": "2025.1.0-dev",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with patch("esphome.writer.__version__", "2025.1.0-dev"):
|
||||
config_hash, build_time, build_time_str = get_build_info()
|
||||
|
||||
assert config_hash == 0x12345678
|
||||
# get_build_info now always returns current time
|
||||
assert build_time != existing_build_time
|
||||
assert build_time > existing_build_time
|
||||
assert build_time_str != existing_build_time_str
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_get_build_info_config_changed(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test get_build_info returns new build_time when config hash changed."""
|
||||
build_info_path = tmp_path / "build_info.json"
|
||||
mock_core.relative_build_path.return_value = build_info_path
|
||||
mock_core.config_hash = 0xABCDEF00 # Different from existing
|
||||
|
||||
# Create existing build_info.json with different config_hash
|
||||
existing_build_time = 1700000000
|
||||
build_info_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config_hash": 0x12345678, # Different
|
||||
"build_time": existing_build_time,
|
||||
"build_time_str": "2023-11-14 22:13:20 +0000",
|
||||
"esphome_version": "2025.1.0-dev",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with patch("esphome.writer.__version__", "2025.1.0-dev"):
|
||||
config_hash, build_time, build_time_str = get_build_info()
|
||||
|
||||
assert config_hash == 0xABCDEF00
|
||||
assert build_time != existing_build_time # New time generated
|
||||
assert build_time > existing_build_time
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_get_build_info_version_changed(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test get_build_info returns new build_time when ESPHome version changed."""
|
||||
build_info_path = tmp_path / "build_info.json"
|
||||
mock_core.relative_build_path.return_value = build_info_path
|
||||
mock_core.config_hash = 0x12345678
|
||||
|
||||
# Create existing build_info.json with different version
|
||||
existing_build_time = 1700000000
|
||||
build_info_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config_hash": 0x12345678,
|
||||
"build_time": existing_build_time,
|
||||
"build_time_str": "2023-11-14 22:13:20 +0000",
|
||||
"esphome_version": "2024.12.0", # Old version
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with patch("esphome.writer.__version__", "2025.1.0-dev"): # New version
|
||||
config_hash, build_time, build_time_str = get_build_info()
|
||||
|
||||
assert config_hash == 0x12345678
|
||||
assert build_time != existing_build_time # New time generated
|
||||
assert build_time > existing_build_time
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_get_build_info_invalid_json(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test get_build_info handles invalid JSON gracefully."""
|
||||
build_info_path = tmp_path / "build_info.json"
|
||||
mock_core.relative_build_path.return_value = build_info_path
|
||||
mock_core.config_hash = 0x12345678
|
||||
|
||||
# Create invalid JSON file
|
||||
build_info_path.write_text("not valid json {{{")
|
||||
|
||||
config_hash, build_time, build_time_str = get_build_info()
|
||||
|
||||
assert config_hash == 0x12345678
|
||||
assert isinstance(build_time, int)
|
||||
assert build_time > 0
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_get_build_info_missing_keys(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test get_build_info handles missing keys gracefully."""
|
||||
build_info_path = tmp_path / "build_info.json"
|
||||
mock_core.relative_build_path.return_value = build_info_path
|
||||
mock_core.config_hash = 0x12345678
|
||||
|
||||
# Create JSON with missing keys
|
||||
build_info_path.write_text(json.dumps({"config_hash": 0x12345678}))
|
||||
|
||||
with patch("esphome.writer.__version__", "2025.1.0-dev"):
|
||||
config_hash, build_time, build_time_str = get_build_info()
|
||||
|
||||
assert config_hash == 0x12345678
|
||||
assert isinstance(build_time, int)
|
||||
assert build_time > 0
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
def test_get_build_info_build_time_str_format(
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test get_build_info returns correctly formatted build_time_str."""
|
||||
build_info_path = tmp_path / "build_info.json"
|
||||
mock_core.relative_build_path.return_value = build_info_path
|
||||
mock_core.config_hash = 0x12345678
|
||||
|
||||
config_hash, build_time, build_time_str = get_build_info()
|
||||
|
||||
# Verify the format matches "%Y-%m-%d %H:%M:%S %z"
|
||||
# e.g., "2025-12-15 16:27:44 +0000"
|
||||
parsed = datetime.strptime(build_time_str, "%Y-%m-%d %H:%M:%S %z")
|
||||
assert parsed.year >= 2024
|
||||
|
||||
|
||||
def test_generate_build_info_data_h_format() -> None:
|
||||
"""Test generate_build_info_data_h produces correct header content."""
|
||||
config_hash = 0x12345678
|
||||
build_time = 1700000000
|
||||
build_time_str = "2023-11-14 22:13:20 +0000"
|
||||
|
||||
result = generate_build_info_data_h(config_hash, build_time, build_time_str)
|
||||
|
||||
assert "#pragma once" in result
|
||||
assert "#define ESPHOME_CONFIG_HASH 0x12345678U" in result
|
||||
assert "#define ESPHOME_BUILD_TIME 1700000000" in result
|
||||
assert 'ESPHOME_BUILD_TIME_STR[] = "2023-11-14 22:13:20 +0000"' in result
|
||||
|
||||
|
||||
def test_generate_build_info_data_h_esp8266_progmem() -> None:
|
||||
"""Test generate_build_info_data_h includes PROGMEM for ESP8266."""
|
||||
result = generate_build_info_data_h(0xABCDEF01, 1700000000, "test")
|
||||
|
||||
# Should have ESP8266 PROGMEM conditional
|
||||
assert "#ifdef USE_ESP8266" in result
|
||||
assert "#include <pgmspace.h>" in result
|
||||
assert "PROGMEM" in result
|
||||
|
||||
|
||||
def test_generate_build_info_data_h_hash_formatting() -> None:
|
||||
"""Test generate_build_info_data_h formats hash with leading zeros."""
|
||||
# Test with small hash value that needs leading zeros
|
||||
result = generate_build_info_data_h(0x00000001, 0, "test")
|
||||
assert "#define ESPHOME_CONFIG_HASH 0x00000001U" in result
|
||||
|
||||
# Test with larger hash value
|
||||
result = generate_build_info_data_h(0xFFFFFFFF, 0, "test")
|
||||
assert "#define ESPHOME_CONFIG_HASH 0xffffffffU" in result
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
@patch("esphome.writer.iter_components")
|
||||
@patch("esphome.writer.walk_files")
|
||||
def test_copy_src_tree_writes_build_info_files(
|
||||
mock_walk_files: MagicMock,
|
||||
mock_iter_components: MagicMock,
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test copy_src_tree writes build_info_data.h and build_info.json."""
|
||||
# Setup directory structure
|
||||
src_path = tmp_path / "src"
|
||||
src_path.mkdir()
|
||||
esphome_core_path = src_path / "esphome" / "core"
|
||||
esphome_core_path.mkdir(parents=True)
|
||||
build_path = tmp_path / "build"
|
||||
build_path.mkdir()
|
||||
|
||||
# Create mock source files for defines.h and version.h
|
||||
mock_defines_h = esphome_core_path / "defines.h"
|
||||
mock_defines_h.write_text("// mock defines.h")
|
||||
mock_version_h = esphome_core_path / "version.h"
|
||||
mock_version_h.write_text("// mock version.h")
|
||||
|
||||
# Create mock FileResource that returns our temp files
|
||||
@dataclass(frozen=True)
|
||||
class MockFileResource:
|
||||
package: str
|
||||
resource: str
|
||||
_path: Path
|
||||
|
||||
@contextmanager
|
||||
def path(self):
|
||||
yield self._path
|
||||
|
||||
# Create mock resources for defines.h and version.h (required by copy_src_tree)
|
||||
mock_resources = [
|
||||
MockFileResource(
|
||||
package="esphome.core",
|
||||
resource="defines.h",
|
||||
_path=mock_defines_h,
|
||||
),
|
||||
MockFileResource(
|
||||
package="esphome.core",
|
||||
resource="version.h",
|
||||
_path=mock_version_h,
|
||||
),
|
||||
]
|
||||
|
||||
# Create mock component with resources
|
||||
mock_component = MagicMock()
|
||||
mock_component.resources = mock_resources
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
|
||||
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
|
||||
mock_core.defines = []
|
||||
mock_core.config_hash = 0xDEADBEEF
|
||||
mock_core.target_platform = "test_platform"
|
||||
mock_core.config = {}
|
||||
mock_iter_components.return_value = [("core", mock_component)]
|
||||
mock_walk_files.return_value = []
|
||||
|
||||
# Create mock module without copy_files attribute (causes AttributeError which is caught)
|
||||
mock_module = MagicMock(spec=[]) # Empty spec = no copy_files attribute
|
||||
|
||||
with (
|
||||
patch("esphome.writer.__version__", "2025.1.0-dev"),
|
||||
patch("esphome.writer.importlib.import_module", return_value=mock_module),
|
||||
):
|
||||
copy_src_tree()
|
||||
|
||||
# Verify build_info_data.h was written
|
||||
build_info_h_path = esphome_core_path / "build_info_data.h"
|
||||
assert build_info_h_path.exists()
|
||||
build_info_h_content = build_info_h_path.read_text()
|
||||
assert "#define ESPHOME_CONFIG_HASH 0xdeadbeefU" in build_info_h_content
|
||||
assert "#define ESPHOME_BUILD_TIME" in build_info_h_content
|
||||
assert "ESPHOME_BUILD_TIME_STR" in build_info_h_content
|
||||
|
||||
# Verify build_info.json was written
|
||||
build_info_json_path = build_path / "build_info.json"
|
||||
assert build_info_json_path.exists()
|
||||
build_info_json = json.loads(build_info_json_path.read_text())
|
||||
assert build_info_json["config_hash"] == 0xDEADBEEF
|
||||
assert "build_time" in build_info_json
|
||||
assert "build_time_str" in build_info_json
|
||||
assert build_info_json["esphome_version"] == "2025.1.0-dev"
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
@patch("esphome.writer.iter_components")
|
||||
@patch("esphome.writer.walk_files")
|
||||
def test_copy_src_tree_detects_config_hash_change(
|
||||
mock_walk_files: MagicMock,
|
||||
mock_iter_components: MagicMock,
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test copy_src_tree detects when config_hash changes."""
|
||||
# Setup directory structure
|
||||
src_path = tmp_path / "src"
|
||||
src_path.mkdir()
|
||||
esphome_core_path = src_path / "esphome" / "core"
|
||||
esphome_core_path.mkdir(parents=True)
|
||||
build_path = tmp_path / "build"
|
||||
build_path.mkdir()
|
||||
|
||||
# Create existing build_info.json with different config_hash
|
||||
build_info_json_path = build_path / "build_info.json"
|
||||
build_info_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config_hash": 0x12345678, # Different from current
|
||||
"build_time": 1700000000,
|
||||
"build_time_str": "2023-11-14 22:13:20 +0000",
|
||||
"esphome_version": "2025.1.0-dev",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Create existing build_info_data.h
|
||||
build_info_h_path = esphome_core_path / "build_info_data.h"
|
||||
build_info_h_path.write_text("// old build_info_data.h")
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
|
||||
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
|
||||
mock_core.defines = []
|
||||
mock_core.config_hash = 0xDEADBEEF # Different from existing
|
||||
mock_core.target_platform = "test_platform"
|
||||
mock_core.config = {}
|
||||
mock_iter_components.return_value = []
|
||||
mock_walk_files.return_value = []
|
||||
|
||||
with (
|
||||
patch("esphome.writer.__version__", "2025.1.0-dev"),
|
||||
patch("esphome.writer.importlib.import_module") as mock_import,
|
||||
):
|
||||
mock_import.side_effect = AttributeError
|
||||
copy_src_tree()
|
||||
|
||||
# Verify build_info files were updated due to config_hash change
|
||||
assert build_info_h_path.exists()
|
||||
new_content = build_info_h_path.read_text()
|
||||
assert "0xdeadbeef" in new_content.lower()
|
||||
|
||||
new_json = json.loads(build_info_json_path.read_text())
|
||||
assert new_json["config_hash"] == 0xDEADBEEF
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
@patch("esphome.writer.iter_components")
|
||||
@patch("esphome.writer.walk_files")
|
||||
def test_copy_src_tree_detects_version_change(
|
||||
mock_walk_files: MagicMock,
|
||||
mock_iter_components: MagicMock,
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test copy_src_tree detects when esphome_version changes."""
|
||||
# Setup directory structure
|
||||
src_path = tmp_path / "src"
|
||||
src_path.mkdir()
|
||||
esphome_core_path = src_path / "esphome" / "core"
|
||||
esphome_core_path.mkdir(parents=True)
|
||||
build_path = tmp_path / "build"
|
||||
build_path.mkdir()
|
||||
|
||||
# Create existing build_info.json with different version
|
||||
build_info_json_path = build_path / "build_info.json"
|
||||
build_info_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config_hash": 0xDEADBEEF,
|
||||
"build_time": 1700000000,
|
||||
"build_time_str": "2023-11-14 22:13:20 +0000",
|
||||
"esphome_version": "2024.12.0", # Old version
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Create existing build_info_data.h
|
||||
build_info_h_path = esphome_core_path / "build_info_data.h"
|
||||
build_info_h_path.write_text("// old build_info_data.h")
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
|
||||
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
|
||||
mock_core.defines = []
|
||||
mock_core.config_hash = 0xDEADBEEF
|
||||
mock_core.target_platform = "test_platform"
|
||||
mock_core.config = {}
|
||||
mock_iter_components.return_value = []
|
||||
mock_walk_files.return_value = []
|
||||
|
||||
with (
|
||||
patch("esphome.writer.__version__", "2025.1.0-dev"), # New version
|
||||
patch("esphome.writer.importlib.import_module") as mock_import,
|
||||
):
|
||||
mock_import.side_effect = AttributeError
|
||||
copy_src_tree()
|
||||
|
||||
# Verify build_info files were updated due to version change
|
||||
assert build_info_h_path.exists()
|
||||
new_json = json.loads(build_info_json_path.read_text())
|
||||
assert new_json["esphome_version"] == "2025.1.0-dev"
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
@patch("esphome.writer.iter_components")
|
||||
@patch("esphome.writer.walk_files")
|
||||
def test_copy_src_tree_handles_invalid_build_info_json(
|
||||
mock_walk_files: MagicMock,
|
||||
mock_iter_components: MagicMock,
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test copy_src_tree handles invalid build_info.json gracefully."""
|
||||
# Setup directory structure
|
||||
src_path = tmp_path / "src"
|
||||
src_path.mkdir()
|
||||
esphome_core_path = src_path / "esphome" / "core"
|
||||
esphome_core_path.mkdir(parents=True)
|
||||
build_path = tmp_path / "build"
|
||||
build_path.mkdir()
|
||||
|
||||
# Create invalid build_info.json
|
||||
build_info_json_path = build_path / "build_info.json"
|
||||
build_info_json_path.write_text("invalid json {{{")
|
||||
|
||||
# Create existing build_info_data.h
|
||||
build_info_h_path = esphome_core_path / "build_info_data.h"
|
||||
build_info_h_path.write_text("// old build_info_data.h")
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
|
||||
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
|
||||
mock_core.defines = []
|
||||
mock_core.config_hash = 0xDEADBEEF
|
||||
mock_core.target_platform = "test_platform"
|
||||
mock_core.config = {}
|
||||
mock_iter_components.return_value = []
|
||||
mock_walk_files.return_value = []
|
||||
|
||||
with (
|
||||
patch("esphome.writer.__version__", "2025.1.0-dev"),
|
||||
patch("esphome.writer.importlib.import_module") as mock_import,
|
||||
):
|
||||
mock_import.side_effect = AttributeError
|
||||
copy_src_tree()
|
||||
|
||||
# Verify build_info files were created despite invalid JSON
|
||||
assert build_info_h_path.exists()
|
||||
new_json = json.loads(build_info_json_path.read_text())
|
||||
assert new_json["config_hash"] == 0xDEADBEEF
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
@patch("esphome.writer.iter_components")
|
||||
@patch("esphome.writer.walk_files")
|
||||
def test_copy_src_tree_build_info_timestamp_behavior(
|
||||
mock_walk_files: MagicMock,
|
||||
mock_iter_components: MagicMock,
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test build_info behaviour: regenerated on change, preserved when unchanged."""
|
||||
# Setup directory structure
|
||||
src_path = tmp_path / "src"
|
||||
src_path.mkdir()
|
||||
esphome_core_path = src_path / "esphome" / "core"
|
||||
esphome_core_path.mkdir(parents=True)
|
||||
esphome_components_path = src_path / "esphome" / "components"
|
||||
esphome_components_path.mkdir(parents=True)
|
||||
build_path = tmp_path / "build"
|
||||
build_path.mkdir()
|
||||
|
||||
# Create a source file
|
||||
source_file = tmp_path / "source" / "test.cpp"
|
||||
source_file.parent.mkdir()
|
||||
source_file.write_text("// version 1")
|
||||
|
||||
# Create destination file in build tree
|
||||
dest_file = esphome_components_path / "test.cpp"
|
||||
|
||||
# Create mock FileResource
|
||||
@dataclass(frozen=True)
|
||||
class MockFileResource:
|
||||
package: str
|
||||
resource: str
|
||||
_path: Path
|
||||
|
||||
@contextmanager
|
||||
def path(self):
|
||||
yield self._path
|
||||
|
||||
mock_resources = [
|
||||
MockFileResource(
|
||||
package="esphome.components",
|
||||
resource="test.cpp",
|
||||
_path=source_file,
|
||||
),
|
||||
]
|
||||
|
||||
mock_component = MagicMock()
|
||||
mock_component.resources = mock_resources
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
|
||||
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
|
||||
mock_core.defines = []
|
||||
mock_core.config_hash = 0xDEADBEEF
|
||||
mock_core.target_platform = "test_platform"
|
||||
mock_core.config = {}
|
||||
mock_iter_components.return_value = [("test", mock_component)]
|
||||
|
||||
build_info_json_path = build_path / "build_info.json"
|
||||
|
||||
# First run: initial setup, should create build_info
|
||||
mock_walk_files.return_value = []
|
||||
with (
|
||||
patch("esphome.writer.__version__", "2025.1.0-dev"),
|
||||
patch("esphome.writer.importlib.import_module") as mock_import,
|
||||
):
|
||||
mock_import.side_effect = AttributeError
|
||||
copy_src_tree()
|
||||
|
||||
# Manually set an old timestamp for testing
|
||||
old_timestamp = 1700000000
|
||||
old_timestamp_str = "2023-11-14 22:13:20 +0000"
|
||||
build_info_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config_hash": 0xDEADBEEF,
|
||||
"build_time": old_timestamp,
|
||||
"build_time_str": old_timestamp_str,
|
||||
"esphome_version": "2025.1.0-dev",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
# Second run: no changes, should NOT regenerate build_info
|
||||
mock_walk_files.return_value = [str(dest_file)]
|
||||
with (
|
||||
patch("esphome.writer.__version__", "2025.1.0-dev"),
|
||||
patch("esphome.writer.importlib.import_module") as mock_import,
|
||||
):
|
||||
mock_import.side_effect = AttributeError
|
||||
copy_src_tree()
|
||||
|
||||
second_json = json.loads(build_info_json_path.read_text())
|
||||
second_timestamp = second_json["build_time"]
|
||||
|
||||
# Verify timestamp was NOT changed
|
||||
assert second_timestamp == old_timestamp, (
|
||||
f"build_info should not be regenerated when no files change: "
|
||||
f"{old_timestamp} != {second_timestamp}"
|
||||
)
|
||||
|
||||
# Third run: change source file, should regenerate build_info with new timestamp
|
||||
source_file.write_text("// version 2")
|
||||
with (
|
||||
patch("esphome.writer.__version__", "2025.1.0-dev"),
|
||||
patch("esphome.writer.importlib.import_module") as mock_import,
|
||||
):
|
||||
mock_import.side_effect = AttributeError
|
||||
copy_src_tree()
|
||||
|
||||
third_json = json.loads(build_info_json_path.read_text())
|
||||
third_timestamp = third_json["build_time"]
|
||||
|
||||
# Verify timestamp WAS changed
|
||||
assert third_timestamp != old_timestamp, (
|
||||
f"build_info should be regenerated when source file changes: "
|
||||
f"{old_timestamp} == {third_timestamp}"
|
||||
)
|
||||
assert third_timestamp > old_timestamp
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
@patch("esphome.writer.iter_components")
|
||||
@patch("esphome.writer.walk_files")
|
||||
def test_copy_src_tree_detects_removed_source_file(
|
||||
mock_walk_files: MagicMock,
|
||||
mock_iter_components: MagicMock,
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test copy_src_tree detects when a non-generated source file is removed."""
|
||||
# Setup directory structure
|
||||
src_path = tmp_path / "src"
|
||||
src_path.mkdir()
|
||||
esphome_components_path = src_path / "esphome" / "components"
|
||||
esphome_components_path.mkdir(parents=True)
|
||||
build_path = tmp_path / "build"
|
||||
build_path.mkdir()
|
||||
|
||||
# Create an existing source file in the build tree
|
||||
existing_file = esphome_components_path / "test.cpp"
|
||||
existing_file.write_text("// test file")
|
||||
|
||||
# Setup mocks - no components, so the file should be removed
|
||||
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
|
||||
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
|
||||
mock_core.defines = []
|
||||
mock_core.config_hash = 0xDEADBEEF
|
||||
mock_core.target_platform = "test_platform"
|
||||
mock_core.config = {}
|
||||
mock_iter_components.return_value = [] # No components = file should be removed
|
||||
mock_walk_files.return_value = [str(existing_file)]
|
||||
|
||||
# Create existing build_info.json
|
||||
build_info_json_path = build_path / "build_info.json"
|
||||
old_timestamp = 1700000000
|
||||
build_info_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config_hash": 0xDEADBEEF,
|
||||
"build_time": old_timestamp,
|
||||
"build_time_str": "2023-11-14 22:13:20 +0000",
|
||||
"esphome_version": "2025.1.0-dev",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("esphome.writer.__version__", "2025.1.0-dev"),
|
||||
patch("esphome.writer.importlib.import_module") as mock_import,
|
||||
):
|
||||
mock_import.side_effect = AttributeError
|
||||
copy_src_tree()
|
||||
|
||||
# Verify file was removed
|
||||
assert not existing_file.exists()
|
||||
|
||||
# Verify build_info was regenerated due to source file removal
|
||||
new_json = json.loads(build_info_json_path.read_text())
|
||||
assert new_json["build_time"] != old_timestamp
|
||||
|
||||
|
||||
@patch("esphome.writer.CORE")
|
||||
@patch("esphome.writer.iter_components")
|
||||
@patch("esphome.writer.walk_files")
|
||||
def test_copy_src_tree_ignores_removed_generated_file(
|
||||
mock_walk_files: MagicMock,
|
||||
mock_iter_components: MagicMock,
|
||||
mock_core: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test copy_src_tree doesn't mark sources_changed when only generated file removed."""
|
||||
# Setup directory structure
|
||||
src_path = tmp_path / "src"
|
||||
src_path.mkdir()
|
||||
esphome_core_path = src_path / "esphome" / "core"
|
||||
esphome_core_path.mkdir(parents=True)
|
||||
build_path = tmp_path / "build"
|
||||
build_path.mkdir()
|
||||
|
||||
# Create existing build_info_data.h (a generated file)
|
||||
build_info_h = esphome_core_path / "build_info_data.h"
|
||||
build_info_h.write_text("// old generated file")
|
||||
|
||||
# Setup mocks
|
||||
mock_core.relative_src_path.side_effect = lambda *args: src_path.joinpath(*args)
|
||||
mock_core.relative_build_path.side_effect = lambda *args: build_path.joinpath(*args)
|
||||
mock_core.defines = []
|
||||
mock_core.config_hash = 0xDEADBEEF
|
||||
mock_core.target_platform = "test_platform"
|
||||
mock_core.config = {}
|
||||
mock_iter_components.return_value = []
|
||||
# walk_files returns the generated file, but it's not in source_files_copy
|
||||
mock_walk_files.return_value = [str(build_info_h)]
|
||||
|
||||
# Create existing build_info.json with old timestamp
|
||||
build_info_json_path = build_path / "build_info.json"
|
||||
old_timestamp = 1700000000
|
||||
build_info_json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"config_hash": 0xDEADBEEF,
|
||||
"build_time": old_timestamp,
|
||||
"build_time_str": "2023-11-14 22:13:20 +0000",
|
||||
"esphome_version": "2025.1.0-dev",
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
with (
|
||||
patch("esphome.writer.__version__", "2025.1.0-dev"),
|
||||
patch("esphome.writer.importlib.import_module") as mock_import,
|
||||
):
|
||||
mock_import.side_effect = AttributeError
|
||||
copy_src_tree()
|
||||
|
||||
# Verify build_info_data.h was regenerated (not removed)
|
||||
assert build_info_h.exists()
|
||||
|
||||
# Note: build_info.json will have a new timestamp because get_build_info()
|
||||
# always returns current time. The key test is that the old build_info_data.h
|
||||
# file was removed and regenerated, not that it triggered sources_changed.
|
||||
new_json = json.loads(build_info_json_path.read_text())
|
||||
assert new_json["config_hash"] == 0xDEADBEEF
|
||||
|
||||
@@ -278,3 +278,31 @@ def test_secret_values_tracking(fixture_path: Path) -> None:
|
||||
assert yaml_util._SECRET_VALUES["super_secret_wifi"] == "wifi_password"
|
||||
assert "0123456789abcdef" in yaml_util._SECRET_VALUES
|
||||
assert yaml_util._SECRET_VALUES["0123456789abcdef"] == "api_key"
|
||||
|
||||
|
||||
def test_dump_sort_keys() -> None:
|
||||
"""Test that dump with sort_keys=True produces sorted output."""
|
||||
# Create a dict with unsorted keys
|
||||
data = {
|
||||
"zebra": 1,
|
||||
"alpha": 2,
|
||||
"nested": {
|
||||
"z_key": "z_value",
|
||||
"a_key": "a_value",
|
||||
},
|
||||
}
|
||||
|
||||
# Without sort_keys, keys are in insertion order
|
||||
unsorted = yaml_util.dump(data, sort_keys=False)
|
||||
lines_unsorted = unsorted.strip().split("\n")
|
||||
# First key should be "zebra" (insertion order)
|
||||
assert lines_unsorted[0].startswith("zebra:")
|
||||
|
||||
# With sort_keys, keys are alphabetically sorted
|
||||
sorted_dump = yaml_util.dump(data, sort_keys=True)
|
||||
lines_sorted = sorted_dump.strip().split("\n")
|
||||
# First key should be "alpha" (alphabetical order)
|
||||
assert lines_sorted[0].startswith("alpha:")
|
||||
# nested keys should also be sorted
|
||||
assert "a_key:" in sorted_dump
|
||||
assert sorted_dump.index("a_key:") < sorted_dump.index("z_key:")
|
||||
|
||||
Reference in New Issue
Block a user