"""Общие фикстуры pyfglair-тестов: сборка ядра, 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[2] 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()