Files
fgl-aircon/tests/components/fglair/test_config_flow.py
T
petr.polezhaev 976a2dcbb8 ha(ux): исправления по ревью — быстрый выход пробинга, prefill шаблона, ниты
- major: probe_template прекращает перебор на unreachable/no_slots/key_error
  (недоступный модуль: ~10 c вместо ~49 c), продолжает только при
  properties_missing (доступен, но чужой шаблон); +тест
- m1: extra_state_attributes не падает до добавления в hass
- m2: reconfigure берёт сохранённый шаблон как дефолт (не пересчитывает по
  модели); +тест
- m3: device_capabilities/display_temperature переведены в необязательные
  зонды (trial не падает на молчащем caps; feature-модель трактует None
  как «не режем»)
- m4: async_sync_now(online_timeout) — честное имя/докстринг
- ниты: удалены PRESET_CAP/as_dict/лишние плейсхолдеры, DIRECTION_KEYS
  упрощён, тавтология в тесте исправлена, en-тире
- 59 components + 47 pyfglair/acceptance зелёные
2026-09-29 18:02:52 +03:00

632 lines
22 KiB
Python

"""Config flow fglair: меню, ручной ввод, импорт, облако (discover замокан)."""
from __future__ import annotations
import json
from unittest.mock import AsyncMock, patch
from helpers import DSN, KEY_ID, LANIP_KEY
from homeassistant import config_entries
from homeassistant.config_entries import SOURCE_RECONFIGURE
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from pytest_homeassistant_custom_component.common import MockConfigEntry
import pyfglair
from custom_components.fglair.const import DOMAIN
from custom_components.fglair.trial import TrialResult
from pyfglair import Prop, Template, Value, ValueKind
from pyfglair.provision import Device
MANUAL_INPUT = {
"host": "192.168.1.50",
"device_port": 80,
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"name": "Bedroom",
"model": "AP-WC1E",
}
LIMITS_INPUT = {
"temp_min": 16.0,
"temp_max": 30.0,
"temp_step": 0.5,
"temp_num": 1,
"temp_den": 1,
"temp_offset": 0,
}
TRIAL_OK = (TrialResult(ok=True), Template.A, False)
TRIAL_NETWORK_FAIL = (
TrialResult(ok=False, reason="unreachable"),
Template.A,
False,
)
def _trial_ok(properties=None):
return (
TrialResult(ok=True, properties=properties or {}),
Template.A,
False,
)
async def _menu(hass: HomeAssistant):
return await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
async def _select(hass: HomeAssistant, flow_id: str, option: str):
return await hass.config_entries.flow.async_configure(
flow_id, {"next_step_id": option}
)
async def _configure(hass: HomeAssistant, flow_id: str, user_input: dict):
return await hass.config_entries.flow.async_configure(flow_id, user_input)
async def _pages(
hass: HomeAssistant,
flow_id: str,
*,
template: str = "A",
caps: dict | None = None,
limits: dict | None = None,
):
"""Проходит страницы template → capabilities → limits."""
result = await _configure(hass, flow_id, {"template": template})
assert result["step_id"] == "capabilities", result
result = await _configure(hass, flow_id, caps or {})
assert result["step_id"] == "limits", result
return await _configure(hass, flow_id, limits or LIMITS_INPUT)
def _suggested(schema, field: str):
for key in schema.schema:
if getattr(key, "schema", None) == field:
description = key.description
if isinstance(description, dict):
return description.get("suggested_value")
return None
async def test_user_menu(hass: HomeAssistant):
result = await _menu(hass)
assert result["type"] == "menu"
assert set(result["menu_options"]) == {"cloud", "manual", "import_json"}
async def test_manual_success(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
) as trial:
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
assert result["type"] == "form"
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry"
assert result["title"] == "Bedroom"
assert result["result"].unique_id == DSN
data = result["data"]
assert data["lanip_key_id"] == KEY_ID
assert data["template"] == "A"
assert data["temp_step"] == 5
assert data["overrides"]["adjust_temperature"] == {
"num": 1,
"den": 1,
"offset": 0,
"min": 160,
"max": 300,
}
assert isinstance(data["features"], dict)
assert data["features"]["mode_heat"] is True
trial.assert_called_once()
assert trial.call_args.args[0]["host"] == "192.168.1.50"
async def test_template_change_and_conversion(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _pages(
hass,
result["flow_id"],
template="B",
limits={
**LIMITS_INPUT,
"temp_min": 17.0,
"temp_max": 28.0,
"temp_step": 1.0,
"temp_num": 2,
"temp_den": 4,
"temp_offset": 5,
},
)
assert result["type"] == "create_entry"
assert result["data"]["template"] == "B"
assert result["data"]["temp_step"] == 10
assert result["data"]["overrides"]["adjust_temperature"] == {
"num": 2,
"den": 4,
"offset": 5,
"min": 170,
"max": 280,
}
async def test_limits_invalid_range(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _configure(
hass, result["flow_id"], {"template": "B"}
)
result = await _configure(hass, result["flow_id"], {})
assert result["step_id"] == "limits"
result = await _configure(
hass,
result["flow_id"],
{**LIMITS_INPUT, "temp_min": 30.0, "temp_max": 16.0},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_range"}
result = await _configure(hass, result["flow_id"], LIMITS_INPUT)
assert result["type"] == "create_entry"
async def test_limits_unreachable_range(hass: HomeAssistant):
"""Конверсия выводит таблицу за min/max — пустой достижимый диапазон."""
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
result = await _configure(
hass, result["flow_id"], {"template": "A"}
)
result = await _configure(hass, result["flow_id"], {})
result = await _configure(
hass,
result["flow_id"],
{
**LIMITS_INPUT,
"temp_min": 30.0,
"temp_max": 40.0,
"temp_num": 1,
"temp_den": 2,
},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_range"}
async def test_template_device_values(hass: HomeAssistant):
props = {
Prop.DISPLAY_TEMPERATURE: Value(ValueKind.INT, 200),
Prop.OPERATION_MODE: Value(ValueKind.INT, 3),
Prop.FAN_SPEED: Value(ValueKind.INT, 4),
Prop.DEVICE_CAPABILITIES: Value(ValueKind.INT, 0b11011),
Prop.AF_VERTICAL_NUM_DIR: Value(ValueKind.INT, 5),
}
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=(
TrialResult(ok=True, properties=props),
Template.A,
False,
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["step_id"] == "template"
values = result["description_placeholders"]["device_values"]
assert "20.0" in values
assert "0x0000001B" in values
assert "cool" in values
assert "направлений заслонок: верт. 5" in values
# capabilities: дефолты из маски, swing выключен
result = await _configure(hass, result["flow_id"], {"template": "A"})
assert result["step_id"] == "capabilities"
assert _suggested(result["data_schema"], "swing_vertical") is False
assert _suggested(result["data_schema"], "mode_cool") is True
assert _suggested(result["data_schema"], "af_vertical_direction") is True
async def test_manual_connect_error(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_NETWORK_FAIL,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["type"] == "form"
assert result["errors"] == {"base": "cannot_connect"}
async def test_manual_key_mismatch_error(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=(
TrialResult(ok=False, reason="key_mismatch"),
Template.A,
False,
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["type"] == "form"
assert result["errors"] == {"base": "key_mismatch"}
async def test_manual_already_configured(hass: HomeAssistant):
existing = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DSN)
existing.add_to_hass(hass)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["type"] == "abort"
assert result["reason"] == "already_configured"
async def test_manual_error_keeps_input(hass: HomeAssistant):
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_NETWORK_FAIL,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(hass, result["flow_id"], MANUAL_INPUT)
assert result["errors"] == {"base": "cannot_connect"}
assert _suggested(result["data_schema"], "host") == MANUAL_INPUT["host"]
assert _suggested(result["data_schema"], "dsn") == DSN
assert _suggested(result["data_schema"], "lanip_key") is None
async def test_import_json_success(hass: HomeAssistant):
payload = json.dumps(
{
"ip_address": "192.168.1.60",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"model": "AP-WB2E",
"name": "Kitchen",
}
)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=(TrialResult(ok=True), Template.B, False),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json")
assert result["type"] == "form"
result = await _configure(
hass, result["flow_id"], {"config_json": payload}
)
assert result["step_id"] == "template"
assert _suggested(result["data_schema"], "template") == "B"
result = await _pages(hass, result["flow_id"], template="B")
assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.60"
assert result["data"]["template"] == "B"
async def test_import_json_invalid(hass: HomeAssistant):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json")
result = await _configure(
hass, result["flow_id"], {"config_json": "not json"}
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_import"}
async def test_cloud_flow_success(hass: HomeAssistant):
device = Device(
name="Bedroom",
model="AP-WC1E",
dsn=DSN,
lan_ip="192.168.1.50",
lanip_key=LANIP_KEY,
lanip_key_id=KEY_ID,
region="eu",
)
with patch(
"pyfglair.discover", AsyncMock(return_value=[device])
), patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud")
assert result["type"] == "form"
result = await _configure(
hass,
result["flow_id"],
{"email": "user@example.com", "password": "secret", "region": "eu"},
)
assert result["step_id"] == "cloud_device"
result = await _configure(hass, result["flow_id"], {"dsn": DSN})
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry"
assert result["data"]["host"] == "192.168.1.50"
assert result["data"]["lanip_key_id"] == KEY_ID
async def test_cloud_no_devices(hass: HomeAssistant):
with patch(
"pyfglair.discover",
AsyncMock(
side_effect=pyfglair.ProvisionError(
"У аккаунта нет устройств", "no_devices"
)
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud")
result = await _configure(
hass,
result["flow_id"],
{"email": "user@example.com", "password": "secret", "region": "eu"},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "no_devices"}
async def test_cloud_auth_error(hass: HomeAssistant):
with patch(
"pyfglair.discover",
AsyncMock(
side_effect=pyfglair.ProvisionError("401 unauthorized", "auth")
),
):
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "cloud")
result = await _configure(
hass,
result["flow_id"],
{"email": "user@example.com", "password": "bad", "region": "eu"},
)
assert result["type"] == "form"
assert result["errors"] == {"base": "invalid_auth"}
async def test_yaml_import(hass: HomeAssistant):
payload = {
"ip_address": "192.168.1.70",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"model": "AP-WF1E",
}
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=(TrialResult(ok=True), Template.F, False),
):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: payload})
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].data["template"] == "F"
assert entries[0].data["host"] == "192.168.1.70"
assert isinstance(entries[0].data["features"], dict)
async def test_yaml_import_invalid(hass: HomeAssistant):
assert await async_setup_component(hass, DOMAIN, {DOMAIN: "not-a-dict"})
await hass.async_block_till_done()
assert hass.config_entries.async_entries(DOMAIN) == []
assert not hass.config_entries.flow.async_progress()
async def test_import_json_null_device_port(hass: HomeAssistant):
payload = json.dumps(
{
"ip_address": "192.168.1.71",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": str(KEY_ID),
"device_port": None,
}
)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
) as trial:
result = await _menu(hass)
result = await _select(hass, result["flow_id"], "import_json")
result = await _configure(
hass, result["flow_id"], {"config_json": payload}
)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "create_entry"
assert result["data"]["device_port"] == 80
assert trial.call_args.args[0]["device_port"] == 80
RECONFIGURE_ENTRY_DATA = {
"host": "127.0.0.1",
"device_port": 9,
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"name": "Old name",
"model": "AP-WC1E",
"template": "A",
}
async def test_reconfigure_success(hass: HomeAssistant, mock_ac, socket_enabled):
from test_entities import _setup_entry
proc = mock_ac(["--set", "operation_mode=6"])
entry = await _setup_entry(hass, proc.port)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
)
assert result["type"] == "menu"
result = await _select(hass, result["flow_id"], "manual")
assert result["type"] == "form"
assert _suggested(result["data_schema"], "host") == "127.0.0.1"
result = await _configure(
hass, result["flow_id"], {**MANUAL_INPUT, "name": "Renamed"}
)
assert result["step_id"] == "template"
result = await _pages(hass, result["flow_id"])
assert result["type"] == "abort"
assert result["reason"] == "reconfigure_successful"
await hass.async_block_till_done()
assert entry.data["name"] == "Renamed"
assert entry.state.name == "LOADED"
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_reconfigure_wrong_device(hass: HomeAssistant):
entry = MockConfigEntry(
domain=DOMAIN, data=RECONFIGURE_ENTRY_DATA, unique_id=DSN
)
entry.add_to_hass(hass)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=TRIAL_OK,
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(
hass,
result["flow_id"],
{**MANUAL_INPUT, "dsn": "AC000W00OTHER001"},
)
assert result["type"] == "abort"
assert result["reason"] == "unique_id_mismatch"
async def test_options_flow_shows_and_updates_key(
hass: HomeAssistant, mock_ac, socket_enabled
):
from test_entities import _setup_entry
proc = mock_ac(["--set", "operation_mode=6"])
entry = await _setup_entry(hass, proc.port)
result = await hass.config_entries.options.async_init(entry.entry_id)
assert result["type"] == "form"
assert result["step_id"] == "init"
assert _suggested(result["data_schema"], "lanip_key") == LANIP_KEY
assert result["description_placeholders"]["dsn"] == DSN
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"lanip_key": LANIP_KEY}
)
assert result["type"] == "create_entry"
await hass.async_block_till_done()
assert entry.data["lanip_key"] == LANIP_KEY
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_options_flow_new_key_reloads(
hass: HomeAssistant, mock_ac, socket_enabled
):
from test_entities import _setup_entry
proc = mock_ac(["--set", "operation_mode=6"])
entry = await _setup_entry(hass, proc.port)
result = await hass.config_entries.options.async_init(entry.entry_id)
result = await hass.config_entries.options.async_configure(
result["flow_id"], {"lanip_key": "bmV3LWtleQ=="}
)
assert result["type"] == "create_entry"
await hass.async_block_till_done()
assert entry.data["lanip_key"] == "bmV3LWtleQ=="
assert entry.state.name in ("LOADED", "SETUP_RETRY")
if entry.state.name == "LOADED":
assert await hass.config_entries.async_unload(entry.entry_id)
async def test_run_trial_probes_unknown_model(hass: HomeAssistant):
"""Без oem_model шаблон определяется пробингом A/B/F."""
from custom_components.fglair import config_flow as cf
with patch.object(
cf, "probe_template", return_value=(Template.B, {})
) as probe, patch.object(
cf, "trial_connect", return_value=TrialResult(ok=True)
):
result = await hass.async_add_executor_job(
cf._run_trial,
{
"host": "127.0.0.1",
"dsn": DSN,
"lanip_key": LANIP_KEY,
"lanip_key_id": KEY_ID,
"model": "",
"template": "A",
},
)
trial_result, template, probed = result
assert trial_result.ok
assert template is Template.B
assert probed is True
probe.assert_called_once()
async def test_reconfigure_prefills_stored_template(
hass: HomeAssistant, mock_ac, socket_enabled
):
"""При reconfigure дефолт — сохранённый шаблон, а не пересчитанный."""
from test_entities import _setup_entry
proc = mock_ac(["--set", "operation_mode=6"])
entry = await _setup_entry(hass, proc.port)
hass.config_entries.async_update_entry(
entry, data={**entry.data, "template": "B"}
)
with patch(
"custom_components.fglair.config_flow._run_trial",
return_value=(TrialResult(ok=True), Template.A, False),
):
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_RECONFIGURE, "entry_id": entry.entry_id},
)
result = await _select(hass, result["flow_id"], "manual")
result = await _configure(
hass, result["flow_id"], {**MANUAL_INPUT, "host": "127.0.0.1",
"device_port": proc.port}
)
assert result["step_id"] == "template"
assert _suggested(result["data_schema"], "template") == "B"
await hass.config_entries.async_unload(entry.entry_id)