import os import subprocess from pathlib import Path from types import SimpleNamespace from typing import Optional, cast from unittest.mock import AsyncMock, Mock import pytest import yaml from pydantic import ValidationError import akkudoktoreos.adapter.homeassistant as homeassistant_module from akkudoktoreos.adapter.homeassistant import HomeAssistantAdapter from akkudoktoreos.core.ems import EnergyManagementStage class TestHomeAssistantAddon: """Tests to ensure the repository root is a valid Home Assistant add-on. Simulates the Home Assistant Supervisor's expectations. """ @property def root(self): """Repository root (repo == addon).""" return Path(__file__).resolve().parent.parent def test_config_yaml_exists(self): """Ensure config.yaml exists in the repo root.""" cfg_path = self.root / "config.yaml" assert cfg_path.is_file(), "config.yaml must exist in repository root." def test_config_yaml_loadable(self): """Verify that config.yaml parses and contains required fields.""" cfg_path = self.root / "config.yaml" with open(cfg_path) as f: cfg = yaml.safe_load(f) required_fields = ["name", "version", "slug", "description", "arch"] for field in required_fields: assert field in cfg, f"Missing required field '{field}' in config.yaml." # Additional validation assert isinstance(cfg["arch"], list), "arch must be a list" assert len(cfg["arch"]) > 0, "arch list cannot be empty" print(f"✓ config.yaml valid:") print(f" Name: {cfg['name']}") print(f" Version: {cfg['version']}") print(f" Slug: {cfg['slug']}") print(f" Architectures: {', '.join(cfg['arch'])}") def test_readme_exists(self): """Ensure README.md exists and is not empty.""" readme_path = self.root / "README.md" assert readme_path.is_file(), "README.md must exist in the repository root." content = readme_path.read_text(encoding="utf-8") assert len(content.strip()) > 0, "README.md is empty" print(f"✓ README.md exists ({len(content)} bytes)") def test_docs_md_exists(self): """Ensure DOCS.md exists in the repo root (for Home Assistant add-on documentation).""" docs_path = self.root / "DOCS.md" assert docs_path.is_file(), "DOCS.md must exist in the repository root for add-on documentation." content = docs_path.read_text(encoding="utf-8") assert len(content.strip()) > 0, "DOCS.md is empty" print(f"✓ DOCS.md exists ({len(content)} bytes)") @pytest.mark.docker def test_dockerfile_exists(self): """Ensure Dockerfile exists in the repo root and has basic structure.""" dockerfile = self.root / "Dockerfile" assert dockerfile.is_file(), "Dockerfile must exist in repository root." content = dockerfile.read_text() # Check for FROM statement assert "FROM" in content, "Dockerfile must contain FROM statement" # Check for common add-on patterns if "ARG BUILD_FROM" in content: print("✓ Dockerfile uses Home Assistant build args") print("✓ Dockerfile exists and has valid structure") @pytest.mark.docker def test_docker_build_context_valid(self): """Runs a Docker build using the root of the repo as Home Assistant supervisor would. Fails if the build context is invalid or Dockerfile has syntax errors. """ # Check if Docker is available try: subprocess.run( ["docker", "--version"], capture_output=True, check=True ) except (FileNotFoundError, subprocess.CalledProcessError): pytest.skip("Docker not found or not running") cmd = [ "docker", "build", "-t", "ha-addon-test:latest", str(self.root), ] print(f"\nBuilding Docker image from: {self.root}") try: result = subprocess.run( cmd, check=True, capture_output=True, text=True, cwd=str(self.root) ) print("✓ Docker build successful") if result.stdout: print("\nBuild output (last 20 lines):") print('\n'.join(result.stdout.splitlines()[-20:])) except subprocess.CalledProcessError as e: print("\n✗ Docker build failed") print("\nSTDOUT:") print(e.stdout) print("\nSTDERR:") print(e.stderr) pytest.fail( f"Docker build failed with exit code {e.returncode}. " "This simulates a Supervisor build failure." ) @pytest.mark.docker def test_addon_builder_validation(self, is_finalize: bool): """Validate add-on can be built using Home Assistant's builder tool. This is the closest to what Supervisor does when installing an add-on. """ if not is_finalize: pytest.skip("Skipping add-on builder validation test — not full run") # Check if Docker is available try: subprocess.run( ["docker", "--version"], capture_output=True, check=True ) except (FileNotFoundError, subprocess.CalledProcessError): pytest.skip("Docker not found or not running") print(f"\nValidating add-on with builder: {self.root}") # Read config to get architecture info cfg_path = self.root / "config.yaml" with open(cfg_path) as f: cfg = yaml.safe_load(f) # Detect host architecture import platform machine = platform.machine().lower() # Map Python's platform names to Home Assistant architectures arch_map = { "x86_64": "amd64", "amd64": "amd64", "aarch64": "aarch64", "arm64": "aarch64", "armv7l": "armv7", "armv7": "armv7", } host_arch = arch_map.get(machine, "amd64") # Check if config supports this architecture if host_arch not in cfg["arch"]: pytest.skip( f"Add-on doesn't support host architecture {host_arch}. " f"Supported: {', '.join(cfg['arch'])}" ) print(f"Using builder for architecture: {host_arch}") # The builder expects specific arguments for building builder_image = f"ghcr.io/home-assistant/{host_arch}-builder:latest" result = subprocess.run( [ "docker", "run", "--rm", "--privileged", "-v", f"{self.root}:/data", "-v", "/var/run/docker.sock:/var/run/docker.sock", builder_image, "--generic", cfg["version"], "--target", "/data", f"--{host_arch}", "--test" ], capture_output=True, text=True, cwd=str(self.root), check=False, timeout=600 ) # Print output for debugging if result.stdout: print("\nBuilder stdout:") print(result.stdout) if result.stderr: print("\nBuilder stderr:") print(result.stderr) # Check result if result.returncode != 0: # Check if it's just because the builder tool is unavailable if "exec format error" in result.stderr or "not found" in result.stderr: pytest.fail( "Builder tool not compatible with this system." ) pytest.fail( f"Add-on builder validation failed with exit code {result.returncode}" ) print("✓ Add-on builder validation passed") def test_build_yaml_if_exists(self): """If build.yaml exists, validate its structure.""" build_path = self.root / "build.yaml" if not build_path.exists(): pytest.skip("build.yaml not present (optional)") with open(build_path) as f: build_cfg = yaml.safe_load(f) assert "build_from" in build_cfg, "build.yaml must contain 'build_from'" assert isinstance(build_cfg["build_from"], dict), "'build_from' must be a dictionary" print("✓ build.yaml structure valid") print(f" Architectures defined: {', '.join(build_cfg['build_from'].keys())}") def test_addon_configuration_complete(self): """Comprehensive validation of add-on configuration. Checks all required fields and common configuration issues. """ cfg_path = self.root / "config.yaml" with open(cfg_path) as f: cfg = yaml.safe_load(f) # Required top-level fields required_fields = ["name", "version", "slug", "description", "arch"] for field in required_fields: assert field in cfg, f"Missing required field: {field}" # Validate specific fields assert isinstance(cfg["arch"], list), "arch must be a list" assert len(cfg["arch"]) > 0, "arch list cannot be empty" valid_archs = ["aarch64", "amd64", "armhf", "armv7", "i386"] for arch in cfg["arch"]: assert arch in valid_archs, f"Invalid architecture: {arch}" # Validate version format (should be semantic versioning) version = cfg["version"] assert isinstance(version, str), "version must be a string" # Validate slug (lowercase, no special chars except dash) slug = cfg["slug"] assert slug.islower() or "-" in slug, "slug should be lowercase" assert slug.replace("-", "").replace("_", "").isalnum(), \ "slug should only contain alphanumeric characters, dash, or underscore" # Optional but common fields if "startup" in cfg: valid_startup = ["initialize", "system", "services", "application", "once"] assert cfg["startup"] in valid_startup, \ f"Invalid startup value: {cfg['startup']}" if "boot" in cfg: valid_boot = ["auto", "manual"] assert cfg["boot"] in valid_boot, f"Invalid boot value: {cfg['boot']}" # Validate ingress configuration if cfg.get("ingress"): assert "ingress_port" in cfg, "ingress_port required when ingress is enabled" ingress_port = cfg["ingress_port"] assert isinstance(ingress_port, int), "ingress_port must be an integer" assert 1 <= ingress_port <= 65535, "ingress_port must be a valid port number" # The dashboard port is optional and has no host mapping by default. ports = cfg.get("ports", {}) port_key = f"{ingress_port}/tcp" assert port_key in ports, f"Port {ingress_port} must be configurable in 'ports'" assert ports[port_key] is None, "Ingress port must be unpublished by default" # Validate URL if present if "url" in cfg: url = cfg["url"] assert url.startswith("http://") or url.startswith("https://"), \ "URL must start with http:// or https://" # Validate map directories if present if "map" in cfg: assert isinstance(cfg["map"], list), "map must be a list" valid_mappings = ["config", "ssl", "addons", "backup", "share", "media"] for mapping in cfg["map"]: # Handle both "config:rw" and "config" formats base_mapping = mapping.split(":")[0] assert base_mapping in valid_mappings, \ f"Invalid map directory: {base_mapping}" print("✓ Add-on configuration validation passed") print(f" Name: {cfg['name']}") print(f" Version: {cfg['version']}") print(f" Slug: {cfg['slug']}") print(f" Architectures: {', '.join(cfg['arch'])}") if "startup" in cfg: print(f" Startup: {cfg['startup']}") if cfg.get("ingress"): print(f" Ingress: enabled on port {cfg['ingress_port']}") def test_ingress_configuration_consistent(self): """If ingress is enabled, ensure port configuration is correct.""" cfg_path = self.root / "config.yaml" with open(cfg_path) as f: cfg = yaml.safe_load(f) if not cfg.get("ingress"): pytest.skip("Ingress not enabled") # If ingress is enabled, check configuration assert "ingress_port" in cfg, "ingress_port must be specified when ingress is enabled" ingress_port = cfg["ingress_port"] # A null mapping lets users opt in to host publication while preserving ingress. ports = cfg.get("ports", {}) port_key = f"{ingress_port}/tcp" assert port_key in ports, f"Port {ingress_port} must be configurable in 'ports'" assert ports[port_key] is None, "Ingress port must be unpublished by default" print(f"✓ Ingress configuration valid (port {ingress_port})") class _HomeAssistantAdapterHarness: """Provide synthetic adapter dependencies without a Home Assistant instance.""" _update_data = HomeAssistantAdapter._update_data _resolve_device_measurement_key = staticmethod( HomeAssistantAdapter._resolve_device_measurement_key ) def __init__(self, configured_key: str, registered_keys: set[str]) -> None: homeassistant = SimpleNamespace( config_entity_ids=None, device_measurement_entity_ids={configured_key: "sensor.synthetic_battery_soc"}, load_emr_entity_ids=None, grid_export_emr_entity_ids=None, grid_import_emr_entity_ids=None, pv_production_emr_entity_ids=None, ) self.config = SimpleNamespace( adapter=SimpleNamespace(homeassistant=homeassistant), devices=SimpleNamespace(measurement_keys=list(registered_keys)), ) self.ems = SimpleNamespace( stage=lambda: EnergyManagementStage.DATA_ACQUISITION, observation_datetime=object(), ) self.ems_start_datetime = object() self.measurement = SimpleNamespace(update_value=AsyncMock()) self.get_entity_state = lambda _entity_id: "0.75" self._legacy_device_measurement_key_warning_emitted = False class TestHomeAssistantDeviceMeasurementKeys: @pytest.fixture(autouse=True) def cec_databases(self): """This adapter test needs no CEC reference-device data.""" yield None @pytest.mark.asyncio async def test_maps_legacy_key_at_observation_time(self) -> None: adapter = _HomeAssistantAdapterHarness( "battery1_soc_factor", {"battery1-soc-factor"} ) await HomeAssistantAdapter._update_data(cast(HomeAssistantAdapter, adapter)) adapter.measurement.update_value.assert_awaited_once_with( adapter.ems.observation_datetime, "battery1-soc-factor", 0.75 ) assert adapter.ems.observation_datetime is not adapter.ems_start_datetime @pytest.mark.asyncio async def test_warns_only_once_when_mapping_legacy_key(self, monkeypatch) -> None: adapter = _HomeAssistantAdapterHarness( "battery1_soc_factor", {"battery1-soc-factor"} ) mock_logger = Mock() monkeypatch.setattr(homeassistant_module, "logger", mock_logger) await HomeAssistantAdapter._update_data(cast(HomeAssistantAdapter, adapter)) await HomeAssistantAdapter._update_data(cast(HomeAssistantAdapter, adapter)) mock_logger.warning.assert_called_once_with( "Legacy device measurement key '{}' mapped to registered key '{}'.", "battery1_soc_factor", "battery1-soc-factor", ) def test_preserves_canonical_key(self) -> None: assert HomeAssistantAdapter._resolve_device_measurement_key( "battery1-soc-factor", {"battery1-soc-factor"} ) == "battery1-soc-factor" def test_preserves_unknown_custom_key(self) -> None: assert HomeAssistantAdapter._resolve_device_measurement_key( "custom_measurement", {"battery1-soc-factor"} ) == "custom_measurement" def test_does_not_guess_ambiguous_legacy_key(self) -> None: key = "battery_1_soc_factor" registered_keys = {"battery-1-soc-factor", "battery_1-soc-factor"} assert HomeAssistantAdapter._resolve_device_measurement_key(key, registered_keys) == key