Skip to content

Commit dad438a

Browse files
kneaveHermes Agent
andcommitted
feat: add baseline test suite (88 tests)
Test coverage: - validate_node: node input validation (21 tests) - scheduling_logic: time math, active/next node, interpolation (27 tests) - storage_crud: schedules, groups, profiles, settings, history, factory reset (28+ tests) Uses FakeStore mock for storage tests — no Home Assistant installation needed. Also adds: - pyproject.toml with pytest config - GitHub Actions CI workflow (Python 3.11/3.12) - Known issue: async_clear_schedule missing from storage.py Co-authored-by: Hermes Agent <hermes@nousresearch>
1 parent 0bb1258 commit dad438a

8 files changed

Lines changed: 916 additions & 0 deletions

File tree

.github/workflows/tests.yml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
name: Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-latest
12+
strategy:
13+
matrix:
14+
python-version: ["3.11", "3.12"]
15+
16+
steps:
17+
- uses: actions/checkout@v4
18+
19+
- name: Set up Python ${{ matrix.python-version }}
20+
uses: actions/setup-python@v5
21+
with:
22+
python-version: ${{ matrix.python-version }}
23+
24+
- name: Install dependencies
25+
run: |
26+
python -m pip install --upgrade pip
27+
pip install pytest pytest-asyncio voluptuous aiohttp
28+
29+
- name: Run tests
30+
run: pytest tests/ -v

pyproject.toml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[tool.pytest.ini_options]
2+
asyncio_mode = "auto"
3+
testpaths = ["tests"]
4+
python_files = ["test_*.py"]
5+
python_classes = ["Test*"]
6+
python_functions = ["test_*"]

tests/README.md

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Climate Scheduler Tests
2+
3+
## Running
4+
5+
```bash
6+
pip install pytest pytest-asyncio voluptuous aiohttp
7+
pytest
8+
```
9+
10+
No Home Assistant installation required — tests use lightweight mocking.
11+
12+
## Test Structure
13+
14+
| File | Coverage | Approach |
15+
|---|---|---|
16+
| `test_validate_node.py` | Node input validation | Pure function, no mocking |
17+
| `test_scheduling_logic.py` | Time math, active/next node, interpolation | Pure function, no mocking |
18+
| `test_storage_crud.py` | Storage CRUD, groups, profiles, settings | `FakeStore` mock |
19+
20+
## Known Issues Found
21+
22+
- **`async_clear_schedule` missing from storage.py**`services.py:995` calls `storage.async_clear_schedule(entity_id)` but the method doesn't exist on `ScheduleStorage`. This will cause a runtime `AttributeError` when the `clear_schedule` service is invoked.
23+
24+
## Adding HA Integration Tests
25+
26+
The current suite covers pure logic and storage operations without Home Assistant. For full service-handler and coordinator coverage, a Docker-based HA instance can be added later via `pytest-homeassistant-custom-component`.
27+
28+
## CI
29+
30+
A GitHub Actions workflow runs `pytest` on every push and PR (see `.github/workflows/tests.yml`).

tests/__init__.py

Whitespace-only changes.

