Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
44fcd53
docs: add design spec for HA test-harness migration
rhammen Jul 25, 2026
1143bf1
test: adopt pytest-homeassistant-custom-component harness with Window…
rhammen Jul 25, 2026
8cd0c0c
test: prevent zaptec_constants fixture from clobbering HA event-loop …
rhammen Jul 25, 2026
6eb68ac
test: add real-harness setup fixtures (mock Zaptec client + MockConfi…
rhammen Jul 25, 2026
bcdb186
test: behavior coverage for ZaptecUpdateCoordinator via real harness
rhammen Jul 25, 2026
cedc13b
test: behavior coverage for ZaptecBaseEntity; xfail documents #410
rhammen Jul 25, 2026
b74943c
test: remove temporary harness smoke test, cover coordinator/entity gaps
rhammen Jul 25, 2026
911d59b
test: document _backed_get MISSING-default/no-normalization divergence
rhammen Jul 25, 2026
0ecebab
test: assert correct entity-availability behavior (#410 is not a bug)
rhammen Jul 25, 2026
650e888
test: unpin pytest-hacc so CI 3.13 matrix leg can install
rhammen Jul 25, 2026
ffa2f78
test: pin pytest-hacc per-Python via markers to match reverted HA on …
rhammen Jul 25, 2026
5475a3b
test: decouple test-job install from requirements.txt (pytest-hacc ow…
rhammen Jul 25, 2026
221406a
test: make requirements.txt + pytest-hacc coexist (relax pydantic pin)
rhammen Jul 25, 2026
06c3eeb
docs: rework HA-harness migration to Linux-native + Option C (#257)
rhammen Jul 26, 2026
95b0800
test: drop committed Windows shim; rely on Linux pytest-hacc autoload
rhammen Jul 26, 2026
70ed43f
ci: run harness + API-client tests as two scoped pytest invocations
rhammen Jul 26, 2026
a77a9b7
test: scripts/test runs harness + API-client invocations, combined co…
rhammen Jul 26, 2026
3ba66e8
docs: explain two-invocation test split (harness vs API-client, #257)
rhammen Jul 26, 2026
6550f73
test: normalize keys in _backed_get via to_under, matching ZaptecBase…
rhammen Jul 26, 2026
b8f212a
test: pin charging-interval test to named constants, keep the relatio…
rhammen Jul 26, 2026
057af4b
test: explain non-obvious white-box test rationale, cover 4th log-for…
rhammen Jul 26, 2026
0b79410
test: explain the mock-name coupling behind test_init.py's entity filter
rhammen Jul 26, 2026
7611e67
test: explain three non-obvious setup choices in trigger_poll/constru…
rhammen Jul 26, 2026
6858e82
test: explain four non-obvious fixture/mock choices in conftest.py
rhammen Jul 26, 2026
4361b0a
docs: remove planning docs (archived to docs/ai-planning-archive)
rhammen Jul 26, 2026
5d9cdaf
merge: pull in upstream #413 (redaction) and #408 (AI policy)
rhammen Jul 26, 2026
9464908
test: drop in-code issue references flagged in PR #414 review
rhammen Jul 26, 2026
e3a7c68
test: split zaptec_constants into tests/zaptec/conftest.py
rhammen Jul 27, 2026
01d997d
test: trim redundant comment block in requirements_test.txt
rhammen Jul 27, 2026
55a5ed1
test: trim wordy docstrings/comments flagged in PR #414 review
rhammen Jul 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .github/workflows/validate.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,11 @@ jobs:
-r requirements.txt \
-r requirements_test.txt

- name: Tests suite
- name: Tests suite (HA integration — harness)
run: |
pytest --cov=./custom_components/zaptec --cov-branch
pytest tests --ignore=tests/zaptec --cov=./custom_components/zaptec --cov-branch

- name: Tests suite (API client — plain pytest, no harness)
run: |
pytest tests/zaptec -p no:homeassistant --cov=./custom_components/zaptec --cov-branch --cov-append

18 changes: 18 additions & 0 deletions DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,24 @@ To run tests and check test coverage:
report, or enable the "Coverage Gutters" extension to view the coverage
directly in VSCode.

The suite runs as **two pytest invocations**, and `./scripts/test` runs both:

- **HA-integration tests** (`tests/test_*.py`) run under the
`pytest-homeassistant-custom-component` harness, which autoloads on Linux.
Run directly with:
`pytest tests --ignore=tests/zaptec --cov=./custom_components/zaptec --cov-branch`
- **API-client tests** (`tests/zaptec/*`) test the vendored `zaptec/` client,
which is destined to become a standalone PyPI library (issue #257) and has no
Home Assistant dependency. They run as plain pytest with the harness disabled
(the harness blocks non-localhost sockets, which would break their live
`api.zaptec.com/api/constants` call):
`pytest tests/zaptec -p no:homeassistant --cov=./custom_components/zaptec --cov-branch --cov-append`

Because the harness (and its socket block) is process-wide, a bare `pytest`
is not the entry point — use `./scripts/test` or the two commands above. The
HA-integration tests require Linux; run them in the Dev Container (native
Windows is not supported for that half). `tests/zaptec/*` run anywhere.

HA requires [95% coverage](https://developers.home-assistant.io/docs/core/integration-quality-scale/rules/test-coverage/)
for all core integration modules, and while HACS doesn't have the same
requirements, reaching this level is still a goal for this integration.
Expand Down
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,5 @@ ruff==0.15.22
# Copy from manifest.json to get this into the dev container
# without needing to start HA
azure-servicebus==7.14.3
pydantic==2.13.1
pydantic>=2.11.7,<2.14
Comment thread
rhammen marked this conversation as resolved.
aiolimiter==1.2.1
4 changes: 3 additions & 1 deletion requirements_test.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
pytest
pytest-asyncio
pytest-mock
pytest-cov
pytest-cov
pytest-homeassistant-custom-component==0.13.324; python_version >= "3.14"
pytest-homeassistant-custom-component==0.13.316; python_version < "3.14"
8 changes: 7 additions & 1 deletion scripts/test
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,14 @@ set -e
if [ "$1" == "--skip-api" ]; then
export SKIP_ZAPTEC_API_TEST="true"
fi

# HA-integration tests run under the pytest-hacc harness (autoloads on Linux).
# API-client tests (tests/zaptec/*) run as plain pytest with the harness
# disabled, so their live constants call is not socket-blocked. Coverage
# from both is combined via --cov-append.
# run tests with -s to display printouts and --log-cli-level to get logger output
pytest --cov=./custom_components/zaptec --cov-branch --log-cli-level=INFO -s
pytest tests --ignore=tests/zaptec --cov=./custom_components/zaptec --cov-branch --log-cli-level=INFO -s
pytest tests/zaptec -p no:homeassistant --cov=./custom_components/zaptec --cov-branch --cov-append --log-cli-level=INFO -s

# generate coverage report in html and xml
coverage html
Expand Down
148 changes: 126 additions & 22 deletions tests/conftest.py
Comment thread
rhammen marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
"""Zaptec testing configuration file."""

import asyncio
from collections.abc import Callable, Iterable
import os
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch

from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
import pytest
from pytest_homeassistant_custom_component.common import MockConfigEntry

from custom_components.zaptec.const import DOMAIN
from custom_components.zaptec.manager import ZaptecManager
from custom_components.zaptec.zaptec import MISSING, Charger, Installation
from custom_components.zaptec.zaptec.api import Zaptec
from custom_components.zaptec.zaptec.utils import to_under


@pytest.fixture(scope="session")
Expand All @@ -24,12 +33,7 @@ def skip_if_user_disabled_api_tests() -> None:

@pytest.fixture(scope="session")
def zaptec_username(skip_if_user_disabled_api_tests, skip_if_in_github_actions) -> str: # noqa: ANN001 (the inputs are purely to create dependencies to the env-flags above)
"""
Get the zaptec username stored in env.

Any test relying on this fixture will be skipped if the test is running
in Gihub Actions, or the user has disabled tests requiring API login.
"""
"""Get the zaptec username from env, skipping if API-login tests are disabled."""
username = os.environ.get("ZAPTEC_USERNAME")
assert username, (
"Missing username, either set it with \"export ZAPTEC_USERNAME='username'\" "
Expand All @@ -40,12 +44,7 @@ def zaptec_username(skip_if_user_disabled_api_tests, skip_if_in_github_actions)

@pytest.fixture(scope="session")
def zaptec_password(skip_if_user_disabled_api_tests, skip_if_in_github_actions) -> str: # noqa: ANN001
"""
Get the zaptec password stored in env.

Any test relying on this fixture will be skipped if the test is running
in Gihub Actions, or the user has disabled tests requiring API login.
"""
"""Get the zaptec password from env, skipping if API-login tests are disabled."""
password = os.environ.get("ZAPTEC_PASSWORD")
assert password, (
"Missing password, either set it with \"export ZAPTEC_PASSWORD='password'\" "
Expand All @@ -54,14 +53,119 @@ def zaptec_password(skip_if_user_disabled_api_tests, skip_if_in_github_actions)
return password


@pytest.fixture(scope="session")
def zaptec_constants() -> dict:
"""Get latest constants from Zaptec API."""
def _backed_get(data: dict[str, Any]) -> Callable[..., Any]:
"""Return a `.get(key, default=MISSING)` implementation backed by `data`.

Mirrors `ZaptecBase.__getitem__`'s key normalization (`to_under`); defaults
to `MISSING` rather than `Mapping.get`'s `None` since every real call site
(`entity.py`'s `_get_zaptec_value`) passes `default=MISSING` explicitly.
"""

def _get(key: str, default: Any = MISSING) -> Any:
return data.get(to_under(key), default)

async def get_zaptec_constants() -> dict:
async with Zaptec("N/A", "N/A") as zaptec:
# the constants API endpoint does not require login
const: dict = await zaptec.request("constants")
return const
return _get

return asyncio.run(get_zaptec_constants())

def make_charger(
data: dict[str, Any], *, installation: MagicMock | None = None, charging: bool = False
) -> MagicMock:
"""Build a spec'd Charger double backed by `data`.

`model` is hardcoded rather than modeling `Charger.model`'s real
`ZCONST.serial_to_model` lookup — a deliberate simplification.
"""
charger = MagicMock(spec=Charger)
charger.id = data["id"]
charger.name = data.get("name", "Mock Charger")
charger.model = "Zaptec Charger"
charger.qual_id = f"Charger[{data['id'][-6:]}]"
charger.get.side_effect = _backed_get(data)
charger.is_charging.return_value = charging
charger.installation = installation
return charger


def make_installation(data: dict[str, Any], *, chargers: Iterable[MagicMock] = ()) -> MagicMock:
"""Build a spec'd Installation double backed by `data`."""
install = MagicMock(spec=Installation)
install.id = data["id"]
install.name = data.get("name", "Mock Installation")
install.model = "Zaptec Installation"
install.qual_id = f"Installation[{data['id'][-6:]}]"
install.get.side_effect = _backed_get(data)
install.chargers = list(chargers)
install.stream_main = AsyncMock(return_value=None)
install.stream_close = AsyncMock(return_value=None)
return install


@pytest.fixture
def mock_zaptec() -> MagicMock:
"""A spec'd Zaptec client seeded with one installation and one charger.

`__getitem__`/`__iter__`/`__contains__`/`__len__` are wired because `Zaptec`
is itself `Mapping[str, ZaptecBase]` in production, and real code (e.g.
`zaptec[deviceid]` in `__init__.py`/`coordinator.py`) indexes into it directly.
"""
installation = make_installation({"id": "inst-mock-1", "name": "Mock Home"})
charger = make_charger(
{
"id": "chg-mock-1",
"name": "Mock Charger",
# Keys read by entities under test; extend as needed for coverage.
"operating_mode": "Connected",
"charger_operation_mode": "Connected",
},
installation=installation,
charging=False,
)
installation.chargers = [charger]

objects = {installation.id: installation, charger.id: charger}

zaptec = MagicMock(spec=Zaptec)
zaptec.__getitem__.side_effect = objects.__getitem__
zaptec.__iter__.side_effect = lambda: iter(objects)
zaptec.__contains__.side_effect = objects.__contains__
zaptec.__len__.side_effect = lambda: len(objects)
zaptec.objects.return_value = list(objects.values())
zaptec.installations = [installation]
zaptec.chargers = [charger]
zaptec.login = AsyncMock(return_value=None)
zaptec.build = AsyncMock(return_value=None)
zaptec.poll = AsyncMock(return_value=None)
zaptec.show_all_updates = False
zaptec.redact = MagicMock()
# Load-bearing, not incidental: __init__.py's startup debug-dump path does
# `message += manager.zaptec.redact.dumps()`, which setup_integration actually
# exercises. An unconfigured MagicMock here would raise TypeError on the +=.
zaptec.redact.dumps.return_value = ""
return zaptec


@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""A MockConfigEntry for the zaptec domain."""
return MockConfigEntry(
domain=DOMAIN,
title="Mock Zaptec",
data={CONF_USERNAME: "user", CONF_PASSWORD: "pass"},
entry_id="mock_entry_1",
)


async def setup_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_zaptec: MagicMock
) -> ZaptecManager:
"""Set the integration up through the real async_setup, with a mocked client.

Patches `custom_components.zaptec.Zaptec` — where `__init__.py` looks the name
up, per unittest.mock's patch-at-the-lookup rule — not the original definition
in `zaptec/api.py`, which `__init__.py`'s own import wouldn't see patched.
"""
mock_config_entry.add_to_hass(hass)
with patch("custom_components.zaptec.Zaptec", return_value=mock_zaptec):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry.runtime_data
Loading
Loading