- custom_components/fglair: manifest (config_flow, local_push, requirements),
const, trial (start -> ONLINE <=10 c -> GET базовых свойств), FglairClient
(сессия pyfglair в executor, колбэки в loop, --runtime_data),
__init__ (setup/unload, рефкаунт глобального лог-пампа)
- config flow: меню cloud/manual/import_json + import-source; discover через
pyfglair.provision; пробное подключение с диагностикой (cannot_connect,
no_slots, key_mismatch, timeout, invalid_auth, invalid_import); unique_id=dsn
- strings.json + translations/{en,ru}.json
- тесты HA (pytest-homeassistant-custom-component): меню/шаги/ошибки/импорт/
облако (discover замокан), setup/unload, занятый порт; trial против
mock_ac.py (online/key_mismatch/unreachable/no_slots)
- pytest.ini (общий), tests/conftest перенесён на уровень tests/,
scripts/py-ci.sh: --setup-ha и прогон tests/components
168 lines
4.8 KiB
Python
168 lines
4.8 KiB
Python
"""Общие фикстуры тестов: сборка ядра, mock-облако, mock-модуль."""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
import pathlib
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import threading
|
||
import time
|
||
|
||
import pytest
|
||
from aiohttp import web
|
||
|
||
ROOT = pathlib.Path(__file__).resolve().parents[1]
|
||
if str(ROOT) not in sys.path:
|
||
sys.path.insert(0, str(ROOT))
|
||
|
||
if "FGL_AIRCON_LIB" not in os.environ:
|
||
from pyfglair._corebuild import build_core
|
||
|
||
os.environ["FGL_AIRCON_LIB"] = str(build_core())
|
||
|
||
MOCK_AC = ROOT / "tests" / "ayla" / "mock_ac.py"
|
||
|
||
LANIP_KEY = "TW9ja0tleUFDbkdvMTIzNDU2Nzg5MDEyMw=="
|
||
KEY_ID = 64201
|
||
DSN = "AC000W00MOCK0001"
|
||
|
||
|
||
def free_port() -> int:
|
||
sock = socket.socket()
|
||
sock.bind(("127.0.0.1", 0))
|
||
port = sock.getsockname()[1]
|
||
sock.close()
|
||
return port
|
||
|
||
|
||
class Proc:
|
||
"""Subprocess с построчным чтением stdout и ожиданием префиксов."""
|
||
|
||
def __init__(self, argv):
|
||
self.lines: list[str] = []
|
||
self.lock = threading.Lock()
|
||
self.event = threading.Event()
|
||
self.proc = subprocess.Popen(
|
||
argv, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
|
||
)
|
||
threading.Thread(target=self._reader, daemon=True).start()
|
||
|
||
def _reader(self) -> None:
|
||
assert self.proc.stdout is not None
|
||
for line in self.proc.stdout:
|
||
with self.lock:
|
||
self.lines.append(line.rstrip("\n"))
|
||
self.event.set()
|
||
|
||
def all_lines(self) -> list[str]:
|
||
with self.lock:
|
||
return list(self.lines)
|
||
|
||
def text(self) -> str:
|
||
return "\n".join(self.all_lines())
|
||
|
||
def wait_line(self, needle: str, timeout: float) -> str | None:
|
||
deadline = time.monotonic() + timeout
|
||
while time.monotonic() < deadline:
|
||
with self.lock:
|
||
for line in self.lines:
|
||
if needle in line:
|
||
return line
|
||
self.event.wait(0.1)
|
||
return None
|
||
|
||
def stop(self) -> None:
|
||
self.proc.terminate()
|
||
try:
|
||
self.proc.wait(timeout=10)
|
||
except subprocess.TimeoutExpired:
|
||
self.proc.kill()
|
||
if self.proc.stdout is not None:
|
||
self.proc.stdout.close()
|
||
|
||
|
||
@pytest.fixture(scope="session")
|
||
def ac_env() -> dict:
|
||
return {"dsn": DSN, "key": LANIP_KEY, "key_id": KEY_ID}
|
||
|
||
|
||
@pytest.fixture
|
||
async def cloud():
|
||
"""Мок облака Ayla: sign_in / devices / lan.json."""
|
||
state = {
|
||
"url": "",
|
||
"fail_login": False,
|
||
"no_key": False,
|
||
"bad_devices": False,
|
||
"bad_lanip": False,
|
||
}
|
||
|
||
async def sign_in(request):
|
||
if state["fail_login"]:
|
||
return web.json_response({"error": "invalid"}, status=401)
|
||
return web.json_response({"access_token": "tok-123"})
|
||
|
||
async def devices(request):
|
||
assert request.headers["Authorization"] == "auth_token tok-123"
|
||
if state["bad_devices"]:
|
||
return web.json_response([{"device": "not-an-object"}])
|
||
return web.json_response([
|
||
{
|
||
"device": {
|
||
"dsn": DSN,
|
||
"product_name": "Bedroom",
|
||
"oem_model": "AP-WC1E",
|
||
"lan_ip": "192.168.1.50",
|
||
"mac": "AA:BB:CC:DD:EE:FF",
|
||
}
|
||
}
|
||
])
|
||
|
||
async def lan(request):
|
||
if state["no_key"]:
|
||
return web.json_response({"lanip": {}})
|
||
if state["bad_lanip"]:
|
||
return web.json_response({"lanip": ["unexpected"]})
|
||
return web.json_response({
|
||
"lanip": {"lanip_key": "TW9ja0tleQ==", "lanip_key_id": KEY_ID}
|
||
})
|
||
|
||
app = web.Application()
|
||
app.router.add_post("/users/sign_in.json", sign_in)
|
||
app.router.add_get("/apiv1/devices.json", devices)
|
||
app.router.add_get("/apiv1/dsns/{dsn}/lan.json", lan)
|
||
runner = web.AppRunner(app)
|
||
await runner.setup()
|
||
site = web.TCPSite(runner, "127.0.0.1", 0)
|
||
await site.start()
|
||
port = site._server.sockets[0].getsockname()[1]
|
||
state["url"] = f"http://127.0.0.1:{port}"
|
||
yield state
|
||
await runner.cleanup()
|
||
|
||
|
||
@pytest.fixture
|
||
def mock_ac():
|
||
procs: list[Proc] = []
|
||
|
||
def start(extra: list[str] | None = None) -> Proc:
|
||
port = free_port()
|
||
proc = Proc([
|
||
sys.executable, str(MOCK_AC),
|
||
"--port", str(port),
|
||
"--lanip-key", LANIP_KEY,
|
||
"--key-id", str(KEY_ID),
|
||
*(extra or []),
|
||
])
|
||
procs.append(proc)
|
||
if proc.wait_line("READY", 15) is None:
|
||
proc.stop()
|
||
raise RuntimeError("mock_ac не стартовал: " + proc.text())
|
||
proc.port = port # type: ignore[attr-defined]
|
||
return proc
|
||
|
||
yield start
|
||
for proc in procs:
|
||
proc.stop()
|