tests/conftest.py

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
"""Shared test fixtures for Climate Scheduler tests.
2+
3+
We use lightweight mocking instead of the full HA test framework
4+
so these tests can run without a Home Assistant installation.
5+
"""
6+
import asyncio
7+
import json
8+
from datetime import time
9+
from unittest.mock import AsyncMock, MagicMock, patch
10+
import pytest
11+
12+
# ---------------------------------------------------------------------------
13+
# Make the integration importable without HA on sys.path
14+
# ---------------------------------------------------------------------------
15+
import sys
16+
import os
17+
18+
COMP_ROOT = os.path.join(os.path.dirname(__file__), "..", "custom_components")
19+
sys.path.insert(0, os.path.abspath(COMP_ROOT))
20+
21+
# Stub out heavy HA imports that the integration relies on
22+
# so we can run pure-logic and storage tests without installing HA.
23+
_ha_stubs = {
24+
"homeassistant": MagicMock(),
25+
"homeassistant.core": MagicMock(),
26+
"homeassistant.helpers": MagicMock(),
27+
"homeassistant.helpers.storage": MagicMock(),
28+
"homeassistant.helpers.update_coordinator": MagicMock(),
29+
"homeassistant.helpers.typing": MagicMock(),
30+
"homeassistant.helpers.event": MagicMock(),
31+
"homeassistant.config_entries": MagicMock(),
32+
"homeassistant.const": MagicMock(),
33+
"homeassistant.util": MagicMock(),
34+
"homeassistant.util.dt": MagicMock(),
35+
"homeassistant.components": MagicMock(),
36+
"homeassistant.components.http": MagicMock(),
37+
"homeassistant.components.climate": MagicMock(),
38+
}
39+
40+
for mod_name, mod_obj in _ha_stubs.items():
41+
if mod_name not in sys.modules:
42+
sys.modules[mod_name] = mod_obj
43+
44+
# Minimal stubs for constants that the code imports
45+
from custom_components.climate_scheduler.const import (
46+
DOMAIN,
47+
STORAGE_KEY,
48+
STORAGE_VERSION,
49+
MIN_TEMP,
50+
MAX_TEMP,
51+
NO_CHANGE_TEMP,
52+
UPDATE_INTERVAL_SECONDS,
53+
)
54+
55+
56+
# ---------------------------------------------------------------------------
57+
# Fake Store – in-memory replacement for HA's Store
58+
# ---------------------------------------------------------------------------
59+
class FakeStore:
60+
"""In-memory storage stub that replaces homeassistant.helpers.storage.Store."""
61+
62+
def __init__(self, hass, version, key):
63+
self._data = None
64+
self._version = version
65+
self._key = key
66+
67+
async def async_load(self):
68+
return self._data
69+
70+
async def async_save(self, data):
71+
self._data = data
72+
73+
74+
# ---------------------------------------------------------------------------
75+
# Fixtures
76+
# ---------------------------------------------------------------------------
77+
@pytest.fixture
78+
def fake_hass():
79+
"""Minimal fake HomeAssistant object."""
80+
hass = MagicMock()
81+
hass.data = {DOMAIN: {}}
82+
hass.services = MagicMock()
83+
hass.bus = MagicMock()
84+
hass.states = MagicMock()
85+
return hass
86+
87+
88+
@pytest.fixture
89+
def fake_store(fake_hass):
90+
"""Return a FakeStore instance."""
91+
return FakeStore(fake_hass, STORAGE_VERSION, STORAGE_KEY)
92+
93+
94+
@pytest.fixture
95+
def storage(fake_hass, fake_store, monkeypatch):
96+
"""Return a ScheduleStorage wired to FakeStore."""
97+
from custom_components.climate_scheduler.storage import ScheduleStorage
98+
99+
# Patch Store class so ScheduleStorage uses our fake
100+
monkeypatch.setattr(
101+
"custom_components.climate_scheduler.storage.Store",
102+
lambda hass, version, key: fake_store,
103+
)
104+
s = ScheduleStorage(fake_hass)
105+
# Pre-seed empty data so async_load doesn't fail
106+
fake_store._data = {"groups": {}, "settings": {}, "advance_history": {}}
107+
return s
108+
109+
110+
def _make_storage_with_data(fake_hass, fake_store, monkeypatch, data):
111+
"""Helper: return a ScheduleStorage pre-loaded with *data*."""
112+
from custom_components.climate_scheduler.storage import ScheduleStorage
113+
114+
monkeypatch.setattr(
115+
"custom_components.climate_scheduler.storage.Store",
116+
lambda hass, version, key: fake_store,
117+
)
118+
fake_store._data = data
119+
s = ScheduleStorage(fake_hass)
120+
s._data = data # skip async_load
121+
return s

0 commit comments

Comments
 (0)