Загрузка данных


http_client.py

import json
import logging
import os
from typing import Optional

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

from constants.architecture_constants import EnvKeyConstants as Env_const
from constants.architecture_constants import HTTPClientConstants as Http_const

logger = logging.getLogger(__name__)


class HttpClient:
    """
    Выполняет http запросы
    """

    def __init__(self):
        self.session = self._create_retry_session()

    @staticmethod
    def make_request(method: str, url: str, **kwargs) -> Optional[requests.Response]:
        """
        Обертка для отправки запроса
        :param method:
        :param url:
        :param kwargs: прочие параметры запроса
        :return: объект ответа
        """
        try:
            logging.info(f"[HTTP_CLIENT] Выполняю запрос: METHOD: {method} URL: {url}")
            response = requests.request(method, url, **kwargs)
            response.raise_for_status()
            return response
        except requests.HTTPError as error:
            logger.error(
                f"[HTTP_CLIENT] [ERROR] При выполнении запроса. METHOD: {method} URL: {url} ERROR_TEXT:{error}"
            )
            raise
        except requests.RequestException:
            logger.exception(f"[HTTP_CLIENT] [ERROR] При выполнении запроса. METHOD: {method} URL: {url}")
            raise

    def make_session_request(self, method: str, url: str, **kwargs) -> Optional[requests.Response]:
        """
        Обертка для отправки запроса
        :param method:
        :param url:
        :param kwargs: прочие параметры запроса
        :return: объект ответа
        """
        try:
            logging.info(f"[HTTP_CLIENT] Выполняю запрос: METHOD: {method} URL: {url}")
            response = self.session.request(method, url, **kwargs)
            response.raise_for_status()
            return response
        except requests.HTTPError as error:
            logger.error(
                f"[HTTP_CLIENT] [ERROR] При выполнении запроса. METHOD: {method} URL: {url} ERROR_TEXT:{error}"
            )
            raise
        except requests.RequestException:
            logger.exception(f"[HTTP_CLIENT] [ERROR] При выполнении запроса. METHOD: {method} URL: {url}")
            raise

    @staticmethod
    def get_base_url(url_key: str) -> str:
        return os.environ.get(url_key)

    @staticmethod
    def generate_full_url(base_url: str, endpoint: str) -> str:
        return f"https://{base_url}{endpoint}"

    def post_upload_allure_results(self, files) -> Optional[requests.Response]:
        """
        Обертка для выполнения запроса на загрузку отчетов
        :return: объект ответа на запрос загрузки отчета
        """
        base_testops_url = self.get_base_url(Env_const.TESTOPS_BASE_URL)
        full_url = self.generate_full_url(base_testops_url, Http_const.TESTOPS_UPLOAD_ENDPOINT)
        response = self.make_request(Http_const.POST_METHOD, full_url, files=files)
        return response

    def get_attachments_list_by_test_case_id(self, test_case_id: int) -> dict:
        """
        Получает список вложений для тест кейса по id через GET запрос к TESTOPS
        :return: содержимое ответа на запрос
        """
        base_testops_url = self.get_base_url(Env_const.TESTOPS_BASE_URL)
        full_endpoint = Http_const.TESTOPS_ATTACHMENTS_LIST_ENDPOINT.format(test_case_id=test_case_id)
        full_url = self.generate_full_url(base_testops_url, full_endpoint)
        response = self.make_request(Http_const.GET_METHOD, full_url)
        return response.json()

    def get_test_case_attachment_by_id(self, test_case_id: int, attachment_id: int) -> bytes:
        """
        Получает вложение по id через GET запрос к TESTOPS
        :return: содержимое ответа на запрос
        """
        base_testops_url = self.get_base_url(Env_const.TESTOPS_BASE_URL)
        full_endpoint = Http_const.TESTOPS_LOAD_ATTACHMENT_ENDPOINT.format(
            test_case_id=test_case_id, attachment_id=attachment_id
        )
        full_url = self.generate_full_url(base_testops_url, full_endpoint)
        response = self.make_request(Http_const.GET_METHOD, full_url)
        if not response.content:
            logger.exception(f"[HTTP_CLIENT] [ERROR] Пустой response.content при запросе URL: {full_url}")
            raise ValueError
        return response.content

    @staticmethod
    def _create_retry_session(
        retries: int = 3,
        backoff_factor: float = 0.3,
        status_forcelist: tuple = Http_const.STATUS_FORCE_LIST,
        allowed_methods: tuple = Http_const.ALLOWED_METHODS,
    ) -> requests.Session():
        """
        Создает сессию для выполнения нескольких одинаковых запросов
        """
        session = requests.Session()
        retry = Retry(
            total=retries,
            read=retries,
            connect=retries,
            backoff_factor=backoff_factor,
            status_forcelist=status_forcelist,
            allowed_methods=allowed_methods,
            raise_on_status=False,
        )
        adapter = HTTPAdapter(max_retries=retry)
        session.mount("http//", adapter)
        session.mount("https//", adapter)
        return session


class StandHttpClient(HttpClient):
    """
    Выполняет http запросы к стендам
    """

    def __init__(self, stand_url: str, token: str) -> None:
        super().__init__()
        self._stand_url = stand_url
        self._token = token
        self._headers = self._add_token_to_headers()
        self.session.headers.update(self._headers)

    def _add_token_to_headers(self) -> dict:
        """
        Добавляет token в headers запроса
        """
        headers = Http_const.DEFAULT_HEADERS.copy()
        headers[Http_const.X_SECURITY_SIGNATURE_KEY] = self._token
        return headers

    def post_request(self, endpoint: str, payload: dict | str) -> Optional[requests.Response]:
        """
        Делает http post запрос к стенду
        """
        full_url = self.generate_full_url(self._stand_url, endpoint)
        json_payload = json.dumps(payload)
        response = self.make_session_request(Http_const.POST_METHOD, full_url, data=json_payload)
        return response




















architecture_constants.py
import os


class StandConstants:
    MAIN_SUBDOMAIN: str = "nginx-szi"
    KEYCLOAK_SUBDOMAIN: str = "sso-szi"
    COMPONENT: str = "lds"
    ROOT_DOMAIN: str = "tn.tngrp.ru"
    API_GATEWAY_PATH_SEGMENT: str = "lds-api-gateway"
    ACKNOWLEDGE_LEAK_URL_PATH: str = "/journals/GetMessages"  # TODO Исправить на актуальный
    IMITATE_SIGNAL_URL_PATH: str = "/layerbuilder/ImitateSignal"
    GET_BASIC_INFO_URL_PATH: str = "/configurator/GetBasicInfo"
    GET_BASIC_INFO_ADMIN_URL_PATH: str = "/configurator/GetBasicInfoAdmin"
    GET_TUS_INFORMATION_URL_PATH: str = "/configurator/GetTusInformation"
    LAUNCH_LDS_URL_PATH: str = "/configurator/LaunchLds"
    STOP_LDS_URL_PATH: str = "/configurator/StopLds"
    PING_URL_PATH: str = "/apigateway/Ping"
    GET_MESSAGES_URL_PATH: str = "/journals/GetMessages"
    GET_OUTPUT_SIGNALS_URL_PATH: str = "/apigateway/GetOutputSignals"
    MASK_SIGNAL_URL_PATH: str = "/layerbuilder/MaskSignal"
    MASK_LDS_URL_PATH: str = "/core/MaskLds"
    UNIMITATE_SIGNAL_URL_PATH: str = "/layerbuilder/UnimitateSignal"
    UNMASK_SIGNAL_URL_PATH: str = "/layerbuilder/UnmaskSignal"
    UNMASK_LDS_URL_PATH: str = "/core/UnmaskLds"


class ImitatorConstants:
    TEST_SETTINGS_KEY_NAME: str = "test_settings"
    IMITATOR_FLAGS_KEY_NAME: str = "imitator_flags"
    IMITATOR_TIME_FORMAT: str = "%Y%m%dT%H%M%S"
    IMITATOR_START_DELAY_S: int = 100
    IMITATOR_FINISH_DELAY_MINUTE: float = 2.0
    IMITATOR_CHECK_CMD: str = "pgrep -f Playground"
    IMITATOR_KILL_CMD: str = "pkill -f Playground"
    IMITATOR_PATH = "/data/imitator/lds-flow-playground-csv-latest"
    IMITATOR_RUN_CMD: str = f"dotnet {IMITATOR_PATH}/TN.LDS.Flow.Playground.Application.dll"
    IMITATOR_LOG_FILE_NAME: str = "imitator.log"
    IMITATOR_KEY_NAME: str = "imitator_key"
    SERVER_IP_KEY_NAME: str = "server_ip"
    SANDBOX_PATH: str = "Sandbox_path"  # Удалить при переработке json_config_model.py
    SANDBOX_DATA: str = "data"
    SANDBOX_RULES: str = "rules.txt"
    SANDBOX_TAGS: str = "tags.txt"
    STAND_ENV_NAMING: str = os.environ.get("STAND_NAME")[:-1]
    CONFIG_PATH: str = f"/data/{STAND_ENV_NAMING}/configs"
    SIGNAL_UNIT_CONVERSION_RULES_FILE_NAME: str = "signal_unit_conversion_rules.json"
    SIGNAL_UNIT_CONVERSION_RULES_BACKUP_DIR: str = "original_conversion_rules"
    SOURCE_TYPE_DEF_VALUE: str = "inflow"
    SPEED_DEF_VALUE: int = 1
    NS_DEF_VALUE: int = 2
    KAFKA_OFFSET_EARLIEST: str = "earliest"
    KAFKA_POLL_TIMEOUT_S: float = 1.0
    KAFKA_SESSION_TIMEOUT_MS: int = 10000
    TEST_ID_KEY: str = "test_id"
    AUTOTEST_DATA_PATH: str = "/data/imitator/autotest_data"
    POPEN_WAIT_TIMOUT_S: int = 5
    LONG_PROCESS_TIMEOUT_S: int = 20
    CMD_STATUS_OK: str = "OK"
    CMD_STATUS_FAIL: str = "FAIL"
    REDIS_STAND_ADDRESS: str = "10.7.49.210"
    CORE_START_DELAY_S: int = 5
    ENCODING_UTF_8: str = "utf-8"
    ENCODING_UTF_8_SIG: str = "utf-8-sig"
    ENCODING_LATIN_1: str = "latin-1"
    WIN_ENCODING_CP866: str = "cp866"  # Нужна только для запуска под WIN
    WIN_ENCODING_CP1251: str = "cp1251"  # Нужна только для запуска под WIN
    OS_NAME_WIN: str = 'nt'
    DEFAULT_ENCODINGS = [ENCODING_UTF_8_SIG, ENCODING_UTF_8, WIN_ENCODING_CP866, WIN_ENCODING_CP1251, ENCODING_LATIN_1]

    HOST_MAP = {
        "dev1": {IMITATOR_KEY_NAME: "DEV1_", SERVER_IP_KEY_NAME: "10.7.49.37"},
        "dev2": {IMITATOR_KEY_NAME: "DEV2_", SERVER_IP_KEY_NAME: "10.7.49.38"},
        "dev3": {IMITATOR_KEY_NAME: "DEV3_", SERVER_IP_KEY_NAME: "10.7.49.205"},
        "test1": {IMITATOR_KEY_NAME: "TEST1_", SERVER_IP_KEY_NAME: "10.7.49.206"},
        "test2": {IMITATOR_KEY_NAME: "TEST2_", SERVER_IP_KEY_NAME: "10.7.49.207"},
        "test3": {IMITATOR_KEY_NAME: "TEST3_", SERVER_IP_KEY_NAME: "10.7.49.208"},
        "test4": {IMITATOR_KEY_NAME: "TEST4_", SERVER_IP_KEY_NAME: "10.7.49.209"},
    }


class ClickhouseConstants(ImitatorConstants):
    CH_TABLE_NAMES: list = ["lds.records", "lds.records_lastvalue"]
    EVO_OBJECT_ID_KEY_NAME: str = "evoObjectId"
    EVO_PARAMETER_ID_KEY_NAME: str = "evoParameterId"
    OBJECT_ID_KEY_NAME: str = "objectId"
    PARAMETER_ID_KEY_NAME: str = "parameterId"
    EVO_ID_PAIRS_CHUNK_SIZE: int = 450
    NAME_CONTAINER: str = "clickhouse-2"


class DockerConstants:
    HOSTNAME_CMD: str = "hostname"
    STOP_CMD: str = "docker stop"
    START_CMD: str = "docker start"
    CHECK_STATUS_CMD: str = "docker inspect -f '{{.State.Status}}'"
    RUNNING_STATUS: str = "running"
    EXITED_STATUS: str = "exited"
    CORE_CONTAINERS_GROUP: list = ["lds-core-node1", "lds-core-node2", "lds-core-node3"]
    LB_CONTAINERS_GROUP: list = ["lds-layer-builder-node1", "lds-layer-builder-node2", "lds-layer-builder-node3"]
    JOURNAL_CONTAINERS_GROUP: list = ["lds-journals-node1", "lds-journals-node2", "lds-journals-node3"]
    WEB_APP_CONTAINERS_GROUP: list = ["lds-web-app-node1", "lds-web-app-node2", "lds-web-app-node3"]
    API_GW_CONTAINERS_GROUP: list = ["lds-api-gw-node1", "lds-api-gw-node2", "lds-api-gw-node3"]
    REPORTS_CONTAINERS_GROUP: list = ["lds-reports-node1", "lds-reports-node2", "lds-reports-node3"]


class RedisConstants:
    LB_REDIS_KEY: str = "lds-layer-builder"
    CORE_REDIS_KEY: str = "lds-core"
    REDIS_KEY_FIND_CMD: str = "docker exec -i redis-redis-01-1-1 redis-cli KEYS"
    REDIS_KEY_DEL_CMD: str = "| xargs -r docker exec -i redis-redis-01-1-1 redis-cli DEL"


class KeycloakClientConstants(StandConstants):
    TOKEN_LEEWAY: int = 30
    GRANT_TYPE: str = "password"
    KEYCLOAK_HEADERS: dict = {"Content-Type": "application/x-www-form-urlencoded"}
    TOKEN_KEY: str = "access_token"
    TOKEN_URL_PATH: str = "/realms/master/protocol/openid-connect/token"
    ISSUED_AT_KEY: str = "issued_at"
    EXPIRES_IN_KEY: str = "expires_in"


class TestOpsConstants:
    TESTOPS_UPLOAD_ENDPOINT: str = "/upload"
    TESTOPS_UPLOAD_ERROR_MSG: str = "Ошибка при загрузке файлов allure отчета"
    TESTOPS_UPLOAD_RESPONSE_MSG_KEY: str = "message"
    TESTOPS_UPLOAD_FILES_KEY: str = "files"
    POST_METHOD: str = "post"
    ALLURE_RESULTS_DIR_NAME: str = "allure-results"
    GZIP_FILE_SIGNATURE: bytes = b'\x1f\x8b'


class HTTPClientConstants(StandConstants):
    GET_METHOD: str = "get"
    POST_METHOD: str = "post"
    X_SECURITY_SIGNATURE_KEY: str = 'x-security-signature'
    X_USER_ID_KEY: str = 'x-user-id'
    TESTOPS_UPLOAD_ENDPOINT: str = "/upload"
    TESTOPS_ATTACHMENTS_LIST_ENDPOINT: str = "/test_cases/{test_case_id}/attachments"
    TESTOPS_LOAD_ATTACHMENT_ENDPOINT: str = "/test_cases/{test_case_id}/attachments/{attachment_id}?download=1"
    TESTOPS_ATTACHMENTS_KEY: str = "items"
    TESTOPS_ATTACHMENT_FILENAME_KEY: str = "original_filename"
    TESTOPS_ATTACHMENT_ID_KEY: str = "id"
    TEST_ID_KEY: str = "test_id"
    IMITATOR_RUN_DATA_FILENAME: str = "imitator_run_data.tar.gz"  # Название архива данных для прогона
    DEFAULT_HEADERS: dict = {'content-type': 'application/json'}
    STATUS_FORCE_LIST: tuple = (400, 401, 500, 501, 502, 503, 504)
    ALLOWED_METHODS: tuple = ("GET", "POST", "PUT", "PATCH", "HEAD")
    COLUMNS_SELECTION_DEFAULT: list = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13]


class WebSocketClientConstants(StandConstants):
    RS: bytes = b'\x1E'  # ASCII Record Separator
    HANDSHAKE_WAITING: float | int = 5.0
    HANDSHAKE_MESSAGE: str = "{\"protocol\":\"messagepack\",\"version\":1}"
    WS_HUBS: str = "/hubs/ldsClientHub"
    START_INVOCATION_ID: str = 1
    DEFAULT_RECONNECT_INTERVAL: float | int = 5.0
    WS_CONNECT_TIMEOUT_SECONDS: float = 120.0
    PING_INTERVAL: int = 3
    PING_TIMEOUT: int = 5
    CLOSE_TIMEOUT: int = 30
    DEFAULT_SIGNALR_MESSAGE_TYPE: int = 1  # invocation
    STREAM_INVOCATION_MESSAGE_TYPE: int = 4  # StreamInvocation
    STREAM_ITEM_MESSAGE_TYPE: int = 2  # StreamItem
    COMPLETION_MESSAGE_TYPE: int = 3  # Completion
    # Текст ошибки Completion при неуспешном streaming (SignalR CompletionWithDetail)
    COMPLETION_ERROR_MESSAGE_INDEX: int = 4
    DEFAULT_SIGNALR_MAP_HEADERS: dict = {}
    EVENT_TYPE_INDEX = 3
    INVOCATION_ID_INDEX = 2
    SERVICE_NAME: str = StandConstants.MAIN_SUBDOMAIN
    FILTERING_TIMEOUT: int | float = 10.0
    ZONE_INFO: str = 'Europe/Moscow'


class MockConstants:
    MOCK_DURATION: int = 60
    MOCK_TEST_DATA_ID: int = 1
    MOCK_TEST_DATA_NAME: str = "mock.tar.gz"


class EnvKeyConstants:
    CONNECTION_HOST: str = "CONNECTION_HOST"
    KEYCLOAK_URL: str = "KEYCLOAK_URL"
    KEYCLOAK_CLIENT_ID: str = "KEYCLOAK_CLIENT_ID"
    KEYCLOAK_CLIENT_SECRET: str = "KEYCLOAK_CLIENT_SECRET"
    KEYCLOAK_SZI_CLIENT_SECRET: str = "KEYCLOAK_SZI_CLIENT_SECRET"
    KEYCLOAK_USERNAME: str = "KEYCLOAK_USERNAME"
    KEYCLOAK_PASSWORD: str = "KEYCLOAK_PASSWORD"
    TESTOPS_BASE_URL: str = "TESTOPS_BASE_URL"
    SSH_KEY_NAME: str = "SSH_KEY_NAME"
    SSH_USER_DEV: str = "SSH_USER_DEV"
    STAND_NAME: str = "STAND_NAME"
    DATA_PATH: str = "DATA_PATH"
    OPC_URL: str = "OPC_URL"
    TU_ID: str = "TU_ID"






















lds_configurator_scenarios.py
"""
Сценарии setup/teardown СОУ через раздел Администрирование (LDS Configurator).
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any, Dict, Optional

from pytest import fail

from clients.http_client import StandHttpClient
from clients.websocket_client import WebSocketClient
from constants.architecture_constants import HTTPClientConstants as HttpConst
from constants.enums import SouAdminStatus
from constants.test_constants import LdsConfiguratorConstants as LdsCfgConst
from test_config.models_for_tests import BaseSuiteConfig
from utils.helpers import lds_configurator_utils as lds_utils
from utils.helpers import ws_test_utils as t_utils
from utils.helpers.ws_message_parser import ws_message_parser as parser

logger = logging.getLogger(__name__)


def _save_group_state(group_state: Optional[Dict[str, Any]], cfg: BaseSuiteConfig, tu_id: int) -> None:
    """
    Сохраняет resolved tu_id и флаги в group_state для teardown в conftest.
    """
    if group_state is None:
        return
    group_state["use_lds_configurator"] = cfg.use_lds_configurator
    group_state["resolved_tu_id"] = tu_id
    group_state["admin_tu_name"] = cfg.admin_tu_name


def _save_pre_run_running_tus(
    group_state: Optional[Dict[str, Any]],
    snapshot: list[Dict[str, Any]],
) -> None:
    """Сохраняет список включённых ТУ стенда до запуска автотестов."""
    if group_state is None:
        return
    group_state["pre_run_running_tus"] = snapshot


async def lds_configurator_admin_setup(
    http_client: StandHttpClient,
    cfg: BaseSuiteConfig,
    group_state: Optional[Dict[str, Any]] = None,
) -> None:
    """
    Холодный запуск СОУ через Администрирование до старта имитатора.

    1. Получить tu_id по admin_tu_name из GetBasicInfoAdmin.
    2. Снять слепок всех RUNNING ТУ и остановить их на стенде.
    3. LaunchLdsRequest и ожидание status=включена.
    4. Подтвердить launchedAt в GetTusInformation.
    """
    tu_id: int

    logger.info("[SETUP] Получение ТУ '%s' из Администрирования", cfg.admin_tu_name)
    admin_reply = lds_utils.get_basic_info_admin_with_retry(http_client, parser)
    admin_tu = lds_utils.find_tu_by_name(admin_reply, cfg.admin_tu_name)
    lds_utils.validate_admin_tu(admin_tu)
    tu_id = admin_tu.tuId
    cfg.resolved_tu_id = tu_id
    _save_group_state(group_state, cfg, tu_id)
    logger.info(
        "[SETUP] Найден ТУ: tuId=%s, tuName=%r, status=%s (%s)",
        tu_id,
        admin_tu.tuName,
        SouAdminStatus(admin_tu.status),
        SouAdminStatus.report_text_by_value(admin_tu.status),
    )

    running_tus = lds_utils.extract_running_tus(admin_reply)
    pre_run_snapshot = lds_utils.running_tus_to_snapshot(running_tus)
    _save_pre_run_running_tus(group_state, pre_run_snapshot)
    logger.info(
        "[SETUP] Список включённых ТУ на стенде: %s шт. %s",
        len(pre_run_snapshot),
        pre_run_snapshot,
    )
    await lds_utils.stop_all_running_tus(http_client, parser, running_tus)
    logger.info("[SETUP] ТЕСТ УСПЕХ await lds_utils.stop_all_running_tus")
    launch_checkpoint = t_utils.moscow_now()
    logger.info(
        "[SETUP] Момент фиксации времени перед LaunchLds: %s",
        t_utils.format_datetime_moscow(launch_checkpoint),
    )

    logger.info("[SETUP] Холодный запуск СОУ (LaunchLdsRequest) для tuId=%s", tu_id)
    lds_utils.run_lds_command(http_client, HttpConst.LAUNCH_LDS_URL_PATH, tu_id)

    logger.info("[SETUP] Ожидание включения СОУ в Администрировании")
    if not await lds_utils.poll_admin_tu_status(http_client, parser, tu_id, SouAdminStatus.RUNNING):
        fail(
            "Не удалось запустить СОУ: статус в Администрировании не стал 'включена' за 2 минуты",
            pytrace=False,
        )

    logger.info("[SETUP] Подтверждение времени запуска (GetTusInformation)")
    lds_utils.verify_launched_at(http_client, parser, tu_id, launch_checkpoint)


async def lds_configurator_verify_after_core(
    ws_client: WebSocketClient,
    http_client: StandHttpClient,
    cfg: BaseSuiteConfig,
) -> None:
    """
    Проверка готовности стенда после запуска lds-core.

    1. Актуальный статус СОУ из Администрирования.
    2. Ожидание согласованного состояния ТУ на Состоянии МТ в BasicInfo из общего запаса времени.
    3. Ожидание согласованного состояния ТУ на Состоянии МТ в MainPageInfoContent - остаток от общего запаса времени.
    4. Сверка статуса СОУ: Администрирование vs Состояние МТ.
    """
    tu_id = cfg.tu_id
    tu_name = cfg.tu_name

    logger.info("[SETUP] Получение актуального статуса СОУ для tuId=%s", tu_id)
    admin_reply = lds_utils.get_basic_info_admin_with_retry(http_client, parser)
    sou_status = lds_utils.get_admin_tu_status(admin_reply, tu_id)
    if sou_status is None:
        fail(
            f"ТУ tuId={tu_id} ('{tu_name}') не найден в GetBasicInfoAdminResponse",
            pytrace=False,
        )

    expect_enabled = sou_status == SouAdminStatus.RUNNING
    action = "появления" if expect_enabled else "отсутствия"
    loop = asyncio.get_running_loop()

    if expect_enabled:
        ui_sync_time_total = LdsCfgConst.VERIFY_UI_SYNC_TIME_SECONDS
        admin_label = "Admin=RUNNING"
    else:
        ui_sync_time_total = LdsCfgConst.POLL_TIMEOUT_SECONDS
        admin_label = "Admin=STOPPED"

    ui_sync_time_seconds = int(ui_sync_time_total)
    ui_sync_deadline = loop.time() + ui_sync_time_total
    basic_timeout = ui_sync_deadline - loop.time()

    logger.info(
        "[SETUP] %s, запас времени %s с, ожидание %s ТУ в BasicInfo (до %s с)",
        admin_label,
        ui_sync_time_seconds,
        action,
        int(basic_timeout),
    )
    basic_info_poll_ok = await lds_utils.poll_basic_info_tu_presence(
        http_client, parser, tu_id, tu_name, expect_present=expect_enabled, total_wait_seconds=basic_timeout
    )
    if not basic_info_poll_ok:
        if expect_enabled:
            fail(
                f"СОУ не отображается на Состоянии МТ в BasicInfo: ТУ не появилась за {ui_sync_time_seconds} c "
                f"после запуска core",
                pytrace=False,
            )
        fail(
            "СОУ отображается на Состоянии МТ в BasicInfo при статусе 'выключена' в Администрировании",
            pytrace=False,
        )

    main_timeout = ui_sync_deadline - loop.time()
    if main_timeout <= 0:
        fail(
            f"Весь запас времени {ui_sync_time_seconds} для синхронизации подписок израсходован на ожидание BasicInfo"
            "на MainPage времени не осталось"
        )

    logger.info(
        "[SETUP] Ожидание %s ТУ в Состоянии МТ (остаток запаса времени %s с)",
        action,
        int(main_timeout),
    )
    main_page_poll_ok = await lds_utils.poll_main_page_tu_presence(
        ws_client, tu_id, expect_present=expect_enabled, total_wait_seconds=main_timeout
    )
    if not main_page_poll_ok:
        if expect_enabled:
            fail(
                "СОУ не отображается в Состоянии МТ: ТУ не появилась за оставшееся время запаса для синхронизации"
                f"({ui_sync_time_seconds} c после запуска core)",
                pytrace=False,
            )
        fail(
            "СОУ отображается в Состоянии МТ при статусе 'выключена' в Администрировании",
            pytrace=False,
        )

    logger.info("[SETUP] Сверка статуса СОУ: Администрирование vs Состояние МТ")
    lds_utils.check_sou_status_sync(sou_status, expect_enabled, expect_enabled, tu_id, tu_name)


async def lds_configurator_teardown(
    ws_client: WebSocketClient,
    http_client: StandHttpClient,
    tu_id: int,
    admin_tu_name: str,
    pre_run_running_tus: Optional[list[Dict[str, Any]]] = None,
) -> None:
    """
    Teardown набора: остановка СОУ автотестов и восстановление ТУ стенда из снимка.
    Некритичные отклонения логируются без падения прогона.
    """
    snapshot = pre_run_running_tus or []
    try:
        logger.info("[TEARDOWN] Проверка статуса СОУ (tuId=%s, «%s»)", tu_id, admin_tu_name)
        ws_client.clear_queue()
        admin_reply = lds_utils.get_basic_info_admin_with_retry(http_client, parser)

        sou_status = lds_utils.get_admin_tu_status(admin_reply, tu_id)
        if sou_status == SouAdminStatus.RUNNING:
            logger.info("[TEARDOWN] Остановка СОУ (StopLdsRequest) для tuId=%s", tu_id)
            lds_utils.run_lds_command(http_client, HttpConst.STOP_LDS_URL_PATH, tu_id)

            logger.info("[TEARDOWN] Ожидание выключения СОУ в Администрировании")
            if not await lds_utils.poll_admin_tu_status(http_client, parser, tu_id, SouAdminStatus.STOPPED):
                lds_utils.attach_allure_alert(
                    f"СОУ не выключилась за 2 минуты после StopLdsRequest. "
                    f"tuId={tu_id}, adminTuName={admin_tu_name!r}. Проверить вручную."
                )
        else:
            lds_utils.attach_allure_alert(
                f"СОУ не в статусе 'включена' при teardown (status={sou_status}), остановка пропущена. "
                f"tuId={tu_id}, adminTuName='{admin_tu_name}'"
            )

        await lds_utils.restore_pre_run_tus(http_client, parser, snapshot, exclude_tu_id=tu_id)
    except BaseException as error:
        logger.warning(
            "[TEARDOWN] [ALERT] LDS Configurator teardown: %s: %r. tuId=%s, adminTuName=%r",
            type(error).__name__,
            error,
            tu_id,
            admin_tu_name,
        )
        lds_utils.attach_allure_alert(
            f"Ошибка LDS Configurator teardown: {type(error).__name__}: {error!r}. "
            f"tuId={tu_id}, adminTuName={admin_tu_name!r}"
        )

















lds_status_scenarios.py
"""
Сценарии setup/teardown СОУ через раздел Администрирование (LDS Configurator).
"""

from __future__ import annotations

import asyncio
import logging
from typing import Any, Dict, Optional

from pytest import fail

from clients.http_client import StandHttpClient
from clients.websocket_client import WebSocketClient
from constants.architecture_constants import HTTPClientConstants as HttpConst
from constants.enums import SouAdminStatus
from constants.test_constants import LdsConfiguratorConstants as LdsCfgConst
from test_config.models_for_tests import BaseSuiteConfig
from utils.helpers import lds_configurator_utils as lds_utils
from utils.helpers import ws_test_utils as t_utils
from utils.helpers.ws_message_parser import ws_message_parser as parser

logger = logging.getLogger(__name__)


def _save_group_state(group_state: Optional[Dict[str, Any]], cfg: BaseSuiteConfig, tu_id: int) -> None:
    """
    Сохраняет resolved tu_id и флаги в group_state для teardown в conftest.
    """
    if group_state is None:
        return
    group_state["use_lds_configurator"] = cfg.use_lds_configurator
    group_state["resolved_tu_id"] = tu_id
    group_state["admin_tu_name"] = cfg.admin_tu_name


def _save_pre_run_running_tus(
    group_state: Optional[Dict[str, Any]],
    snapshot: list[Dict[str, Any]],
) -> None:
    """Сохраняет список включённых ТУ стенда до запуска автотестов."""
    if group_state is None:
        return
    group_state["pre_run_running_tus"] = snapshot


async def lds_configurator_admin_setup(
    http_client: StandHttpClient,
    cfg: BaseSuiteConfig,
    group_state: Optional[Dict[str, Any]] = None,
) -> None:
    """
    Холодный запуск СОУ через Администрирование до старта имитатора.

    1. Получить tu_id по admin_tu_name из GetBasicInfoAdmin.
    2. Снять слепок всех RUNNING ТУ и остановить их на стенде.
    3. LaunchLdsRequest и ожидание status=включена.
    4. Подтвердить launchedAt в GetTusInformation.
    """
    tu_id: int

    logger.info("[SETUP] Получение ТУ '%s' из Администрирования", cfg.admin_tu_name)
    admin_reply = lds_utils.get_basic_info_admin_with_retry(http_client, parser)
    admin_tu = lds_utils.find_tu_by_name(admin_reply, cfg.admin_tu_name)
    lds_utils.validate_admin_tu(admin_tu)
    tu_id = admin_tu.tuId
    cfg.resolved_tu_id = tu_id
    _save_group_state(group_state, cfg, tu_id)
    logger.info(
        "[SETUP] Найден ТУ: tuId=%s, tuName=%r, status=%s (%s)",
        tu_id,
        admin_tu.tuName,
        SouAdminStatus(admin_tu.status),
        SouAdminStatus.report_text_by_value(admin_tu.status),
    )

    running_tus = lds_utils.extract_running_tus(admin_reply)
    pre_run_snapshot = lds_utils.running_tus_to_snapshot(running_tus)
    _save_pre_run_running_tus(group_state, pre_run_snapshot)
    logger.info(
        "[SETUP] Список включённых ТУ на стенде: %s шт. %s",
        len(pre_run_snapshot),
        pre_run_snapshot,
    )
    await lds_utils.stop_all_running_tus(http_client, parser, running_tus)
    logger.info("[SETUP] ТЕСТ УСПЕХ await lds_utils.stop_all_running_tus")
    launch_checkpoint = t_utils.moscow_now()
    logger.info(
        "[SETUP] Момент фиксации времени перед LaunchLds: %s",
        t_utils.format_datetime_moscow(launch_checkpoint),
    )

    logger.info("[SETUP] Холодный запуск СОУ (LaunchLdsRequest) для tuId=%s", tu_id)
    lds_utils.run_lds_command(http_client, HttpConst.LAUNCH_LDS_URL_PATH, tu_id)

    logger.info("[SETUP] Ожидание включения СОУ в Администрировании")
    if not await lds_utils.poll_admin_tu_status(http_client, parser, tu_id, SouAdminStatus.RUNNING):
        fail(
            "Не удалось запустить СОУ: статус в Администрировании не стал 'включена' за 2 минуты",
            pytrace=False,
        )

    logger.info("[SETUP] Подтверждение времени запуска (GetTusInformation)")
    lds_utils.verify_launched_at(http_client, parser, tu_id, launch_checkpoint)


async def lds_configurator_verify_after_core(
    ws_client: WebSocketClient,
    http_client: StandHttpClient,
    cfg: BaseSuiteConfig,
) -> None:
    """
    Проверка готовности стенда после запуска lds-core.

    1. Актуальный статус СОУ из Администрирования.
    2. Ожидание согласованного состояния ТУ на Состоянии МТ в BasicInfo из общего запаса времени.
    3. Ожидание согласованного состояния ТУ на Состоянии МТ в MainPageInfoContent - остаток от общего запаса времени.
    4. Сверка статуса СОУ: Администрирование vs Состояние МТ.
    """
    tu_id = cfg.tu_id
    tu_name = cfg.tu_name

    logger.info("[SETUP] Получение актуального статуса СОУ для tuId=%s", tu_id)
    admin_reply = lds_utils.get_basic_info_admin_with_retry(http_client, parser)
    sou_status = lds_utils.get_admin_tu_status(admin_reply, tu_id)
    if sou_status is None:
        fail(
            f"ТУ tuId={tu_id} ('{tu_name}') не найден в GetBasicInfoAdminResponse",
            pytrace=False,
        )

    expect_enabled = sou_status == SouAdminStatus.RUNNING
    action = "появления" if expect_enabled else "отсутствия"
    loop = asyncio.get_running_loop()

    if expect_enabled:
        ui_sync_time_total = LdsCfgConst.VERIFY_UI_SYNC_TIME_SECONDS
        admin_label = "Admin=RUNNING"
    else:
        ui_sync_time_total = LdsCfgConst.POLL_TIMEOUT_SECONDS
        admin_label = "Admin=STOPPED"

    ui_sync_time_seconds = int(ui_sync_time_total)
    ui_sync_deadline = loop.time() + ui_sync_time_total
    basic_timeout = ui_sync_deadline - loop.time()

    logger.info(
        "[SETUP] %s, запас времени %s с, ожидание %s ТУ в BasicInfo (до %s с)",
        admin_label,
        ui_sync_time_seconds,
        action,
        int(basic_timeout),
    )
    basic_info_poll_ok = await lds_utils.poll_basic_info_tu_presence(
        http_client, parser, tu_id, tu_name, expect_present=expect_enabled, total_wait_seconds=basic_timeout
    )
    if not basic_info_poll_ok:
        if expect_enabled:
            fail(
                f"СОУ не отображается на Состоянии МТ в BasicInfo: ТУ не появилась за {ui_sync_time_seconds} c "
                f"после запуска core",
                pytrace=False,
            )
        fail(
            "СОУ отображается на Состоянии МТ в BasicInfo при статусе 'выключена' в Администрировании",
            pytrace=False,
        )

    main_timeout = ui_sync_deadline - loop.time()
    if main_timeout <= 0:
        fail(
            f"Весь запас времени {ui_sync_time_seconds} для синхронизации подписок израсходован на ожидание BasicInfo"
            "на MainPage времени не осталось"
        )

    logger.info(
        "[SETUP] Ожидание %s ТУ в Состоянии МТ (остаток запаса времени %s с)",
        action,
        int(main_timeout),
    )
    main_page_poll_ok = await lds_utils.poll_main_page_tu_presence(
        ws_client, tu_id, expect_present=expect_enabled, total_wait_seconds=main_timeout
    )
    if not main_page_poll_ok:
        if expect_enabled:
            fail(
                "СОУ не отображается в Состоянии МТ: ТУ не появилась за оставшееся время запаса для синхронизации"
                f"({ui_sync_time_seconds} c после запуска core)",
                pytrace=False,
            )
        fail(
            "СОУ отображается в Состоянии МТ при статусе 'выключена' в Администрировании",
            pytrace=False,
        )

    logger.info("[SETUP] Сверка статуса СОУ: Администрирование vs Состояние МТ")
    lds_utils.check_sou_status_sync(sou_status, expect_enabled, expect_enabled, tu_id, tu_name)


async def lds_configurator_teardown(
    ws_client: WebSocketClient,
    http_client: StandHttpClient,
    tu_id: int,
    admin_tu_name: str,
    pre_run_running_tus: Optional[list[Dict[str, Any]]] = None,
) -> None:
    """
    Teardown набора: остановка СОУ автотестов и восстановление ТУ стенда из снимка.
    Некритичные отклонения логируются без падения прогона.
    """
    snapshot = pre_run_running_tus or []
    try:
        logger.info("[TEARDOWN] Проверка статуса СОУ (tuId=%s, «%s»)", tu_id, admin_tu_name)
        ws_client.clear_queue()
        admin_reply = lds_utils.get_basic_info_admin_with_retry(http_client, parser)

        sou_status = lds_utils.get_admin_tu_status(admin_reply, tu_id)
        if sou_status == SouAdminStatus.RUNNING:
            logger.info("[TEARDOWN] Остановка СОУ (StopLdsRequest) для tuId=%s", tu_id)
            lds_utils.run_lds_command(http_client, HttpConst.STOP_LDS_URL_PATH, tu_id)

            logger.info("[TEARDOWN] Ожидание выключения СОУ в Администрировании")
            if not await lds_utils.poll_admin_tu_status(http_client, parser, tu_id, SouAdminStatus.STOPPED):
                lds_utils.attach_allure_alert(
                    f"СОУ не выключилась за 2 минуты после StopLdsRequest. "
                    f"tuId={tu_id}, adminTuName={admin_tu_name!r}. Проверить вручную."
                )
        else:
            lds_utils.attach_allure_alert(
                f"СОУ не в статусе 'включена' при teardown (status={sou_status}), остановка пропущена. "
                f"tuId={tu_id}, adminTuName='{admin_tu_name}'"
            )

        await lds_utils.restore_pre_run_tus(http_client, parser, snapshot, exclude_tu_id=tu_id)
    except BaseException as error:
        logger.warning(
            "[TEARDOWN] [ALERT] LDS Configurator teardown: %s: %r. tuId=%s, adminTuName=%r",
            type(error).__name__,
            error,
            tu_id,
            admin_tu_name,
        )
        lds_utils.attach_allure_alert(
            f"Ошибка LDS Configurator teardown: {type(error).__name__}: {error!r}. "
            f"tuId={tu_id}, adminTuName={admin_tu_name!r}"
        )




















smoke_scenarios.py
"""
Сценарии тестов - функции-обёртки без
pytest маркеров.

Каждая функция содержит логику одного теста.
Pytest маркеры и allure декораторы применяются в тестовых файлах.
"""

import time
from collections import defaultdict
from datetime import datetime, timedelta

import allure
import pytest

from constants.architecture_constants import HTTPClientConstants as HttpConst
from constants.enums import (
    ConfirmationStatus,
    Direction,
    ExportedDataType,
    ExportStatus,
    GravityPipe,
    LdsStatus,
    LeakStatus,
    MessagePriority,
    MessageType,
    ReplyStatus,
    ReservedType,
    SignalType,
    SiteKpKp,
    StationaryStatus,
    UserActions,
)
from constants.test_constants import BaseTN3Constants as TestConst
from constants.test_constants import ExportLdsStatusReportConstants as LdsReportConst
from constants.test_constants import ExportMtModeReportConstants as MtReportConst
from constants.test_constants import ExportReportConstants as ReportConst
from models.get_messages_model import Filtering, FilteringObjects, Pagination
from test_config.models_for_tests import (
    BaseSuiteConfig,
    CaseData,
    ExportLdsStatusReportState,
    ExportLeaksReportState,
    ExportMtModeReportState,
    LDSStatusConfig,
    LeakTestConfig,
    SmokeSuiteConfig,
)
from utils.helpers import lds_status_report_xlsx_utils as lds_report_utils
from utils.helpers import mt_mode_report_xlsx_utils as mt_report_utils
from utils.helpers import report_xlsx_utils as report_utils
from utils.helpers import ws_test_utils as t_utils
from utils.helpers.asserts import SoftAssertions, StepCheck
from utils.helpers.ws_message_parser import ws_message_parser as parser
from utils.helpers.ws_test_utils import get_value


def basic_info(http_client, cfg: BaseSuiteConfig):
    """
    Проверка базовой информации СОУ: список ТУ.
    """
    with allure.step("Http запрос, получение и обработка ответа типа: BasicInfoContent"):
        expected_tu = [(cfg.tu_id, cfg.tu_name)]
        response = http_client.post_request(HttpConst.GET_BASIC_INFO_URL_PATH, {})
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_basic_info_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        tus = getattr(getattr(parsed_payload.replyContent, 'basicInfo', None), 'tus', [])
        actual_tu = [(tu.tuId, tu.tuName) for tu in tus if tu.tuId == cfg.tu_id]
        StepCheck("Проверка наличия данных с базовой информацией СОУ", "tus").actual(actual_tu).is_not_none()

    with allure.step(f"Поверка наличия {cfg.tu_name} в списке доступных ТУ на сервере"):
        # Критическая проверка: если нужного ТУ нет в BasicInfoContent — считаем что ТУ отключен (через Zookeeper)
        # и прерываем весь прогон.
        if expected_tu[0] not in actual_tu:
            msg = (
                f"ТУ отключен: в BasicInfoContent отсутствует ТУ для запущенного набора данных: "
                f"tuId={cfg.tu_id}, tuName='{cfg.tu_name}', suite={cfg.suite_name}. "
                f"Необходимо убедиться, что ТУ включен (Zookeeper) и перезапустить прогон."
            )
            allure.attach(
                f"Ожидаемый ТУ: {expected_tu}\nПолученные ТУ: {actual_tu}",
                name="Предварительная проверка: ТУ отключен",
                attachment_type=allure.attachment_type.TEXT,
            )
            pytest.fail(msg, pytrace=False)

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка наличия объектов в списке ТУ", "tus", soft_failures).actual(
            parsed_payload.replyContent.basicInfo.tus
        ).is_not_empty()

        StepCheck(
            f"Проверка наличия ТУ: {cfg.tu_name} в списке ТУ ",
            "(tuId, tuName)",
            soft_failures,
        ).actual(
            actual_tu
        ).expected(expected_tu).equal_to()


def messages_exist_in_journal(http_client):
    """
    Проверка наличия сообщений в журнале.
    """
    with allure.step("Http запрос, получение и обработка ответа типа: MessagesInfoContent"):
        request_body = t_utils.create_journal_req_body()
        response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_journal_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки"):
        messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', [])

    StepCheck("Проверка наличия сообщений в журнале", "messagesInfo").actual(messages_info).is_not_empty()


async def lds_status_initialization(ws_client, cfg: SmokeSuiteConfig):
    """
    Проверка режима работы СОУ: Инициализация.
    """
    with allure.step("Подключение по ws, получение и обработка сообщения типа: CommonSchemeContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "CommonSchemeContent",
            "SubscribeCommonSchemeRequest",
            {'tuId': cfg.tu_id, 'additionalProperties': None},
        )
        parsed_payload = parser.parse_common_scheme_info_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        # Получает список участков карты течения
        flow_areas = getattr(parsed_payload.replyContent, 'flowAreas', [])
        # Получает самый протяженный участок карты течения
        longest_flow_area = t_utils.get_longest_flow_area(flow_areas)
        # Получает список ДУ
        diagnostic_areas = getattr(longest_flow_area, 'diagnosticAreas', [])
        StepCheck("Проверка наличия данных диагностических участков", "diagnosticAreas").actual(
            diagnostic_areas
        ).is_not_empty()
        allure.attach(
            f"Самый протяженный участок карты течений: {longest_flow_area}",
            name="flowArea. Инициализация",
            attachment_type=allure.attachment_type.TEXT,
        )
        # Получает коллекцию статусов списка ДУ
        lds_status_set = {diagnostic_area.ldsStatus for diagnostic_area in diagnostic_areas}
        # Определяет режим работы СОУ по приоритету
        lds_status_int = t_utils.determine_lds_status_by_priority(lds_status_set)
        lds_status = LdsStatus(lds_status_int) if lds_status_int else None

    StepCheck("Проверка режима работы СОУ", "ldsStatus").actual(lds_status).expected(
        LdsStatus.INITIALIZATION
    ).equal_to()


async def diagnostics_of_signals_after_initialization(ws_client, cfg: SmokeSuiteConfig, test_data: CaseData):
    """
    Проверка выходных сигналов после окончания режима Инициализация по причине "холодного" пуска СОУ.

    """
    # Распаковка данных для теста
    exp_first_site_reg_lu, exp_first_site_reg_sou = test_data.expected_result.get("exp_tixoreczkaya_novovelichkovskaya")
    exp_second_site_reg_lu, exp_second_site_reg_sou = test_data.expected_result.get("exp_novovelichkovskaya_krymskaya")
    exp_third_site_reg_lu, exp_third_site_reg_sou = test_data.expected_result.get("exp_krymskaya_grushovaya")
    exp_fourth_site_reg_lu, exp_fourth_site_reg_sou = test_data.expected_result.get("exp_backup_route_bejsug")
    exp_fifth_site_reg_lu, exp_fifth_site_reg_sou = test_data.expected_result.get("exp_backup_route_ponura")
    exp_sixth_site_reg_lu, exp_sixth_site_reg_sou = test_data.expected_result.get("exp_backup_route_kuban")
    exp_seventh_site_reg_lu, exp_seventh_site_reg_sou = test_data.expected_result.get("exp_npz_afipskij")
    exp_eighth_site_reg_lu, exp_eighth_site_reg_sou = test_data.expected_result.get("exp_npz_ilinskij")

    with allure.step("Подключение по ws, получение и обработка сообщения типа: OutputSignalsInfo"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "OutputSignalsInfo",
            "SubscribeOutputSignalsRequest",
            {
                'objects': {
                    'linearParts': [],
                    'controlledSites': [
                        SiteKpKp.TIXORECZKAYA_NOVOVELICHKOVSKAYA.value,
                        SiteKpKp.NOVOVELICHKOVSKAYA_KRYMSKAYA.value,
                        SiteKpKp.KRYMSKAYA_GRUSHOVAYA.value,
                        SiteKpKp.BACKUP_ROUTE_BEJSUG.value,
                        SiteKpKp.BACKUP_ROUTE_PONURA.value,
                        SiteKpKp.BACKUP_ROUTE_KUBAN.value,
                        SiteKpKp.NPZ_AFIPSKIJ.value,
                        SiteKpKp.NPZ_ILINSKIJ.value,
                    ],
                },
                'signalTypes': 1023,
                'tuId': cfg.tu_id,
                'additionalProperties': None,
            },
        )

        parsed_payload = parser.parse_output_signals_info_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        controlled_site_dict = {
            "controlled_site_first": SiteKpKp.TIXORECZKAYA_NOVOVELICHKOVSKAYA.value,
            "controlled_site_second": SiteKpKp.NOVOVELICHKOVSKAYA_KRYMSKAYA.value,
            "controlled_site_third": SiteKpKp.KRYMSKAYA_GRUSHOVAYA.value,
            "controlled_site_fourth": SiteKpKp.BACKUP_ROUTE_BEJSUG.value,
            "controlled_site_fifth": SiteKpKp.BACKUP_ROUTE_PONURA.value,
            "controlled_site_sixth": SiteKpKp.BACKUP_ROUTE_KUBAN.value,
            "controlled_site_seventh": SiteKpKp.NPZ_AFIPSKIJ.value,
            "controlled_site_eight": SiteKpKp.NPZ_ILINSKIJ.value,
        }

        controlled_site_messages = {}
        for name, key in controlled_site_dict.items():
            controlled_site_messages[name] = t_utils.find_object_by_a_few_fields(
                parsed_payload.replyContent.controlledSiteSignals, key
            )

        all_signals = {}
        for site_name, site_message in controlled_site_messages.items():
            signal_dict = {'pump': None, 'sou': None, 'gravity': None}
            if site_message:
                all_signals[site_name] = {
                    'pump': t_utils.get_signal(site_message, SignalType.REGLU),
                    'sou': t_utils.get_signal(site_message, SignalType.REGSOU),
                    'gravity': t_utils.get_signal(site_message, SignalType.GRAVITYPIPE),
                }
            else:
                all_signals[site_name] = signal_dict

        first_kp_kp = all_signals.get("controlled_site_first") or {}

        if first_kp_kp:
            first_site_signal_pump = (
                StationaryStatus(int(get_value(first_kp_kp.get("pump")))) if first_kp_kp.get("pump") else None
            )
            first_site_signal_sou = (
                LdsStatus(int(get_value(first_kp_kp.get("sou")))) if first_kp_kp.get("sou") else None
            )
            first_site_signal_gravity = get_value(first_kp_kp.get("gravity"))

        second_kp_kp = all_signals.get("controlled_site_second") or {}
        if second_kp_kp:
            second_site_signal_pump = (
                StationaryStatus(int(get_value(second_kp_kp.get("pump")))) if second_kp_kp.get("pump") else None
            )
            second_site_signal_sou = (
                LdsStatus(int(get_value(second_kp_kp.get("sou")))) if second_kp_kp.get("sou") else None
            )
            second_site_signal_gravity = get_value(second_kp_kp.get("gravity"))

        third_kp_kp = all_signals.get("controlled_site_third") or {}
        if third_kp_kp:
            third_site_signal_pump = (
                StationaryStatus(int(get_value(third_kp_kp.get("pump")))) if third_kp_kp.get("pump") else None
            )
            third_site_signal_sou = (
                LdsStatus(int(get_value(third_kp_kp.get("sou")))) if third_kp_kp.get("sou") else None
            )
            third_site_signal_gravity = get_value(third_kp_kp.get("gravity"))

        fourth_kp_kp = all_signals.get("controlled_site_fourth") or {}
        if fourth_kp_kp:
            fourth_site_signal_pump = (
                StationaryStatus(int(get_value(fourth_kp_kp.get("pump")))) if fourth_kp_kp.get("pump") else None
            )
            fourth_site_signal_sou = (
                LdsStatus(int(get_value(fourth_kp_kp.get("sou")))) if fourth_kp_kp.get("sou") else None
            )
            fourth_site_signal_gravity = get_value(fourth_kp_kp.get("gravity"))

        fifth_kp_kp = all_signals.get("controlled_site_fifth") or {}
        if fifth_kp_kp:
            fifth_site_signal_pump = (
                StationaryStatus(int(get_value(fifth_kp_kp.get("pump")))) if fifth_kp_kp.get("pump") else None
            )
            fifth_site_signal_sou = (
                LdsStatus(int(get_value(fifth_kp_kp.get("sou")))) if fifth_kp_kp.get("sou") else None
            )
            fifth_site_signal_gravity = get_value(fifth_kp_kp.get("gravity"))

        sixth_kp_kp = all_signals.get("controlled_site_sixth") or {}
        if sixth_kp_kp:
            sixth_site_signal_pump = (
                StationaryStatus(int(get_value(sixth_kp_kp.get("pump")))) if sixth_kp_kp.get("pump") else None
            )
            sixth_site_signal_sou = (
                LdsStatus(int(get_value(sixth_kp_kp.get("sou")))) if sixth_kp_kp.get("sou") else None
            )
            sixth_site_signal_gravity = get_value(sixth_kp_kp.get("gravity"))

        seventh_kp_kp = all_signals.get("controlled_site_seventh") or {}
        if seventh_kp_kp:
            seventh_site_signal_pump = (
                StationaryStatus(int(get_value(seventh_kp_kp.get("pump")))) if seventh_kp_kp.get("pump") else None
            )
            seventh_site_signal_sou = (
                LdsStatus(int(get_value(seventh_kp_kp.get("sou")))) if seventh_kp_kp.get("sou") else None
            )
            seventh_site_signal_gravity = get_value(seventh_kp_kp.get("gravity"))

        eighth_kp_kp = all_signals.get("controlled_site_eight") or {}
        if eighth_kp_kp:
            eight_site_signal_pump = (
                StationaryStatus(int(get_value(eighth_kp_kp.get("pump")))) if eighth_kp_kp.get("pump") else None
            )
            eight_site_signal_sou = (
                LdsStatus(int(get_value(eighth_kp_kp.get("sou")))) if eighth_kp_kp.get("sou") else None
            )
            eight_site_signal_gravity = get_value(eighth_kp_kp.get("gravity"))

    with SoftAssertions() as soft_failures:
        StepCheck(
            "Проверка сигнала - режим МТ на участке Тихорецкая-Нововеличковская",
            "Режим МТ",
            soft_failures,
        ).actual(first_site_signal_pump).expected(exp_first_site_reg_lu).equal_to()
        StepCheck(
            "Проверка сигнала - режим СОУ на участке Тихорецкая-Нововеличковская",
            "Режим СОУ",
            soft_failures,
        ).actual(first_site_signal_sou).expected(exp_first_site_reg_sou).equal_to()
        StepCheck(
            f"Проверка {GravityPipe.absent_gravity.description} \n" f"на участке Тихорецкая-Нововеличковская",
            "Количество самотеков",
            soft_failures,
        ).actual(first_site_signal_gravity).expected(str(GravityPipe.absent_gravity.id)).equal_to()
        StepCheck(
            "Проверка сигнала - режим МТ на участке Нововеличковская-Крымская",
            "Режим МТ",
            soft_failures,
        ).actual(second_site_signal_pump).expected(exp_second_site_reg_lu).equal_to()
        StepCheck(
            f"Проверка {GravityPipe.absent_gravity.description}\n" f"на участке Нововеличковская-Крымская",
            "Количество самотеков",
            soft_failures,
        ).actual(second_site_signal_gravity).expected(str(GravityPipe.absent_gravity.id)).equal_to()
        StepCheck(
            "Проверка сигнала - режим СОУ на участке Нововеличковская-Крымская",
            "Режим СОУ",
            soft_failures,
        ).actual(second_site_signal_sou).expected(exp_second_site_reg_sou).equal_to()
        StepCheck(
            "Проверка сигнала - режим МТ на участке Крымская-Грушовая",
            "Режим МТ",
            soft_failures,
        ).actual(
            third_site_signal_pump
        ).expected(exp_third_site_reg_lu).equal_to()
        StepCheck(
            f"Проверка {GravityPipe.present_gravity.description} на участке Крымская-Грушовая",
            "Количество самотеков",
            soft_failures,
        ).actual(third_site_signal_gravity).expected(str(GravityPipe.present_gravity.id)).equal_to()
        StepCheck(
            "Проверка сигнала - режим СОУ на участке Крымская-Грушовая",
            "Режим СОУ",
            soft_failures,
        ).actual(
            third_site_signal_sou
        ).expected(exp_third_site_reg_sou).equal_to()
        StepCheck(
            "Проверка сигнала - режим МТ на резервной нитке Бейсуг",
            "Режим МТ",
            soft_failures,
        ).actual(
            fourth_site_signal_pump
        ).expected(exp_fourth_site_reg_lu).equal_to()
        StepCheck(
            f"Проверка {GravityPipe.absent_gravity.description} на резервной нитке Бейсуг",
            "Количество самотеков",
            soft_failures,
        ).actual(fourth_site_signal_gravity).expected(str(GravityPipe.absent_gravity.id)).equal_to()
        StepCheck(
            "Проверка сигнала - режим СОУ на резервной нитке Бейсуг",
            "Режим СОУ",
            soft_failures,
        ).actual(
            fourth_site_signal_sou
        ).expected(exp_fourth_site_reg_sou).equal_to()
        StepCheck(
            "Проверка сигнала - режим МТ на резервной нитке Понура",
            "Режим МТ",
            soft_failures,
        ).actual(
            fifth_site_signal_pump
        ).expected(exp_fifth_site_reg_lu).equal_to()
        StepCheck(
            "Проверка сигнала - режим СОУ на резервной нитке Понура",
            "Режим СОУ",
            soft_failures,
        ).actual(
            fifth_site_signal_sou
        ).expected(exp_fifth_site_reg_sou).equal_to()
        StepCheck(
            f"Проверка {GravityPipe.absent_gravity.description} на резервной нитке Понура",
            "Количество самотеков",
            soft_failures,
        ).actual(fifth_site_signal_gravity).expected(str(GravityPipe.absent_gravity.id)).equal_to()
        StepCheck(
            "Проверка сигнала - режим МТ на резервной нитке Кубань",
            "Режим МТ",
            soft_failures,
        ).actual(
            sixth_site_signal_pump
        ).expected(exp_sixth_site_reg_lu).equal_to()
        StepCheck(
            "Проверка сигнала - режим СОУ на резервной нитке Кубань",
            "Режим СОУ",
            soft_failures,
        ).actual(
            sixth_site_signal_sou
        ).expected(exp_sixth_site_reg_sou).equal_to()
        StepCheck(
            f"Проверка {GravityPipe.absent_gravity.description} на резервной нитке Кубань",
            "Количество самотеков",
            soft_failures,
        ).actual(sixth_site_signal_gravity).expected(str(GravityPipe.absent_gravity.id)).equal_to()
        StepCheck(
            "Проверка сигнала - режим МТ на НПЗ Афипский",
            "Режим МТ",
            soft_failures,
        ).actual(
            seventh_site_signal_pump
        ).expected(exp_seventh_site_reg_lu).equal_to()
        StepCheck(
            "Проверка сигнала - режим СОУ на НПЗ Афипский",
            "Режим СОУ",
            soft_failures,
        ).actual(
            seventh_site_signal_sou
        ).expected(exp_seventh_site_reg_sou).equal_to()
        StepCheck(
            f"Проверка {GravityPipe.absent_gravity.description} на НПЗ Афипский",
            "Количество самотеков",
            soft_failures,
        ).actual(seventh_site_signal_gravity).expected(str(GravityPipe.absent_gravity.id)).equal_to()
        StepCheck(
            "Проверка сигнала - режим МТ на НПЗ Ильинский",
            "Режим МТ",
            soft_failures,
        ).actual(
            eight_site_signal_pump
        ).expected(exp_eighth_site_reg_lu).equal_to()
        StepCheck(
            "Проверка сигнала - режим СОУ на НПЗ Ильинский",
            "Режим СОУ",
            soft_failures,
        ).actual(
            eight_site_signal_sou
        ).expected(exp_eighth_site_reg_sou).equal_to()
        StepCheck(
            f"Проверка {GravityPipe.absent_gravity.description} на НПЗ Ильинский",
            "Количество самотеков",
            soft_failures,
        ).actual(eight_site_signal_gravity).expected(str(GravityPipe.absent_gravity.id)).equal_to()


def lds_status_init_in_journal(http_client, cfg: SmokeSuiteConfig | LDSStatusConfig, imitator_start_time):
    """
    Проверка наличия записи в журнале о входе СОУ в режим Инициализация.
    """
    with allure.step("Http запрос сообщений журнала с фильтром messageTypes=LDS_STATUS"):
        end_time = datetime.now()
        request_body = t_utils.create_journal_req_body(
            pagination=Pagination(limit=TestConst.JOURNAL_PAGINATION_LIMIT, direction=Direction.FIRST.value),
            filtering=Filtering(messageTypes=int(MessageType.LDS_STATUS), objects=FilteringObjects(tuId=cfg.tu_id)),
        )
        response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_journal_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки"):
        messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', [])
        StepCheck("Проверка наличия сообщений в журнале", "messagesInfo").actual(messages_info).is_not_empty()

    with allure.step("Фильтрация сообщений по времени и technologicalSection"):
        filter_start_msk = t_utils.localize_as_moscow(imitator_start_time)
        filter_end_msk = t_utils.localize_as_moscow(end_time)

        time_filtered = [
            msg
            for msg in messages_info
            if filter_start_msk <= t_utils.ensure_moscow_timezone(msg.time) <= filter_end_msk
        ]
        time_filtered.sort(key=lambda msg: t_utils.ensure_moscow_timezone(msg.time), reverse=True)

        lds_msg = next(
            (
                msg
                for msg in time_filtered
                if msg.technologicalSection == cfg.tu_name and msg.event == TestConst.JOURNAL_EVENT_LDS_INIT_COLD_START
            ),
            None,
        )

        allure.attach(
            f"Всего получено сообщений: {len(messages_info)}\n"
            f"После фильтрации по времени ({filter_start_msk} - {filter_end_msk}): {len(time_filtered)}\n"
            f"проверка: найдено ли сообщение с technologicalSection='{cfg.tu_name}' "
            f"и event='{TestConst.JOURNAL_EVENT_LDS_INIT_ACCUM_DATA}': {'True' if lds_msg else 'False'}",
            name="Результат фильтрации сообщений журнала",
            attachment_type=allure.attachment_type.TEXT,
        )

        with allure.step(
            f"Проверка: найдено ли сообщение с technologicalSection='{cfg.tu_name}' "
            f"и event='{TestConst.JOURNAL_EVENT_LDS_INIT_COLD_START}'"
        ):
            if lds_msg is None:
                pytest.fail(
                    f"Сообщение с technologicalSection='{cfg.tu_name}' "
                    f"и event='{TestConst.JOURNAL_EVENT_LDS_INIT_COLD_START}' "
                    f"не найдено среди {len(time_filtered)} отфильтрованных по времени сообщений"
                )

    with allure.step("Проверка актуальности сообщения"):
        msg_time_msk = t_utils.ensure_moscow_timezone(lds_msg.time)
        start_time_msk = t_utils.localize_as_moscow(imitator_start_time)

        StepCheck(
            f"Проверка: время сообщения позднее времени старта имитатора {msg_time_msk} > {start_time_msk}",
            "time",
        ).actual(msg_time_msk > start_time_msk).expected(True).equal_to()

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка event", "event", soft_failures).actual(lds_msg.event).expected(
            TestConst.JOURNAL_EVENT_LDS_INIT_COLD_START
        ).equal_to()

        StepCheck("Проверка technologicalSection", "technologicalSection", soft_failures).actual(
            lds_msg.technologicalSection
        ).expected(cfg.tu_name).equal_to()

        StepCheck("Проверка technologicalObject не пустой", "technologicalObject", soft_failures).actual(
            lds_msg.technologicalObject
        ).is_not_none()

        StepCheck("Проверка priority не пустой", "priority", soft_failures).actual(lds_msg.priority).is_not_none()

        StepCheck("Проверка messageType", "messageType", soft_failures).actual(lds_msg.messageType).expected(
            TestConst.JOURNAL_MESSAGE_TYPE_LDS_STATUS
        ).equal_to()


async def main_page_info(ws_client, cfg: SmokeSuiteConfig):
    """
    Проверка установки режима МТ.
    """
    with allure.step("Подключение по ws, получение и обработка сообщения типа: MainPageInfoContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "MainPageInfoContent",
            "subscribeMainPageInfoRequest",
            {'tuIds': [cfg.tu_id], 'additionalProperties': None},
        )
        parsed_payload = parser.parse_main_page_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        tu_info = getattr(parsed_payload.replyContent, 'tuInfo', None)
        StepCheck("Проверка наличия данных по ТУ", "tuInfo").actual(tu_info).is_not_none()
        main_pipeline_stationary_status = (
            StationaryStatus(tu_info.stationaryStatus) if tu_info.stationaryStatus else None
        )

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка id полученного ТУ", "tu_id", soft_failures).actual(
            parsed_payload.replyContent.tuId
        ).expected(cfg.tu_id).equal_to()

        StepCheck(
            f"Проверка установки стационара для ТУ {cfg.tu_name}",
            "stationaryStatus",
            soft_failures,
        ).actual(
            main_pipeline_stationary_status
        ).expected(cfg.expected_stationary_status).equal_to()


async def main_page_info_signals(ws_client, cfg: SmokeSuiteConfig):
    """
    Проверка счетчиков состояния сигналов
    """
    with allure.step("Подключение по ws, получение и обработка сообщения типа: MainPageSignalsInfoContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "MainPageSignalsInfoContent",
            "subscribeMainPageSignalsInfoRequest",
            {'tuIds': [cfg.tu_id], 'additionalProperties': None},
        )
        parsed_payload = parser.parse_main_page_signals_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        signals_info = getattr(parsed_payload.replyContent, 'signalsInfo', None)
        StepCheck("Проверка данных сигналов ТУ", "signalsInfo").actual(signals_info).is_not_none()

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка id полученного ТУ", "tu_id", soft_failures).actual(
            parsed_payload.replyContent.tuId
        ).expected(cfg.tu_id).equal_to()
        field_name = "numberOfRejectedSignals"
        # Проверяет что количество отбракованных сигналов больше или равно ОР
        StepCheck(
            f"Проверка количества отбракованных сигналов ТУ {cfg.tu_name}",
            field_name,
            soft_failures,
        ).actual(
            signals_info.numberOfRejectedSignals
        ).is_greater_than_or_equal_to(cfg.expected_main_page_signals[field_name])
        field_name = "numberOfMaskedSignals"
        StepCheck(
            f"Проверка количества маскированных сигналов ТУ {cfg.tu_name}",
            field_name,
            soft_failures,
        ).actual(
            signals_info.numberOfMaskedSignals
        ).expected(cfg.expected_main_page_signals[field_name]).equal_to()
        field_name = "numberOfImitatedSignals"
        StepCheck(
            f"Проверка количества имитированных сигналов ТУ {cfg.tu_name}",
            field_name,
            soft_failures,
        ).actual(
            signals_info.numberOfImitatedSignals
        ).expected(cfg.expected_main_page_signals[field_name]).equal_to()


async def main_page_info_unstationary(ws_client, cfg: SmokeSuiteConfig):
    """
    Проверка установки режима Нестационар (для наборов с несколькими утечками).
    Запускается после первой утечки, когда режим переходит в Нестационар.
    """
    with allure.step("Подключение по ws, получение и обработка сообщения типа: MainPageInfoContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "MainPageInfoContent",
            "subscribeMainPageInfoRequest",
            {'tuIds': [cfg.tu_id], 'additionalProperties': None},
        )
        parsed_payload = parser.parse_main_page_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        tu_info = getattr(parsed_payload.replyContent, 'tuInfo', None)
        StepCheck("Проверка наличия данных по ТУ", "tuInfo").actual(tu_info).is_not_none()

        main_pipeline_stationary_status = (
            StationaryStatus(tu_info.stationaryStatus) if tu_info.stationaryStatus else None
        )
    with SoftAssertions() as soft_failures:
        StepCheck("Проверка id полученного ТУ", "tu_id", soft_failures).actual(
            parsed_payload.replyContent.tuId
        ).expected(cfg.tu_id).equal_to()

        StepCheck(
            f"Проверка установки режима Нестационар для ТУ {cfg.tu_name}",
            "stationaryStatus",
            soft_failures,
        ).actual(main_pipeline_stationary_status).expected(StationaryStatus.UNSTATIONARY).equal_to()


async def leak_is_confirm_on_main_page(ws_client, cfg: SmokeSuiteConfig):
    """
    MainPageInfoContent - проверка подтвержденной утечки на ЭФ Состояние МТ
    """
    with allure.step("Подключение по ws, получение и обработка сообщения типа: MainPageInfoContent."):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "MainPageInfoContent",
            "subscribeMainPageInfoRequest",
            {'tuIds': [cfg.tu_id], 'additionalProperties': None},
        )
        parsed_payload = parser.parse_main_page_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки"):
        # Ставим object() как заглушку, что бы исключить получение None
        leaks_info = getattr(getattr(parsed_payload.replyContent, 'tuInfo', object()), 'leaksInfo', [])
        StepCheck("Проверка наличия списка сообщений об утечках", "leakStatus").actual(leaks_info).is_not_empty()
        confirm_leak = t_utils.find_object_by_field(leaks_info, "leakStatus", LeakStatus.CONFIRMED.value)
        lds_status_int = getattr(confirm_leak, 'leakStatus', None)
        lds_status = LeakStatus(lds_status_int) if lds_status_int else None
    StepCheck("Проверка подтвержденной утечки на ЭФ Состояние МТ", "leakStatus").actual(lds_status).expected(
        LeakStatus.CONFIRMED
    ).equal_to()


async def leak_is_complete_on_main_page(ws_client, cfg: SmokeSuiteConfig):
    """
    MainPageInfoContent - отсутствует подтвержденная утечка на ЭФ Состояние МТ
    """
    with allure.step("Подключение по ws, получение и обработка сообщения типа: MainPageInfoContent."):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "MainPageInfoContent",
            "subscribeMainPageInfoRequest",
            {'tuIds': [cfg.tu_id], 'additionalProperties': None},
        )
        parsed_payload = parser.parse_main_page_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки"):
        main_page_leak_info = parsed_payload.replyContent.tuInfo.leaksInfo
        confirmed_and_closed_leaks = t_utils.find_confirmed_leaks_on_main_page(main_page_leak_info)

    StepCheck("Проверка подтвержденной утечки на ЭФ Состояние МТ", "leakStatus").actual(
        confirmed_and_closed_leaks
    ).is_empty()


async def imitate_sensor_signal(
    ws_client, http_client, cfg: SmokeSuiteConfig, test_data: CaseData, imitator_start_time
):
    """
    Проверка имитации сигнала датчика.
    """
    # Распаковка данных для теста
    sensor_address = test_data.params.get("sensor_address")
    sensor_val, sensor_quality, imitation_event, unimitation_event = test_data.expected_result
    # Получение актуального id датчика
    sensor_id = TestConst.SENSOR_IDS_BY_ADDRESS.get(sensor_address)
    with allure.step(f"Http запрос и обработка ответа об имитации сигнала датчика с id: {sensor_id}"):
        request_body = {
            'id': sensor_id,
            'tuId': cfg.tu_id,
            'imitateInfo': {'value': str(sensor_val), 'quality': sensor_quality},
        }
        response = http_client.post_request(HttpConst.IMITATE_SIGNAL_URL_PATH, request_body)
        sensor_imitate_reply_status = response.status_code

        StepCheck("Проверка кода ответа на запрос об имитации", "replyStatus").actual(
            sensor_imitate_reply_status
        ).expected(ReplyStatus.OK.value).equal_to()

        time.sleep(cfg.basic_message_timeout)

    with allure.step(
        "Получение данных для проверки имитации. Тип сообщения: InputSignalsContent. ЭФ Диагностика Сигналов."
    ):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "InputSignalsContent",
            "SubscribeInputSignalsRequest",
            {
                'signalIds': [sensor_id],
                'tuId': cfg.tu_id,
                'additionalProperties': None,
            },
        )
        parsed_payload = parser.parse_input_signals_info_msg(payload)

    with allure.step(
        "Подготовка данных для проверки имитации. Тип сообщения: InputSignalsContent. ЭФ Диагностика Сигналов."
    ):
        sensor_data = getattr(parsed_payload.replyContent, 'inputSignals', [])
        diagnostics_sensor_imitate_data = t_utils.find_object_by_field(sensor_data, "id", sensor_id)
        value_diagnostics_sensor_imitate = getattr(
            getattr(diagnostics_sensor_imitate_data, 'imitation', []), 'value', []
        )
        is_imitated_diagnostics_sensor_imitate = getattr(diagnostics_sensor_imitate_data, 'isImitated', [])
        quality_diagnostics_sensor_imitate = getattr(diagnostics_sensor_imitate_data, 'quality', [])

    with allure.step("Подготовка данных для проверки имитации. Тип сообщения: SchemeSignalsStateContent. ЭФ Схема."):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "SchemeSignalsStateContent",
            "SubscribeSchemeSignalsStateRequest",
            {'tuId': cfg.tu_id},
        )
        parsed_payload = parser.parse_scheme_signals_state_msg(payload)
        journal_signal_state_list = parsed_payload.replyContent.signalsStates
        scheme_sensor_imitate_data = t_utils.find_object_by_field(journal_signal_state_list, "id", sensor_id)
        is_imitated_scheme_sensor_imitate = getattr(scheme_sensor_imitate_data, 'isImitated', [])

    with allure.step(f"Http запрос и обработка ответа о снятии имитации датчика с id: {sensor_id}"):
        request_body = {'id': sensor_id, 'tuId': cfg.tu_id}
        response = http_client.post_request(HttpConst.UNIMITATE_SIGNAL_URL_PATH, request_body)
        sensor_unimitate_reply_status = response.status_code

        StepCheck("Проверка кода ответа на запрос о снятии имитации", "replyStatus").actual(
            sensor_unimitate_reply_status
        ).expected(ReplyStatus.OK.value).equal_to()
        time.sleep(cfg.basic_message_timeout)

    with allure.step("Получение данных для проверки имитации. InputSignalsContent. ЭФ Диагностика Сигналов."):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "InputSignalsContent",
            "SubscribeInputSignalsRequest",
            {
                'signalIds': [sensor_id],
                'tuId': cfg.tu_id,
                'additionalProperties': None,
            },
        )
        parsed_payload = parser.parse_input_signals_info_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки снятия имитации"):
        sensor_data = getattr(parsed_payload.replyContent, 'inputSignals', [])
        diagnostics_sensor_unimitate_data = t_utils.find_object_by_field(sensor_data, 'id', sensor_id)
        is_unimitated_diagnostics_sensor_unimitate = getattr(diagnostics_sensor_unimitate_data, 'isImitated', [])

    with allure.step(
        "Получение данных для проверки снятия имитации. Тип сообщения: SchemeSignalsStateContent. ЭФ Схема"
    ):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "SchemeSignalsStateContent",
            "SubscribeSchemeSignalsStateRequest",
            {'tuId': cfg.tu_id},
        )
        parsed_payload = parser.parse_scheme_signals_state_msg(payload)
        journal_signal_state_list = parsed_payload.replyContent.signalsStates
        scheme_sensor_unimitate = t_utils.find_object_by_field(journal_signal_state_list, "id", sensor_id)
        is_unimitated_scheme_sensor_unimitate = getattr(scheme_sensor_unimitate, 'isImitated', [])

    with allure.step("Http запрос сообщений журнала с фильтром userActions"):
        # Запрос сообщений по типу действий пользователя маскирование и имитация сигналов
        request_body = t_utils.create_journal_req_body(
            pagination=Pagination(limit=TestConst.JOURNAL_PAGINATION_LIMIT, direction=Direction.FIRST.value),
            filtering=Filtering(userActions=int(UserActions.SIGNAL_MASK_SIM), objects=FilteringObjects(tuId=cfg.tu_id)),
        )
        response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_journal_msg(payload)
        messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', [])

        # Фильтр полученных сообщений по времени
        end_time = datetime.now()
        filter_start_msk = t_utils.localize_as_moscow(imitator_start_time)
        filter_end_msk = t_utils.localize_as_moscow(end_time)
        filter_time_messages = [
            msg
            for msg in messages_info
            if filter_start_msk <= t_utils.ensure_moscow_timezone(msg.time) <= filter_end_msk
        ]
        filter_by_imitation_messages = [
            msg
            for msg in filter_time_messages
            if getattr(msg, "event", None) == imitation_event and getattr(msg, "tag", None) == sensor_address
        ]
        filter_by_unimitation_messages = [
            msg
            for msg in filter_time_messages
            if getattr(msg, "event", None) == imitation_event and getattr(msg, "tag", None) == sensor_address
        ]

    with SoftAssertions() as soft_failures:
        StepCheck(
            f"Проверка имитации датчика с id: {sensor_id}. ЭФ Диагностика Сигналов.", "isImitated", soft_failures
        ).actual(is_imitated_diagnostics_sensor_imitate).expected(True).equal_to()
        StepCheck(f"Проверка имитации датчика с id: {sensor_id}. ЭФ Схема.", "isImitated", soft_failures).actual(
            is_imitated_scheme_sensor_imitate
        ).expected(True).equal_to()
        StepCheck(
            f"Проверка показаний датчика с id: {sensor_id}. ЭФ Диагностика Сигналов.", "value", soft_failures
        ).actual(value_diagnostics_sensor_imitate).expected(sensor_val).equal_to()
        StepCheck(
            f"Проверка качества сигнала датчика с id: {sensor_id}. ЭФ Диагностика Сигналов.", "quality", soft_failures
        ).actual(quality_diagnostics_sensor_imitate).expected(sensor_quality).equal_to()
        StepCheck(
            f"Проверка наличия сообщения с событием {imitation_event} и тегом {sensor_address}. ЭФ Журнал.",
            "event",
            soft_failures,
        ).actual(filter_by_imitation_messages).is_not_empty()
        StepCheck(
            f"Проверка снятия имитации датчика с id: {sensor_id}. ЭФ Диагностика Сигналов.", "isImitated", soft_failures
        ).actual(is_unimitated_diagnostics_sensor_unimitate).expected(False).equal_to()
        StepCheck(f"Проверка снятия имитации датчика с id: {sensor_id}. ЭФ Схема.", "isImitated", soft_failures).actual(
            is_unimitated_scheme_sensor_unimitate
        ).expected(False).equal_to()
        StepCheck(
            f" Проверка наличия сообщения с событием {unimitation_event} и тегом {sensor_address}. ЭФ Журнал.",
            "event",
            soft_failures,
        ).actual(filter_by_unimitation_messages).is_not_empty()


async def mask_signal_test(ws_client, http_client, cfg: SmokeSuiteConfig, test_data: CaseData):
    """
    Проверка маскирования датчиков.
    """

    if not test_data:
        pytest.fail("Данные датчиков отсутствуют")
    pressure_sensor_address = test_data.params.get("pressure_sensor_address")
    flowmeter_address = test_data.params.get("flowmeter_address")
    # Получение актуальных id датчиков
    pressure_sensor_id = TestConst.SENSOR_IDS_BY_ADDRESS.get(pressure_sensor_address)
    flowmeter_id = TestConst.SENSOR_IDS_BY_ADDRESS.get(flowmeter_address)
    with allure.step("Маскирование датчиков"):
        with allure.step(f"Http запрос и обработка ответа о маскировании датчика давления с id: {pressure_sensor_id}"):
            request_body = {'id': pressure_sensor_id, 'tuId': cfg.tu_id}
            response = http_client.post_request(HttpConst.MASK_SIGNAL_URL_PATH, request_body)
            pressure_sensor_mask_reply_status = response.status_code

            StepCheck("Проверка кода ответа на запрос о маскировании", "replyStatus").actual(
                pressure_sensor_mask_reply_status
            ).expected(ReplyStatus.OK.value).equal_to()

        with allure.step(f"Http запрос и обработка ответа о маскировании расходомера с id: {flowmeter_id}"):
            request_body = {'id': flowmeter_id, 'tuId': cfg.tu_id}
            response = http_client.post_request(HttpConst.MASK_SIGNAL_URL_PATH, request_body)
            flowmeter_mask_reply_status = response.status_code

            StepCheck("Проверка кода ответа на запрос о маскировании", "replyStatus").actual(
                flowmeter_mask_reply_status
            ).expected(ReplyStatus.OK.value).equal_to()

    with allure.step(
        "Подключение по ws, получение и обработка данных о статусе датчиков из сообщения типа: InputSignalsContent"
    ):
        time.sleep(cfg.basic_message_timeout)
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "InputSignalsContent",
            "SubscribeInputSignalsRequest",
            {
                'signalIds': [pressure_sensor_id, flowmeter_id],
                'tuId': cfg.tu_id,
                'additionalProperties': None,
            },
        )
        parsed_payload = parser.parse_input_signals_info_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки маскирования"):
        sensor_data = getattr(parsed_payload.replyContent, 'inputSignals', [])
        pressure_sensor_mask_data = t_utils.find_object_by_field(sensor_data, "id", pressure_sensor_id)
        flowmeter_mask_data = t_utils.find_object_by_field(sensor_data, "id", flowmeter_id)
        with SoftAssertions() as soft_failures:
            StepCheck("Проверка наличия данных о маскировании датчика давления", "inputSignals", soft_failures).actual(
                pressure_sensor_mask_data
            ).is_not_none()
            StepCheck("Проверка наличия данных о маскировании расходомера", "inputSignals", soft_failures).actual(
                flowmeter_mask_data
            ).is_not_none()

    with allure.step("Снятие маскирования датчиков"):
        with allure.step(f"Http запрос о снятии маскирования датчика давления с id: {pressure_sensor_id}"):
            request_body = {'id': pressure_sensor_id, 'tuId': cfg.tu_id}
            response = http_client.post_request(HttpConst.UNMASK_SIGNAL_URL_PATH, request_body)
            pressure_sensor_unmask_reply_status = response.status_code
            StepCheck("Проверка кода ответа на запрос о снятии маскирования", "replyStatus").actual(
                pressure_sensor_unmask_reply_status
            ).expected(ReplyStatus.OK.value).equal_to()

        with allure.step(f"Http запрос о снятии маскирования расходомера с id: {flowmeter_id}"):
            request_body = {'id': flowmeter_id, 'tuId': cfg.tu_id}
            response = http_client.post_request(HttpConst.UNMASK_SIGNAL_URL_PATH, request_body)
            flowmeter_unmask_reply_status = response.status_code
            StepCheck("Проверка кода ответа на запрос о снятии маскирования", "replyStatus").actual(
                flowmeter_unmask_reply_status
            ).expected(ReplyStatus.OK.value).equal_to()

    with allure.step(
        "Подключение по ws, получение и обработка данных о статусе датчиков из сообщения типа: InputSignalsContent"
    ):
        time.sleep(cfg.basic_message_timeout)
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "InputSignalsContent",
            "SubscribeInputSignalsRequest",
            {
                'signalIds': [pressure_sensor_id, flowmeter_id],
                'tuId': cfg.tu_id,
                'additionalProperties': None,
            },
        )
        parsed_payload = parser.parse_input_signals_info_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки снятия маскирования"):
        sensor_data = getattr(parsed_payload.replyContent, 'inputSignals', [])
        pressure_sensor_unmask_data = t_utils.find_object_by_field(sensor_data, "id", pressure_sensor_id)
        flowmeter_unmask_data = t_utils.find_object_by_field(sensor_data, "id", flowmeter_id)
        with SoftAssertions() as soft_failures:
            StepCheck(
                "Проверка наличия данных о снятии маскировании датчика давления", "inputSignals", soft_failures
            ).actual(pressure_sensor_unmask_data).is_not_none()
            StepCheck(
                "Проверка наличия данных о снятии маскировании расходомера", "inputSignals", soft_failures
            ).actual(flowmeter_unmask_data).is_not_none()

    with SoftAssertions() as soft_failures:
        StepCheck(
            f"Проверка маскирования датчика давления с id: {pressure_sensor_id}", "isMasked", soft_failures
        ).actual(pressure_sensor_mask_data.isMasked).expected(True).equal_to()
        StepCheck(f"Проверка маскирования расходомера с id: {flowmeter_id}", "isMasked", soft_failures).actual(
            flowmeter_mask_data.isMasked
        ).expected(True).equal_to()
        StepCheck(
            f"Проверка снятия маскирования датчика давления с id: {pressure_sensor_id}", "isMasked", soft_failures
        ).actual(pressure_sensor_unmask_data.isMasked).expected(False).equal_to()
        StepCheck(f"Проверка снятия маскирования расходомера с id: {flowmeter_id}", "isMasked", soft_failures).actual(
            flowmeter_unmask_data.isMasked
        ).expected(False).equal_to()


async def mask_info_in_journal(http_client, cfg: SmokeSuiteConfig, imitator_start_time):
    """
    Проверка записей журнала о маскировании и размаскировании.
    """
    with allure.step("Http запрос сообщений журнала с фильтром userActions"):
        end_time = datetime.now()
        request_body = t_utils.create_journal_req_body(
            pagination=Pagination(limit=TestConst.JOURNAL_MASK_PAGINATION_LIMIT, direction=Direction.FIRST.value),
            filtering=Filtering(userActions=int(UserActions.SIGNAL_MASK_SIM), objects=FilteringObjects(tuId=cfg.tu_id)),
        )
        response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_journal_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки"):
        messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', [])
        StepCheck("Проверка наличия сообщений в журнале", "messagesInfo").actual(messages_info).is_not_empty()

    with allure.step("Фильтрация сообщений по событиям маскирования и временному диапазону"):
        filter_start_msk = t_utils.localize_as_moscow(imitator_start_time)
        filter_end_msk = t_utils.localize_as_moscow(end_time)

        mask_unmask_msgs = [
            msg
            for msg in messages_info
            if msg.event in TestConst.JOURNAL_MASK_EXPECTED_EVENTS
            and msg.signalName in TestConst.JOURNAL_MASK_EXPECTED_SIGNALS
        ]

        journal_messages = [
            msg
            for msg in mask_unmask_msgs
            if filter_start_msk <= t_utils.ensure_moscow_timezone(msg.time) <= filter_end_msk
        ]

        allure.attach(
            f"Всего получено сообщений: {len(messages_info)}\n"
            f"После фильтрации по event и signalName осталось сообщений: {len(mask_unmask_msgs)}\n"
            f"После фильтрации по времени ({filter_start_msk} - {filter_end_msk}) "
            f"осталось сообщений: {len(journal_messages)}",
            name="Результат фильтрации сообщений журнала",
            attachment_type=allure.attachment_type.TEXT,
        )

    with allure.step("Группировка отфильтрованных сообщений"):
        pressure_msgs = [msg for msg in journal_messages if msg.signalName == TestConst.JOURNAL_SIGNAL_PRESSURE]
        flow_msgs = [msg for msg in journal_messages if msg.signalName == TestConst.JOURNAL_SIGNAL_FLOW]

        mask_event_msgs = [msg for msg in journal_messages if msg.event == TestConst.JOURNAL_EVENT_MASK]
        unmask_event_msgs = [msg for msg in journal_messages if msg.event == TestConst.JOURNAL_EVENT_UNMASK]
        mask_signal_names = {msg.signalName for msg in mask_event_msgs}
        unmask_signal_names = {msg.signalName for msg in unmask_event_msgs}

    with SoftAssertions() as journal_soft_failures:
        StepCheck(
            "Проверка соответствия количества сообщений о действиях пользователя (снятие и установка "
            "маскирования для датчиков давления и расходомеров)",
            "total_count",
            journal_soft_failures,
        ).actual(len(journal_messages)).expected(TestConst.JOURNAL_EXPECTED_MASK_MSG_TOTAL).equal_to()

        StepCheck(
            f"Проверка соответствия количества сообщений "
            f"о действиях пользователя для датчиков давления - '{TestConst.JOURNAL_SIGNAL_PRESSURE}'",
            "count",
            journal_soft_failures,
        ).actual(len(pressure_msgs)).expected(TestConst.JOURNAL_EXPECTED_MSG_COUNT_PER_SIGNAL).equal_to()

        StepCheck(
            f"Проверка соответствия количества сообщений "
            f"о действиях пользователя для расходомеров - '{TestConst.JOURNAL_SIGNAL_FLOW}'",
            "count",
            journal_soft_failures,
        ).actual(len(flow_msgs)).expected(TestConst.JOURNAL_EXPECTED_MSG_COUNT_PER_SIGNAL).equal_to()

        StepCheck(
            f"Проверка: событие '{TestConst.JOURNAL_EVENT_MASK}' содержит '{TestConst.JOURNAL_SIGNAL_PRESSURE}'",
            "signalName",
            journal_soft_failures,
        ).actual(TestConst.JOURNAL_SIGNAL_PRESSURE in mask_signal_names).expected(True).equal_to()

        StepCheck(
            f"Проверка: событие '{TestConst.JOURNAL_EVENT_MASK}' содержит '{TestConst.JOURNAL_SIGNAL_FLOW}'",
            "signalName",
            journal_soft_failures,
        ).actual(TestConst.JOURNAL_SIGNAL_FLOW in mask_signal_names).expected(True).equal_to()

        StepCheck(
            f"Проверка: событие '{TestConst.JOURNAL_EVENT_UNMASK}' содержит '{TestConst.JOURNAL_SIGNAL_PRESSURE}'",
            "signalName",
            journal_soft_failures,
        ).actual(TestConst.JOURNAL_SIGNAL_PRESSURE in unmask_signal_names).expected(True).equal_to()

        StepCheck(
            f"Проверка: событие '{TestConst.JOURNAL_EVENT_UNMASK}' содержит '{TestConst.JOURNAL_SIGNAL_FLOW}'",
            "signalName",
            journal_soft_failures,
        ).actual(TestConst.JOURNAL_SIGNAL_FLOW in unmask_signal_names).expected(True).equal_to()

        for signal_name in [TestConst.JOURNAL_SIGNAL_PRESSURE, TestConst.JOURNAL_SIGNAL_FLOW]:
            mask_msg_for_signal = next((msg for msg in mask_event_msgs if msg.signalName == signal_name), None)
            unmask_msg_for_signal = next((msg for msg in unmask_event_msgs if msg.signalName == signal_name), None)

            if mask_msg_for_signal and unmask_msg_for_signal:
                StepCheck(
                    f"Проверка совпадения tag для '{signal_name}' между маскированием и снятием",
                    "tag",
                    journal_soft_failures,
                ).actual(mask_msg_for_signal.tag).expected(unmask_msg_for_signal.tag).equal_to()

        for msg in journal_messages:
            msg_label = f"{msg.event} - {msg.signalName}"

            StepCheck(
                f"Проверка user не пустой [{msg_label}]",
                "user",
                journal_soft_failures,
            ).actual(msg.user).is_not_none()

            StepCheck(
                f"Проверка object не пустой [{msg_label}]",
                "object",
                journal_soft_failures,
            ).actual(msg.object).is_not_none()

            StepCheck(
                f"Проверка technologicalObject не пустой [{msg_label}]",
                "technologicalObject",
                journal_soft_failures,
            ).actual(msg.technologicalObject).is_not_none()

            StepCheck(
                f"Проверка technologicalSection [{msg_label}]",
                "technologicalSection",
                journal_soft_failures,
            ).actual(msg.technologicalSection).expected(cfg.tu_name).equal_to()

            StepCheck(
                f"Проверка priority не пустой [{msg_label}]",
                "priority",
                journal_soft_failures,
            ).actual(msg.priority).is_not_none()

            StepCheck(
                f"Проверка messageType [{msg_label}]",
                "messageType",
                journal_soft_failures,
            ).actual(
                msg.messageType
            ).expected(TestConst.JOURNAL_MESSAGE_TYPE_USER_ACTIONS).equal_to()

            StepCheck(
                f"Проверка status [{msg_label}]",
                "status",
                journal_soft_failures,
            ).actual(
                msg.status
            ).expected(TestConst.JOURNAL_STATUS_SUCCESS).equal_to()


async def mask_du_on_mini_scheme(ws_client, http_client, cfg: SmokeSuiteConfig):
    """
    Маскирование ДУ на мини-схеме
    Проверка маскированного участка в выходных сигналах

    """
    linear_part_id = cfg.linear_part_identifier_for_mask
    mask_reason = cfg.mask_reason

    with allure.step("Http запрос на маскирование ДУ утечки типа: MaskLdsRequest"):
        request_body = {
            'tuId': cfg.tu_id,
            'maskInfo': [
                {
                    'linearPartId': linear_part_id,
                    'reason': mask_reason,
                    'additionalProperties': None,
                }
            ],
            'additionalProperties': None,
        }
        response = http_client.post_request(HttpConst.MASK_LDS_URL_PATH, request_body)
        mask_du_reply_status = response.status_code
        time.sleep(cfg.basic_message_timeout)

    with allure.step("Подключение по ws и http, получение и обработка данных сообщений для теста"):
        with allure.step("Http запрос, получение и обработка ответа типа: GetOutputSignalsRequest"):
            request_body = {'tuId': cfg.tu_id}
            response = http_client.post_request(HttpConst.GET_OUTPUT_SIGNALS_URL_PATH, request_body)
            payload = t_utils.get_json_from_http_response(response)
            parsed_payload = parser.parse_output_signals_msg(payload)

            # Получение данных линейного участка утечки по id
            with allure.step(
                "Извлечение и подготовка данных типов выходных сигналов из обработанных данных ЭФ Выходные сигналы"
            ):
                leak_linear_part = t_utils.find_object_by_field(
                    parsed_payload.replyContent.linearPartSignals,
                    TestConst.LEAK_LINEAR_PART_ID_KEY,
                    linear_part_id,
                )
                leak_signals_list = leak_linear_part.signals
                mask_signal_type = t_utils.find_signal_type_by_address_suffix(
                    leak_signals_list, TestConst.ADDRESS_SUFFIX_MASK
                )
                StepCheck(
                    "Проверка получения типов выходных сигналов в ЭФ Выходные сигналы", "linearPartSignals"
                ).actual(mask_signal_type).is_not_none()

        with allure.step(
            "Получение сообщения типа: OutputSignalsInfo. "
            f"С данными выходных сигналов для линейного участка с id: {linear_part_id}\n"
        ):
            payload = await t_utils.connect_and_subscribe_msg(
                ws_client,
                "OutputSignalsInfo",
                "SubscribeOutputSignalsRequest",
                {
                    'objects': {
                        'linearParts': [{'linearPartId': linear_part_id}],
                        'controlledSites': [],
                    },
                    'signalTypes': 1023,
                    'tuId': cfg.tu_id,
                    'additionalProperties': None,
                },
            )
            parsed_payload = parser.parse_output_signals_info_msg(payload)
        with allure.step("Извлечение и подготовка данных для проверки маскирования ДУ ЭФ Выходные сигналы"):
            leak_linear_part = t_utils.find_object_by_field(
                parsed_payload.replyContent.linearPartSignals,
                TestConst.LEAK_LINEAR_PART_ID_KEY,
                linear_part_id,
            )
            StepCheck("Проверка наличия данных о маскировании в ЭФ Выходные сигналы", "linearPartSignals").actual(
                leak_linear_part
            ).is_not_none()
            leak_signals_list = leak_linear_part.signals
            mask_leak_value = t_utils.find_signal_val_by_signal_type(leak_signals_list, mask_signal_type)

        with allure.step("Подключение по ws, получение и обработка сообщения типа: CommonSchemeContent. ЭФ Схема"):
            payload = await t_utils.connect_and_subscribe_msg(
                ws_client,
                "CommonSchemeContent",
                "SubscribeCommonSchemeRequest",
                {'tuId': cfg.tu_id, 'additionalProperties': None},
            )
            parsed_payload = parser.parse_common_scheme_info_msg(payload)

        with allure.step("Извлечение и подготовка данных для проверки маскирования ДУ ЭФ Схема"):
            linear_parts = parsed_payload.replyContent.linearParts
            mask_linear_part = next((lp for lp in linear_parts if lp.id == linear_part_id), None)

        with allure.step("Http запрос сообщений журнала с фильтром messageTypes=MASKING_LDS"):
            request_body = t_utils.create_journal_req_body(
                pagination=Pagination(limit=10, direction=Direction.FIRST.value),
                filtering=Filtering(
                    messageTypes=int(MessageType.MASKING_LDS), objects=FilteringObjects(tuId=cfg.tu_id)
                ),
            )
            response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
            payload = t_utils.get_json_from_http_response(response)
            parsed_payload = parser.parse_journal_msg(payload)

        with allure.step("Извлечение и подготовка данных для проверки маскирования ДУ ЭФ Журнал"):
            messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', None)
            if cfg.technological_section:
                journal_mask_message = t_utils.find_object_by_field(
                    messages_info, "technologicalSection", cfg.technological_section
                )
            else:
                journal_mask_message = parsed_payload.replyContent.messagesInfo[0]
            StepCheck("Проверка наличия данных о маскировании в журнале", "technologicalSection").actual(
                journal_mask_message
            ).is_not_none()

        with allure.step(
            "Подключение по ws, получение и обработка сообщения типа: MainPageInfoContent. "
            "Получен результат маскирования ДУ на ЭФ Состояние МТ"
        ):
            payload = await t_utils.connect_and_subscribe_msg(
                ws_client,
                "MainPageInfoContent",
                "subscribeMainPageInfoRequest",
                {'tuIds': [cfg.tu_id], 'additionalProperties': None},
            )
            parsed_payload = parser.parse_main_page_msg(payload)

        with allure.step("Извлечение и подготовка данных для проверки маскирования ДУ ЭФ Состояние МТ"):
            lds_status = getattr(getattr(parsed_payload.replyContent, "tuInfo", None), 'ldsStatus', None)
            number_of_masked_lps = getattr(lds_status, 'numberOfMaskedLps', None)
            mask_du_list = getattr(lds_status, "maskedLps", None)
            if mask_du_list:
                masked_lps_name = next(iter(mask_du_list), None)
            else:
                masked_lps_name = None
    # Проверки сообщений
    with SoftAssertions() as soft_failures:
        StepCheck("Проверка кода ответа на запрос о маскировании ДУ", "replyStatus", soft_failures).actual(
            mask_du_reply_status
        ).expected(ReplyStatus.OK.value).equal_to()
        StepCheck(
            "Проверка сигнала маскирования ДУ в выходных сигналах", TestConst.ADDRESS_SUFFIX_MASK, soft_failures
        ).actual(mask_leak_value).expected(TestConst.OUTPUT_IS_MASK).equal_to()
        StepCheck("Проверка статуса маскирования ДУ на схеме", "isMasked", soft_failures).actual(
            mask_linear_part.isMasked
        ).expected(True).equal_to()
        StepCheck("Проверка причины маски на схеме", "maskReason").actual(mask_linear_part.maskReason).expected(
            cfg.mask_reason
        ).equal_to()
        StepCheck("Проверка имени ТУ в журнале", "mainPipeline", soft_failures).actual(
            journal_mask_message.mainPipeline
        ).expected(cfg.main_pipe_line).equal_to()
        StepCheck("Проверка имени ДУ в журнале", "technologicalObject", soft_failures).actual(
            journal_mask_message.technologicalObject
        ).expected(cfg.mask_du_name).equal_to()
        StepCheck("Проверка события в журнале", "event", soft_failures).actual(journal_mask_message.event).expected(
            cfg.mask_du_event
        ).equal_to()
        StepCheck("Проверка количества маскированных ДУ. ЭФ Состояние МТ", "numberOfMaskedLps", soft_failures).actual(
            number_of_masked_lps
        ).expected(cfg.mask_one_du).equal_to()
        StepCheck("Проверка имени маскированного ДУ. ЭФ Состояние МТ", "maskedLps", soft_failures).actual(
            masked_lps_name
        ).expected(cfg.mask_du_name).equal_to()


async def unmask_du_on_mini_scheme(ws_client, http_client, cfg: SmokeSuiteConfig):
    """
    Размаскирование ДУ на мини-схеме
    Проверка маскированного участка в выходных сигналах

    """
    linear_part_id = cfg.linear_part_identifier_for_mask
    unmask_reason = cfg.unmask_reason
    with allure.step("Http запрос на снятие маскирования ДУ типа: UnmaskLdsRequest"):
        request_body = {
            'tuId': cfg.tu_id,
            'maskInfo': [
                {
                    'linearPartId': linear_part_id,
                    'reason': unmask_reason,
                    'additionalProperties': None,
                }
            ],
            'additionalProperties': None,
        }
        response = http_client.post_request(HttpConst.UNMASK_LDS_URL_PATH, request_body)
        unmask_du_reply_status = response.status_code
        time.sleep(cfg.basic_message_timeout)

    with allure.step("Подключение по ws, получение и обработка данных сообщений для теста"):
        with allure.step("Http запрос, получение и обработка ответа типа: GetOutputSignalsRequest"):
            request_body = {'tuId': cfg.tu_id}
            response = http_client.post_request(HttpConst.GET_OUTPUT_SIGNALS_URL_PATH, request_body)
            payload = t_utils.get_json_from_http_response(response)
            parsed_payload = parser.parse_output_signals_msg(payload)
            with allure.step("Извлечение и подготовка данных типов выходных сигналов ЭФ Выходные сигналы"):
                # Получение данных линейного участка утечки по id
                leak_linear_part = t_utils.find_object_by_field(
                    parsed_payload.replyContent.linearPartSignals,
                    TestConst.LEAK_LINEAR_PART_ID_KEY,
                    linear_part_id,
                )
                leak_signals_list = leak_linear_part.signals
                mask_signal_type = t_utils.find_signal_type_by_address_suffix(
                    leak_signals_list, TestConst.ADDRESS_SUFFIX_MASK
                )
                StepCheck("Проверка наличия данных типов выходных сигналов ЭФ Выходные сигналы", "signalType").actual(
                    mask_signal_type
                ).is_not_none()

        with allure.step(
            "Получение сообщения типа: OutputSignalsInfo. "
            f"С данными выходных сигналов для линейного участка с id: {linear_part_id}\n"
        ):
            payload = await t_utils.connect_and_subscribe_msg(
                ws_client,
                "OutputSignalsInfo",
                "SubscribeOutputSignalsRequest",
                {
                    'objects': {
                        'linearParts': [{'linearPartId': linear_part_id}],
                        'controlledSites': [],
                    },
                    'signalTypes': 1023,
                    'tuId': cfg.tu_id,
                    'additionalProperties': None,
                },
            )
            parsed_payload = parser.parse_output_signals_info_msg(payload)
        with allure.step("Извлечение и подготовка данных для проверки снятия маскирования ДУ ЭФ Выходные сигналы"):
            leak_linear_part = t_utils.find_object_by_field(
                parsed_payload.replyContent.linearPartSignals,
                TestConst.LEAK_LINEAR_PART_ID_KEY,
                linear_part_id,
            )
            StepCheck(
                "Проверка наличия данных о снятии маскирования в ЭФ Выходные сигналы", "linearPartSignals"
            ).actual(leak_linear_part).is_not_none()
            leak_signals_list = leak_linear_part.signals
            mask_leak_value = t_utils.find_signal_val_by_signal_type(leak_signals_list, mask_signal_type)

        with allure.step("Http запрос сообщений журнала с фильтром messageTypes=MASKING_LDS"):
            request_body = t_utils.create_journal_req_body(
                pagination=Pagination(limit=10, direction=Direction.FIRST.value),
                filtering=Filtering(
                    messageTypes=int(MessageType.MASKING_LDS), objects=FilteringObjects(tuId=cfg.tu_id)
                ),
            )
            response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
            payload = t_utils.get_json_from_http_response(response)
            parsed_payload = parser.parse_journal_msg(payload)

        with allure.step("Извлечение и подготовка данных для проверки снятия маскирования ДУ ЭФ Журнал"):
            messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', None)

            if cfg.technological_section:
                journal_unmask_message = t_utils.find_object_by_field(
                    messages_info, "technologicalSection", cfg.technological_section
                )
            else:
                journal_unmask_message = parsed_payload.replyContent.messagesInfo[0]
            StepCheck("Проверка наличия данных о снятии маскирования в журнале", "technologicalSection").actual(
                journal_unmask_message
            ).is_not_none()

        with allure.step(
            "Подключение по ws, получение и обработка сообщения типа: MainPageInfoContent. "
            "Получен результат маскирования ДУ на ЭФ Состояние МТ"
        ):
            payload = await t_utils.connect_and_subscribe_msg(
                ws_client,
                "MainPageInfoContent",
                "subscribeMainPageInfoRequest",
                {'tuIds': [cfg.tu_id], 'additionalProperties': None},
            )
            parsed_payload = parser.parse_main_page_msg(payload)
            lds_status = getattr(getattr(parsed_payload.replyContent, "tuInfo", None), 'ldsStatus', None)
            number_of_masked_lps = getattr(lds_status, 'numberOfMaskedLps', None)
            masked_lps_name = getattr(lds_status, "maskedLps", None)

    # Проверки сообщений
    with SoftAssertions() as soft_failures:
        StepCheck("Проверка кода ответа на запрос о размаскировании", "replyStatus", soft_failures).actual(
            unmask_du_reply_status
        ).expected(ReplyStatus.OK.value).equal_to()
        StepCheck(
            "Проверяем, что тег маскирования ДУ в выходных сигналах равен null",
            TestConst.ADDRESS_SUFFIX_MASK,
            soft_failures,
        ).actual(mask_leak_value).expected(TestConst.OUTPUT_IS_NOT_MASK).equal_to()
        StepCheck("Проверяем имя ТУ в сообщении в журнале", "mainPipeline", soft_failures).actual(
            journal_unmask_message.mainPipeline
        ).expected(cfg.main_pipe_line).equal_to()
        StepCheck("Проверяем имя ДУ в сообщении в журнале", "technologicalObject", soft_failures).actual(
            journal_unmask_message.technologicalObject
        ).expected(cfg.mask_du_name).equal_to()
        StepCheck("Проверка события в сообщении в журнале", "event", soft_failures).actual(
            journal_unmask_message.event
        ).expected(cfg.unmask_du_event).equal_to()
        StepCheck("Проверка количества маскированных ДУ", "numberOfMaskedLps", soft_failures).actual(
            number_of_masked_lps
        ).expected(cfg.not_mask_du).equal_to()
        StepCheck("Проверка счетчика маски. ЭФ Состояние МТ", "Количество замаскированных ДУ", soft_failures).actual(
            number_of_masked_lps
        ).expected(cfg.not_mask_du).equal_to()
        StepCheck(
            "Проверка отсутствия списка маскированных ДУ. ЭФ Состояние МТ",
            "Отсутствуют замаскированные ДУ",
            soft_failures,
        ).actual(masked_lps_name).is_none()


async def lds_status_initialization_out(ws_client, cfg: SmokeSuiteConfig):
    """
    Проверка выхода СОУ из Инициализации.
    """
    with allure.step("Подключение по ws, получение и обработка сообщения типа: CommonSchemeContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "CommonSchemeContent",
            "SubscribeCommonSchemeRequest",
            {'tuId': cfg.tu_id, 'additionalProperties': None},
        )

        parsed_payload = parser.parse_common_scheme_info_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        flow_areas = getattr(parsed_payload.replyContent, 'flowAreas', None)
        longest_flow_area = t_utils.get_longest_flow_area(flow_areas)
        diagnostic_areas = getattr(longest_flow_area, 'diagnosticAreas', [])
        StepCheck("Проверка наличия данных диагностических участков", "diagnosticAreas").actual(
            diagnostic_areas
        ).is_not_empty()
        allure.attach(
            f"Самый протяженный участок карты течений: {longest_flow_area}",
            name="flowArea. Выход из Инициализации",
            attachment_type=allure.attachment_type.TEXT,
        )
        lds_status_set = {diagnostic_area.ldsStatus for diagnostic_area in diagnostic_areas}
        lds_status_int = t_utils.determine_lds_status_by_priority(lds_status_set)
        lds_status = LdsStatus(lds_status_int) if lds_status_int else None

    StepCheck(
        "Проверка: СОУ находится не в режиме 'Инициализация'",
        "ldsStatus",
    ).actual(
        lds_status
    ).expected(LdsStatus.INITIALIZATION).is_not_equal_to()


def lds_status_init_out_in_journal(http_client, cfg: SmokeSuiteConfig, imitator_start_time):
    """
    Проверка наличия записи в журнале о выходе СОУ из режима Инициализация.
    """

    with allure.step("Http запрос сообщений журнала с фильтром messageTypes=LDS_STATUS"):
        end_time = datetime.now()
        request_body = t_utils.create_journal_req_body(
            pagination=Pagination(limit=TestConst.JOURNAL_PAGINATION_LIMIT, direction=Direction.FIRST.value),
            filtering=Filtering(messageTypes=int(MessageType.LDS_STATUS), objects=FilteringObjects(tuId=cfg.tu_id)),
        )
        response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_journal_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', [])

        StepCheck("Проверка наличия сообщений в журнале", "messagesInfo").actual(messages_info).is_not_empty()

    with allure.step("Фильтрация сообщений по времени и technologicalSection"):
        filter_start_msk = t_utils.localize_as_moscow(imitator_start_time)
        filter_end_msk = t_utils.localize_as_moscow(end_time)

        time_filtered = [
            msg
            for msg in messages_info
            if filter_start_msk <= t_utils.ensure_moscow_timezone(msg.time) <= filter_end_msk
        ]
        time_filtered.sort(key=lambda msg: t_utils.ensure_moscow_timezone(msg.time), reverse=True)

        lds_msg = next(
            (msg for msg in time_filtered if msg.technologicalSection == cfg.tu_name),
            None,
        )

        allure.attach(
            f"Всего получено сообщений: {len(messages_info)}\n"
            f"После фильтрации по времени ({filter_start_msk} - {filter_end_msk}): {len(time_filtered)}\n"
            f"Проверка: найдено ли сообщение с technologicalSection='{cfg.tu_name}': {'True' if lds_msg else 'False'}",
            name="Результат фильтрации сообщений журнала",
            attachment_type=allure.attachment_type.TEXT,
        )

        with allure.step(f"Проверка: найдено ли сообщение с technologicalSection='{cfg.tu_name}'"):
            if lds_msg is None:
                pytest.fail(
                    f"Сообщение с technologicalSection='{cfg.tu_name}' "
                    f"не найдено среди {len(time_filtered)} отфильтрованных по времени сообщений"
                )

    with allure.step("Проверка актуальности сообщения"):
        msg_time_msk = t_utils.ensure_moscow_timezone(lds_msg.time)
        start_time_msk = t_utils.localize_as_moscow(imitator_start_time)

        StepCheck(
            f"Проверка: время сообщения позднее времени старта имитатора {msg_time_msk} > {start_time_msk}",
            "time",
        ).actual(msg_time_msk > start_time_msk).expected(True).equal_to()

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка: event не является Инициализацией", "event", soft_failures).actual(lds_msg.event).expected(
            TestConst.JOURNAL_EVENT_LDS_INIT_ACCUM_DATA
        ).is_not_equal_to()

        StepCheck("Проверка technologicalSection", "technologicalSection", soft_failures).actual(
            lds_msg.technologicalSection
        ).expected(cfg.tu_name).equal_to()

        StepCheck("Проверка technologicalObject не пустой", "technologicalObject", soft_failures).actual(
            lds_msg.technologicalObject
        ).is_not_none()

        StepCheck("Проверка priority не пустой", "priority", soft_failures).actual(lds_msg.priority).is_not_none()

        StepCheck("Проверка messageType", "messageType", soft_failures).actual(lds_msg.messageType).expected(
            TestConst.JOURNAL_MESSAGE_TYPE_LDS_STATUS
        ).equal_to()


async def leaks_content(ws_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig, imitator_start_time):
    """
    Проверка утечки через сообщение LeaksContent.
    """
    with allure.step("Подключение по ws и получение сообщения об утечке типа: LeaksContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "LeaksContent",
            "SubscribeLeaksRequest",
            {'tuId': cfg.tu_id},
        )
        parsed_payload = parser.parse_leaks_content_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        leaks_list_info = getattr(parsed_payload.replyContent, 'leaksListInfo', [])
        # Ищет подтвержденные утечки
        confirmed_leaks_list = t_utils.find_confirmed_leaks(leaks_list_info)
        first_leak_info = t_utils.find_leak_by_coordinate(confirmed_leaks_list, leak.coordinate_meters)
        StepCheck("Проверка наличия собщения", "Leak").actual(first_leak_info).is_not_none()

        # Конвертируем время обнаружения в московское время
        leak_detected_at = t_utils.ensure_moscow_timezone(first_leak_info.detectedAt)
        leak_wait_start_time, leak_wait_end_time = t_utils.get_leak_time_window(
            imitator_start_time,
            leak.leak_start_interval_seconds,
            leak.allowed_time_diff_seconds,
            detected_at_tz=leak_detected_at.tzinfo,
        )
        leak_volume_m3 = t_utils.convert_leak_volume_m3(first_leak_info.leakVolume)
        leak_coordinate_round = round(first_leak_info.leakCoordinate, cfg.precision)
        leak_algorithm_type = ReservedType(first_leak_info.type) if first_leak_info.type else None
        leak_confirmation_status = (
            ConfirmationStatus(first_leak_info.confirmationStatus) if first_leak_info.confirmationStatus else None
        )

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка id полученного ТУ", "tu_id", soft_failures).actual(
            parsed_payload.replyContent.tuId
        ).expected(cfg.tu_id).equal_to()

        StepCheck("Проверка наличия названия участка утечки", "diagnosticAreaName", soft_failures).actual(
            first_leak_info.diagnosticAreaName
        ).is_not_none()

        StepCheck("Проверка статуса утечки", "confirmationStatus", soft_failures).actual(
            leak_confirmation_status
        ).expected(leak.expected_leak_status).equal_to()

        StepCheck("Проверка источника события (алгоритм)", "type", soft_failures).actual(leak_algorithm_type).expected(
            leak.expected_algorithm_type
        ).equal_to()

        StepCheck("Проверка наличия id утечки", "id", soft_failures).actual(first_leak_info.id).is_not_none()

        StepCheck("Проверка координаты утечки", "leakCoordinate", soft_failures).actual(
            leak_coordinate_round
        ).is_close_to(
            leak.coordinate_meters,
            cfg.allowed_distance_diff_meters,
            f"значение допустимой погрешности координаты {cfg.allowed_distance_diff_meters}",
        )

        StepCheck("Проверка времени обнаружения утечки", "leakDetectedAt", soft_failures).actual(
            leak_detected_at
        ).is_between(leak_wait_start_time, leak_wait_end_time)

        StepCheck("Проверка объема утечки", "volume", soft_failures).actual(leak_volume_m3).is_close_to(
            leak.volume_m3,
            leak.allowed_volume_m3,
            f"значение допустимой погрешности по объему {leak.allowed_volume_m3}",
        )


def possible_leak_in_journal(http_client, cfg: SmokeSuiteConfig, imitator_start_time):
    """
    Проверка наличия сообщения 'Возможна утечка' в журнале.
    """
    with allure.step("Http запрос сообщений журнала с фильтром messageTypes=LEAKS"):
        end_time = datetime.now()
        request_body = t_utils.create_journal_req_body(
            pagination=Pagination(limit=TestConst.JOURNAL_PAGINATION_LIMIT, direction=Direction.FIRST.value),
            filtering=Filtering(messageTypes=int(MessageType.LEAKS), objects=FilteringObjects(tuId=cfg.tu_id)),
        )
        response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_journal_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', [])
        StepCheck("Проверка наличия сообщений в журнале", "messagesInfo").actual(messages_info).is_not_empty()

    with allure.step("Фильтрация сообщений по времени и technologicalSection"):
        filter_start_msk = t_utils.localize_as_moscow(imitator_start_time)
        filter_end_msk = t_utils.localize_as_moscow(end_time)

        time_filtered = [
            msg
            for msg in messages_info
            if filter_start_msk <= t_utils.ensure_moscow_timezone(msg.time) <= filter_end_msk
        ]
        time_filtered.sort(key=lambda msg: t_utils.ensure_moscow_timezone(msg.time), reverse=True)

        possible_leak_msg = next(
            (
                msg
                for msg in time_filtered
                if msg.technologicalSection == cfg.tu_name and msg.event == TestConst.JOURNAL_EVENT_POSSIBLE_LEAK
            ),
            None,
        )

        allure.attach(
            f"Всего получено сообщений: {len(messages_info)}\n"
            f"После фильтрации по времени ({filter_start_msk} - {filter_end_msk}): {len(time_filtered)}\n"
            f"Проверка: найдено ли сообщение с technologicalSection='{cfg.tu_name}' "
            f"и event='{TestConst.JOURNAL_EVENT_POSSIBLE_LEAK}': {'True' if possible_leak_msg else 'False'}",
            name="Результат фильтрации сообщений журнала",
            attachment_type=allure.attachment_type.TEXT,
        )

        with allure.step(
            f"Проверка: найдено ли сообщение с technologicalSection='{cfg.tu_name}' "
            f"и event='{TestConst.JOURNAL_EVENT_POSSIBLE_LEAK}'"
        ):
            if possible_leak_msg is None:
                pytest.fail(
                    f"Сообщение с technologicalSection='{cfg.tu_name}' "
                    f"и event='{TestConst.JOURNAL_EVENT_POSSIBLE_LEAK}' "
                    f"не найдено среди {len(time_filtered)} отфильтрованных по времени сообщений"
                )

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка статуса утечки в журнале", "event", soft_failures).actual(possible_leak_msg.event).expected(
            TestConst.JOURNAL_EVENT_POSSIBLE_LEAK
        ).equal_to()

        StepCheck("Проверка messageType", "messageType", soft_failures).actual(possible_leak_msg.messageType).expected(
            TestConst.JOURNAL_MESSAGE_TYPE_LEAKS
        ).equal_to()

        StepCheck("Проверка technologicalSection не пустой", "technologicalSection", soft_failures).actual(
            possible_leak_msg.technologicalSection
        ).is_not_none()

        StepCheck("Проверка technologicalObject не пустой", "technologicalObject", soft_failures).actual(
            possible_leak_msg.technologicalObject
        ).is_not_none()


def leak_info_in_journal(http_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig, imitator_start_time):

    with allure.step("Http запрос сообщений журнала с фильтром messageTypes=LEAKS"):
        end_time = datetime.now()
        request_body = t_utils.create_journal_req_body(
            pagination=Pagination(limit=TestConst.JOURNAL_PAGINATION_LIMIT, direction=Direction.FIRST.value),
            filtering=Filtering(messageTypes=int(MessageType.LEAKS), objects=FilteringObjects(tuId=cfg.tu_id)),
        )
        response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_journal_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки"):
        messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', [])
        StepCheck("Проверка наличия сообщений в журнале", "messagesInfo").actual(messages_info).is_not_empty()

        with allure.step("Фильтрация сообщений по времени и technologicalSection"):
            filter_start_msk = t_utils.localize_as_moscow(imitator_start_time)
            filter_end_msk = t_utils.localize_as_moscow(end_time)

            time_filtered = [
                msg
                for msg in messages_info
                if filter_start_msk <= t_utils.ensure_moscow_timezone(msg.time) <= filter_end_msk
            ]
            time_filtered.sort(key=lambda msg: t_utils.ensure_moscow_timezone(msg.time), reverse=True)

            leak_message = next(
                (
                    msg
                    for msg in time_filtered
                    if msg.technologicalSection == cfg.tu_name and TestConst.JOURNAL_EVENT_DETECTED_LEAK in msg.event
                ),
                None,
            )

            allure.attach(
                f"Всего получено сообщений: {len(messages_info)}\n"
                f"После фильтрации по времени ({filter_start_msk} - {filter_end_msk}): {len(time_filtered)}\n",
                name="Результат фильтрации сообщений журнала",
                attachment_type=allure.attachment_type.TEXT,
            )

    with allure.step("Первичная проверка после фильтрации"):
        StepCheck(
            f"Проверка: найдено ли сообщение с technologicalSection='{cfg.tu_name}' "
            f"и event содержит подстроку подтвержденной утечки'{TestConst.JOURNAL_EVENT_DETECTED_LEAK}'",
            "event",
        ).actual(leak_message).is_not_none()

        leak_coordinate_km, leak_volume_m3 = t_utils.parse_journal_msg_value(leak_message.value)
        leak_coordinate_round = round(leak_coordinate_km * TestConst.KM_TO_METERS, TestConst.PRECISION)
        leak_message_time = t_utils.ensure_moscow_timezone(leak_message.time)

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка полученного события event", "event", soft_failures).contains(
            leak_message.event, TestConst.JOURNAL_EVENT_DETECTED_LEAK
        )

        StepCheck("Проверка полученного ТУ", "technologicalSection", soft_failures).actual(
            leak_message.technologicalSection
        ).expected(cfg.tu_name).equal_to()

        StepCheck("Проверка типа полученного сообщения", "messageType", soft_failures).actual(
            leak_message.messageType
        ).expected(TestConst.JOURNAL_MESSAGE_TYPE_LEAKS).equal_to()

        StepCheck("Проверка имени ДУ", "technologicalObject", soft_failures).actual(
            leak_message.technologicalObject
        ).is_not_none()

        StepCheck("Проверка координаты утечки", "leakCoordinate", soft_failures).actual(
            leak_coordinate_round
        ).is_close_to(
            leak.coordinate_meters,
            cfg.allowed_distance_diff_meters,
            f"значение допустимой погрешности координаты {cfg.allowed_distance_diff_meters}",
        )

        StepCheck("Проверка времени обнаружения утечки", "leakDetectedAt", soft_failures).actual(
            leak_message_time
        ).is_between(filter_start_msk, filter_end_msk)

        StepCheck("Проверка объема утечки", "volume", soft_failures).actual(leak_volume_m3).is_close_to(
            leak.volume_m3,
            leak.allowed_volume_m3,
            f"значение допустимой погрешности по объему {leak.allowed_volume_m3}",
        )


def completed_leak_info_in_journal(http_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig, imitator_start_time):
    """
    Проверка наличия сообщения 'Утечка завершена' в журнале.
    """
    with allure.step("Http запрос сообщений журнала с фильтром messageTypes=LEAKS"):
        end_time = datetime.now()
        request_body = t_utils.create_journal_req_body(
            pagination=Pagination(limit=TestConst.JOURNAL_PAGINATION_LIMIT, direction=Direction.FIRST.value),
            filtering=Filtering(messageTypes=int(MessageType.LEAKS), objects=FilteringObjects(tuId=cfg.tu_id)),
        )
        response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_journal_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки"):
        messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', [])
        StepCheck("Проверка наличия сообщений в журнале", "messagesInfo").actual(messages_info).is_not_empty()

        with allure.step("Фильтрация сообщений по времени и technologicalSection"):
            filter_start_msk = t_utils.localize_as_moscow(imitator_start_time)
            filter_end_msk = t_utils.localize_as_moscow(end_time)

            time_filtered = [
                msg
                for msg in messages_info
                if filter_start_msk <= t_utils.ensure_moscow_timezone(msg.time) <= filter_end_msk
            ]
            time_filtered.sort(key=lambda msg: t_utils.ensure_moscow_timezone(msg.time), reverse=True)

            completed_leak_message = next(
                (
                    msg
                    for msg in time_filtered
                    if msg.technologicalSection == cfg.tu_name and msg.event == TestConst.JOURNAL_EVENT_COMPLETED_LEAKS
                ),
                None,
            )

            allure.attach(
                f"Всего получено сообщений: {len(messages_info)}\n"
                f"После фильтрации по времени ({filter_start_msk} - {filter_end_msk}): {len(time_filtered)}\n",
                name="Результат фильтрации сообщений журнала",
                attachment_type=allure.attachment_type.TEXT,
            )

    with allure.step("Первичная проверка после фильтрации"):
        StepCheck(
            f"Проверка: найдено ли сообщение с technologicalSection='{cfg.tu_name}' "
            f"и event='{TestConst.JOURNAL_EVENT_COMPLETED_LEAKS}'",
            "event",
        ).actual(completed_leak_message).is_not_none()

        leak_coordinate_km, leak_volume_m3 = t_utils.parse_journal_msg_value(completed_leak_message.value)
        leak_coordinate_round = round(leak_coordinate_km * TestConst.KM_TO_METERS, TestConst.PRECISION)
        leak_message_time = t_utils.ensure_moscow_timezone(completed_leak_message.time)

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка статуса утечки в журнале", "event", soft_failures).actual(
            completed_leak_message.event
        ).expected(TestConst.JOURNAL_EVENT_COMPLETED_LEAKS).equal_to()

        StepCheck("Проверка полученного ТУ", "technologicalSection", soft_failures).actual(
            completed_leak_message.technologicalSection
        ).expected(cfg.tu_name).equal_to()

        StepCheck("Проверка координаты утечки", "leakCoordinate", soft_failures).actual(
            leak_coordinate_round
        ).is_close_to(
            leak.coordinate_meters,
            cfg.allowed_distance_diff_meters,
            f"значение допустимой погрешности координаты {cfg.allowed_distance_diff_meters}",
        )

        StepCheck("Проверка времени завершения утечки", "leakDetectedAt", soft_failures).actual(
            leak_message_time
        ).is_between(filter_start_msk, filter_end_msk)


async def all_leaks_info(ws_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig, imitator_start_time):
    """
    Проверка сообщения AllLeaksInfo об утечке.
    """
    with allure.step("Подключение по ws и получение сообщения об утечке типа: AllLeaksInfoContent"):
        parsed_payload = await t_utils.connect_and_get_parsed_msg_by_tu_id(
            cfg.tu_id,
            ws_client,
            "AllLeaksInfoContent",
            "subscribeAllLeaksInfoRequest",
            [],
        )
    with allure.step("Извлечение и подготовка данных для проверки"):
        leaks_info = getattr(parsed_payload.replyContent, 'leaksInfo', [])
        first_leak_info = t_utils.find_leak_by_coordinate(leaks_info, leak.coordinate_meters)
        StepCheck("Проверка наличия сообщения об утечке типа AllLeaksInfoContent", "leaksInfo").actual(
            first_leak_info
        ).is_not_none()
        # Конвертируем время обнаружения в московское время
        leak_detected_at = t_utils.ensure_moscow_timezone(first_leak_info.leakDetectedAt)
        leak_wait_start_time, leak_wait_end_time = t_utils.get_leak_time_window(
            imitator_start_time,
            leak.leak_start_interval_seconds,
            leak.allowed_time_diff_seconds,
            detected_at_tz=leak_detected_at.tzinfo,
        )
        leak_volume_m3 = t_utils.convert_leak_volume_m3(first_leak_info.volume)
        leak_coordinate_round = round(first_leak_info.leakCoordinate, cfg.precision)
        leak_lds_status = LdsStatus(first_leak_info.ldsStatus) if first_leak_info.ldsStatus else None
        leak_stationary_status = (
            StationaryStatus(first_leak_info.stationaryStatus) if first_leak_info.stationaryStatus else None
        )

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка id полученного ТУ", "tu_id", soft_failures).actual(
            parsed_payload.replyContent.tuId
        ).expected(cfg.tu_id).equal_to()

        StepCheck("Проверка наличия названия участка утечки", "diagnosticAreaName", soft_failures).actual(
            first_leak_info.diagnosticAreaName
        ).is_not_none()

        StepCheck("Проверка статуса СОУ", "ldsStatus", soft_failures).actual(leak_lds_status).expected(
            leak.expected_lds_status
        ).equal_to()

        StepCheck("Проверка маскирования утечки", "isMasked", soft_failures).actual(first_leak_info.isMasked).expected(
            False
        ).equal_to()

        StepCheck("Проверка квитирования утечки", "isAcknowledged", soft_failures).actual(
            first_leak_info.isAcknowledged
        ).expected(False).equal_to()

        StepCheck("Проверка наличия id утечки", "id", soft_failures).actual(first_leak_info.id).is_not_none()

        StepCheck("Проверка координаты утечки", "leakCoordinate", soft_failures).actual(
            leak_coordinate_round
        ).is_close_to(
            leak.coordinate_meters,
            cfg.allowed_distance_diff_meters,
            f"значение допустимой погрешности координаты {cfg.allowed_distance_diff_meters}",
        )

        StepCheck("Проверка времени обнаружения утечки", "leakDetectedAt", soft_failures).actual(
            leak_detected_at
        ).is_between(leak_wait_start_time, leak_wait_end_time)

        StepCheck("Проверка объема утечки", "volume", soft_failures).actual(leak_volume_m3).is_close_to(
            leak.volume_m3,
            leak.allowed_volume_m3,
            f"значение допустимой погрешности по объему {leak.allowed_volume_m3}",
        )

        StepCheck("Проверка режима ТУ", "stationaryStatus", soft_failures).actual(leak_stationary_status).expected(
            leak.expected_stationary_status
        ).equal_to()


async def all_leaks_is_empty(ws_client, cfg: SmokeSuiteConfig):
    """
    Проверка отсутствия информации об утечке
    """
    with allure.step("Подключение по ws и получение сообщения об утечке типа: AllLeaksInfoContent"):
        parsed_payload = await t_utils.connect_and_get_parsed_msg_by_tu_id(
            cfg.tu_id,
            ws_client,
            "AllLeaksInfoContent",
            "subscribeAllLeaksInfoRequest",
            [],
        )
    with allure.step("Извлечение и подготовка данных для проверки"):
        leaks_info = getattr(parsed_payload.replyContent, 'leaksInfo', [])

    StepCheck("Проверка отсутствия информации об утечке в сообщении AllLeaksInfoContent", "leaksInfo").actual(
        leaks_info
    ).is_empty()


async def tu_leaks_info(ws_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig, imitator_start_time):
    """
    Проверка сообщения TuLeaksInfo об утечке.
    """
    with allure.step("Подключение по ws и получение сообщения об утечке типа: TuLeaksInfoContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "TuLeaksInfoContent",
            "subscribeTuLeaksInfoRequest",
            {'tuId': cfg.tu_id},
        )
        parsed_payload = parser.parse_tu_leaks_info_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки"):
        tu_leaks_info_list = getattr(parsed_payload.replyContent, 'leaksInfo', [])
        first_leak_info = t_utils.find_leak_by_coordinate(tu_leaks_info_list, leak.coordinate_meters)
        StepCheck("Проверка наличия сообщения об утечке типа TuLeaksInfoContent", "leaksInfo").actual(
            first_leak_info
        ).is_not_none()
        # Конвертируем время обнаружения в московское время
        leak_detected_at = t_utils.ensure_moscow_timezone(first_leak_info.leakDetectedAt)
        leak_wait_start_time, leak_wait_end_time = t_utils.get_leak_time_window(
            imitator_start_time,
            leak.leak_start_interval_seconds,
            leak.allowed_time_diff_seconds,
            detected_at_tz=leak_detected_at.tzinfo,
        )
        leak_volume_m3 = t_utils.convert_leak_volume_m3(first_leak_info.volume)
        leak_coordinate_round = round(first_leak_info.leakCoordinate, cfg.precision)
        leak_lds_status = LdsStatus(first_leak_info.ldsStatus) if first_leak_info.ldsStatus else None
        leak_stationary_status = (
            StationaryStatus(first_leak_info.stationaryStatus) if first_leak_info.stationaryStatus else None
        )

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка id полученного ТУ", "tu_id", soft_failures).actual(
            parsed_payload.replyContent.tuId
        ).expected(cfg.tu_id).equal_to()

        StepCheck("Проверка наличия id участка утечки", "controlledSiteId", soft_failures).actual(
            first_leak_info.controlledSiteId
        ).is_not_none()

        StepCheck("Проверка статуса СОУ", "ldsStatus", soft_failures).actual(leak_lds_status).expected(
            leak.expected_lds_status
        ).equal_to()

        StepCheck("Проверка маскирования утечки", "isMasked", soft_failures).actual(first_leak_info.isMasked).expected(
            False
        ).equal_to()

        StepCheck("Проверка наличия pipeId в сообщении", "pipeId", soft_failures).actual(
            first_leak_info.pipeId
        ).is_not_none()

        StepCheck("Проверка наличия id утечки", "id", soft_failures).actual(first_leak_info.id).is_not_none()

        StepCheck("Проверка координаты утечки", "leakCoordinate", soft_failures).actual(
            leak_coordinate_round
        ).is_close_to(
            leak.coordinate_meters,
            cfg.allowed_distance_diff_meters,
            f"значение допустимой погрешности координаты {cfg.allowed_distance_diff_meters}",
        )

        StepCheck("Проверка времени обнаружения утечки", "leakDetectedAt", soft_failures).actual(
            leak_detected_at
        ).is_between(leak_wait_start_time, leak_wait_end_time)

        StepCheck("Проверка объема утечки", "volume", soft_failures).actual(leak_volume_m3).is_close_to(
            leak.volume_m3,
            leak.allowed_volume_m3,
            f"значение допустимой погрешности по объему {leak.allowed_volume_m3}",
        )

        StepCheck("Проверка режима ТУ", "stationaryStatus", soft_failures).actual(leak_stationary_status).expected(
            leak.expected_stationary_status
        ).equal_to()


async def lds_status_during_leak(ws_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig):
    """
    Проверка режима работы СОУ во время утечки.
    """
    with allure.step("Подключение по ws, получение и обработка сообщения типа: CommonSchemeContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "CommonSchemeContent",
            "SubscribeCommonSchemeRequest",
            {'tuId': cfg.tu_id, 'additionalProperties': None},
        )

        parsed_payload = parser.parse_common_scheme_info_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        flow_areas = getattr(parsed_payload.replyContent, 'flowAreas', [])
        status_config = leak.lds_status_during_leak_config
        if status_config is None:
            pytest.fail("Не задан leak.lds_status_during_leak_config для теста lds_status_during_leak")

        leak_diagnostic_area_name = status_config.leak_diagnostic_area_name
        leak_diagnostic_area_id, neighbor_ids = TestConst.DIAGNOSTIC_AREA_BASE_IDS.get(
            leak_diagnostic_area_name, (None, None)
        )

        leak_diagnostic_area = t_utils.find_diagnostic_area_by_id(flow_areas, leak_diagnostic_area_id)
        leak_lds_status_int = getattr(leak_diagnostic_area, 'ldsStatus', None)
        leak_lds_status = LdsStatus(leak_lds_status_int) if leak_lds_status_int else None

    with SoftAssertions() as soft_failures:
        StepCheck(
            f"Проверка режима работы СОУ на ДУ с утечкой, pipe_id ДУ: {leak_diagnostic_area_id}",
            "ldsStatus",
            soft_failures,
        ).actual(leak_lds_status).expected(status_config.leak_du_expected_lds_status).equal_to()
        if neighbor_ids:
            found_neighbor_count = 0
            for neighbor_id in neighbor_ids:
                neighbor_area = t_utils.find_diagnostic_area_by_id(flow_areas, neighbor_id)
                neighbor_lds_status_int = getattr(neighbor_area, 'ldsStatus', None)
                neighbor_lds_status = LdsStatus(neighbor_lds_status_int) if neighbor_lds_status_int else None
                if neighbor_lds_status:
                    found_neighbor_count += 1
                    StepCheck(
                        f"Проверка режима работы СОУ на соседнем ДУ, id ДУ: {neighbor_id}",
                        "ldsStatus",
                        soft_failures,
                    ).actual(neighbor_lds_status).expected(status_config.neighbors_du_expected_lds_status).equal_to()
            if found_neighbor_count == 0:
                pytest.fail(f"Не найдены соседние с утечкой ДУ по _id: {neighbor_ids}")


async def acknowledge_leak_info(ws_client, http_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig = None):
    """
    Проверка квитирования утечки.

    Для multi-leak наборов: после квитирования проверяется что утечка удалена из списка.
    Для single-leak наборов: проверяется что список утечек пуст.
    """
    with allure.step("Получение id утечки"):
        with allure.step("Подключение по ws, получение и обработка сообщения об утечке типа: TuLeaksInfoContent"):
            payload = await t_utils.connect_and_subscribe_msg(
                ws_client,
                "TuLeaksInfoContent",
                "subscribeTuLeaksInfoRequest",
                {'tuId': cfg.tu_id},
            )
            parsed_payload = parser.parse_tu_leaks_info_msg(payload)
        with allure.step("Извлечение и подготовка данных для получения id утечки"):
            leaks_info = getattr(parsed_payload.replyContent, 'leaksInfo', [])
            leak_to_ack = t_utils.find_leak_by_coordinate(leaks_info, leak.coordinate_meters)
            StepCheck("Проверка наличия сообщения об утечке", "leaksInfo").actual(leak_to_ack).is_not_none()

            acknowledged_leak_id = leak_to_ack.id
    with allure.step("Http запрос на квитирование утечки типа: AcknowledgeLeakRequest"):
        request_body = {'leakId': str(acknowledged_leak_id), 'tuId': cfg.tu_id}
        response = http_client.post_request(HttpConst.ACKNOWLEDGE_LEAK_URL_PATH, request_body)
        acknowledge_reply_status = response.status_code

    with allure.step(
        "Подключение по ws и получение сообщения об утечке типа: AllLeaksInfoContent для проверки квитирования"
    ):
        with allure.step("Очистка очереди websocket сообщений"):
            ws_client.clear_queue()
        time.sleep(cfg.basic_message_timeout)
        parsed_payload = await t_utils.connect_and_get_parsed_msg_by_tu_id(
            cfg.tu_id,
            ws_client,
            "AllLeaksInfoContent",
            "subscribeAllLeaksInfoRequest",
            [],
        )
    with allure.step("Извлечение и подготовка данных для проверки"):
        remaining_leaks = getattr(parsed_payload.replyContent, 'leaksInfo', None)
        remaining_leak_ids = [leak.id for leak in remaining_leaks] if remaining_leaks else []

    StepCheck("Проверка кода ответа на запрос о квитировании", "replyStatus").actual(acknowledge_reply_status).expected(
        ReplyStatus.OK.value
    ).equal_to()

    # Проверяем что квитированная утечка исчезла из списка
    StepCheck("Проверка отсутствия квитированной утечки в списке AllLeaksInfo", "id").does_not_contain(
        remaining_leak_ids, acknowledged_leak_id
    )


def acknowledge_leak_in_journal(http_client, cfg: SmokeSuiteConfig, imitator_start_time):
    """
    Проверка записи в журнале о квитировании утечки.
    """
    with allure.step("Http запрос сообщений журнала с фильтром messageTypes=LEAKS"):
        end_time = datetime.now()
        request_body = t_utils.create_journal_req_body(
            pagination=Pagination(limit=TestConst.JOURNAL_PAGINATION_LIMIT, direction=Direction.FIRST.value),
            filtering=Filtering(messageTypes=int(MessageType.LEAKS), objects=FilteringObjects(tuId=cfg.tu_id)),
        )
        response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_journal_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки"):
        messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', [])
        StepCheck("Проверка наличия сообщений в журнале", "messagesInfo").actual(messages_info).is_not_empty()

    with allure.step("Фильтрация сообщений по времени и technologicalSection"):
        filter_start_msk = t_utils.localize_as_moscow(imitator_start_time)
        filter_end_msk = t_utils.localize_as_moscow(end_time)

        time_filtered = [
            msg
            for msg in messages_info
            if filter_start_msk <= t_utils.ensure_moscow_timezone(msg.time) <= filter_end_msk
        ]
        time_filtered.sort(key=lambda msg: t_utils.ensure_moscow_timezone(msg.time), reverse=True)

        ack_message = next(
            (
                msg
                for msg in time_filtered
                if msg.technologicalSection == cfg.tu_name and msg.event == TestConst.JOURNAL_EVENT_LEAK_ACKNOWLEDGED
            ),
            None,
        )

        allure.attach(
            f"Всего получено сообщений: {len(messages_info)}\n"
            f"После фильтрации по времени ({filter_start_msk} - {filter_end_msk}): {len(time_filtered)}\n"
            f"Проверка: найдено ли сообщение с technologicalSection='{cfg.tu_name}' "
            f"и event='{TestConst.JOURNAL_EVENT_LEAK_ACKNOWLEDGED}': {'True' if ack_message else 'False'}",
            name="Результат фильтрации сообщений журнала",
            attachment_type=allure.attachment_type.TEXT,
        )

        with allure.step(
            f"Проверка: найдено ли сообщение с technologicalSection='{cfg.tu_name}' "
            f"и event='{TestConst.JOURNAL_EVENT_LEAK_ACKNOWLEDGED}'"
        ):
            if ack_message is None:
                pytest.fail(
                    f"Сообщение с technologicalSection='{cfg.tu_name}' "
                    f"и event='{TestConst.JOURNAL_EVENT_LEAK_ACKNOWLEDGED}' "
                    f"не найдено среди {len(time_filtered)} отфильтрованных по времени сообщений"
                )

    with allure.step("Проверка актуальности сообщения"):
        msg_time_msk = t_utils.ensure_moscow_timezone(ack_message.time)
        start_time_msk = t_utils.localize_as_moscow(imitator_start_time)

        StepCheck(
            "Проверка: время сообщения позднее времени старта имитатора",
            "time",
        ).actual(
            msg_time_msk > start_time_msk
        ).expected(True).equal_to()

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка event", "event", soft_failures).actual(ack_message.event).expected(
            TestConst.JOURNAL_EVENT_LEAK_ACKNOWLEDGED
        ).equal_to()

        StepCheck("Проверка technologicalSection", "technologicalSection", soft_failures).actual(
            ack_message.technologicalSection
        ).expected(cfg.tu_name).equal_to()

        StepCheck("Проверка technologicalObject не пустой", "technologicalObject", soft_failures).actual(
            ack_message.technologicalObject
        ).is_not_none()


async def output_signals(ws_client, http_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig, imitator_start_time):
    """
    Проверка наличия данных об утечке в выходных сигналах.
    """
    linear_part_id = leak.linear_part_id

    with allure.step("Http запрос, получение и обработка ответа типа: GetOutputSignalsRequest"):
        request_body = {'tuId': cfg.tu_id}
        response = http_client.post_request(HttpConst.GET_OUTPUT_SIGNALS_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_output_signals_msg(payload)

        # Получение данных линейного участка утечки по id
        with allure.step("Извлечение и подготовка данных для списка выходных сигналов"):
            linear_part_signals = getattr(parsed_payload.replyContent, 'linearPartSignals', [])
            leak_linear_part = t_utils.find_object_by_field(
                linear_part_signals,
                TestConst.LEAK_LINEAR_PART_ID_KEY,
                linear_part_id,
            )
            StepCheck("Проверка наличия данных выходных сигналов", "linearPartSignals").actual(
                leak_linear_part
            ).is_not_none()

        with allure.step("Получение типов выходных сигналов из обработанных данных"):
            leak_signals_list = leak_linear_part.signals
            ack_leak_signal_type = t_utils.find_signal_type_by_address_suffix(
                leak_signals_list, TestConst.ADDRESS_SUFFIX_ACK_LEAK
            )
            leak_signal_type = t_utils.find_signal_type_by_address_suffix(
                leak_signals_list, TestConst.ADDRESS_SUFFIX_LEAK
            )
            mask_signal_type = t_utils.find_signal_type_by_address_suffix(
                leak_signals_list, TestConst.ADDRESS_SUFFIX_MASK
            )
            point_leak_signal_type = t_utils.find_signal_type_by_address_suffix(
                leak_signals_list, TestConst.ADDRESS_SUFFIX_POINT_LEAK
            )
            q_leak_signal_type = t_utils.find_signal_type_by_address_suffix(
                leak_signals_list, TestConst.ADDRESS_SUFFIX_Q_LEAK
            )
            time_leak_signal_type = t_utils.find_signal_type_by_address_suffix(
                leak_signals_list, TestConst.ADDRESS_SUFFIX_TIME_LEAK
            )

    with allure.step(f"Получение данных выходных сигналов для линейного участка с id: {linear_part_id}"):
        with allure.step("Получение сообщения с данными выходных сигналов типа: OutputSignalsInfo"):
            payload = await t_utils.connect_and_subscribe_msg(
                ws_client,
                "OutputSignalsInfo",
                "SubscribeOutputSignalsRequest",
                {
                    'objects': {
                        'linearParts': [{'linearPartId': linear_part_id}],
                        'controlledSites': [],
                    },
                    'signalTypes': 1023,
                    'tuId': cfg.tu_id,
                    'additionalProperties': None,
                },
            )
            parsed_payload = parser.parse_output_signals_info_msg(payload)
        with allure.step("Извлечение и подготовка данных Выходных сигналов для проверки"):
            linear_part_signals = getattr(parsed_payload.replyContent, 'linearPartSignals', [])
            leak_linear_part = t_utils.find_object_by_field(
                linear_part_signals,
                TestConst.LEAK_LINEAR_PART_ID_KEY,
                linear_part_id,
            )
            leak_signals_list = getattr(leak_linear_part, 'signals', [])
            StepCheck(
                f"Проверка наличия данных выходных сигналов линейного участка {linear_part_id}", "linearPartSignals"
            ).actual(leak_signals_list).is_not_empty()
            ack_leak_value = t_utils.find_signal_val_by_signal_type(leak_signals_list, ack_leak_signal_type)
            leak_value = t_utils.find_signal_val_by_signal_type(leak_signals_list, leak_signal_type)
            mask_leak_value = t_utils.find_signal_val_by_signal_type(leak_signals_list, mask_signal_type)
            point_leak_value = t_utils.find_signal_val_by_signal_type(leak_signals_list, point_leak_signal_type)
            q_leak_leak_value = t_utils.find_signal_val_by_signal_type(leak_signals_list, q_leak_signal_type)
            time_leak_value = t_utils.find_signal_val_by_signal_type(leak_signals_list, time_leak_signal_type).strip()
            if not time_leak_value:
                pytest.fail(f"В данных линейного участка: {leak_signals_list} \n Отсутствуют данные об утечке")
            time_leak_value_datetime = t_utils.to_moscow_timezone(time_leak_value)
            leak_wait_start_time, leak_wait_end_time = t_utils.get_leak_time_window(
                imitator_start_time,
                leak.leak_start_interval_seconds,
                leak.output_allowed_time_diff_seconds,
                detected_at_tz=time_leak_value_datetime.tzinfo,
            )
            q_leak_value_m3 = t_utils.convert_leak_volume_m3(float(q_leak_leak_value))
            point_leak_value_round = round(float(point_leak_value), cfg.precision)

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка сигнала квитирования утечки", TestConst.ADDRESS_SUFFIX_ACK_LEAK, soft_failures).actual(
            ack_leak_value
        ).expected(TestConst.OUTPUT_IS_ACK_LEAK).equal_to()

        StepCheck("Проверка сигнала наличия утечки", TestConst.ADDRESS_SUFFIX_LEAK, soft_failures).actual(
            leak_value
        ).expected(TestConst.OUTPUT_IS_LEAK).equal_to()

        StepCheck("Проверка сигнала маскирования утечки", TestConst.ADDRESS_SUFFIX_MASK, soft_failures).actual(
            mask_leak_value
        ).expected(TestConst.OUTPUT_IS_NOT_MASK).equal_to()

        StepCheck("Проверка сигнала координаты утечки", TestConst.ADDRESS_SUFFIX_POINT_LEAK, soft_failures).actual(
            point_leak_value_round
        ).is_close_to(
            leak.coordinate_meters,
            cfg.allowed_distance_diff_meters,
            f"значение допустимой погрешности координаты {cfg.allowed_distance_diff_meters}",
        )

        StepCheck("Проверка сигнала объема утечки", TestConst.ADDRESS_SUFFIX_Q_LEAK, soft_failures).actual(
            q_leak_value_m3
        ).is_close_to(
            leak.volume_m3,
            leak.allowed_volume_m3,
            f"значение допустимой погрешности по объему {leak.allowed_volume_m3}",
        )

        StepCheck("Проверка времени обнаружения утечки", TestConst.ADDRESS_SUFFIX_TIME_LEAK, soft_failures).actual(
            time_leak_value_datetime
        ).is_between(leak_wait_start_time, leak_wait_end_time)


async def balance_algorithm_leak_waiting(ws_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig, imitator_start_time):
    """
    Проверка подозрения утечки через BalanceAlgorithmResults

    Логика:
    - Подписка на BalanceAlgorithmResults однократно
    - Раз в BALANCE_ALGORITHM_POLL_INTERVAL секунд забираем из очереди свежее сообщение
    - Собираем все diagnosticAreas (только из flowAreas с непустым списком)
    - Проверяем, что на ДУ с утечкой хотя бы раз пришёл isLeakPossible=True
    - Проверяем, что на всех остальных ДУ isLeakPossible всегда False
    - Проверяем дебаланс на ДУ с будущей утечкой, дебаланс должен быть выше значения порога - 20%
    """
    poll_interval = TestConst.BALANCE_ALGORITHM_POLL_INTERVAL
    total_wait = TestConst.BALANCE_ALGORITHM_TOTAL_WAIT
    end_time = imitator_start_time + timedelta(
        seconds=leak.balance_algorithm_leak_waiting_test.offset * 60 + total_wait
    )

    with allure.step(
        f"Подписка и сбор BalanceAlgorithmResults раз в {poll_interval} с, в течение {total_wait} с после начала утечки"
    ):
        await t_utils.connect(
            ws_client,
            "SubscribeBalanceAlgorithmResultsRequest",
            {'tuId': cfg.tu_id, 'additionalProperties': None},
        )

        collected_diagnostic_areas = await t_utils.poll_balance_algorithm_diagnostic_areas(
            ws_client,
            parser,
            imitator_start_time,
            end_time,
            poll_interval,
        )

        if collected_diagnostic_areas is not None:
            allure.attach(
                str(collected_diagnostic_areas),
                name="Тестируемый фрагмент ответа с бэка",
                attachment_type=allure.attachment_type.TEXT,
            )

        diagnostic_area_names_with_possible = [
            diagnostic_area.name for diagnostic_area in collected_diagnostic_areas if diagnostic_area.isLeakPossible
        ]

        diagnostic_area_possible_leak = next(
            (diagnostic_area for diagnostic_area in collected_diagnostic_areas if diagnostic_area.isLeakDetected),
            None,
        )

        is_leak_possible_seen = any(diagnostic_area.isLeakPossible for diagnostic_area in collected_diagnostic_areas)

    with SoftAssertions() as soft_failures:

        StepCheck(
            "Проверка: получен хотя бы один ДУ с подозрением на утечку",
            "isLeakPossible",
            soft_failures,
        ).actual(diagnostic_area_names_with_possible).is_not_empty()

        StepCheck(
            f"Проверка: на ДУ {str(diagnostic_area_names_with_possible)} бы раз за "
            f"{TestConst.BALANCE_ALGORITHM_TOTAL_WAIT / TestConst.SEC_PER_MIN} минут приходил"
            " статус 'подозрение на утечку': isLeakPossible=True",
            "isLeakPossible",
            soft_failures,
        ).actual(is_leak_possible_seen).expected(True).equal_to()

        if leak.flow_rate_settings_threshold is not None and diagnostic_area_possible_leak is not None:
            threshold = leak.flow_rate_settings_threshold
            tolerance = TestConst.DEBALANCE_TOLERANCE
            lower_bound = threshold * (1 - tolerance)

            StepCheck(
                f"Проверка значения дебаланса на ДУ name={diagnostic_area_possible_leak.name} с будущей утечкой"
                f" в пределах {int(tolerance * 100)}% снизу от порогового значения по объему: {threshold}).",
                "debalance",
                soft_failures,
            ).actual(abs(diagnostic_area_possible_leak.debalance)).is_greater_than(lower_bound)


async def balance_algorithm_leak_detected(ws_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig):
    """
    Проверка наличия утечки (isLeakDetected) через BalanceAlgorithmResults.

    Логика:
    - Подписка на BalanceAlgorithmResultsContent
    - Получение первого подходящего сообщения типа BalanceAlgorithmResultsContent
    - Проверяем, что на ДУ с утечкой isLeakDetected=True
    - Проверяем, что на всех остальных ДУ isLeakDetected=False
    - Проверяем, что дебаланс на ДУ с утечкой > FLOW_RATE_SETTINGS_THRESHOLD
    """

    with allure.step("Подписка и получение BalanceAlgorithmResultsContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "BalanceAlgorithmResultsContent",
            "SubscribeBalanceAlgorithmResultsRequest",
            {'tuId': cfg.tu_id, 'additionalProperties': None},
        )

        parsed_payload = parser.parse_balance_algorithm_msg(payload)
        reply_content = parsed_payload.replyContent
        if not reply_content or not reply_content.flowAreas:
            pytest.fail(
                "В ответе с бэка в DTO BalanceAlgorithmResults отсутствуют flowAreas, "
                "невозможно проверить наличие утечки"
            )

        all_diagnostic_areas = []
        flow_areas = getattr(reply_content, 'flowAreas', [])
        for flow_area in flow_areas:
            if flow_area.diagnosticAreas:
                all_diagnostic_areas.extend(flow_area.diagnosticAreas)

        if not all_diagnostic_areas:
            pytest.fail(
                "В ответе с бэка в DTO BalanceAlgorithmResults во всех flowAreas отсутствуют diagnosticAreas, "
                "невозможно проверить наличие утечки"
            )

        leak_diagnostic_area = next(
            (diagnostic_area for diagnostic_area in all_diagnostic_areas if diagnostic_area.isLeakDetected),
            None,
        )

        if leak_diagnostic_area is None:
            pytest.fail("Ни одного ДУ с утечкой не найдено в ответе BalanceAlgorithmResultsContent")

        leak_diagnostic_area_name = leak_diagnostic_area.name

    with SoftAssertions() as soft_failures:
        StepCheck(
            f"Проверка: на ДУ name={leak_diagnostic_area_name} обнаружена утечка",
            "isLeakDetected",
            soft_failures,
        ).actual(leak_diagnostic_area.isLeakDetected).expected(True).equal_to()

        foreign_with_detected = [
            diagnostic_area
            for diagnostic_area in all_diagnostic_areas
            if diagnostic_area.name != leak_diagnostic_area_name and diagnostic_area.isLeakDetected
        ]

        if not cfg.has_multiple_leaks:
            StepCheck(
                "Проверка: на остальных ДУ не обнаружена утечка, "
                f" количество ДУ с неправильным статусом: {len(foreign_with_detected)}, "
                f"их id: {[diagnostic_area.id for diagnostic_area in foreign_with_detected]})",
                "isLeakDetected_without_leak",
                soft_failures,
            ).actual(len(foreign_with_detected)).expected(0).equal_to()

        if leak.flow_rate_settings_threshold is not None:
            threshold = leak.flow_rate_settings_threshold
            StepCheck(
                f"Дебаланс на ДУ name={leak_diagnostic_area_name} по модулю больше порога для данного режима МТ:"
                f" {threshold}",
                "debalance",
                soft_failures,
            ).actual(abs(leak_diagnostic_area.debalance)).is_greater_than(threshold)


async def balance_algorithm_leak_completed(ws_client, cfg: SmokeSuiteConfig):
    """
    Проверка отсутствия утечки (isLeakDetected) через BalanceAlgorithmResults.

    Логика:
    - Подписка на BalanceAlgorithmResultsContent.
    - Получение первого подходящего сообщения типа BalanceAlgorithmResultsContent.
    - Проверяем, что на всех ДУ флаг isLeakDetected=False.
    - Проверяем, что дебаланс на всех ДУ < FLOW_RATE_SETTINGS_THRESHOLD.
    """

    with allure.step("Подписка и получение BalanceAlgorithmResultsContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "BalanceAlgorithmResultsContent",
            "SubscribeBalanceAlgorithmResultsRequest",
            {'tuId': cfg.tu_id, 'additionalProperties': None},
        )

        parsed_payload = parser.parse_balance_algorithm_msg(payload)
        reply_content = parsed_payload.replyContent
        if not reply_content or not reply_content.flowAreas:
            pytest.fail(
                "В ответе с бэка в DTO BalanceAlgorithmResults отсутствуют flowAreas, "
                "невозможно проверить наличие/отсутствие утечки"
            )

        all_diagnostic_areas = []
        flow_areas = getattr(reply_content, 'flowAreas', [])
        for flow_area in flow_areas:
            if flow_area.diagnosticAreas:
                all_diagnostic_areas.extend(flow_area.diagnosticAreas)

        if not all_diagnostic_areas:
            pytest.fail(
                "В ответе с бэка в DTO BalanceAlgorithmResults во всех flowAreas отсутствуют diagnosticAreas, "
                "невозможно проверить наличие/отсутствие утечки"
            )

    with SoftAssertions() as soft_failures:

        for diagnostic_area in all_diagnostic_areas:
            diagnostic_area_name = diagnostic_area.name
            StepCheck(
                f"Проверка: на ДУ {diagnostic_area_name} не должно быть утечки",
                "isLeakDetected_without_leak",
                soft_failures,
            ).actual(diagnostic_area.isLeakDetected).expected(False).equal_to()


async def leak_is_complete_on_kg(ws_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig):
    """
    Проверка факта завершения утечки на ЭФ КГ(табличное представление).

    Логика:
    LeaksContent - проверить, что утечка в статусе завершена
    """
    with allure.step("Подключение по ws и получение сообщения об утечке типа: LeaksContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "LeaksContent",
            "SubscribeLeaksRequest",
            {'tuId': cfg.tu_id},
        )
        parsed_payload = parser.parse_leaks_content_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        leaks_list_info = getattr(parsed_payload.replyContent, 'leaksListInfo', None)
        complete_leak_info = t_utils.find_leak_by_coordinate(leaks_list_info, leak.coordinate_meters)
        leak_coordinate_round = round(complete_leak_info.leakCoordinate, cfg.precision)
        complete_leak = t_utils.find_object_by_field(
            leaks_list_info, "confirmationStatus", ConfirmationStatus.CONFIRMED_AND_LEAK_CLOSED.value
        )
        leak_algorithm_type = ReservedType(complete_leak_info.type) if complete_leak_info.type else None
        leak_confirmation_status = (
            ConfirmationStatus(complete_leak.confirmationStatus) if complete_leak.confirmationStatus else None
        )

    with SoftAssertions() as soft_failures:
        StepCheck("Проверка статуса утечки в КГ - завершена", "confirmationStatus", soft_failures).actual(
            leak_confirmation_status
        ).expected(leak.expected_complete_leak_status).equal_to()
        StepCheck("Проверка наличия названия участка утечки", "diagnosticAreaName", soft_failures).actual(
            complete_leak_info.diagnosticAreaName
        ).is_not_none()
        StepCheck("Проверка источника события (алгоритм)", "type", soft_failures).actual(leak_algorithm_type).expected(
            leak.expected_algorithm_type
        ).equal_to()
        StepCheck("Проверка координаты утечки", "leakCoordinate", soft_failures).actual(
            leak_coordinate_round
        ).is_close_to(
            leak.coordinate_meters,
            cfg.allowed_distance_diff_meters,
            f"значение допустимой погрешности координаты {cfg.allowed_distance_diff_meters}",
        )


async def leak_is_complete_in_output_signals(ws_client, http_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig):
    """OutputSignalsInfo - нет утечки в выходных сигналах"""
    linear_part_id = leak.linear_part_id
    with allure.step("Http запрос, получение и обработка ответа типа: GetOutputSignalsRequest"):
        request_body = {'tuId': cfg.tu_id}
        response = http_client.post_request(HttpConst.GET_OUTPUT_SIGNALS_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_output_signals_msg(payload)

        # Получение данных линейного участка утечки по id
        with allure.step("Извлечение и подготовка данных для проверки"):
            linear_part_signals = getattr(parsed_payload.replyContent, 'linearPartSignals', [])
            leak_linear_part = t_utils.find_object_by_field(
                linear_part_signals,
                TestConst.LEAK_LINEAR_PART_ID_KEY,
                linear_part_id,
            )
            StepCheck("Проверка наличия данных выходных сигналов", "linearPartSignals").actual(
                leak_linear_part
            ).is_not_none()
            leak_signals_list = leak_linear_part.signals
            leak_signal_type = t_utils.find_signal_type_by_address_suffix(
                leak_signals_list, TestConst.ADDRESS_SUFFIX_LEAK
            )

    with allure.step(f"Получение данных выходных сигналов для линейного участка с id: {linear_part_id}"):
        with allure.step("Получение сообщения с данными выходных сигналов типа: OutputSignalsInfo"):
            payload = await t_utils.connect_and_subscribe_msg(
                ws_client,
                "OutputSignalsInfo",
                "SubscribeOutputSignalsRequest",
                {
                    'objects': {
                        'linearParts': [{'linearPartId': linear_part_id}],
                        'controlledSites': [],
                    },
                    'signalTypes': 1023,
                    'tuId': cfg.tu_id,
                    'additionalProperties': None,
                },
            )
            parsed_payload = parser.parse_output_signals_info_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки"):
        linear_part_signals = getattr(parsed_payload.replyContent, 'linearPartSignals', [])
        leak_linear_part = t_utils.find_object_by_field(
            linear_part_signals,
            TestConst.LEAK_LINEAR_PART_ID_KEY,
            linear_part_id,
        )
        StepCheck("Проверка наличия данных выходных сигналов", "linearPartSignals").actual(
            leak_linear_part
        ).is_not_none()
        leak_signals_list = getattr(leak_linear_part, 'signals', [])
        leak_value = t_utils.find_signal_val_by_signal_type(leak_signals_list, leak_signal_type)

    with SoftAssertions() as soft_failures:
        StepCheck(
            "Проверка отсутствия времени утечки в выходных сигналах",
            TestConst.ADDRESS_SUFFIX_TIME_LEAK,
            soft_failures,
        ).actual(leak_value).expected(TestConst.OUTPUT_IS_NOT_LEAK).equal_to()
        StepCheck(
            "Проверка отсутствия утечки в выходных сигналах",
            TestConst.ADDRESS_SUFFIX_LEAK,
            soft_failures,
        ).actual(leak_value).expected(TestConst.OUTPUT_IS_NOT_LEAK).equal_to()
        StepCheck(
            "Проверка отсутствия квитирования утечки в выходных сигналах",
            TestConst.ADDRESS_SUFFIX_ACK_LEAK,
            soft_failures,
        ).actual(leak_value).expected(TestConst.OUTPUT_IS_NOT_LEAK).equal_to()
        StepCheck(
            "Проверка отсутствия объема утечки в выходных сигналах",
            TestConst.ADDRESS_SUFFIX_Q_LEAK,
            soft_failures,
        ).actual(leak_value).expected(TestConst.OUTPUT_IS_NOT_LEAK).equal_to()
        StepCheck(
            "Проверка отсутствия координаты утечки в выходных сигналах",
            TestConst.ADDRESS_SUFFIX_POINT_LEAK,
            soft_failures,
        ).actual(leak_value).expected(TestConst.OUTPUT_IS_NOT_LEAK).equal_to()


async def complete_tu_leaks_info_content(ws_client, cfg: SmokeSuiteConfig):
    """
    TuLeaksInfoContent - проверка отсутствия утечки на схеме
    """
    with allure.step("Подключение по ws, получение и обработка сообщения об утечке типа: TuLeaksInfoContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "TuLeaksInfoContent",
            "subscribeTuLeaksInfoRequest",
            {'tuId': cfg.tu_id},
        )
        parsed_payload = parser.parse_tu_leaks_info_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        leak_on_scheme = getattr(parsed_payload.replyContent, 'leaksInfo', [])

    StepCheck("Проверка отсутствия утечки на схеме", "leaksInfo").actual(leak_on_scheme).is_empty()


async def export_leaks_report(ws_client, cfg: SmokeSuiteConfig, leak: LeakTestConfig, imitator_start_time: datetime):
    """
    Сценарий формирования отчёта об утечках.

    Этапы:
    1. Подписка SubscribeReportsDataExportedRequest на пуш-нотификации.
    2. Отправка ExportReportsCommandRequest с фильтром по времени
       (start = старт имитатора, end = старт имитатора + offset теста).
    3. Ожидание пуш-нотификации ReportDataExportedNotification о готовности отчёта.
    4. Лонг-поллинг GetExportedDataListRequest до появления нашего отчёта в списке.
    5. Отправка DownloadExportedDataRequest по id отчёта.
    6. Получение fileChunk по ответу на скачивание.
    7-10. Проверки: формат файла, имя, шапка xlsx, строка утечки.

    Скачанный файл удаляется по завершению, прикладывается к Allure только при падении теста.
    """
    #  Создаем универсальный словарь для сбора фактических результатов ответов с бэка
    actual_report_state = ExportLeaksReportState()

    with allure.step("Подготовка параметров сценария формирования отчёта об утечках"):
        actual_report_state.report_test = leak.export_leaks_report_test
        StepCheck("В конфигурации задан export_leaks_report_test", "export_leaks_report_test").actual(
            actual_report_state.report_test
        ).is_not_none()

        actual_report_state.period_start = t_utils.localize_as_moscow(imitator_start_time)
        actual_report_state.period_end = t_utils.localize_as_moscow(
            imitator_start_time + timedelta(minutes=actual_report_state.report_test.offset)
        )
        actual_report_state.period_start_naive = report_utils.normalize_report_period_naive(
            actual_report_state.period_start
        )
        actual_report_state.period_end_naive = report_utils.normalize_report_period_naive(
            actual_report_state.period_end
        )
        actual_report_state.expected_mt_mode = ReportConst.STATIONARY_STATUS_TO_REPORT_TEXT.get(
            leak.expected_report_stationary_status
        )
        actual_report_state.expected_lds_status_text = LdsStatus.report_text_by_value(
            leak.expected_lds_status_in_leaks_report
        )
        actual_report_state.tu_description_lower = cfg.tu_name.lower()
        time_offset_hours = t_utils.report_time_offset_hours()
        StepCheck(
            f"Смещение timeOffset для запросов отчёта (часовой пояс {TestConst.ZONE_INFO})",
            "time_offset_hours",
        ).actual(time_offset_hours).is_not_none()
        actual_report_state.time_offset_hours = time_offset_hours

        StepCheck(
            "Задан ожидаемый текст режима МТ для отчёта",
            "expected_mt_mode",
        ).actual(actual_report_state.expected_mt_mode).is_not_none()

        allure.attach(
            f"period.start={actual_report_state.period_start}\n"
            f"period.end={actual_report_state.period_end}\n"
            f"offset_minutes={actual_report_state.report_test.offset}",
            name="Фильтр периода отчёта",
            attachment_type=allure.attachment_type.TEXT,
        )

    with allure.step(f"Этап 1. Подписка на пуш-нотификации ({ReportConst.SUBSCRIBE_REPORTS_DATA_EXPORTED_REQUEST})"):
        await t_utils.connect(ws_client, ReportConst.SUBSCRIBE_REPORTS_DATA_EXPORTED_REQUEST, [])

    with allure.step(f"Этап 2. Запрос формирования отчёта ({ReportConst.EXPORT_REPORTS_COMMAND_REQUEST})"):
        request_payload = {
            "tuId": cfg.tu_id,
            "exportedDataTypes": [ExportedDataType.LEAKS_REPORT.value],
            "timeOffset": actual_report_state.time_offset_hours,
            "period": {
                "start": t_utils.datetime_to_msgpack_timestamp(actual_report_state.period_start),
                "end": t_utils.datetime_to_msgpack_timestamp(actual_report_state.period_end),
                "additionalProperties": {},
            },
        }
        await t_utils.connect(ws_client, ReportConst.EXPORT_REPORTS_COMMAND_REQUEST, request_payload)

    with allure.step(
        f"Этап 3. Ожидание пуш-нотификации {ReportConst.REPORT_DATA_EXPORTED_NOTIFICATION} о готовности отчёта"
    ):
        actual_report_state.notification = await t_utils.poll_for_report_export_notification(
            ws_client=ws_client,
            parser=parser,
            total_wait_seconds=ReportConst.NOTIFICATION_TIMEOUT_SECONDS,
            poll_interval_seconds=ReportConst.LIST_POLL_INTERVAL_SECONDS,
        )

    with allure.step("Извлечение полей пуш-нотификации"):
        notification = actual_report_state.notification
        notification_reply_status = notification.replyStatus if notification else None
        notification_reply_content = notification.replyContent if notification else None
        notification_export_status = notification_reply_content.exportStatus if notification_reply_content else None
        notification_error_message = (
            (notification_reply_content.errorMessage or "") if notification_reply_content else ""
        )

    with allure.step(f"Этап 4. Лонг-поллинг {ReportConst.GET_EXPORTED_DATA_LIST_REQUEST} до появления отчёта в списке"):
        actual_report_state.report_item = await t_utils.poll_for_exported_file(
            ws_client=ws_client,
            parser=parser,
            list_limit=ReportConst.EXPORTED_DATA_LIST_LIMIT,
            expected_data_type=ExportedDataType.LEAKS_REPORT,
            name_substring=ReportConst.LEAKS_REPORT_NAME_PART,
            tu_name_substring=cfg.tu_name,
            period_start=actual_report_state.period_start,
            period_end=actual_report_state.period_end,
            total_wait_seconds=ReportConst.LIST_POLL_TOTAL_WAIT_SECONDS,
            poll_interval_seconds=ReportConst.LIST_POLL_INTERVAL_SECONDS,
        )

    with allure.step("Подготовка данных найденного отчёта в списке"):
        report_item = actual_report_state.report_item
        if report_item is not None:
            allure.attach(
                f"id={report_item.id}, name={report_item.name}, "
                f"exportedDataType={report_item.exportedDataType}, "
                f"start={t_utils.format_datetime_moscow(report_item.start)}, "
                f"end={t_utils.format_datetime_moscow(report_item.end)}",
                name="Найденный отчёт в списке",
                attachment_type=allure.attachment_type.TEXT,
            )
        actual_report_state.report_file_name = report_utils.build_export_report_file_name(
            cfg.tu_name,
            actual_report_state.period_start,
            actual_report_state.period_end,
        )

    with allure.step("Проверка: отчёт найден в списке сформированных файлов"):
        StepCheck("Отчёт найден в списке сформированных файлов", "report_item").actual(
            actual_report_state.report_item
        ).is_not_none()

    with allure.step(
        f"Этап 5. Streaming-вызов {ReportConst.DOWNLOAD_EXPORTED_DATA_REQUEST} по "
        f"id={actual_report_state.report_item.id}"
    ):
        download_request = {
            "exportedDataId": actual_report_state.report_item.id,
            "exportedDataType": ExportedDataType.LEAKS_REPORT.to_download_name(),
            "additionalProperties": None,
            "timeOffset": actual_report_state.time_offset_hours,
        }

        download_purpose = (
            f"скачивание xlsx-отчёта об утечках (exportedDataId={actual_report_state.report_item.id}) "
            f"после формирования отчёта и выбора файла в списке GetExportedDataListRequest - "
            f"выпадашка уведомлений на UI"
        )

        await t_utils.connect_stream(
            ws_client,
            ReportConst.DOWNLOAD_EXPORTED_DATA_REQUEST,
            download_request,
            purpose=download_purpose,
        )
        actual_report_state.download_invocation_id = ws_client.invocation_id

    with allure.step("Этап 6. Получение fileChunk - скачивание отчёта по утечкам"):
        actual_report_state.download_reply = await t_utils.receive_download_exported_data_reply(
            ws_client=ws_client,
            parser=parser,
            invocation_id=actual_report_state.download_invocation_id,
            request_name=ReportConst.DOWNLOAD_EXPORTED_DATA_REQUEST,
            total_wait_seconds=ReportConst.DOWNLOAD_TIMEOUT_SECONDS,
            purpose=download_purpose,
        )

    with allure.step("Извлечение данных ответа на скачивание"):
        download_reply = actual_report_state.download_reply
        download_reply_status = download_reply.replyStatus
        has_download_reply_content = download_reply.replyContent is not None
        actual_report_state.file_bytes = download_reply.replyContent.fileChunk if has_download_reply_content else None
        is_xlsx_signature = (
            report_utils.is_xlsx_file_bytes(actual_report_state.file_bytes) if actual_report_state.file_bytes else False
        )

    with allure.step("Проверка ответа на скачивание и формата xlsx"):
        StepCheck("Проверка статуса ответа на скачивание", "replyStatus").actual(download_reply_status).expected(
            ReplyStatus.OK.value
        ).equal_to()
        StepCheck("Проверка наличия контента ответа на скачивание", "replyContent").actual(
            has_download_reply_content
        ).expected(True).equal_to()
        StepCheck("Проверка наличия байт файла", "fileChunk").actual(actual_report_state.file_bytes).is_not_empty()
        StepCheck("Проверка xlsx (zip) сигнатуры файла", "file_signature").actual(is_xlsx_signature).expected(
            True
        ).equal_to()

    with allure.step("Подготовка данных для проверки имени файла отчёта"):
        report_file_name = actual_report_state.report_file_name
        report_file_name_lower = report_file_name.lower()
        file_name_period_start, file_name_period_end = report_utils.parse_period_from_export_file_name(report_file_name)
        period_start_lo, period_start_hi, period_end_lo, period_end_hi = report_utils.report_period_comparison_bounds(
            actual_report_state.period_start_naive,
            actual_report_state.period_end_naive,
        )
        has_xlsx_extension = report_utils.is_xlsx_extension(report_file_name)
        leaks_report_name_part_lower = ReportConst.LEAKS_REPORT_NAME_PART.lower()

    with allure.step("Этап 8. Сохранение, обработка и проверка отчета по утечкам"):
        actual_report_state.temp_file_path = report_utils.save_report_bytes_to_temp_file(actual_report_state.file_bytes)

    try:
        with allure.step("Проверка: временный xlsx файл создан"):
            StepCheck("Временный xlsx файл создан", "temp_file_path").actual(
                actual_report_state.temp_file_path
            ).is_not_none()

        with allure.step("Этап 9. Открытие xlsx и чтение шапки"):
            actual_report_state.worksheet = report_utils.load_report_worksheet(actual_report_state.temp_file_path)
            actual_report_state.title_info = report_utils.parse_report_title(
                report_utils.get_report_title_cell(actual_report_state.worksheet)
            )
            allure.attach(
                f"Шапка отчёта (raw): {actual_report_state.title_info.raw_title}\n"
                f"period_start: {actual_report_state.title_info.period_start}\n"
                f"period_end: {actual_report_state.title_info.period_end}",
                name="Шапка отчёта (1-я строка)",
                attachment_type=allure.attachment_type.TEXT,
            )

        with allure.step("Подготовка данных шапки отчёта для проверки"):
            title_info = actual_report_state.title_info
            report_title_lower = title_info.raw_title.lower()
            leaks_report_name_part_lower = ReportConst.LEAKS_REPORT_NAME_PART.lower()
            column_headers = report_utils.get_report_column_headers(actual_report_state.worksheet)
            period_start_lo, period_start_hi, period_end_lo, period_end_hi = (
                report_utils.report_period_comparison_bounds(
                    actual_report_state.period_start_naive,
                    actual_report_state.period_end_naive,
                )
            )
            header_period_start = title_info.period_start
            header_period_end = title_info.period_end

        with allure.step("Этап 10. Извлечение строк данных из отчёта"):
            actual_report_state.data_rows = report_utils.iter_report_data_rows(actual_report_state.worksheet)
            actual_report_state.target_row = report_utils.find_row_with_object(
                actual_report_state.data_rows, cfg.tu_name
            )
            allure.attach(
                "\n".join(f"row#{row.row_index}: {row.cells}" for row in actual_report_state.data_rows),
                name="Все строки данных отчёта",
                attachment_type=allure.attachment_type.TEXT,
            )

        with allure.step("Подготовка данных строки утечки для проверки"):
            target_row = actual_report_state.target_row
            leak_datetime_value = target_row.datetime_value if target_row else None
            object_value_lower = target_row.object_value.lower() if target_row else ""
            lds_status_value = target_row.lds_status.strip() if target_row else ""
            masking_info_lower = target_row.masking_info.lower() if target_row else ""
            leak_coordinate_meters = target_row.coordinate_meters if target_row else None
            leak_volume_value = target_row.leak_volume if target_row else None
            mt_mode_lower = target_row.mt_mode.lower() if target_row else ""
            expected_mt_mode_lower = actual_report_state.expected_mt_mode.lower()
            expected_lds_status_lower = actual_report_state.expected_lds_status_text.lower()
            masking_not_masked_lower = ReportConst.MASKING_NOT_MASKED_TEXT.lower()
            period_start_lo, period_start_hi, period_end_lo, period_end_hi = (
                report_utils.report_period_comparison_bounds(
                    actual_report_state.period_start_naive,
                    actual_report_state.period_end_naive,
                )
            )

        with allure.step("Проверка содержимого строки утечки"):
            StepCheck("В отчёте есть хотя бы одна строка с данными", "data_rows").actual(
                actual_report_state.data_rows
            ).is_not_empty()
            StepCheck(
                f"Строка с объектом, содержащим '{cfg.tu_name}'",
                ReportConst.COL_OBJECT,
            ).actual(actual_report_state.target_row).is_not_none()

            with SoftAssertions() as soft_failures:
                StepCheck(
                    "Время утечки в диапазоне [старт имитатора, старт + offset теста] (+-1 мин)",
                    ReportConst.COL_DATETIME,
                    soft_failures,
                ).actual(leak_datetime_value).is_between(period_start_lo, period_end_hi)

                StepCheck(
                    f"Колонка '{ReportConst.COL_OBJECT}' содержит '{cfg.tu_name}'",
                    ReportConst.COL_OBJECT,
                    soft_failures,
                ).contains(object_value_lower, actual_report_state.tu_description_lower)

                StepCheck(
                    f"Колонка '{ReportConst.COL_LDS_STATUS}' содержит "
                    f"'{actual_report_state.expected_lds_status_text}'",
                    ReportConst.COL_LDS_STATUS,
                    soft_failures,
                ).contains(lds_status_value.lower(), expected_lds_status_lower)

                StepCheck(
                    f"Колонка '{ReportConst.COL_MASK_INFO}' содержит '{ReportConst.MASKING_NOT_MASKED_TEXT}'",
                    ReportConst.COL_MASK_INFO,
                    soft_failures,
                ).contains(masking_info_lower, masking_not_masked_lower)

                StepCheck(
                    f"Колонка '{ReportConst.COL_COORDINATE}' (с погрешностью {cfg.allowed_distance_diff_meters} м)",
                    ReportConst.COL_COORDINATE,
                    soft_failures,
                ).actual(leak_coordinate_meters).is_close_to(
                    leak.coordinate_meters,
                    cfg.allowed_distance_diff_meters,
                    f"значение допустимой погрешности координаты {cfg.allowed_distance_diff_meters}",
                )

                StepCheck(
                    f"Колонка '{ReportConst.COL_LEAK_VOLUME}' не пустая",
                    ReportConst.COL_LEAK_VOLUME,
                    soft_failures,
                ).actual(leak_volume_value).is_not_none()

                StepCheck(
                    f"Колонка '{ReportConst.COL_MT_MODE}' содержит '{actual_report_state.expected_mt_mode}'",
                    ReportConst.COL_MT_MODE,
                    soft_failures,
                ).contains(mt_mode_lower, expected_mt_mode_lower)
    except Exception:
        with allure.step("Прикрепление xlsx отчёта к Allure при падении теста"):
            if actual_report_state.temp_file_path and actual_report_state.report_file_name:
                report_utils.attach_report_file_to_allure(
                    actual_report_state.temp_file_path, actual_report_state.report_file_name
                )
        raise

    with allure.step("Проверка имени файла отчёта"):
        with SoftAssertions() as soft_failures:
            StepCheck(f"Имя файла оканчивается на {ReportConst.XLSX_EXTENSION}", "file_name", soft_failures).actual(
                has_xlsx_extension
            ).expected(True).equal_to()
            StepCheck(
                f"Имя файла содержит '{ReportConst.LEAKS_REPORT_NAME_PART}'", "file_name", soft_failures
            ).contains(report_file_name_lower, leaks_report_name_part_lower)
            StepCheck(f"Имя файла содержит описание ТУ '{cfg.tu_name}'", "file_name", soft_failures).contains(
                report_file_name_lower, actual_report_state.tu_description_lower
            )
            StepCheck(
                "Дата начала периода в имени файла совпадает с фильтром запроса (+-1 мин)",
                "period_start_in_file_name",
                soft_failures,
            ).actual(file_name_period_start).is_between(period_start_lo, period_start_hi)
            StepCheck(
                "Дата конца периода в имени файла совпадает с фильтром запроса (+-1 мин)",
                "period_end_in_file_name",
                soft_failures,
            ).actual(file_name_period_end).is_between(period_end_lo, period_end_hi)

    with allure.step("Проверка двойной шапки отчёта"):
        StepCheck("Лист xlsx открыт", "worksheet").actual(actual_report_state.worksheet).is_not_none()
        with SoftAssertions() as soft_failures:
            StepCheck(
                f"В шапке отчёта присутствует '{ReportConst.LEAKS_REPORT_NAME_PART}'",
                "report_title",
                soft_failures,
            ).contains(report_title_lower, leaks_report_name_part_lower)

            StepCheck(
                "Время начала периода в шапке совпадает с фильтром запроса (+-1 мин)",
                "period_start",
                soft_failures,
            ).actual(header_period_start).is_between(period_start_lo, period_start_hi)
            StepCheck(
                "Время конца периода в шапке совпадает с фильтром запроса (+-1 мин)",
                "period_end",
                soft_failures,
            ).actual(header_period_end).is_between(period_end_lo, period_end_hi)
            StepCheck(
                "Названия колонок в шапке отчёта",
                "column_headers",
                soft_failures,
            ).actual(
                column_headers
            ).expected(ReportConst.EXPECTED_COLUMN_HEADERS).equal_to()

    with allure.step("Проверка пуш-нотификации о готовности отчёта"):
        with SoftAssertions() as soft_failures:
            StepCheck("Получена пуш-нотификация о готовности отчёта", "notification", soft_failures).actual(
                actual_report_state.notification
            ).is_not_none()
            StepCheck("Проверка статуса пуш-нотификации", "replyStatus", soft_failures).actual(
                notification_reply_status
            ).expected(ReplyStatus.OK.value).equal_to()
            StepCheck("Проверка наличия контента нотификации", "replyContent", soft_failures).actual(
                notification_reply_content
            ).is_not_none()
            StepCheck("Проверка exportStatus в нотификации", "exportStatus", soft_failures).actual(
                notification_export_status
            ).expected(ExportStatus.DONE).equal_to()
            StepCheck("В нотификации нет текста ошибки", "errorMessage", soft_failures).actual(
                notification_error_message
            ).is_empty()


async def export_lds_status_report(ws_client, cfg: SmokeSuiteConfig, imitator_start_time: datetime):
    """
    Сценарий формирования xlsx-отчёта о режиме работы СОУ.
    """
    report_state = ExportLdsStatusReportState()

    with allure.step("Подготовка параметров сценария формирования отчёта о режиме работы СОУ"):
        report_state.report_test = cfg.export_lds_status_report_test
        StepCheck("В конфигурации задан export_lds_status_report_test", "export_lds_status_report_test").actual(
            report_state.report_test
        ).is_not_none()
        report_state.period_start = t_utils.localize_as_moscow(imitator_start_time)
        report_state.period_end = t_utils.localize_as_moscow(
            imitator_start_time + timedelta(minutes=report_state.report_test.offset)
        )
        report_state.period_start_naive = report_utils.normalize_report_period_naive(report_state.period_start)
        report_state.period_end_naive = report_utils.normalize_report_period_naive(report_state.period_end)
        report_state.tu_description_lower = cfg.tu_name.lower()
        time_offset_hours = t_utils.report_time_offset_hours()
        StepCheck(
            f"Смещение timeOffset для запросов отчёта (часовой пояс {TestConst.ZONE_INFO})",
            "time_offset_hours",
        ).actual(time_offset_hours).is_not_none()
        report_state.time_offset_hours = time_offset_hours

        allure.attach(
            f"period.start={report_state.period_start}\n"
            f"period.end={report_state.period_end}\n"
            f"offset_minutes={report_state.report_test.offset}\n"
            f"sections={LdsReportConst.SECTION_NAMES}",
            name="Фильтр периода отчёта о режиме СОУ",
            attachment_type=allure.attachment_type.TEXT,
        )

    with allure.step(f"Этап 1. Подписка на пуш-нотификации {ReportConst.SUBSCRIBE_REPORTS_DATA_EXPORTED_REQUEST}"):
        await t_utils.connect(ws_client, ReportConst.SUBSCRIBE_REPORTS_DATA_EXPORTED_REQUEST, [])

    with allure.step(f"Этап 2. Запрос формирования отчёта {ReportConst.EXPORT_REPORTS_COMMAND_REQUEST}"):
        request_payload = {
            "tuId": cfg.tu_id,
            "exportedDataTypes": [ExportedDataType.LDS_STATUS_REPORT.value],
            "timeOffset": report_state.time_offset_hours,
            "period": {
                "start": t_utils.datetime_to_msgpack_timestamp(report_state.period_start),
                "end": t_utils.datetime_to_msgpack_timestamp(report_state.period_end),
                "additionalProperties": {},
            },
        }
        await t_utils.connect(ws_client, ReportConst.EXPORT_REPORTS_COMMAND_REQUEST, request_payload)

    with allure.step(
        f"Этап 3. Ожидание пуш-нотификации {ReportConst.REPORT_DATA_EXPORTED_NOTIFICATION} о готовности отчёта"
    ):
        report_state.notification = await t_utils.poll_for_report_export_notification(
            ws_client=ws_client,
            parser=parser,
            total_wait_seconds=ReportConst.NOTIFICATION_TIMEOUT_SECONDS,
            poll_interval_seconds=ReportConst.LIST_POLL_INTERVAL_SECONDS,
        )

    with allure.step(f"Этап 4. Лонг-поллинг {ReportConst.GET_EXPORTED_DATA_LIST_REQUEST} до появления отчёта в списке"):
        report_state.report_item = await t_utils.poll_for_exported_file(
            ws_client=ws_client,
            parser=parser,
            list_limit=ReportConst.EXPORTED_DATA_LIST_LIMIT,
            expected_data_type=ExportedDataType.LDS_STATUS_REPORT,
            name_substring=LdsReportConst.LDS_STATUS_REPORT_NAME_PART,
            tu_name_substring=cfg.tu_name,
            period_start=report_state.period_start,
            period_end=report_state.period_end,
            total_wait_seconds=ReportConst.LIST_POLL_TOTAL_WAIT_SECONDS,
            poll_interval_seconds=ReportConst.LIST_POLL_INTERVAL_SECONDS,
        )

    with allure.step("Подготовка данных найденного отчёта в списке"):
        report_item = report_state.report_item
        if report_item is not None:
            allure.attach(
                f"id={report_item.id}, name={report_item.name}, "
                f"exportedDataType={report_item.exportedDataType}, "
                f"start={t_utils.format_datetime_moscow(report_item.start)}, "
                f"end={t_utils.format_datetime_moscow(report_item.end)}",
                name="Найденный отчёт в списке",
                attachment_type=allure.attachment_type.TEXT,
            )
        report_state.report_file_name = report_utils.build_export_report_file_name(
            cfg.tu_name,
            report_state.period_start,
            report_state.period_end,
            LdsReportConst.LDS_STATUS_REPORT_NAME_PART,
            ". ",
        )

    with allure.step("Проверка: отчёт найден в списке сформированных файлов"):
        StepCheck("Отчёт найден в списке сформированных файлов", "report_item").actual(
            report_state.report_item
        ).is_not_none()

    with allure.step(
        f"Этап 5. Streaming-вызов {ReportConst.DOWNLOAD_EXPORTED_DATA_REQUEST} по id={report_state.report_item.id}"
    ):
        download_request = {
            "exportedDataId": report_state.report_item.id,
            "exportedDataType": ExportedDataType.LDS_STATUS_REPORT.to_download_name(),
            "additionalProperties": None,
            "timeOffset": report_state.time_offset_hours,
        }
        download_purpose = (
            f"скачивание xlsx-отчёта о режиме СОУ (exportedDataId={report_state.report_item.id}) "
            f"после формирования отчёта и выбора файла в списке GetExportedDataListRequest"
        )
        await t_utils.connect_stream(
            ws_client,
            ReportConst.DOWNLOAD_EXPORTED_DATA_REQUEST,
            download_request,
            purpose=download_purpose,
        )
        report_state.download_invocation_id = ws_client.invocation_id

    with allure.step("Этап 6. Получение fileChunk - скачивание отчёта о режиме СОУ"):
        report_state.download_reply = await t_utils.receive_download_exported_data_reply(
            ws_client=ws_client,
            parser=parser,
            invocation_id=report_state.download_invocation_id,
            request_name=ReportConst.DOWNLOAD_EXPORTED_DATA_REQUEST,
            total_wait_seconds=ReportConst.DOWNLOAD_TIMEOUT_SECONDS,
            purpose=download_purpose,
        )

    with allure.step("Извлечение данных ответа на скачивание"):
        download_reply = report_state.download_reply
        download_reply_status = download_reply.replyStatus
        has_download_reply_content = download_reply.replyContent is not None
        report_state.file_bytes = download_reply.replyContent.fileChunk if has_download_reply_content else None
        is_xlsx_signature = (
            report_utils.is_xlsx_file_bytes(report_state.file_bytes) if report_state.file_bytes else False
        )

    with allure.step("Проверка ответа на скачивание и формата xlsx"):
        StepCheck("Проверка статуса ответа на скачивание", "replyStatus").actual(download_reply_status).expected(
            ReplyStatus.OK.value
        ).equal_to()
        StepCheck("Проверка наличия контента ответа на скачивание", "replyContent").actual(
            has_download_reply_content
        ).expected(True).equal_to()
        StepCheck("Проверка наличия байт файла", "fileChunk").actual(report_state.file_bytes).is_not_empty()
        StepCheck("Проверка xlsx (zip) сигнатуры файла", "file_signature").actual(is_xlsx_signature).expected(
            True
        ).equal_to()

    with allure.step("Подготовка данных для проверки имени файла отчёта"):
        report_file_name = report_state.report_file_name
        report_file_name_lower = report_file_name.lower()
        file_name_period_start, file_name_period_end = report_utils.parse_period_from_export_file_name(
            report_file_name,
            LdsReportConst.REPORT_FILE_NAME_PERIOD_PATTERN,
        )
        period_start_lo, period_start_hi, period_end_lo, period_end_hi = report_utils.report_period_comparison_bounds(
            report_state.period_start_naive,
            report_state.period_end_naive,
        )
        has_xlsx_extension = report_utils.is_xlsx_extension(report_file_name)
        lds_report_name_part_lower = LdsReportConst.LDS_STATUS_REPORT_NAME_PART.lower()

    try:
        with allure.step("Этап 7. Сохранение и разбор xlsx-отчёта о режиме СОУ"):
            report_state.temp_file_path = report_utils.save_report_bytes_to_temp_file(
                report_state.file_bytes,
                prefix="lds_status_report_",
            )
            StepCheck("Временный xlsx файл создан", "temp_file_path").actual(report_state.temp_file_path).is_not_none()
            report_state.worksheet = report_utils.load_report_worksheet(report_state.temp_file_path)
            report_state.parsed_report = lds_report_utils.parse_lds_status_report_worksheet(
                report_state.worksheet,
                LdsReportConst.SECTION_NAMES,
            )
            allure.attach(
                f"Шапка (raw): {report_state.parsed_report.title_info.raw_title}\n"
                f"period_start: {report_state.parsed_report.title_info.period_start}\n"
                f"period_end: {report_state.parsed_report.title_info.period_end}\n"
                f"total_duration: {report_state.parsed_report.total_duration_raw}",
                name="Шапка отчёта о режиме СОУ",
                attachment_type=allure.attachment_type.TEXT,
            )
            allure.attach(
                lds_report_utils.format_section_rows_for_allure(report_state.parsed_report.section_rows),
                name="Строки участков отчёта",
                attachment_type=allure.attachment_type.TEXT,
            )

        with allure.step("Подготовка данных таблицы отчёта для проверки"):
            parsed_report = report_state.parsed_report
            expected_section_names = LdsReportConst.SECTION_NAMES
            section_rows = parsed_report.section_rows
            total_duration_seconds = parsed_report.total_duration_seconds
            duration_tolerance = LdsReportConst.TOTAL_DURATION_TOLERANCE_SECONDS

        with allure.step("Проверка содержимого таблицы отчёта о режиме СОУ"):
            StepCheck("Лист xlsx открыт", "worksheet").actual(report_state.worksheet).is_not_none()
            with SoftAssertions() as soft_failures:
                StepCheck(
                    "Количество строк участков в отчёте",
                    "section_rows_count",
                    soft_failures,
                ).actual(
                    len(section_rows)
                ).expected(len(expected_section_names)).equal_to()

                for section_index, expected_section_name in enumerate(expected_section_names):
                    actual_section_name = (
                        section_rows[section_index].section_name if section_index < len(section_rows) else None
                    )
                    StepCheck(
                        f"Наименование участка #{section_index + 1}",
                        LdsReportConst.COL_SECTION,
                        soft_failures,
                    ).actual(actual_section_name).expected(expected_section_name).equal_to()

                for section_row in section_rows:
                    for column_name in LdsReportConst.MODE_DURATION_COLUMNS:
                        cell_value = section_row.cells.get(column_name)
                        StepCheck(
                            f"Длительность '{column_name}' для участка '{section_row.section_name}' заполнена",
                            column_name,
                            soft_failures,
                        ).actual(lds_report_utils.is_duration_cell_filled(cell_value)).expected(True).equal_to()

                StepCheck(
                    "В отчёте найдена строка 'Суммарное время работы:'",
                    "total_work_duration_label",
                    soft_failures,
                ).actual(parsed_report.total_label_row_index).is_not_none()
                StepCheck(
                    "Суммарное время работы в отчёте не нулевое",
                    "total_work_duration",
                    soft_failures,
                ).actual(
                    total_duration_seconds
                ).is_greater_than(0, LdsReportConst.ZERO_DURATION_TEXT)

                for section_row in section_rows:
                    duration_diff = abs(section_row.modes_sum_seconds - (total_duration_seconds or 0))
                    StepCheck(
                        f"Сумма режимов СОУ для '{section_row.section_name}' "
                        f"совпадает с суммарным временем (+-{duration_tolerance} с)",
                        "modes_sum_seconds",
                        soft_failures,
                    ).actual(duration_diff).is_less_than(
                        duration_tolerance + 1,
                        f"погрешность {duration_tolerance} с",
                    )

        with allure.step("Подготовка данных шапки отчёта для проверки"):
            title_info = parsed_report.title_info
            report_title_lower = title_info.raw_title.lower()
            column_headers = parsed_report.column_headers
            header_period_start = title_info.period_start
            header_period_end = title_info.period_end

        with allure.step("Проверка двойной шапки отчёта о режиме СОУ"):
            with SoftAssertions() as soft_failures:
                StepCheck(
                    f"В шапке отчёта присутствует '{LdsReportConst.LDS_STATUS_REPORT_NAME_PART}'",
                    "report_title",
                    soft_failures,
                ).contains(report_title_lower, lds_report_name_part_lower)
                StepCheck(
                    "Время начала периода в шапке совпадает с фильтром запроса (+-1 мин)",
                    "period_start",
                    soft_failures,
                ).actual(header_period_start).is_between(period_start_lo, period_start_hi)
                StepCheck(
                    "Время конца периода в шапке совпадает с фильтром запроса (+-1 мин)",
                    "period_end",
                    soft_failures,
                ).actual(header_period_end).is_between(period_end_lo, period_end_hi)
                StepCheck(
                    "Названия колонок в шапке отчёта",
                    "column_headers",
                    soft_failures,
                ).actual(
                    column_headers
                ).expected(LdsReportConst.EXPECTED_COLUMN_HEADERS).equal_to()

        with allure.step("Проверка имени файла отчёта о режиме СОУ"):
            with SoftAssertions() as soft_failures:
                StepCheck(f"Имя файла оканчивается на {ReportConst.XLSX_EXTENSION}", "file_name", soft_failures).actual(
                    has_xlsx_extension
                ).expected(True).equal_to()
                StepCheck(
                    f"Имя файла содержит '{LdsReportConst.LDS_STATUS_REPORT_NAME_PART}'",
                    "file_name",
                    soft_failures,
                ).contains(report_file_name_lower, lds_report_name_part_lower)
                StepCheck(
                    f"Имя файла содержит описание ТУ '{cfg.tu_name}'",
                    "file_name",
                    soft_failures,
                ).contains(report_file_name_lower, report_state.tu_description_lower)
                StepCheck(
                    "Дата начала периода в имени файла совпадает с фильтром запроса (+-1 мин)",
                    "period_start_in_file_name",
                    soft_failures,
                ).actual(file_name_period_start).is_between(period_start_lo, period_start_hi)
                StepCheck(
                    "Дата конца периода в имени файла совпадает с фильтром запроса (+-1 мин)",
                    "period_end_in_file_name",
                    soft_failures,
                ).actual(file_name_period_end).is_between(period_end_lo, period_end_hi)

    except Exception:
        with allure.step("Прикрепление xlsx отчёта к Allure при падении теста"):
            if report_state.temp_file_path and report_state.report_file_name:
                report_utils.attach_report_file_to_allure(report_state.temp_file_path, report_state.report_file_name)
        raise

    with allure.step("Проверка пуш-нотификации о готовности отчёта"):
        notification = report_state.notification
        notification_reply_status = notification.replyStatus if notification else None
        notification_reply_content = notification.replyContent if notification else None
        notification_export_status = notification_reply_content.exportStatus if notification_reply_content else None
        notification_error_message = (
            (notification_reply_content.errorMessage or "") if notification_reply_content else ""
        )
        with SoftAssertions() as soft_failures:
            StepCheck("Получена пуш-нотификация о готовности отчёта", "notification", soft_failures).actual(
                notification
            ).is_not_none()
            StepCheck("Проверка статуса пуш-нотификации", "replyStatus", soft_failures).actual(
                notification_reply_status
            ).expected(ReplyStatus.OK.value).equal_to()
            StepCheck("Проверка наличия контента нотификации", "replyContent", soft_failures).actual(
                notification_reply_content
            ).is_not_none()
            StepCheck("Проверка exportStatus в нотификации", "exportStatus", soft_failures).actual(
                notification_export_status
            ).expected(ExportStatus.DONE).equal_to()
            StepCheck("В нотификации нет текста ошибки", "errorMessage", soft_failures).actual(
                notification_error_message
            ).is_empty()


def stationary_status_in_journal(http_client, cfg: SmokeSuiteConfig, imitator_start_time, test_data: CaseData):
    """
    Проверка записей журнала о режиме мт
    """
    if not test_data:
        pytest.fail("Не заполнены обязательные параметры теста")
    exp_mode_part_message, exp_reason_part_message, exp_priority_message = test_data.expected_result

    with allure.step("Http запрос сообщений журнала с фильтром messageTypes=PUMPING_STATUS"):
        end_time = datetime.now()
        request_body = t_utils.create_journal_req_body(
            pagination=Pagination(limit=TestConst.LIMIT_CONTROLLED_SITES),
            filtering=Filtering(messageTypes=int(MessageType.PUMPING_STATUS), objects=FilteringObjects(tuId=cfg.tu_id)),
        )
        response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_journal_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки"):
        messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', [])
        StepCheck("Проверка наличия сообщений в журнале", "messagesInfo").actual(messages_info).is_not_empty()

        filter_start_msk = t_utils.localize_as_moscow(imitator_start_time)
        filter_end_msk = t_utils.localize_as_moscow(end_time)

        messages_time_filtered = [
            msg
            for msg in messages_info
            if filter_start_msk <= t_utils.ensure_moscow_timezone(msg.time) <= filter_end_msk
        ]
        messages_time_filtered.sort(key=lambda msg: t_utils.ensure_moscow_timezone(msg.time), reverse=True)

        # Фильтрация уникальных наименований участков КП-КП
        control_points_list = []

        for msg in messages_time_filtered:
            control_points_list.append(msg.controlPoint)

        unique_control_points_list = set(control_points_list)
        count_unique_control_points = len(unique_control_points_list)

        # Собираю список сообщений с уникальными наименованиями участков КП_КП
        already_been = set()  # Создание пустого множества для хранения отобранных сообщений
        filter_messages_with_unique_control_points = []  # пустой список

        for msg in messages_time_filtered:
            if msg.controlPoint in unique_control_points_list:  # условие1: наименование КП-КП в списке
                if msg.controlPoint not in already_been:  # условие2: сообщение еще не в already_been
                    filter_messages_with_unique_control_points.append(
                        msg
                    )  # действие1: добавляется сообщение в filter_messages_with_unique_control_points
                    already_been.add(msg.controlPoint)  # действие2: добавляется КП-КП во множество, для условия 2

        allure.attach(
            f" Список: {filter_messages_with_unique_control_points}",
            name="Результат фильтрации сообщений журнала",
            attachment_type=allure.attachment_type.TEXT,
        )

        # Создание и наполнение списков сообщений с фильтром по 'event'

        containers = defaultdict(list)

        for msg in filter_messages_with_unique_control_points:
            event = msg.event
            if event in (
                TestConst.JOURNAL_MESSAGE_EVENT_STATIONARY,
                TestConst.JOURNAL_MESSAGE_EVENT_NOT_STATIONARY,
                TestConst.JOURNAL_MESSAGE_EVENT_STOP,
            ):
                containers[event].append(msg)
            else:
                containers["another"].append(msg)

        stationary_status_list = list(containers[TestConst.JOURNAL_MESSAGE_EVENT_STATIONARY])
        unstationary_status_list = list(containers[TestConst.JOURNAL_MESSAGE_EVENT_NOT_STATIONARY])
        stopped_status_list = list(containers[TestConst.JOURNAL_MESSAGE_EVENT_STOP])
        another_event_list = list(containers["another"])
        most_long_event_list = max(
            [unstationary_status_list, stationary_status_list, stopped_status_list, another_event_list], key=len
        )

        first_message = next(iter(most_long_event_list)) if most_long_event_list else None
        if first_message:
            priority_message = MessagePriority(first_message.priority) if first_message.priority else None
            mode_part, reason_part = t_utils.parse_event(getattr(first_message, "event", None))
        else:
            priority_message = None
            mode_part, reason_part = None, None

    with SoftAssertions() as soft_failures:
        StepCheck(
            "Проверка результата фильтрации сообщений о режиме МТ",
            "Кол-во сообщений о режиме МТ с уникальными наименованиями участков КП-КП",
            soft_failures,
        ).actual(count_unique_control_points).expected(TestConst.COUNT_CONTROLLED_SITES).equal_to()
        StepCheck(
            "Проверка режима МТ на ЛЧ в наибольшей области связности.",
            "Режим МТ",
            soft_failures,
        ).actual(
            mode_part
        ).expected(exp_mode_part_message).equal_to()
        if reason_part:
            StepCheck(
                "Проверка причины режима МТ на ЛЧ в наибольшей области связности.", "Причина режима МТ", soft_failures
            ).contains(reason_part, exp_reason_part_message)
        StepCheck("Проверка значимости сообщения", "Важность", soft_failures).actual(priority_message).expected(
            exp_priority_message
        ).equal_to()


async def export_mt_mode_report(ws_client, cfg: SmokeSuiteConfig, imitator_start_time: datetime):
    """
    Сценарий формирования xlsx-отчёта о режиме работы МТ.

    Этапы:
    1. Подписка SubscribeReportsDataExportedRequest на пуш-нотификации.
    2. Отправка ExportReportsCommandRequest (тип StationaryStatusReport, фильтр по периоду).
    3. Ожидание ReportDataExportedNotification о готовности отчёта.
    4. Лонг-поллинг GetExportedDataListRequest до появления отчёта в списке.
    5. DownloadExportedDataRequest и получение fileChunk.
    6. Проверка xlsx: участки, длительности режимов МТ, основной режим.
    7. Проверка двойной шапки и имени файла.

    Скачанный файл удаляется по завершению, прикладывается к Allure только при падении теста.
    """
    report_state = ExportMtModeReportState()

    with allure.step("Подготовка параметров сценария формирования отчёта о режиме работы МТ"):
        report_state.expected_report_test = cfg.export_mt_mode_report_test
        StepCheck("В конфигурации задан export_mt_mode_report_test", "export_mt_mode_report_test").actual(
            report_state.expected_report_test
        ).is_not_none()

        report_state.expected_period_start = t_utils.localize_as_moscow(imitator_start_time)
        report_state.expected_period_end = t_utils.localize_as_moscow(
            imitator_start_time + timedelta(minutes=report_state.expected_report_test.offset)
        )
        report_state.expected_period_start_naive = report_utils.normalize_report_period_naive(
            report_state.expected_period_start
        )
        report_state.expected_period_end_naive = report_utils.normalize_report_period_naive(
            report_state.expected_period_end
        )
        report_state.expected_tu_description_lower = cfg.tu_name.lower()
        report_state.expected_section_names = list(MtReportConst.SECTION_NAMES)
        report_state.expected_dominant_mode_column = MtReportConst.STATIONARY_STATUS_TO_COLUMN.get(
            cfg.expected_report_stationary_status
        )
        report_state.expected_file_name = report_utils.build_export_report_file_name(
            cfg.tu_name,
            report_state.expected_period_start,
            report_state.expected_period_end,
            MtReportConst.MT_MODE_REPORT_NAME_PART,
            ". ",
        )

        time_offset_hours = t_utils.report_time_offset_hours()
        StepCheck(
            f"Смещение timeOffset для запросов отчёта (часовой пояс {TestConst.ZONE_INFO})",
            "time_offset_hours",
        ).actual(time_offset_hours).is_not_none()
        report_state.actual_time_offset_hours = time_offset_hours

        StepCheck(
            "Для набора задан ожидаемый доминирующий режим МТ",
            "expected_dominant_mode_column",
        ).actual(report_state.expected_dominant_mode_column).is_not_none()

        allure.attach(
            f"period.start={report_state.expected_period_start}\n"
            f"period.end={report_state.expected_period_end}\n"
            f"offset_minutes={report_state.expected_report_test.offset}\n"
            f"sections={report_state.expected_section_names}\n"
            f"expected_dominant_mode_column={report_state.expected_dominant_mode_column}",
            name="Фильтр периода отчёта о режиме МТ",
            attachment_type=allure.attachment_type.TEXT,
        )

    with allure.step(f"Этап 1. Подписка на пуш-нотификации {ReportConst.SUBSCRIBE_REPORTS_DATA_EXPORTED_REQUEST}"):
        await t_utils.connect(ws_client, ReportConst.SUBSCRIBE_REPORTS_DATA_EXPORTED_REQUEST, [])

    with allure.step(f"Этап 2. Запрос формирования отчёта {ReportConst.EXPORT_REPORTS_COMMAND_REQUEST}"):
        request_payload = {
            "tuId": cfg.tu_id,
            "exportedDataTypes": [ExportedDataType.STATIONARY_STATUS_REPORT.value],
            "timeOffset": report_state.actual_time_offset_hours,
            "period": {
                "start": t_utils.datetime_to_msgpack_timestamp(report_state.expected_period_start),
                "end": t_utils.datetime_to_msgpack_timestamp(report_state.expected_period_end),
                "additionalProperties": {},
            },
        }
        await t_utils.connect(ws_client, ReportConst.EXPORT_REPORTS_COMMAND_REQUEST, request_payload)

    with allure.step(
        f"Этап 3. Ожидание пуш-нотификации {ReportConst.REPORT_DATA_EXPORTED_NOTIFICATION} о готовности отчёта"
    ):
        report_state.actual_notification = await t_utils.poll_for_report_export_notification(
            ws_client=ws_client,
            parser=parser,
            total_wait_seconds=ReportConst.NOTIFICATION_TIMEOUT_SECONDS,
            poll_interval_seconds=ReportConst.LIST_POLL_INTERVAL_SECONDS,
        )

    with allure.step(f"Этап 4. Лонг-поллинг {ReportConst.GET_EXPORTED_DATA_LIST_REQUEST} до появления отчёта в списке"):
        report_state.actual_report_item = await t_utils.poll_for_exported_file(
            ws_client=ws_client,
            parser=parser,
            list_limit=ReportConst.EXPORTED_DATA_LIST_LIMIT,
            expected_data_type=ExportedDataType.STATIONARY_STATUS_REPORT,
            name_substring=MtReportConst.MT_MODE_REPORT_NAME_PART,
            tu_name_substring=cfg.tu_name,
            period_start=report_state.expected_period_start,
            period_end=report_state.expected_period_end,
            total_wait_seconds=ReportConst.LIST_POLL_TOTAL_WAIT_SECONDS,
            poll_interval_seconds=ReportConst.LIST_POLL_INTERVAL_SECONDS,
        )

    with allure.step("Подготовка данных найденного отчёта в списке"):
        report_item = report_state.actual_report_item
        if report_item is not None:
            allure.attach(
                f"id={report_item.id}, name={report_item.name}, "
                f"exportedDataType={report_item.exportedDataType}, "
                f"start={t_utils.format_datetime_moscow(report_item.start)}, "
                f"end={t_utils.format_datetime_moscow(report_item.end)}",
                name="Найденный отчёт в списке",
                attachment_type=allure.attachment_type.TEXT,
            )

    with allure.step("Проверка: отчёт найден в списке сформированных файлов"):
        StepCheck("Отчёт найден в списке сформированных файлов", "report_item").actual(
            report_state.actual_report_item
        ).is_not_none()

    with allure.step(
        f"Этап 5. Streaming-вызов {ReportConst.DOWNLOAD_EXPORTED_DATA_REQUEST} "
        f"по id={report_state.actual_report_item.id}"
    ):
        download_request = {
            "exportedDataId": report_state.actual_report_item.id,
            "exportedDataType": ExportedDataType.STATIONARY_STATUS_REPORT.to_download_name(),
            "additionalProperties": None,
            "timeOffset": report_state.actual_time_offset_hours,
        }
        download_purpose = (
            f"скачивание xlsx-отчёта о режиме МТ (exportedDataId={report_state.actual_report_item.id}) "
            f"после формирования отчёта и выбора файла в списке GetExportedDataListRequest"
        )
        await t_utils.connect_stream(
            ws_client,
            ReportConst.DOWNLOAD_EXPORTED_DATA_REQUEST,
            download_request,
            purpose=download_purpose,
        )
        report_state.actual_download_invocation_id = ws_client.invocation_id

    with allure.step("Этап 6. Получение fileChunk - скачивание отчёта о режиме МТ"):
        report_state.actual_download_reply = await t_utils.receive_download_exported_data_reply(
            ws_client=ws_client,
            parser=parser,
            invocation_id=report_state.actual_download_invocation_id,
            request_name=ReportConst.DOWNLOAD_EXPORTED_DATA_REQUEST,
            total_wait_seconds=ReportConst.DOWNLOAD_TIMEOUT_SECONDS,
            purpose=download_purpose,
        )

    with allure.step("Извлечение данных ответа на скачивание"):
        download_reply = report_state.actual_download_reply
        download_reply_status = download_reply.replyStatus
        has_download_reply_content = download_reply.replyContent is not None
        report_state.actual_file_bytes = download_reply.replyContent.fileChunk if has_download_reply_content else None
        is_xlsx_signature = (
            report_utils.is_xlsx_file_bytes(report_state.actual_file_bytes) if report_state.actual_file_bytes else False
        )

    with allure.step("Проверка ответа на скачивание и формата xlsx"):
        StepCheck("Проверка статуса ответа на скачивание", "replyStatus").actual(download_reply_status).expected(
            ReplyStatus.OK.value
        ).equal_to()
        StepCheck("Проверка наличия контента ответа на скачивание", "replyContent").actual(
            has_download_reply_content
        ).expected(True).equal_to()
        StepCheck("Проверка наличия байт файла", "fileChunk").actual(report_state.actual_file_bytes).is_not_empty()
        StepCheck("Проверка xlsx (zip) сигнатуры файла", "file_signature").actual(is_xlsx_signature).expected(
            True
        ).equal_to()

    with allure.step("Подготовка данных для проверки имени файла отчёта"):
        report_file_name = report_state.expected_file_name
        report_file_name_lower = report_file_name.lower()
        file_name_period_start, file_name_period_end = report_utils.parse_period_from_export_file_name(
            report_file_name,
            MtReportConst.REPORT_FILE_NAME_PERIOD_PATTERN,
        )
        period_start_lo, period_start_hi, period_end_lo, period_end_hi = report_utils.report_period_comparison_bounds(
            report_state.expected_period_start_naive,
            report_state.expected_period_end_naive,
        )
        has_xlsx_extension = report_utils.is_xlsx_extension(report_file_name)
        mt_report_name_part_lower = MtReportConst.MT_MODE_REPORT_NAME_PART.lower()

    try:
        with allure.step("Этап 7. Сохранение и разбор xlsx-отчёта о режиме МТ"):
            report_state.actual_temp_file_path = report_utils.save_report_bytes_to_temp_file(
                report_state.actual_file_bytes,
                prefix="mt_mode_report_",
            )
            StepCheck("Временный xlsx файл создан", "temp_file_path").actual(
                report_state.actual_temp_file_path
            ).is_not_none()
            report_state.actual_worksheet = report_utils.load_report_worksheet(report_state.actual_temp_file_path)
            report_state.actual_parsed_report = mt_report_utils.parse_mt_mode_report_worksheet(
                report_state.actual_worksheet,
                report_state.expected_section_names,
            )
            parsed_report = report_state.actual_parsed_report
            allure.attach(
                f"Шапка (raw): {parsed_report.title_info.raw_title}\n"
                f"period_start: {parsed_report.title_info.period_start}\n"
                f"period_end: {parsed_report.title_info.period_end}\n"
                f"total_duration: {parsed_report.total_duration_raw}",
                name="Шапка отчёта о режиме МТ",
                attachment_type=allure.attachment_type.TEXT,
            )
            allure.attach(
                lds_report_utils.format_section_rows_for_allure(parsed_report.section_rows),
                name="Строки участков отчёта",
                attachment_type=allure.attachment_type.TEXT,
            )

        with allure.step("Подготовка данных таблицы отчёта для проверки"):
            parsed_report = report_state.actual_parsed_report
            section_rows = parsed_report.section_rows
            total_duration_seconds = parsed_report.total_duration_seconds
            duration_tolerance = MtReportConst.TOTAL_DURATION_TOLERANCE_SECONDS
            mode_totals = mt_report_utils.sum_duration_columns_across_rows(
                section_rows,
                MtReportConst.MODE_DURATION_COLUMNS,
            )

        with allure.step("Проверка содержимого таблицы отчёта о режиме МТ"):
            StepCheck("Лист xlsx открыт", "worksheet").actual(report_state.actual_worksheet).is_not_none()
            with SoftAssertions() as soft_failures:
                StepCheck(
                    "Количество строк участков в отчёте",
                    "section_rows_count",
                    soft_failures,
                ).actual(
                    len(section_rows)
                ).expected(len(report_state.expected_section_names)).equal_to()

                for section_index, expected_section_name in enumerate(report_state.expected_section_names):
                    actual_section_name = (
                        section_rows[section_index].section_name if section_index < len(section_rows) else None
                    )
                    StepCheck(
                        f"Наименование участка #{section_index + 1}",
                        MtReportConst.COL_SECTION,
                        soft_failures,
                    ).actual(actual_section_name).expected(expected_section_name).equal_to()

                for section_row in section_rows:
                    for column_name in MtReportConst.MODE_DURATION_COLUMNS:
                        cell_value = section_row.cells.get(column_name)
                        duration_text = (
                            section_row.cells.get(column_name) or ""
                        ).strip() or MtReportConst.ZERO_DURATION_TEXT
                        StepCheck(
                            f"Участок '{section_row.section_name}': колонка '{column_name}'",
                            "длительность",
                            soft_failures,
                        ).actual(mt_report_utils.is_duration_cell_filled(cell_value)).is_true_with_details(
                            expected_text="указано время в формате H:MM:SS (допускается 0:00:00)",
                            actual_text=duration_text,
                        )

                total_duration_text = parsed_report.total_duration_raw or lds_report_utils.format_duration_seconds(
                    total_duration_seconds or 0
                )
                total_label_row = parsed_report.total_label_row_index
                StepCheck(
                    "В отчёте присутствует строка 'Суммарное время работы:'",
                    "структура отчёта",
                    soft_failures,
                ).actual(total_label_row is not None).is_true_with_details(
                    expected_text=f"найдена строка с текстом '{MtReportConst.TOTAL_WORK_DURATION_LABEL}'",
                    actual_text=(
                        f"строка {total_label_row} содержит '{MtReportConst.TOTAL_WORK_DURATION_LABEL}'"
                        if total_label_row is not None
                        else "строка не найдена"
                    ),
                )
                StepCheck(
                    "Суммарное время работы в отчёте больше нуля",
                    "суммарное время",
                    soft_failures,
                ).actual((total_duration_seconds or 0) > 0).is_true_with_details(
                    expected_text="суммарное время больше 0:00:00",
                    actual_text=total_duration_text,
                )

                for section_row in section_rows:
                    duration_diff = abs(section_row.modes_sum_seconds - (total_duration_seconds or 0))
                    section_sum_text = lds_report_utils.format_duration_seconds(section_row.modes_sum_seconds)
                    diff_text = lds_report_utils.format_duration_seconds(duration_diff)
                    sums_match = duration_diff < duration_tolerance + 1
                    StepCheck(
                        f"Участок '{section_row.section_name}': сумма режимов МТ совпадает с общим временем "
                        f"(±{duration_tolerance} с)",
                        "согласованность длительностей",
                        soft_failures,
                    ).actual(sums_match).is_true_with_details(
                        expected_text=(
                            f"сумма режимов ({section_sum_text}) равна суммарному времени ({total_duration_text}), "
                            f"погрешность не более {duration_tolerance} с"
                        ),
                        actual_text=(
                            f"сумма режимов: {section_sum_text}, суммарное время: {total_duration_text}, "
                            f"разница: {diff_text} ({duration_diff} с)"
                        ),
                    )

                dominant_column = report_state.expected_dominant_mode_column
                dominant_total = mode_totals.get(dominant_column, 0)
                max_column = max(mode_totals, key=mode_totals.get)
                max_total = mode_totals[max_column]
                all_modes_text = ", ".join(
                    f"'{column_name}': {lds_report_utils.format_duration_seconds(total_seconds)}"
                    for column_name, total_seconds in mode_totals.items()
                )
                StepCheck(
                    f"Режим '{dominant_column}' имеет максимальное суммарное время по всем участкам",
                    "доминирующий режим МТ",
                    soft_failures,
                ).actual(
                    mt_report_utils.is_expected_dominant_mode_column(mode_totals, dominant_column)
                ).is_true_with_details(
                    expected_text=(
                        f"'{dominant_column}': {lds_report_utils.format_duration_seconds(dominant_total)} - "
                        f"наибольшее время; по всем участкам: {all_modes_text}"
                    ),
                    actual_text=(
                        f"наибольшее время у режима '{max_column}': "
                        f"{lds_report_utils.format_duration_seconds(max_total)}"
                    ),
                )
                allure.attach(
                    "\n".join(
                        f"{column_name}: {lds_report_utils.format_duration_seconds(total_seconds)}"
                        for column_name, total_seconds in mode_totals.items()
                    ),
                    name="Суммарные длительности режимов МТ по всем участкам",
                    attachment_type=allure.attachment_type.TEXT,
                )

        with allure.step("Подготовка данных шапки отчёта для проверки"):
            title_info = parsed_report.title_info
            report_title_lower = title_info.raw_title.lower()
            column_headers = parsed_report.column_headers
            header_period_start = title_info.period_start
            header_period_end = title_info.period_end

        with allure.step("Проверка двойной шапки отчёта о режиме МТ"):
            with SoftAssertions() as soft_failures:
                StepCheck(
                    f"В шапке отчёта присутствует '{MtReportConst.MT_MODE_REPORT_NAME_PART}'",
                    "report_title",
                    soft_failures,
                ).contains(report_title_lower, mt_report_name_part_lower)
                StepCheck(
                    "Время начала периода в шапке совпадает с фильтром запроса (+-1 мин)",
                    "period_start",
                    soft_failures,
                ).actual(header_period_start).is_between(period_start_lo, period_start_hi)
                StepCheck(
                    "Время конца периода в шапке совпадает с фильтром запроса (+-1 мин)",
                    "period_end",
                    soft_failures,
                ).actual(header_period_end).is_between(period_end_lo, period_end_hi)
                StepCheck(
                    "Названия колонок в шапке отчёта",
                    "column_headers",
                    soft_failures,
                ).actual(
                    column_headers
                ).expected(MtReportConst.EXPECTED_COLUMN_HEADERS).equal_to()

        with allure.step("Проверка имени файла отчёта о режиме МТ"):
            with SoftAssertions() as soft_failures:
                StepCheck(f"Имя файла оканчивается на {ReportConst.XLSX_EXTENSION}", "file_name", soft_failures).actual(
                    has_xlsx_extension
                ).expected(True).equal_to()
                StepCheck(
                    f"Имя файла содержит '{MtReportConst.MT_MODE_REPORT_NAME_PART}'",
                    "file_name",
                    soft_failures,
                ).contains(report_file_name_lower, mt_report_name_part_lower)
                StepCheck(
                    f"Имя файла содержит описание ТУ '{cfg.tu_name}'",
                    "file_name",
                    soft_failures,
                ).contains(report_file_name_lower, report_state.expected_tu_description_lower)
                StepCheck(
                    "Дата начала периода в имени файла совпадает с фильтром запроса (+-1 мин)",
                    "period_start_in_file_name",
                    soft_failures,
                ).actual(file_name_period_start).is_between(period_start_lo, period_start_hi)
                StepCheck(
                    "Дата конца периода в имени файла совпадает с фильтром запроса (+-1 мин)",
                    "period_end_in_file_name",
                    soft_failures,
                ).actual(file_name_period_end).is_between(period_end_lo, period_end_hi)

    except Exception:
        with allure.step("Прикрепление xlsx отчёта к Allure при падении теста"):
            if report_state.actual_temp_file_path and report_state.expected_file_name:
                report_utils.attach_report_file_to_allure(
                    report_state.actual_temp_file_path,
                    report_state.expected_file_name,
                )
        raise

    with allure.step("Проверка пуш-нотификации о готовности отчёта"):
        notification = report_state.actual_notification
        notification_reply_status = notification.replyStatus if notification else None
        notification_reply_content = notification.replyContent if notification else None
        notification_export_status = notification_reply_content.exportStatus if notification_reply_content else None
        notification_error_message = (
            (notification_reply_content.errorMessage or "") if notification_reply_content else ""
        )
        with SoftAssertions() as soft_failures:
            StepCheck("Получена пуш-нотификация о готовности отчёта", "notification", soft_failures).actual(
                notification
            ).is_not_none()
            StepCheck("Проверка статуса пуш-нотификации", "replyStatus", soft_failures).actual(
                notification_reply_status
            ).expected(ReplyStatus.OK.value).equal_to()
            StepCheck("Проверка наличия контента нотификации", "replyContent", soft_failures).actual(
                notification_reply_content
            ).is_not_none()
            StepCheck("Проверка exportStatus в нотификации", "exportStatus", soft_failures).actual(
                notification_export_status
            ).expected(ExportStatus.DONE).equal_to()
            StepCheck("В нотификации нет текста ошибки", "errorMessage", soft_failures).actual(
                notification_error_message
            ).is_empty()






















stationary_status_scenarios.py
"""
Сценарии тестов по режимам МТ - функции-обёртки без pytest маркеров.

Каждая функция содержит логику одного теста.
Pytest маркеры и allure декораторы применяются в тестовых файлах.
"""

from datetime import datetime

import allure
import pytest

from constants.architecture_constants import HTTPClientConstants as HttpConst
from constants.enums import Direction, MessageType, StationaryStatus
from constants.test_constants import BaseTN3Constants as TestConst
from models.get_messages_model import Filtering, FilteringObjects, Pagination
from test_config.models_for_tests import BaseSuiteConfig, CaseData
from utils.helpers import ws_test_utils as t_utils
from utils.helpers.asserts import SoftAssertions, StepCheck
from utils.helpers.ws_message_parser import ws_message_parser as parser


async def stationary_status_check_with_reasons(ws_client, cfg: BaseSuiteConfig, test_data: CaseData):
    """
    Проверка режима работы и причины режима МТ на заданном ДУ
    """
    # Распаковка данных для теста
    pipe_id = test_data.params.get(TestConst.PIPE_ID_KEY)
    expected_stationary_status, expected_stationary_status_reasons = test_data.expected_result
    with allure.step("Подключение по ws, получение и обработка сообщения типа: CommonSchemeContent"):
        payload = await t_utils.connect_and_subscribe_msg(
            ws_client,
            "CommonSchemeContent",
            "SubscribeCommonSchemeRequest",
            {'tuId': cfg.tu_id, 'additionalProperties': None},
        )
        parsed_payload = parser.parse_common_scheme_info_msg(payload)
    with allure.step("Извлечение и подготовка данных для проверки"):
        # Получает список участков карты течения
        flow_areas = getattr(parsed_payload.replyContent, 'flowAreas', [])
        # Получает ДУ
        diagnostic_area = t_utils.find_diagnostic_area_by_pipe_id(flow_areas, pipe_id)
        StepCheck("Проверка наличия данных диагностического участка", "diagnosticAreas").actual(
            diagnostic_area
        ).is_not_none()
        stationary_status_int = getattr(diagnostic_area, 'stationaryStatus', None)
        stationary_status_reasons_int = getattr(diagnostic_area, 'stationaryStatusReasons', None)
        diagnostic_area_stationary_status = StationaryStatus(stationary_status_int) if stationary_status_int else None
    StepCheck(f"Проверка режима работы МТ на ДУ с id:{diagnostic_area.id}", "stationaryStatus").actual(
        diagnostic_area_stationary_status
    ).expected(expected_stationary_status).equal_to()
    stationary_status_reasons = t_utils.parse_stationary_status_reasons(
        stationary_status_int, stationary_status_reasons_int
    )
    StepCheck(f"Проверка причины режима работы МТ на ДУ с id:{diagnostic_area.id}", "stationaryStatusReasons").contains(
        stationary_status_reasons, expected_stationary_status_reasons
    )


def stationary_status_in_journal(http_client, cfg: BaseSuiteConfig, test_data: CaseData):
    """
    Проверка наличия записи в журнале о режиме МТ.
    """
    # Распаковка данных для теста
    if not test_data:
        pytest.fail("Не заполнены данные для теста")
    control_points = test_data.params.get(TestConst.CONTROL_POINTS_KEY, [])
    expected_stationary_status, expected_stationary_status_reasons = (
        test_data.expected_result if test_data.expected_result else None
    ), None

    with allure.step("Http запрос сообщений журнала с фильтром messageTypes=PUMPING_STATUS"):
        end_time = datetime.now()
        start_time = t_utils.datetime_minus_seconds(end_time, TestConst.JOURNAL_STATUS_TOTAL_WAIT)
        request_body = t_utils.create_journal_req_body(
            pagination=Pagination(limit=TestConst.JOURNAL_PAGINATION_STATUS_LIMIT, direction=Direction.FIRST.value),
            filtering=Filtering(messageTypes=int(MessageType.PUMPING_STATUS), objects=FilteringObjects(tuId=cfg.tu_id)),
        )
        response = http_client.post_request(HttpConst.GET_MESSAGES_URL_PATH, request_body)
        payload = t_utils.get_json_from_http_response(response)
        parsed_payload = parser.parse_journal_msg(payload)

    with allure.step("Извлечение и подготовка данных для проверки"):
        messages_info = getattr(parsed_payload.replyContent, 'messagesInfo', [])
        StepCheck("Проверка наличия сообщений в журнале", "messagesInfo").actual(messages_info).is_not_empty()

    with allure.step("Фильтрация сообщений по времени и controlPoint"):
        filter_start_msk = t_utils.localize_as_moscow(start_time)
        filter_end_msk = t_utils.localize_as_moscow(end_time)
        stationary_msg_by_control_points = []
        time_filtered = [
            msg
            for msg in messages_info
            if filter_start_msk <= t_utils.ensure_moscow_timezone(msg.time) <= filter_end_msk
        ]
        time_filtered.sort(key=lambda msg: t_utils.ensure_moscow_timezone(msg.time), reverse=True)
        # Поиск нужных сообщений по КП
        for control_point in control_points:
            stationary_msg = next(
                (msg for msg in time_filtered if msg.controlPoint == control_point),
                None,
            )
            if stationary_msg:
                stationary_msg_by_control_points.append(stationary_msg)
        StepCheck(
            f"Проверка наличия сообщений c controlPoint из списка {control_points} в журнале", "messagesInfo"
        ).actual(stationary_msg_by_control_points).is_not_empty()
    with SoftAssertions() as soft_failures:
        for msg in stationary_msg_by_control_points:
            msg_event = getattr(msg, 'event', None)
            cp_stationary_status, cp_stationary_status_reasons = t_utils.parse_event(msg_event)
            StepCheck(f"Проверка режима работы МТ на КП:{msg.controlPoint}", "event", soft_failures).actual(
                cp_stationary_status
            ).expected(expected_stationary_status).equal_to()
            StepCheck(f"Проверка причины режима работы МТ на КП:{msg.controlPoint}", "event", soft_failures).contains(
                cp_stationary_status_reasons, expected_stationary_status_reasons
            )



















lds_configurator_utils.py



"""
Вспомогательные функции setup/teardown СОУ через раздел Администрирование.
"""

from __future__ import annotations

import asyncio
import logging
import time
from contextlib import contextmanager
from datetime import datetime, timedelta
from typing import Any, Optional

import allure
from _pytest.outcomes import Failed
from pytest import fail

from clients.http_client import StandHttpClient
from clients.websocket_client import WebSocketClient
from constants.architecture_constants import HTTPClientConstants as HttpConst
from constants.enums import ReplyStatus, SouAdminStatus
from constants.test_constants import LdsConfiguratorConstants as LdsCfgConst
from models.basic_info_model import BasicInfoReply, BasicTUInfo
from models.get_basic_info_admin_model import AdminTuInfo, GetBasicInfoAdminReply
from models.get_tus_information_model import GetTusInformationReply
from utils.helpers import ws_test_utils as t_utils
from utils.helpers.ws_message_parser import WsMessageParser
from utils.msgpack_utils.message_filters import is_desired_type

logger = logging.getLogger(__name__)

_configurator_flow_active: bool = False


def set_configurator_flow_active(enabled: bool) -> None:
    """Включает логирование setup/teardown при запуске СОУ через Администрирование"""
    global _configurator_flow_active
    _configurator_flow_active = enabled


def is_configurator_flow_active() -> bool:
    """True во время setup/teardown/verify СОУ через Администрирование"""
    return _configurator_flow_active


def _fail_or_raise(message: str) -> None:
    """В configurator flow - RuntimeError для retry/teardown; иначе pytest.fail."""
    if _configurator_flow_active:
        raise RuntimeError(message)
    fail(message, pytrace=False)


@contextmanager
def _step(name: str):
    if _configurator_flow_active:
        logger.info("[LDS_CONFIGURATOR] %s", name)
        yield
    else:
        with allure.step(name):
            yield


def attach_allure_alert(message: str) -> None:
    """
    Публикует предупреждение в Allure и лог без падения прогона.

    Используется в teardown при некритичных отклонениях.
    """
    logger.warning("[LDS_CONFIGURATOR] %s", message)
    if not _configurator_flow_active:
        allure.attach(message, name="ALERT", attachment_type=allure.attachment_type.TEXT)


def get_basic_info(http_client: StandHttpClient, parser: WsMessageParser) -> BasicInfoReply:
    """
    Выполняет getBasicInfoRequest и парсит ответ BasicInfoContent.
    """
    response = http_client.post_request(HttpConst.GET_BASIC_INFO_URL_PATH, {})
    payload = t_utils.get_json_from_http_response(response)
    return parser.parse_basic_info_msg(payload)


def is_tu_in_basic_info(tus: Optional[list[BasicTUInfo]], tu_id: int, tu_name: str) -> bool:
    """True, если в basicInfo.tus есть запись с указанными tuId и tuName."""
    return any(tu.tuId == tu_id and tu.tuName == tu_name for tu in (tus or []))


def get_basic_info_admin(
    http_client: StandHttpClient,
    parser: WsMessageParser,
) -> GetBasicInfoAdminReply:
    """
    Выполняет GetBasicInfoAdminRequest и парсит ответ.
    """
    response = http_client.post_request(HttpConst.GET_BASIC_INFO_ADMIN_URL_PATH, {})
    payload = t_utils.get_json_from_http_response(response)
    return parser.parse_get_basic_info_admin_msg(payload)


def get_basic_info_admin_with_retry(
    http_client: StandHttpClient,
    parser: WsMessageParser,
    retries: int = LdsCfgConst.GET_BASIC_INFO_ADMIN_RETRIES,
) -> GetBasicInfoAdminReply:
    """
    Запрашивает GetBasicInfoAdminResponse с повторными попытками.
    """
    last_error: Optional[BaseException] = None
    for attempt in range(1, retries + 1):
        with _step(f"Запрос списка ТУ в Администрировании - попытка {attempt} из {retries}"):
            try:
                return get_basic_info_admin(http_client, parser)
            except (
                asyncio.TimeoutError,
                ConnectionError,
                ConnectionResetError,
                OSError,
                RuntimeError,
                KeyError,
                Failed,
            ) as error:
                last_error = error
                logger.warning(
                    "[LDS_CONFIGURATOR] GetBasicInfoAdmin попытка %s/%s: %s: %r",
                    attempt,
                    retries,
                    type(error).__name__,
                    error,
                )
                if attempt < retries:
                    time.sleep(1)

    with _step("Проверка: GetBasicInfoAdminResponse получен"):
        _fail_or_raise(
            f"Не удалось получить GetBasicInfoAdminResponse за {retries} попыток: {last_error}",
        )


def find_tu_by_name(admin_reply: GetBasicInfoAdminReply, tu_name: str) -> AdminTuInfo:
    """
    Ищет ТУ по точному совпадению tuName в ответе Администрирования.
    """
    with _step(f"Поиск ТУ '{tu_name}' по точному совпадению tuName в GetBasicInfoAdminResponse"):
        tus = admin_reply.replyContent.basicInfo.tus if admin_reply.replyContent else None
        with _step("Проверка: в ответе есть список ТУ"):
            if not tus:
                fail(
                    f"GetBasicInfoAdminResponse не содержит списка ТУ (ожидался tuName='{tu_name}')",
                    pytrace=False,
                )
        for tu in tus:
            if tu.tuName == tu_name:
                return tu
        available = [tu.tuName for tu in tus]
        with _step("Проверка: ТУ из набора данных найден в Администрировании"):
            fail(
                f"ТУ '{tu_name}' не найден в GetBasicInfoAdminResponse. Доступные tu_name: {available}",
                pytrace=False,
            )


def validate_admin_tu(tu: AdminTuInfo) -> None:
    """
    Проверяет обязательные поля AdminTuInfo и допустимость статуса СОУ.
    """
    with _step(f"Валидация параметров ТУ '{tu.tuName}' (tuId={tu.tuId})"):
        with _step("Проверка: tuId и mnId заполнены"):
            if not tu.tuId:
                fail(f"Некорректный tuId для ТУ '{tu.tuName}': {tu.tuId}", pytrace=False)
            if not tu.mnId:
                fail(f"Некорректный mnId для ТУ '{tu.tuName}': {tu.mnId}", pytrace=False)
        with _step("Проверка: статус СОУ известен Администрированию"):
            try:
                SouAdminStatus(tu.status)
            except ValueError:
                fail(f"Неизвестный статус СОУ для ТУ '{tu.tuName}': {tu.status}", pytrace=False)


def extract_running_tus(admin_reply: GetBasicInfoAdminReply) -> list[AdminTuInfo]:
    """Возвращает все ТУ со статусом RUNNING из GetBasicInfoAdminResponse."""
    tus = admin_reply.replyContent.basicInfo.tus if admin_reply.replyContent else []
    return [tu for tu in tus if tu.status == SouAdminStatus.RUNNING.value]


def running_tus_to_snapshot(tus: list[AdminTuInfo]) -> list[dict[str, Any]]:
    """Сериализует список ТУ для хранения в group_state."""
    return [{"tuId": tu.tuId, "tuName": tu.tuName} for tu in tus]


def _tu_id_in_main_page_message(msg: Any, tu_id: int) -> bool:
    """True, если WS-сообщение MainPageInfoContent содержит указанный tuId."""
    if not isinstance(msg, list) or not is_desired_type(msg, LdsCfgConst.MAIN_PAGE_INFO_CONTENT):
        return False
    for item in msg:
        if isinstance(item, dict) and item.get("replyContent", {}).get("tuId") == tu_id:
            return True
        if isinstance(item, list):
            for elem in item:
                if isinstance(elem, dict) and elem.get("replyContent", {}).get("tuId") == tu_id:
                    return True
    return False


def _drain_recv_queue(ws_client: WebSocketClient) -> list[Any]:
    """Забирает все сообщения из очереди WS без блокирующего ожидания."""
    messages: list[Any] = []
    while not ws_client.recv_queue.empty():
        try:
            messages.append(ws_client.recv_queue.get_nowait())
        except asyncio.QueueEmpty:
            break
    return messages


async def is_tu_present_on_main_page(
    ws_client: WebSocketClient,
    parser: WsMessageParser,
    tu_id: int,
    timeout: float = LdsCfgConst.MAIN_PAGE_SYNC_TIMEOUT_SECONDS,
) -> bool:
    """
    Подписывается на MainPageInfoContent и определяет, отображается ли ТУ в Состоянии МТ.
    """
    with _step(f"Подписка на Состояние МТ (MainPageInfoContent) для tuId={tu_id}"):
        ws_client.clear_queue()
        await t_utils.connect(
            ws_client,
            LdsCfgConst.SUBSCRIBE_MAIN_PAGE_INFO_REQUEST,
            {"tuIds": [tu_id], "additionalProperties": None},
        )

    deadline = asyncio.get_running_loop().time() + timeout
    while asyncio.get_running_loop().time() < deadline:
        remaining = deadline - asyncio.get_running_loop().time()
        if remaining <= 0:
            break
        try:
            payload = await ws_client.receive_by_type(LdsCfgConst.MAIN_PAGE_INFO_CONTENT, timeout=remaining)
        except asyncio.TimeoutError:
            break
        parsed = parser.parse_main_page_msg(payload)
        if parsed.replyContent and parsed.replyContent.tuId == tu_id:
            return True
    return False


def check_sou_status_sync(
    sou_status: SouAdminStatus,
    is_in_basic_info: bool,
    is_on_main_page: bool,
    tu_id: int,
    tu_name: str,
) -> None:
    """
    Сверяет статус СОУ в Администрировании и на ЭФ Состояние МТ по двум DTO BasicInfo и MainPageInfoContent.
    """
    with _step(f"Сверка статуса СОУ: Администрирование vs Состояние МТ (tuId={tu_id}, '{tu_name}')"):
        expected_enabled = sou_status == SouAdminStatus.RUNNING
        with _step("Проверка согласованности статусов Администрирования и Состояния МТ"):
            if is_in_basic_info == expected_enabled and is_on_main_page == expected_enabled:
                return
            admin_text = SouAdminStatus.report_text_by_value(sou_status.value)
            basic_text = "СОУ запущена" if is_in_basic_info else "СОУ не запущена"
            page_text = "СОУ запущена" if is_on_main_page else "СОУ не запущена"
            fail(
                f"Рассинхронизация для ТУ '{tu_name}' (tuId={tu_id}): "
                f"Администрирование - {admin_text} ({sou_status.value}); "
                f"Состояние МТ в BasicInfo - {basic_text}; "
                f"Состояние МТ в MainPageInfoContent - {page_text}. "
                f"Статусы в разных подписках не совпадают.",
                pytrace=False,
            )


def run_lds_command(
    http_client: StandHttpClient,
    request_name: str,
    tu_id: int,
) -> None:
    """
    Отправляет StopLdsRequest или LaunchLdsRequest и ждёт Completion с replyStatus=200.
    """
    with _step(f"Команда {request_name} для tuId={tu_id}"):
        response = http_client.post_request(request_name, {"tuId": tu_id})
        with _step(f"Проверка: {request_name} завершился успешно (response.status_code=200)"):
            if response.status_code != ReplyStatus.OK:
                fail(
                    f"{request_name} завершился с replyStatus={response.status_code}, " f"ошибки: {response.text}",
                    pytrace=False,
                )


async def poll_admin_tu_status(
    http_client: StandHttpClient,
    parser: WsMessageParser,
    tu_id: int,
    expected_status: SouAdminStatus,
    total_wait_seconds: float = LdsCfgConst.POLL_TIMEOUT_SECONDS,
    poll_interval_seconds: float = LdsCfgConst.POLL_INTERVAL_SECONDS,
) -> bool:
    """
    Long-poll GetBasicInfoAdmin до смены статуса ТУ в Администрировании.
    """
    status_label = SouAdminStatus.report_text_by_value(expected_status.value)
    with _step(
        f"Ожидание статуса '{status_label}' в Администрировании " f"(tuId={tu_id}, таймаут {int(total_wait_seconds)} с)"
    ):
        deadline = asyncio.get_running_loop().time() + total_wait_seconds
        while asyncio.get_running_loop().time() < deadline:
            admin_reply = get_basic_info_admin(http_client, parser)
            tus = admin_reply.replyContent.basicInfo.tus if admin_reply.replyContent else []
            tu = next((item for item in tus if item.tuId == tu_id), None)
            if tu and tu.status == expected_status.value:
                return True
            await asyncio.sleep(poll_interval_seconds)
        return False


async def stop_tu_and_wait(
    http_client: StandHttpClient,
    parser: WsMessageParser,
    tu_id: int,
    tu_name: Optional[str] = None,
) -> None:
    """
    Останавливает СОУ и ждёт статус STOPPED в Администрировании.
    При таймауте - pytest.fail (блокирующая ошибка setup).
    """
    label = f"tuId={tu_id}, tuName={tu_name!r}" if tu_name else f"tuId={tu_id}"
    with _step(f"Остановка СОУ ({label})"):
        run_lds_command(http_client, HttpConst.STOP_LDS_URL_PATH, tu_id)
        logger.info("[LDS CONFIGURATOR] ТЕСТ УСПЕХ!!!")
        if not await poll_admin_tu_status(http_client, parser, tu_id, SouAdminStatus.STOPPED):
            fail(
                f"СОУ не выключилась за {int(LdsCfgConst.POLL_TIMEOUT_SECONDS)} с: {label}",
                pytrace=False,
            )


async def launch_tu_and_wait(
    http_client: StandHttpClient,
    parser: WsMessageParser,
    tu_id: int,
) -> bool:
    """
    Запускает СОУ и ждёт статус RUNNING в Администрировании.
    Возвращает True при успехе (для soft teardown).
    """
    with _step(f"Запуск СОУ (tuId={tu_id})"):
        run_lds_command(http_client, HttpConst.LAUNCH_LDS_URL_PATH, tu_id)
        return await poll_admin_tu_status(http_client, parser, tu_id, SouAdminStatus.RUNNING)


async def stop_all_running_tus(
    http_client: StandHttpClient,
    parser: WsMessageParser,
    tus: list[AdminTuInfo],
) -> None:
    """Последовательно останавливает все включённые ТУ на стенде."""
    if not tus:
        return
    logger.info("[SETUP] Остановка всех включённых ТУ на стенде: %s шт.", len(tus))
    for tu in tus:
        logger.info("[SETUP] Остановка ТУ на стенде: tuId=%s, tuName=%r", tu.tuId, tu.tuName)
        await stop_tu_and_wait(http_client, parser, tu.tuId, tu.tuName)


async def restore_pre_run_tus(
    http_client: StandHttpClient,
    parser: WsMessageParser,
    snapshot: list[dict[str, Any]],
    exclude_tu_id: int,
) -> None:
    """
    Включает обратно ТУ из снимка, кроме ТУ автотестов.
    Ошибки по отдельным ТУ оформляются как ALERT без падения прогона.
    """
    to_restore = [entry for entry in snapshot if entry.get("tuId") != exclude_tu_id]
    if not to_restore:
        return
    logger.info(
        "[TEARDOWN] Восстановление ТУ стенда: %s шт. (исключена ТУ автотестов tuId=%s)",
        len(to_restore),
        exclude_tu_id,
    )
    for entry in to_restore:
        restore_tu_id = entry["tuId"]
        restore_tu_name = entry.get("tuName", "")
        logger.info(
            "[TEARDOWN] Запуск ТУ из снимка: tuId=%s, tuName=%r",
            restore_tu_id,
            restore_tu_name,
        )
        if not await launch_tu_and_wait(http_client, parser, restore_tu_id):
            attach_allure_alert(
                f"Не удалось восстановить ТУ из снимка: tuId={restore_tu_id}, "
                f"tuName={restore_tu_name!r}. Проверить вручную."
            )


async def poll_basic_info_tu_presence(
    http_client: StandHttpClient,
    parser: WsMessageParser,
    tu_id: int,
    tu_name: str,
    expect_present: bool,
    total_wait_seconds: float = LdsCfgConst.POLL_TIMEOUT_SECONDS,
    poll_interval_seconds: float = LdsCfgConst.POLL_INTERVAL_SECONDS,
) -> bool:
    """
    Long-poll getBasicInfoRequest: ожидание появления или исчезновения ТУ в basicInfo.tus.
    """
    action = "появления" if expect_present else "исчезновения"
    with _step(
        f"Ожидание {action} ТУ в BasicInfo " f"(tuId={tu_id}, tuName='{tu_name}', таймаут {int(total_wait_seconds)} с)"
    ):
        deadline = asyncio.get_running_loop().time() + total_wait_seconds
        while asyncio.get_running_loop().time() < deadline:
            reply = get_basic_info(http_client, parser)
            tus = reply.replyContent.basicInfo.tus if reply.replyContent else None
            found = is_tu_in_basic_info(tus, tu_id, tu_name)
            if expect_present and found:
                return True
            if not expect_present and not found:
                return True
            await asyncio.sleep(poll_interval_seconds)

        if _configurator_flow_active:
            logger.error(
                "[LDS_CONFIGURATOR] Таймаут ожидания %s ТУ tuId=%s в BasicInfo за %s с",
                action,
                tu_id,
                int(total_wait_seconds),
            )
        return False


async def poll_main_page_tu_presence(
    ws_client: WebSocketClient,
    tu_id: int,
    expect_present: bool,
    total_wait_seconds: float = LdsCfgConst.POLL_TIMEOUT_SECONDS,
    poll_interval_seconds: float = LdsCfgConst.POLL_INTERVAL_SECONDS,
) -> bool:
    """
    Long-poll MainPageInfoContent: ожидание появления или исчезновения ТУ в Состоянии МТ.
    """
    action = "появления" if expect_present else "исчезновения"
    with _step(f"Ожидание {action} ТУ в Состоянии МТ " f"(tuId={tu_id}, таймаут {int(total_wait_seconds)} с)"):
        ws_client.clear_queue()
        await t_utils.connect(
            ws_client,
            LdsCfgConst.SUBSCRIBE_MAIN_PAGE_INFO_REQUEST,
            {"tuIds": [tu_id], "additionalProperties": None},
        )
        deadline = asyncio.get_running_loop().time() + total_wait_seconds
        while asyncio.get_running_loop().time() < deadline:
            await asyncio.sleep(poll_interval_seconds)
            batch = _drain_recv_queue(ws_client)
            found = any(_tu_id_in_main_page_message(msg, tu_id) for msg in batch)
            if expect_present and found:
                return True
            if not expect_present and not found:
                return True

        if not _configurator_flow_active:
            t_utils.attach_ws_poll_failure(
                [],
                total_wait_seconds,
                f"{LdsCfgConst.MAIN_PAGE_INFO_CONTENT} tuId={tu_id} present={expect_present}",
            )
        else:
            logger.warning(
                "[LDS_CONFIGURATOR] Таймаут ожидания %s ТУ tuId=%s за %s с",
                action,
                tu_id,
                int(total_wait_seconds),
            )
        return False


def verify_launched_at(
    http_client: StandHttpClient,
    parser: WsMessageParser,
    tu_id: int,
    launch_checkpoint: datetime,
) -> None:
    """
    Проверяет, что launchedAt в GetTusInformation не раньше момента LaunchLds с допуском.
    """
    tolerance = timedelta(seconds=LdsCfgConst.LAUNCHED_AT_TOLERANCE_SECONDS)
    with _step(f"Запрос GetTusInformation для tuId={tu_id}"):
        response = http_client.post_request(HttpConst.GET_TUS_INFORMATION_URL_PATH, {"tuIds": [tu_id]})
        payload = t_utils.get_json_from_http_response(response)
        reply: GetTusInformationReply = parser.parse_get_tus_information_msg(payload)
        tus_info = reply.replyContent.tusInfo if reply.replyContent else []
        tu_info = next((item for item in tus_info if item.tuId == tu_id), None)

        with _step("Проверка: в ответе есть информация о запуске ТУ"):
            if tu_info is None:
                fail(f"GetTusInformationResponse не содержит tuId={tu_id}", pytrace=False)

        launched_at = parser.timestamp_to_datetime(tu_info.launchedAt)
        with _step("Проверка: поле launchedAt заполнено"):
            if launched_at is None:
                fail(f"GetTusInformationResponse: launchedAt отсутствует для tuId={tu_id}", pytrace=False)

        launched_at_msk = t_utils.localize_as_moscow(launched_at)
        checkpoint_msk = t_utils.localize_as_moscow(launch_checkpoint)
        compare_msg = (
            f"launchedAt: {t_utils.format_datetime_moscow(launched_at_msk)}\n"
            f"checkpoint: {t_utils.format_datetime_moscow(checkpoint_msk)}\n"
            f"tolerance: {LdsCfgConst.LAUNCHED_AT_TOLERANCE_SECONDS} с"
        )
        if _configurator_flow_active:
            logger.info("[LDS_CONFIGURATOR] Сравнение времени запуска СОУ:\n%s", compare_msg)
        else:
            allure.attach(
                compare_msg,
                name="Сравнение времени запуска СОУ",
                attachment_type=allure.attachment_type.TEXT,
            )

        with _step("Проверка: launchedAt не раньше момента команды 'Запустить СОУ' (с допуском)"):
            if launched_at_msk < checkpoint_msk - tolerance:
                fail(
                    f"Время запуска СОУ на бэкенде ({t_utils.format_datetime_moscow(launched_at_msk)}) "
                    f"раньше момента команды 'Запустить СОУ' "
                    f"({t_utils.format_datetime_moscow(checkpoint_msk)}) "
                    f"с учётом допуска {int(LdsCfgConst.LAUNCHED_AT_TOLERANCE_SECONDS)} с",
                    pytrace=False,
                )


def get_admin_tu_status(admin_reply: GetBasicInfoAdminReply, tu_id: int) -> Optional[SouAdminStatus]:
    """
    Возвращает статус СОУ из GetBasicInfoAdmin для указанного tuId.
    """
    tus = admin_reply.replyContent.basicInfo.tus if admin_reply.replyContent else []
    tu = next((item for item in tus if item.tuId == tu_id), None)
    if tu is None:
        return None
    try:
        return SouAdminStatus(tu.status)
    except ValueError:
        return None






























ws_message_parser.py
from __future__ import annotations

import logging
from datetime import datetime, timedelta, timezone
from typing import Any, Dict, List, Optional, Type, TypeVar
from uuid import UUID
from zoneinfo import ZoneInfo

from allure import attach, attachment_type
from dacite import Config, DaciteError, from_dict
from msgpack import Timestamp
from pytest import fail

import models.subscribe_scheme_signals_state_model as signals_state_model
from constants.architecture_constants import WebSocketClientConstants
from constants.enums import ExportedDataType, ExportStatus, ReplyStatus
from models.acknowledge_leak_model import AcknowledgeLeakReply
from models.basic_info_model import BasicInfoReply
from models.export_reports_model import ReportDataExportedNotification
from models.get_basic_info_admin_model import GetBasicInfoAdminReply
from models.get_exported_files_list_model import GetExportedDataListReply
from models.get_input_signals_model import GetInputSignalsReply
from models.get_messages_model import GetMessagesReply
from models.get_output_signals_model import GetOutputSignalsReply
from models.get_tus_information_model import GetTusInformationReply
from models.imitate_signal_model import ImitateSignalReply
from models.launch_lds_model import LaunchLdsReply
from models.launch_pig_model import LaunchPigReply
from models.mask_lds_command_model import MaskLdsReply
from models.mask_signal_model import MaskSignalReply
from models.stop_lds_model import StopLdsReply
from models.subscribe_all_leaks_info_model import SubscribeAllLeaksInfoReply
from models.subscribe_balance_algorithm_results_model import SubscribeBalanceAlgorithmResultsReply
from models.subscribe_common_scheme_model import SubscribeCommonSchemeReply
from models.subscribe_input_signals_model import InputSignal, SubscribeInputSignalsContent, SubscribeInputSignalsReply
from models.subscribe_leaks_model import SubscribeLeaksReply
from models.subscribe_main_page_info_model import SubscribeMainPageInfoReply
from models.subscribe_main_page_signals_info_model import SubscribeMainPageSignalsInfoReply
from models.subscribe_output_signals_model import SubscribeOutputSignalsReply
from models.subscribe_tu_leaks_info_model import SubscribeTuLeaksInfoReply
from models.unimitate_signal_model import UnimitateSignalReply
from models.unmask_lds_command_model import UnmaskLdsReply
from models.unmask_signal_model import UnmaskSignalReply
from models.upload_exported_file_model import DownloadExportedDataReply

logger = logging.getLogger(__name__)

MessageType = TypeVar("MessageType")  # создает типовую переменную для парсинга сообщений
ContentType = TypeVar("ContentType")

_SIGNAL_DATA_POSITION = 1
_MIN_SIGNAL_TUPLE_LENGTH = 2


class WsMessageParser:
    """
    Парсинг websocket сообщений
    """

    def __init__(self, dacite_config: Config = None):
        self._dacite_config = dacite_config or self._get_default_config()
        self.suppress_recv_logging: bool = False

    @staticmethod
    def timestamp_to_datetime(value: Any) -> Optional[datetime]:
        """
        Преобразует время из формата пары msgpack. Timestamp и tz в datetime с timezone
        """
        try:
            if value is None:
                return None
            if isinstance(value, list) and len(value) == 2:
                time_timestamp, timezone_offset = value
                if isinstance(time_timestamp, Timestamp):
                    datetime_timezone = timezone(timedelta(minutes=timezone_offset))
                    return datetime.fromtimestamp(time_timestamp.seconds, datetime_timezone)
            if isinstance(value, Timestamp):
                return datetime.fromtimestamp(value.seconds, tz=timezone.utc)
            if isinstance(value, str):
                return datetime.fromisoformat(value)
        except (AttributeError, TypeError, ValueError) as error:
            fail(f"Ошибка конвертации времени: {error}")

    @staticmethod
    def convert_to_uuid(value: Any) -> Optional[UUID]:
        """
        Преобразует строку в UUID
        """
        try:
            if value is None:
                return None
            elif isinstance(value, UUID):
                return value
            elif isinstance(value, str):
                return UUID(value)

        except (AttributeError, TypeError, ValueError) as error:
            fail(f"Ошибка конвертации UUID: {error}")

    def parse_acknowledge_leak_msg(self, data: dict) -> AcknowledgeLeakReply:
        """
        Парсит acknowledgeLeak сообщение
        """
        return self._parse_message(data_class=AcknowledgeLeakReply, data=data)

    def parse_all_leaks_info_msg(self, data: list) -> SubscribeAllLeaksInfoReply:
        """
        Парсит allLeaksInfo сообщение
        """
        return self._find_and_parse_message(data_class=SubscribeAllLeaksInfoReply, data=data)

    def parse_basic_info_msg(self, data: dict) -> BasicInfoReply:
        """
        Парсит basicInfo сообщение
        """
        return self._parse_message(data_class=BasicInfoReply, data=data)

    def parse_common_scheme_info_msg(self, data: list) -> SubscribeCommonSchemeReply:
        """
        Парсит tuLeaksInfo сообщение
        """
        return self._find_and_parse_message(data_class=SubscribeCommonSchemeReply, data=data)

    def parse_imitate_signal_msg(self, data: dict) -> ImitateSignalReply:
        """
        Парсит сообщение ImitateSignal
        """
        return self._parse_message(data_class=ImitateSignalReply, data=data)

    def parse_input_signals_info_msg(self, data: list) -> SubscribeInputSignalsReply:
        """
        Парсит сообщение InputSignalsInfo
        """
        payload = self.find_reply_status_in_ws_msg(data)
        reply_content = payload.get('replyContent')
        input_signals_list = reply_content.get('inputSignals')
        parsed_payload = SubscribeInputSignalsReply(
            replyStatus=payload.get('replyStatus'),
            replyErrors=payload.get('replyErrors'),
            replyContent=SubscribeInputSignalsContent(
                tuId=reply_content.get('tuId'),
                inputSignals=[
                    self._parse_message(InputSignal, item[_SIGNAL_DATA_POSITION])
                    for item in input_signals_list
                    if self._is_valid_signal_tuple(item)
                ],
            ),
        )
        if parsed_payload.replyErrors:
            fail(f"Ошибка в сообщении типа InputSignalsInfo: {parsed_payload.replyErrors}")
        return parsed_payload

    def parse_input_signals_msg(self, data: list) -> GetInputSignalsReply:
        """
        Парсит сообщение getInputSignals
        """
        return self._find_and_parse_message(data_class=GetInputSignalsReply, data=data)

    def parse_journal_msg(self, data: dict) -> GetMessagesReply:
        """
        Парсит сообщение журнала messagesInfo
        """
        return self._parse_message(data_class=GetMessagesReply, data=data)

    def parse_leaks_content_msg(self, data: list) -> SubscribeLeaksReply:
        """
        Парсит LeaksContent сообщение
        """
        return self._find_and_parse_message(data_class=SubscribeLeaksReply, data=data)

    def parse_main_page_msg(self, data: list) -> SubscribeMainPageInfoReply:
        """
        Парсит сообщение mainPageInfo
        """
        return self._find_and_parse_message(data_class=SubscribeMainPageInfoReply, data=data)

    def parse_main_page_signals_msg(self, data: list) -> SubscribeMainPageSignalsInfoReply:
        """
        Парсит сообщение mainPageSignalsInfo
        """
        return self._find_and_parse_message(data_class=SubscribeMainPageSignalsInfoReply, data=data)

    def parse_launch_pig_msg(self, data: list) -> LaunchPigReply:
        """
        Парсит сообщение LaunchPig
        """
        return self._find_and_parse_message(data_class=LaunchPigReply, data=data)

    def parse_mask_signal_msg(self, data: list) -> MaskSignalReply:
        """
        Парсит сообщение MaskSignal
        """
        return self._find_and_parse_message(data_class=MaskSignalReply, data=data)

    def parse_mask_lds_message(self, data: list) -> MaskLdsReply:
        """
        Парсит сообщение maskLDSRequest
        """
        return self._find_and_parse_message(data_class=MaskLdsReply, data=data)

    def parse_unmask_lds_message(self, data: list) -> UnmaskLdsReply:
        """
        Парсит сообщение UnmaskLDSRequest
        """
        return self._find_and_parse_message(data_class=UnmaskLdsReply, data=data)

    def parse_output_signals_info_msg(self, data: list) -> SubscribeOutputSignalsReply:
        """
        Парсит OutputSignalsInfo сообщение
        """
        return self._find_and_parse_message(data_class=SubscribeOutputSignalsReply, data=data)

    def parse_output_signals_msg(self, data: dict):
        """
        Парсит getOutputSignals сообщение
        """
        return self._parse_message(data_class=GetOutputSignalsReply, data=data)

    def parse_balance_algorithm_msg(self, data: list) -> SubscribeBalanceAlgorithmResultsReply:
        """
        Парсит BalanceAlgorithmResults сообщение
        """
        return self._find_and_parse_message(data_class=SubscribeBalanceAlgorithmResultsReply, data=data)

    def parse_tu_leaks_info_msg(self, data: list) -> SubscribeTuLeaksInfoReply:
        """
        Парсит tuLeaksInfo сообщение
        """
        return self._find_and_parse_message(data_class=SubscribeTuLeaksInfoReply, data=data)

    def parse_scheme_signals_state_msg(self, data: list) -> signals_state_model.SchemeSignalsStateReply:
        """
        Парсит сообщение SchemeSignalsStateContent.
        signalsStates приходят как [[signal_type, data_dict], ...] - конвертируем кортежи в словари.
        """
        payload = self.find_reply_status_in_ws_msg(data)
        reply_content = payload.get('replyContent') or {}

        raw_signals = reply_content.get('signalsStates')
        if raw_signals is None:
            raw_signals = payload.get('signalsStates') or []

        raw_to_states = reply_content.get('toStates')
        if raw_to_states is None:
            raw_to_states = payload.get('toStates') or []

        tu_id = reply_content.get('tuId')
        if tu_id is None:
            tu_id = payload.get('tuId', 0)

        parsed_payload = signals_state_model.SchemeSignalsStateReply(
            replyStatus=payload.get('replyStatus', 0),
            replyErrors=payload.get('replyErrors'),
            replyContent=signals_state_model.SchemeSignalsStateContent(
                tuId=tu_id,
                signalsStates=self._parse_scheme_signal_states(raw_signals),
                toStates=self._parse_scheme_to_states(raw_to_states),
            ),
        )
        if parsed_payload.replyErrors:
            fail(f"Ошибка в сообщении типа SchemeSignalsStateContent: {parsed_payload.replyErrors}")
        return parsed_payload

    def parse_unimitate_signal_msg(self, data: dict) -> UnimitateSignalReply:
        """
        Парсит сообщение UnimitateSignal
        """
        return self._parse_message(data_class=UnimitateSignalReply, data=data)

    def parse_unmask_signal_msg(self, data: list) -> UnmaskSignalReply:
        """
        Парсит сообщение UnmaskSignal
        """
        return self._find_and_parse_message(data_class=UnmaskSignalReply, data=data)

    def parse_report_data_exported_notification_msg(self, data: list) -> ReportDataExportedNotification:
        """
        Парсит пуш-нотификацию ReportDataExportedNotification о готовности отчёта.
        """
        return self._find_and_parse_message(data_class=ReportDataExportedNotification, data=data)

    def parse_exported_data_list_msg(self, data: list) -> GetExportedDataListReply:
        """
        Парсит ответ GetExportedDataListReply со списком сформированных файлов.
        """
        return self._find_and_parse_message(data_class=GetExportedDataListReply, data=data)

    def parse_download_exported_data_msg(self, data: list) -> DownloadExportedDataReply:
        """
        Парсит ответ DownloadExportedDataReply со скачиваемым контентом файла (fileChunk).
        """
        return self._find_and_parse_message(data_class=DownloadExportedDataReply, data=data)

    def parse_get_basic_info_admin_msg(self, data: dict) -> GetBasicInfoAdminReply:
        """Парсит ответ GetBasicInfoAdminRequest."""
        return self._parse_message(data_class=GetBasicInfoAdminReply, data=data)

    def parse_launch_lds_msg(self, data: list) -> LaunchLdsReply:
        """Парсит Completion-ответ LaunchLdsRequest."""
        return self._find_and_parse_message(data_class=LaunchLdsReply, data=data)

    def parse_stop_lds_msg(self, data: list) -> StopLdsReply:
        """Парсит Completion-ответ StopLdsRequest."""
        return self._find_and_parse_message(data_class=StopLdsReply, data=data)

    def parse_get_tus_information_msg(self, data: dict) -> GetTusInformationReply:
        """Парсит ответ GetTusInformationRequest."""
        return self._parse_message(data_class=GetTusInformationReply, data=data)

    def _find_and_parse_message(
        self,
        data_class: Type[ContentType],
        data: List[Any],
        config: Optional[Config] = None,
    ) -> ContentType:
        """
        Ищет объект с replyStatus в ws сообщении и парсит его
        """
        payload = self.find_reply_status_in_ws_msg(data)
        parsed_payload = self._parse_message(data_class=data_class, data=payload, config=config)

        return parsed_payload

    def _parse_message(
        self,
        data_class: Type[MessageType],
        data: dict,
        config: Optional[Config] = None,
    ) -> MessageType:
        """
        Универсальная функция парсинга сообщений
        """
        data_class_name = data_class.__name__
        if not data:
            fail(f"Пустое сообщение типа: {data_class_name}")
        error_message = data.get('replyErrors', None)
        if error_message:
            fail(f"Ошибка в сообщении типа: {data_class_name} текст ошибки: {error_message}")
        try:
            message = from_dict(
                data_class=data_class, data=data, config=config or self._dacite_config  # type: ignore[arg-type]
            )
            if not self.suppress_recv_logging:
                try:
                    attach(
                        str(message) + f" {datetime.now(ZoneInfo(WebSocketClientConstants.ZONE_INFO))}",
                        name=data_class_name,
                        attachment_type=attachment_type.TEXT,
                    )
                except (KeyError, RuntimeError) as error:
                    logger.debug("Allure attach пропущен: %s", error)

            return message
        except DaciteError as error:
            fail(f"Ошибка парсинга сообщения типа: {data_class_name} текст ошибки: {error}")

    def _parse_scheme_signal_states(self, raw_signals: list) -> list[signals_state_model.SignalState]:
        """Разворачивает signalsStates: поддерживает формат [type, dict] и уже готовые dict."""
        signals_states = []
        for item in raw_signals:
            if self._is_valid_signal_tuple(item):
                signals_states.append(self._parse_message(signals_state_model.SignalState, item[_SIGNAL_DATA_POSITION]))
            elif isinstance(item, dict):
                signals_states.append(self._parse_message(signals_state_model.SignalState, item))
        return signals_states

    def _parse_scheme_to_states(self, raw_to_states: list) -> list[signals_state_model.ToState]:
        """Разворачивает toStates: поддерживает формат [type, dict] и уже готовые dict."""
        to_states = []
        for item in raw_to_states:
            if self._is_valid_signal_tuple(item):
                to_states.append(self._parse_message(signals_state_model.ToState, item[_SIGNAL_DATA_POSITION]))
            elif isinstance(item, dict):
                to_states.append(self._parse_message(signals_state_model.ToState, item))
        return to_states

    @staticmethod
    def _is_valid_signal_tuple(item: Any) -> bool:
        """Проверяет, что элемент - кортеж/список [signal_type, signal_data_dict]."""
        return (
            isinstance(item, (list, tuple))
            and len(item) >= _MIN_SIGNAL_TUPLE_LENGTH
            and isinstance(item[_SIGNAL_DATA_POSITION], dict)
        )

    @staticmethod
    def find_reply_status_in_ws_msg(data: List[Any]) -> Optional[Dict[str, Any]]:
        """
        Ищет объект с replyStatus в ws сообщении
        """
        if not data:
            fail("Пустое сообщение")
        try:
            for item in reversed(data):
                # 1) Если сам элемент — словарь с replyStatus
                if isinstance(item, dict) and 'replyStatus' in item:
                    return item
                # 2) Если элемент — список / кортеж — проверяем все элементы в нём
                if isinstance(item, (list, tuple)):
                    for elem in item:
                        if isinstance(elem, dict) and 'replyStatus' in elem:
                            return elem
        except (AttributeError, KeyError, TypeError, RuntimeError, ValueError):
            fail("Не удалось найти replyStatus в сообщении")

    def _get_default_config(self) -> Config:
        """
        Получает конфиг с правилами обработки полей
        """

        # TODO добавить strict=True, после выполнения задачи LDS-8792
        def _to_export_status(value: Any) -> ExportStatus:
            return value if isinstance(value, ExportStatus) else ExportStatus(value)

        def _to_exported_data_type(value: Any) -> ExportedDataType:
            return value if isinstance(value, ExportedDataType) else ExportedDataType(value)

        def _to_reply_status(value: Any) -> ReplyStatus:
            return value if isinstance(value, ReplyStatus) else ReplyStatus(value)

        return Config(
            type_hooks={
                UUID: self.convert_to_uuid,
                datetime: self.timestamp_to_datetime,
                ExportStatus: _to_export_status,
                ExportedDataType: _to_exported_data_type,
                ReplyStatus: _to_reply_status,
            }
        )


# Создает экземпляр класса для удобства импорта
ws_message_parser = WsMessageParser()



























ws_test_utils.py
from __future__ import annotations

import asyncio
import pprint
import random
import re
from dataclasses import asdict, is_dataclass
from datetime import datetime, timedelta, timezone
from enum import IntEnum, IntFlag
from typing import Any, Callable, List, Optional, Set, Type, TypeVar
from zoneinfo import ZoneInfo

import allure
from msgpack import Timestamp as MsgpackTimestamp
from pytest import fail
from requests import Response

from clients.websocket_client import WebSocketClient
from constants.architecture_constants import WebSocketClientConstants as WS_Const
from constants.enums import (
    ConfirmationStatus,
    ExportStatus,
    LdsStatus,
    LdsStatusDegradation,
    LdsStatusFaulty,
    LdsStatusInitialization,
    LeakStatus,
    ReplyStatus,
    StationaryReason,
    StationaryStatus,
    StoppedPumpingReason,
    UnStationaryReason,
)
from constants.test_constants import BaseTN3Constants as TestConst
from constants.test_constants import ExportReportConstants as ReportConst
from models.export_reports_model import ReportDataExportedContent, ReportDataExportedNotification
from models.get_messages_model import GetMessagesRequest
from models.subscribe_all_leaks_info_model import SubscribeAllLeaksInfoReply
from models.subscribe_common_scheme_model import DiagnosticArea, FlowArea
from models.subscribe_leaks_model import Leak
from models.subscribe_main_page_info_model import MainPageLeakInfo
from utils.helpers.ws_message_parser import ws_message_parser
from utils.msgpack_utils.message_filters import is_desired_invocation_id, is_desired_type

ObjectType = TypeVar("ObjectType")  # создает типовую переменную для поиска объектов в списке
RandomObjectType = TypeVar("RandomObjectType")
Event = TypeVar("Event")
ParsedPayloadType = TypeVar("ParsedPayloadType")
SignalType = TypeVar("SignalType")


def convert_leak_volume_m3(volume: float) -> float:
    """
    Преобразует объем утечки в м3/час
    """
    #  Округляет результат для читабельности
    return round(volume * TestConst.MASS_KG, 3)


def datetime_minus_seconds(datetime_obj: datetime, delta_s: int) -> datetime:
    """
    Вычитает время в секундах из datetime
    """
    return (datetime_obj - timedelta(seconds=delta_s)).replace(microsecond=0)


def calculate_leak_start_time(imitator_start_time: datetime, leak_interval_seconds: int) -> Optional[datetime]:
    """
    Рассчитывает время начала утечки на основе времени старта имитатора.

    :param imitator_start_time: datetime объект времени старта имитатора
    :param leak_interval_seconds: интервал от старта до утечки в секундах (LEAK_START_INTERVAL)
    :return: datetime время ожидаемого начала утечки
    """
    if not imitator_start_time:
        return None
    return (imitator_start_time + timedelta(seconds=leak_interval_seconds)).replace(microsecond=0)


def calculate_leak_end_time(
    imitator_start_time: datetime, leak_interval_seconds: int, allowed_diff_seconds: int
) -> Optional[datetime]:
    """
    Рассчитывает крайнее время обнаружения утечки (с учётом допустимой погрешности).

    :param imitator_start_time: datetime объект времени старта имитатора
    :param leak_interval_seconds: интервал от старта до утечки в секундах (LEAK_START_INTERVAL)
    :param allowed_diff_seconds: допустимая погрешность времени обнаружения (ALLOWED_TIME_DIFF_SECONDS)
    :return: datetime крайнее время обнаружения утечки
    """
    if not imitator_start_time:
        return None
    total_seconds = leak_interval_seconds + allowed_diff_seconds
    return (imitator_start_time + timedelta(seconds=total_seconds)).replace(microsecond=0)


def get_leak_time_window(
    imitator_start_time: datetime, leak_interval_seconds: int, allowed_diff_seconds: int, detected_at_tz=None
) -> tuple[datetime, datetime]:
    """
    Возвращает временное окно для проверки времени обнаружения утечки.

    :param imitator_start_time: datetime объект времени старта имитатора
    :param leak_interval_seconds: интервал от старта до утечки в секундах
    :param allowed_diff_seconds: допустимая погрешность времени обнаружения
    :param detected_at_tz: timezone из времени обнаружения утечки (опционально)
    :return: tuple (leak_start_time, leak_end_time) для использования в is_between проверке
    """
    leak_start = calculate_leak_start_time(imitator_start_time, leak_interval_seconds)
    leak_end = calculate_leak_end_time(imitator_start_time, leak_interval_seconds, allowed_diff_seconds)

    # Если передан timezone, применяем его к временам для корректного сравнения
    if detected_at_tz:
        leak_start = leak_start.replace(tzinfo=detected_at_tz)
        leak_end = leak_end.replace(tzinfo=detected_at_tz)

    return leak_start, leak_end


def ensure_moscow_timezone(input_datetime: datetime) -> None | datetime:
    """
    Конвертирует datetime в московское время, если оно не в московской таймзоне.

    :param input_datetime: datetime объект
    :return: datetime в московской таймзоне
    """
    if input_datetime is None:
        return input_datetime

    # Если datetime без timezone - считаем что это UTC
    if input_datetime.tzinfo is None:
        input_datetime = input_datetime.replace(tzinfo=timezone.utc)

    # Конвертируем в московское время
    return input_datetime.astimezone(ZoneInfo(TestConst.ZONE_INFO))


def report_time_offset_hours(tz_name: str = TestConst.ZONE_INFO) -> Optional[int]:
    """
    Смещение часового пояса (часы от UTC) для поля timeOffset в запросах отчётов.
    """
    now = datetime.now(ZoneInfo(tz_name))
    utc_offset = now.utcoffset()
    if utc_offset is None:
        return None
    return int(utc_offset.total_seconds() // TestConst.SECONDS_PER_HOUR)


def localize_as_moscow(input_datetime: datetime) -> None | datetime:
    """
    Присваивает datetime московский часовой пояс без сдвига времени.
    Если datetime уже имеет timezone - конвертирует в московское время.
    """
    if input_datetime is None:
        return input_datetime

    moscow_tz = ZoneInfo(TestConst.ZONE_INFO)
    if input_datetime.tzinfo is None:
        return input_datetime.replace(tzinfo=moscow_tz)
    return input_datetime.astimezone(moscow_tz)


def format_datetime_moscow(value: Optional[datetime]) -> str:
    """Строковое представление datetime в Europe/Moscow для вложений Allure."""
    if value is None:
        return "None"
    return str(localize_as_moscow(value))


def get_rejection_time_window(
    imitator_start_time: datetime,
    start_seconds: int | float,
    reserve_seconds: int | float = 0,
) -> tuple[datetime, datetime]:
    """
    Возвращает временное окно для проверки сообщения об отбраковке.
    """
    imitator_msk = localize_as_moscow(imitator_start_time)
    range_start = imitator_msk + timedelta(seconds=start_seconds - reserve_seconds)
    range_end = localize_as_moscow(datetime.now())
    return range_start, range_end


def find_rejection_journal_message(
    messages_info: List[ObjectType],
    tag: str,
    range_start: datetime,
    range_end: datetime,
    technological_section: str,
    expected_event: str,
) -> tuple[list[ObjectType], ObjectType | None]:
    """
    Фильтрует сообщения журнала по tag и временному диапазону,
    затем ищет целевое сообщение по technologicalSection и event.
    """
    time_filtered = [
        msg for msg in messages_info if msg.tag == tag and range_start <= ensure_moscow_timezone(msg.time) <= range_end
    ]
    time_filtered.sort(key=lambda msg: ensure_moscow_timezone(msg.time), reverse=True)

    target_msg = next(
        (
            msg
            for msg in time_filtered
            if msg.technologicalSection == technological_section and msg.event.rstrip() == expected_event
        ),
        None,
    )
    return time_filtered, target_msg


def get_random_item(item_list: List[RandomObjectType]) -> RandomObjectType:
    """
    Получает случайный объект из списка
    """
    if not item_list:
        return None
    try:
        return random.choice(item_list)
    except (TypeError, ValueError):
        return None


def get_longest_flow_area(flow_areas: List[FlowArea]) -> Optional[FlowArea]:
    """
    Получает самый протяженный участок карты течения по количеству ДУ из списка всех участков
    """
    if not flow_areas:
        return None
    try:
        longest_flow_area = max(flow_areas, key=lambda flow_area: len(flow_area.diagnosticAreas))
        return longest_flow_area
    except (TypeError, ValueError):
        return None


def determine_lds_status_by_priority(lds_status_set: Set[int]) -> Optional[int]:
    """
    Определяет режим работы СОУ по приоритету и наличию режимов работы у ДУ на самом протяженном участки карты течений
    """
    lds_status_priority = [
        LdsStatus.FAULTY.value,
        LdsStatus.INITIALIZATION.value,
        LdsStatus.DEGRADATION.value,
        LdsStatus.SERVICEABLE.value,
    ]
    if not lds_status_set:
        return None
    try:
        for status in lds_status_priority:
            if status in lds_status_set:
                return status
    except (AttributeError, KeyError, RuntimeError, TypeError, ValueError):
        return None


def find_signal_type_by_address_suffix(signals_list: list, address_suffix: str) -> Optional[int]:
    """
    Ищет в списке сигналов тип сигнала по части адреса
    """
    if not signals_list:
        return None
    try:
        for sensor_signal in signals_list:
            if sensor_signal.address is not None and str(sensor_signal.address).endswith(address_suffix):
                return sensor_signal.signalType
        return None
    except (AttributeError, KeyError, RuntimeError, TypeError, ValueError):
        return None


def find_signal_val_by_signal_type(signals_list: list, signal_type: int) -> Optional[str]:
    """
    Ищет в списке сигналов значение сигнала по типу
    """
    if not signals_list:
        return None
    try:
        for sensor_signal in signals_list:
            if sensor_signal.signalType is not None and sensor_signal.signalType == signal_type:
                return sensor_signal.value
        return None
    except (AttributeError, KeyError, RuntimeError, TypeError, ValueError):
        return None


def find_object_by_field(item_list: List[ObjectType], field_name: str, value: Any) -> ObjectType:
    """
    Ищет объект в списке объектов по значению одного из полей объекта
    """
    if not item_list:
        return None
    try:
        return next((item for item in item_list if getattr(item, field_name) == value))
    except Exception:
        return None


def find_object_by_a_few_fields(item_list: List[ObjectType], fields_dict: dict) -> ObjectType:
    """
    Ищет объект в списке объектов по значениям нескольких полей
    """
    if not item_list:
        return None

    return next(
        (item for item in item_list if all(getattr(item, field) == value for field, value in fields_dict.items())), None
    )


def parse_event(event_value: str) -> tuple[str | None, str | None]:
    """
    Разделяет строку события на имя и причину, вложенную в скобки
    """
    if not event_value or not isinstance(event_value, str):
        return None, None

    # Паттерн для "Состояние режима (Причина)"
    pattern = r'^([^\(]+?)\s*\(([^\)]+)\)\s*$'
    match = re.match(pattern, event_value)

    if match:
        mode_part = match.group(1).strip()
        reason_part = match.group(2).strip()
        return mode_part if mode_part else None, reason_part if reason_part else None
    # Если значение события не соответствует паттерну, возвращаю текст, если нет текста, тогда None
    return event_value.strip(), None


def get_signal(site_message, signal_type):
    if not site_message:
        return None
    return next((s for s in site_message.signals if s.signalType == signal_type.value), None)


def get_value(obj):
    return getattr(obj, "value", None)


def find_confirmed_leaks(item_list: List[Leak]) -> List[Leak]:
    """Ищет подтвержденные утечки"""
    try:
        return [
            item
            for item in item_list
            if item.confirmationStatus == ConfirmationStatus.CONFIRMED.value and item.detectedAt is not None
        ]
    except (AttributeError, KeyError, TypeError, ValueError):
        return []


def find_confirmed_leaks_on_main_page(item_list: List[MainPageLeakInfo]) -> List[MainPageLeakInfo]:
    """Ищет подтвержденные утечки"""
    try:
        return [
            item
            for item in item_list
            if item.leakStatus == LeakStatus.CONFIRMED.value and item.leakDetectedAt is not None
        ]
    except (AttributeError, KeyError, TypeError, ValueError):
        return []


def find_diagnostic_area_by_id(flow_areas: List[FlowArea], id_value: Optional[int]) -> Optional[DiagnosticArea]:
    """
    Ищет ДУ по id в списке участков карты течений, исключает дубликаты по количеству pipeIds
    """
    candidates = []
    if not flow_areas or not id_value:
        return None
    try:
        for flow_area in flow_areas:
            for diagnostic_area in flow_area.diagnosticAreas:
                if diagnostic_area.id == id_value:
                    candidates.append(diagnostic_area)
        if not candidates:
            return None
        elif len(candidates) == 1:
            return candidates[0]
        else:
            # Среди дубликатов ищет ДУ с наибольшим количеством pipeIds
            return max(candidates, key=lambda candidate: len(candidate.pipeIds))
    except (AttributeError, KeyError, RuntimeError, TypeError, ValueError):
        return None


def find_diagnostic_area_by_pipe_id(flow_areas: List[FlowArea], pipe_id: int) -> Optional[DiagnosticArea]:
    """
    Ищет ДУ по pipe id в списке участков карты течений, исключает дубликаты по количеству pipeIds
    """
    candidates = []
    if not flow_areas:
        return None
    try:
        for flow_area in flow_areas:
            for diagnostic_area in flow_area.diagnosticAreas:
                if diagnostic_area.pipeIds and pipe_id in diagnostic_area.pipeIds:
                    candidates.append(diagnostic_area)
        if not candidates:
            return None
        elif len(candidates) == 1:
            return candidates[0]
        else:
            # Среди дубликатов ищет ДУ с наибольшим количеством pipeIds
            return max(candidates, key=lambda candidate: len(candidate.pipeIds))
    except (AttributeError, KeyError, RuntimeError, TypeError, ValueError):
        return None


def find_diagnostic_areas_by_ids(flow_areas: List[FlowArea], id_list: List[int]) -> List[DiagnosticArea]:
    """
    Получает список ДУ из списка flow_areas по списку id
    """
    diagnostic_areas = [
        result
        for diagnostic_area_id in id_list
        if (result := find_diagnostic_area_by_id(flow_areas, diagnostic_area_id)) is not None
    ]

    return diagnostic_areas


def find_diagnostic_areas_by_pipe_ids(flow_areas: List[FlowArea], id_list: List[int]) -> List[DiagnosticArea]:
    """
    Получает список ДУ из списка flow_areas по списку pipe id
    """
    diagnostic_areas = [
        result for pipe_id in id_list if (result := find_diagnostic_area_by_pipe_id(flow_areas, pipe_id)) is not None
    ]

    return diagnostic_areas


def find_base_diagnostic_areas(flow_areas: List[FlowArea]) -> List[DiagnosticArea]:
    """
    Получает список базовых ДУ из списка flow_areas
    """
    return find_diagnostic_areas_by_ids(flow_areas, TestConst.DIAGNOSTIC_AREA_BASE_IDS)


def find_leak_by_coordinate(
    leaks_list: List[ObjectType], expected_coordinate: float, tolerance: float = TestConst.ALLOWED_DISTANCE_DIFF_METERS
) -> ObjectType:
    """
    Ищет утечку в списке по координатам с допустимой погрешностью
    """
    if not leaks_list:
        return None

    for leak in leaks_list:
        leak_coordinate = getattr(leak, "leakCoordinate")
        if leak_coordinate is None or "":
            continue
        if abs(leak_coordinate - expected_coordinate) <= tolerance:
            return leak
    return None


def to_moscow_timezone(date_str: str) -> Optional[datetime]:
    """
    Преобразует строку времени в московское время
    """
    if not date_str or not date_str.strip():
        return None

    try:
        if date_str.startswith(("'", '"', '')) or date_str.endswith(("'", '"', '')):
            date_str = date_str.strip().strip("'").strip('"')

        date_utc = datetime.strptime(date_str, TestConst.OUTPUT_TIME_FORMAT).replace(tzinfo=timezone.utc)
        return date_utc.astimezone(ZoneInfo(TestConst.ZONE_INFO))

    except (AttributeError, TypeError, ValueError):
        return None


def create_dict_from_dataclass(cls: Type, **kwargs) -> Optional[dict]:
    """Создает словарь из экземпляра dataclass c нужными параметрами"""
    if not is_dataclass(cls):
        return None
    instance = cls(**kwargs)
    return asdict(instance)


def datetime_to_msgpack_timestamp(dt: datetime) -> list:
    """Конвертирует datetime в формат [Timestamp(seconds, nanoseconds), tz_offset] для отправки на бэкенд."""
    return [MsgpackTimestamp(seconds=int(dt.timestamp()), nanoseconds=0), 0]


def create_journal_req_body(**kwargs) -> dict:
    """Создает дефолтные параметры запроса к журналу"""
    result = create_dict_from_dataclass(GetMessagesRequest, **kwargs)
    period = result.get('periodTime')
    if period:
        for key in ('start', 'end'):
            if isinstance(period.get(key), datetime):
                period[key] = datetime_to_msgpack_timestamp(period[key])
    return result


def extract_first_number(value: object) -> Optional[float]:
    """
    Извлекает первое число из ячейки (int/float/str вида '55.55 км', '111.11 м3/ч').
    """
    if value is None:
        return None
    if isinstance(value, (int, float)) and not isinstance(value, bool):
        return float(value)
    if isinstance(value, str):
        matches = re.findall(TestConst.DIGITS_WITH_DOT_PATTERN, value)
        if matches:
            try:
                return float(matches[0].replace(",", "."))
            except ValueError:
                return None
    return None


def attach_ws_poll_failure(
    collected_messages: List[Any],
    total_wait_seconds: float,
    expected_message_type: str,
) -> None:
    """Краткая сводка и pprint каждого WS-сообщения при таймауте поллинга."""
    allure.attach(
        "\n".join(
            [
                f"Таймаут ожидания: {total_wait_seconds} с",
                f"Ожидаемый тип сообщения: {expected_message_type}",
                f"Всего сообщений за период поллинга: {len(collected_messages)}",
            ]
        ),
        name="WS poll timeout",
        attachment_type=allure.attachment_type.TEXT,
    )
    for msg in collected_messages:
        allure.attach(
            pprint.pformat(msg, width=120, sort_dicts=False),
            name="received ws message",
            attachment_type=allure.attachment_type.TEXT,
        )


def _attach_ws_reply_parse_failure(
    reply_payload: Optional[Any],
    invocation_id: str,
    request_name: str,
    error: BaseException,
) -> None:
    """Прикрепляет к Allure ответ бэка при ошибке парсинга."""
    allure.attach(
        "\n".join(
            [
                f"Запрос: {request_name}",
                f"invocation_id: {invocation_id}",
                f"Ошибка: {error}",
            ]
        ),
        name="WS parse failure",
        attachment_type=allure.attachment_type.TEXT,
    )
    if reply_payload is not None:
        allure.attach(
            pprint.pformat(reply_payload, width=120, sort_dicts=False),
            name="received ws message",
            attachment_type=allure.attachment_type.TEXT,
        )


def _drain_recv_queue(ws_client: WebSocketClient) -> List[Any]:
    """Забирает все сообщения из очереди ws без блокирующего receive_by_..."""
    messages: List[Any] = []
    while not ws_client.recv_queue.empty():
        try:
            messages.append(ws_client.recv_queue.get_nowait())
        except asyncio.QueueEmpty:
            break
    return messages


def _find_valid_report_export_notification(
    messages: List[Any],
    parser,
    notification_type: str,
) -> Optional[ReportDataExportedNotification]:
    """Ищет среди уже полученных сообщений успешную ReportDataExportedNotification."""
    for msg in messages:
        if not isinstance(msg, list) or not is_desired_type(msg, notification_type):
            continue
        try:
            notification = parser.parse_report_data_exported_notification_msg(msg)
        except (ValueError, TypeError, KeyError):
            continue
        if notification.replyStatus != ReplyStatus.OK.value:
            continue
        content: Optional[ReportDataExportedContent] = notification.replyContent
        if content is None:
            continue
        if content.exportStatus != ExportStatus.DONE:
            continue
        return notification
    return None


async def poll_for_report_export_notification(
    ws_client: WebSocketClient,
    parser,
    total_wait_seconds: float,
    poll_interval_seconds: float,
) -> Optional[Any]:
    """
    Собирает сообщения из очереди ws и ищет ReportDataExportedNotification с успешным exportStatus.
    При таймауте прикрепляет к Allure все полученные за период сообщения.
    """

    deadline = asyncio.get_event_loop().time() + total_wait_seconds
    collected_messages: List[Any] = []
    ws_client.suppress_recv_logging = True
    parser.suppress_recv_logging = True
    try:
        while asyncio.get_event_loop().time() < deadline:
            await asyncio.sleep(poll_interval_seconds)
            batch = _drain_recv_queue(ws_client)
            collected_messages.extend(batch)
            notification = _find_valid_report_export_notification(
                batch, parser, ReportConst.REPORT_DATA_EXPORTED_NOTIFICATION
            )
            if notification is not None:
                return notification
    finally:
        collected_messages.extend(_drain_recv_queue(ws_client))
        ws_client.suppress_recv_logging = False
        parser.suppress_recv_logging = False

    attach_ws_poll_failure(
        collected_messages,
        total_wait_seconds,
        ReportConst.REPORT_DATA_EXPORTED_NOTIFICATION,
    )
    return None


def _find_ws_reply_by_invocation_id(messages: List[Any], invocation_id: str, parser) -> Optional[list]:
    """
    Ищет последний ответ с заданным invocation_id и телом replyStatus.
    """
    reply_payload = None
    for msg in messages:
        if not isinstance(msg, list) or not is_desired_invocation_id(msg, invocation_id):
            continue
        if parser.find_reply_status_in_ws_msg(msg):
            reply_payload = msg
    return reply_payload


async def poll_for_exported_file(
    ws_client: WebSocketClient,
    parser,
    list_limit: int,
    expected_data_type: Any,
    name_substring: str,
    tu_name_substring: str,
    period_start: datetime,
    period_end: datetime,
    total_wait_seconds: float,
    poll_interval_seconds: float,
    period_tolerance_minutes: int = ReportConst.REPORT_PERIOD_TOLERANCE_MINUTES,
) -> Optional[Any]:
    """
    Периодически шлёт GetExportedDataListRequest, забирает ответы из очереди
    по invocation_id среди всех накопленных сообщений.
    При таймауте или ошибке парсинга прикрепляет к Allure полученные ответы.
    """

    deadline = asyncio.get_event_loop().time() + total_wait_seconds
    last_items_count = -1
    collected_messages: List[Any] = []
    request_name = ReportConst.GET_EXPORTED_DATA_LIST_REQUEST
    ws_client.suppress_recv_logging = True
    parser.suppress_recv_logging = True
    try:
        while asyncio.get_event_loop().time() < deadline:
            drained_before_request = _drain_recv_queue(ws_client)
            collected_messages.extend(drained_before_request)
            await connect(
                ws_client,
                request_name,
                {"limit": list_limit},
            )
            invocation_id = ws_client.invocation_id
            await asyncio.sleep(poll_interval_seconds)

            batch = _drain_recv_queue(ws_client)
            collected_messages.extend(batch)
            list_reply_payload = _find_ws_reply_by_invocation_id(batch, invocation_id, parser)

            if list_reply_payload is None:
                continue

            try:
                parsed_payload = parser.parse_exported_data_list_msg(list_reply_payload)
            except Exception as error:
                _attach_ws_reply_parse_failure(list_reply_payload, invocation_id, request_name, error)
                for msg in collected_messages:
                    allure.attach(
                        pprint.pformat(msg, width=120, sort_dicts=False),
                        name="received ws message",
                        attachment_type=allure.attachment_type.TEXT,
                    )
                fail(f"Не удалось разобрать ответ на {request_name}: {error}")

            items = []
            if parsed_payload.replyContent is not None:
                items = parsed_payload.replyContent.exportedData or []

            if len(items) != last_items_count:
                allure.attach(
                    "\n".join(
                        f"id={item.id}, name={item.name}, type={item.exportedDataType}, "
                        f"start={format_datetime_moscow(item.start)}, end={format_datetime_moscow(item.end)}"
                        for item in items
                    ),
                    name=f"Список сформированных файлов (всего: {len(items)})",
                    attachment_type=allure.attachment_type.TEXT,
                )
                last_items_count = len(items)

            match = find_matching_exported_item(
                items=items,
                expected_data_type=expected_data_type,
                name_substring=name_substring,
                tu_name_substring=tu_name_substring,
                period_start=period_start,
                period_end=period_end,
                period_tolerance_minutes=period_tolerance_minutes,
            )
            if match is not None:
                return match
    finally:
        collected_messages.extend(_drain_recv_queue(ws_client))
        ws_client.suppress_recv_logging = False
        parser.suppress_recv_logging = False

    attach_ws_poll_failure(
        collected_messages,
        total_wait_seconds,
        request_name,
    )
    return None


def _normalize_report_period_datetime(value: datetime) -> datetime:
    """Приводит datetime периода отчёта к московскому времени без микросекунд."""
    return localize_as_moscow(value).replace(microsecond=0)


def _exported_item_period_matches(
    item_start: datetime,
    item_end: datetime,
    period_start: datetime,
    period_end: datetime,
    tolerance_minutes: int,
) -> bool:
    """Проверяет start/end элемента списка в пределах периода запроса +- tolerance_minutes."""
    item_start_norm = _normalize_report_period_datetime(item_start)
    item_end_norm = _normalize_report_period_datetime(item_end)
    period_start_norm = _normalize_report_period_datetime(period_start)
    period_end_norm = _normalize_report_period_datetime(period_end)
    delta = timedelta(minutes=tolerance_minutes)
    return (period_start_norm - delta) <= item_start_norm <= (period_start_norm + delta) and (
        period_end_norm - delta
    ) <= item_end_norm <= (period_end_norm + delta)


def _normalize_report_text_for_match(text: str) -> str:
    return text.lower().replace("ё", "е")


def find_matching_exported_item(
    items: List[Any],
    expected_data_type: Any,
    name_substring: str,
    tu_name_substring: str,
    period_start: datetime,
    period_end: datetime,
    period_tolerance_minutes: int = ReportConst.REPORT_PERIOD_TOLERANCE_MINUTES,
) -> Optional[Any]:
    """
    Ищет элемент списка по типу, подстрокам в имени (отчёт + ТУ) и периоду start/end с допуском.
    """
    name_substring_normalized = _normalize_report_text_for_match(name_substring)
    tu_name_normalized = _normalize_report_text_for_match(tu_name_substring)

    matched_items = []
    for item in items:
        if item.exportedDataType != expected_data_type:
            continue
        item_name_normalized = _normalize_report_text_for_match(item.name or "")
        if name_substring_normalized not in item_name_normalized:
            continue
        if tu_name_normalized not in item_name_normalized:
            continue
        if item.start is None or item.end is None:
            continue
        if not _exported_item_period_matches(item.start, item.end, period_start, period_end, period_tolerance_minutes):
            continue
        matched_items.append(item)

    if not matched_items:
        return None
    return max(matched_items, key=lambda exported_item: exported_item.id)


def parse_journal_msg_value(value: str) -> tuple:
    """Парсит поле value в сообщении журнала"""
    try:
        # ищет группы цифр с точкой в строке
        matches = re.findall(TestConst.DIGITS_WITH_DOT_PATTERN, value)
        coordinate, volume = (matches + [None, None])[:2]
        if coordinate is not None:
            try:
                coordinate = float(coordinate)
            except ValueError:
                coordinate = None
        if volume is not None:
            try:
                volume = float(volume)
            except ValueError:
                volume = None
        return coordinate, volume
    except (AttributeError, TypeError, ValueError):
        return None, None


def parse_bit_flags(
    value: int, enum_cls: Type[IntEnum | IntFlag], failures: Optional[List[str]] = None
) -> List[IntFlag]:
    """
    Распаковка битовых флагов
    """
    # 0 - это валидное состояние когда причин нет, в прошлой реализации тест бы падал если причин нет
    # хотя это может быть ожидаемо, например при тестировании исправности или где-нибудь еще
    if value == 0:
        return []

    found_flags = [flag for flag in enum_cls if value & flag.value]
    known_bits = sum(flag.value for flag in found_flags)

    if known_bits != value:
        unknown_bits = value ^ known_bits
        error_message = f"Неизвестные биты при распаковке {enum_cls.__name__}: {unknown_bits}"
        if failures is not None:
            failures.append(error_message)
        else:
            fail(f"Неизвестные биты при распаковке {enum_cls.__name__}: {unknown_bits}")

    # та же сортировка только не цифры, а их текстовое значение
    return sorted(found_flags, key=lambda flag: flag.value)


def get_json_from_http_response(response: Response) -> dict:
    try:
        return response.json()
    except ValueError:
        fail("Не удалось преобразовать тело ответа в JSON")


def get_reason_enum_by_lds_status(lds_status: int | LdsStatus, failures: Optional[List[str]] = None) -> Type[IntFlag]:
    """
    Получение класса причин по режимам СОУ
    """

    if isinstance(lds_status, int):
        try:
            lds_status = LdsStatus(lds_status)
        except ValueError:
            error_message = f"Неизвестный LdsStatus: {lds_status}"
            if failures is not None:
                failures.append(error_message)
            else:
                fail(error_message)

    reason_by_lds_status = {
        LdsStatus.FAULTY: LdsStatusFaulty,
        LdsStatus.INITIALIZATION: LdsStatusInitialization,
        LdsStatus.DEGRADATION: LdsStatusDegradation,
    }
    enum_class = reason_by_lds_status.get(lds_status)
    if enum_class is None:
        error_message = f"Для LdsStatus{lds_status.name} не определены причины"
        if failures is not None:
            failures.append(error_message)
        else:
            fail(error_message)
    return enum_class


def get_reason_enum_by_stationary_status(
    stationary_status: int | StationaryStatus, failures: Optional[List[str]] = None
) -> Type[IntFlag]:
    """
    Получение класса причин по режимам МТ
    """

    if isinstance(stationary_status, int):
        try:
            stationary_status = StationaryStatus(stationary_status)
        except ValueError:
            error_message = f"Неизвестный StationaryStatus: {stationary_status}"
            if failures is not None:
                failures.append(error_message)
            else:
                fail(error_message)

    reason_by_stationary_status = {
        StationaryStatus.STATIONARY: StationaryReason,
        StationaryStatus.UNSTATIONARY: UnStationaryReason,
        StationaryStatus.STOPPED: StoppedPumpingReason,
    }
    enum_class = reason_by_stationary_status.get(stationary_status)
    if enum_class is None:
        error_message = f"Для StationaryStatus{stationary_status.name} не определены причины"
        if failures is not None:
            failures.append(error_message)
        else:
            fail(error_message)
    return enum_class


def parse_lds_status_reasons(lds_status: int, lds_status_reasons: int, failures: Optional[List[str]] = None):
    """
    Получение списка ldsStatusReasons, соответствующего ldsStatus
    """
    enum_cls = get_reason_enum_by_lds_status(lds_status, failures)
    flags = enum_cls(lds_status_reasons)
    if flags == str(lds_status_reasons):
        error_message = f"Не удалось распаковать флаги: {lds_status_reasons} для {enum_cls.__name__}"
        if failures is not None:
            failures.append(error_message)
    return flags


def _is_configurator_flow_active() -> bool:
    from utils.helpers import lds_configurator_utils as lds_cfg

    return lds_cfg.is_configurator_flow_active()


def parse_stationary_status_reasons(
    stationary_status: int, stationary_status_reasons: int, failures: Optional[List[str]] = None
):
    """
    Получение списка stationaryStatusReasons, соответствующего stationaryStatus
    """
    enum_cls = get_reason_enum_by_stationary_status(stationary_status, failures)
    flags = enum_cls(stationary_status_reasons)
    if flags == str(stationary_status_reasons):
        error_message = f"Не удалось распаковать флаги: {stationary_status_reasons} для {enum_cls.__name__}"
        if failures is not None:
            failures.append(error_message)
    return flags


async def connect(ws_client: WebSocketClient, ws_invoke_type: str, ws_invoke_params: Any = None) -> None:
    """
    Подключение к заданной подписке
    """
    try:
        if _is_configurator_flow_active():
            await ws_client.invoke(ws_invoke_type, ws_invoke_params)
        else:
            with allure.step(f"Вызов {ws_invoke_type} c параметрами {ws_invoke_params}"):
                await ws_client.invoke(ws_invoke_type, ws_invoke_params)
    except (asyncio.TimeoutError, ConnectionError, ConnectionResetError, OSError) as error:
        if _is_configurator_flow_active():
            raise
        fail(f"Не удалось отправить сообщение типа: {ws_invoke_type} c параметрами {ws_invoke_params}. Ошибка: {error}")


async def connect_stream(
    ws_client: WebSocketClient,
    ws_invoke_type: str,
    ws_invoke_params: Any = None,
    purpose: str = "streaming-вызов WS",
) -> None:
    """
    Streaming-вызов (StreamInvocation)
    """
    try:
        with allure.step(f"Streaming-вызов {ws_invoke_type} c параметрами {ws_invoke_params}"):
            await ws_client.invoke_stream(ws_invoke_type, ws_invoke_params)
    except (asyncio.TimeoutError, ConnectionError, ConnectionResetError, OSError) as error:
        fail(
            f"Не удалось выполнить {purpose} ({ws_invoke_type}, StreamInvocation). "
            f"Параметры запроса: {ws_invoke_params}. Ошибка соединения: {error}"
        )


def _stream_completion_error(msg: Any, invocation_id: str) -> Optional[str]:
    """Текст ошибки из ответа SignalR Completion для данного invocation_id, если есть."""
    if not isinstance(msg, list):
        return None
    if msg[0] != WS_Const.COMPLETION_MESSAGE_TYPE:
        return None
    if not is_desired_invocation_id(msg, invocation_id):
        return None
    if len(msg) <= WS_Const.COMPLETION_ERROR_MESSAGE_INDEX:
        return None
    error_text = msg[WS_Const.COMPLETION_ERROR_MESSAGE_INDEX]
    if isinstance(error_text, str):
        return error_text
    return None


async def receive_download_exported_data_reply(
    ws_client: WebSocketClient,
    parser,
    invocation_id: str,
    request_name: str,
    total_wait_seconds: float,
    poll_interval_seconds: float = 0.5,
    purpose: str = "скачивании xlsx-отчёта после выбора файла в списке сформированных отчётов",
) -> Any:
    """
    Ожидает StreamItem с fileChunk после streaming DownloadExportedDataRequest.
    """
    deadline = asyncio.get_event_loop().time() + total_wait_seconds
    collected_messages: List[Any] = []
    ws_client.suppress_recv_logging = True
    parser.suppress_recv_logging = True
    try:
        while asyncio.get_event_loop().time() < deadline:
            await asyncio.sleep(poll_interval_seconds)
            batch = _drain_recv_queue(ws_client)
            collected_messages.extend(batch)

            for msg in batch:
                stream_error = _stream_completion_error(msg, invocation_id)
                if stream_error:
                    _attach_ws_reply_parse_failure(msg, invocation_id, request_name, RuntimeError(stream_error))
                    fail(
                        f"При {purpose} бэк вернул Completion с ошибкой "
                        f"({request_name}, invocation_id={invocation_id}): {stream_error}"
                    )

            for msg in batch:
                if (
                    not isinstance(msg, list)
                    or msg[0] != WS_Const.STREAM_ITEM_MESSAGE_TYPE
                    or not is_desired_invocation_id(msg, invocation_id)
                ):
                    continue
                if parser.find_reply_status_in_ws_msg(msg) is None:
                    continue
                try:
                    return parser.parse_download_exported_data_msg(msg)
                except Exception as error:
                    _attach_ws_reply_parse_failure(msg, invocation_id, request_name, error)
                    fail(
                        f"При {purpose} получен StreamItem ({request_name}, invocation_id={invocation_id}), "
                        f"но не удалось разобрать ответ с fileChunk: {error}"
                    )
    finally:
        collected_messages.extend(_drain_recv_queue(ws_client))
        ws_client.suppress_recv_logging = False
        parser.suppress_recv_logging = False

    attach_ws_poll_failure(collected_messages, total_wait_seconds, f"{request_name} (StreamItem)")
    fail(
        f"При {purpose} за {total_wait_seconds} с не получен StreamItem с fileChunk "
        f"({request_name}, invocation_id={invocation_id}). Смотреть вложения received ws message"
    )


async def connect_and_get_parsed_msg_by_tu_id(
    tu_id: int,
    ws_client: WebSocketClient,
    ws_message_type: str,
    ws_invoke_type: str,
    ws_invoke_params: Any = None,
    timeout: float = TestConst.BASIC_MESSAGE_TIMEOUT,
) -> SubscribeAllLeaksInfoReply:
    """
    Подключается, ищет и парсит allLeaksInfo сообщение для конкретного ТУ
    """
    await connect(ws_client, ws_invoke_type, ws_invoke_params)

    async def get_parsed_msg():
        """
        Ищет и парсит allLeaksInfo сообщение для конкретного ТУ
        """
        while True:
            payload = await ws_client.receive_by_type(ws_message_type, timeout=timeout)
            parsed_payload = ws_message_parser.parse_all_leaks_info_msg(payload)
            #  Ищет сообщение с нужным ТУ
            if parsed_payload.replyContent.tuId == tu_id:
                return parsed_payload

    try:
        with allure.step(f"Получение сообщения с контентом типа: {ws_message_type} для ТУ {tu_id}"):
            return await asyncio.wait_for(get_parsed_msg(), timeout=timeout)
    except (asyncio.TimeoutError, ConnectionError, ConnectionResetError, OSError) as error:
        fail(f"Не удалось получить сообщение allLeaksInfo для ТУ {tu_id}. Ошибка: {error}")


async def connect_and_get_msg(
    ws_client: WebSocketClient,
    ws_invoke_type: str,
    ws_invoke_params: Any = None,
    receive_timeout: Optional[float] = None,
) -> list:
    """
    Подключение типа get к заданной подписке и получение сообщения с заданным типом контента
    """
    await connect(ws_client, ws_invoke_type, ws_invoke_params)
    invocation_id = ws_client.invocation_id
    timeout = receive_timeout if receive_timeout is not None else WS_Const.FILTERING_TIMEOUT

    try:
        if _is_configurator_flow_active():
            payload = await ws_client.receive_by_invocation_id(invocation_id, timeout=timeout)
        else:
            with allure.step(f"Получение входящего сообщения c invocation_id: {invocation_id}"):
                payload = await ws_client.receive_by_invocation_id(invocation_id, timeout=timeout)
        return payload
    except (asyncio.TimeoutError, ConnectionError, ConnectionResetError, OSError) as error:
        if _is_configurator_flow_active():
            raise
        fail(f"Не удалось получить сообщение типа: {ws_invoke_type}. Ошибка: {error}")


async def connect_and_poll_subscribed_signal(
    ws_client: WebSocketClient,
    ws_message_type: str,
    ws_invoke_type: str,
    ws_invoke_params: Any,
    sensor_id: int,
    sensor_description: str,
    parse_payload: Callable[[list], ParsedPayloadType],
    extract_signals: Callable[[ParsedPayloadType], List[SignalType]],
    *,
    max_messages: int = TestConst.SUBSCRIBE_MESSAGE_POLL_ATTEMPTS,
    timeout: float = TestConst.BASIC_MESSAGE_TIMEOUT,
) -> tuple[ParsedPayloadType, SignalType]:
    """
    Подписывается один раз и читает до max_messages сообщений из потока,
    пока не найдёт сигнал с нужным id.
    """
    await connect(ws_client, ws_invoke_type, ws_invoke_params)

    received_ids: Set[int] = set()

    try:
        for attempt in range(1, max_messages + 1):
            with allure.step(
                f"Получение сообщения с контентом типа: {ws_message_type} - попытка {attempt} из {max_messages}"
            ):
                payload = await ws_client.receive_by_type(ws_message_type, timeout=timeout)

            parsed = parse_payload(payload)
            signals = extract_signals(parsed) or []

            for signal in signals:
                signal_id = getattr(signal, "id", None)
                if signal_id is not None:
                    received_ids.add(signal_id)

            target_signal = find_object_by_field(signals, "id", sensor_id)
            if target_signal is not None:
                allure.attach(
                    f"Сигнал id={sensor_id} ({sensor_description}) найден на попытке {attempt} из {max_messages}\n"
                    f"Всего сигналов в сообщении: {len(signals)}\n"
                    f"Полученные id: {sorted(received_ids)}",
                    name=f"Результат поиска сигнала в {ws_message_type}",
                    attachment_type=allure.attachment_type.TEXT,
                )
                return parsed, target_signal

        received_ids_text = ", ".join(str(signal_id) for signal_id in sorted(received_ids)) or "нет"
        fail(
            f"Информация по датчику {sensor_description} (id={sensor_id}) не пришла в ответе {ws_message_type} "
            f"после {max_messages} попыток чтения из потока подписки. Полученные id: {received_ids_text}"
        )
    except (asyncio.TimeoutError, OSError, ConnectionError, ConnectionResetError) as error:
        fail(
            f"Не удалось получить сообщение типа: {ws_message_type} для датчика {sensor_description} "
            f"(id={sensor_id}). Ошибка: {error}"
        )


async def connect_and_subscribe_msg(
    ws_client: WebSocketClient,
    ws_message_type: str,
    ws_invoke_type: str,
    ws_invoke_params: Any = None,
    timeout: float = TestConst.BASIC_MESSAGE_TIMEOUT,
) -> list:
    """
    Подключение типа subscribe к заданной подписке и получение сообщения с заданным типом контента
    """
    await connect(ws_client, ws_invoke_type, ws_invoke_params)
    try:
        with allure.step(f"Получение сообщения с контентом типа: {ws_message_type}"):
            payload = await ws_client.receive_by_type(ws_message_type, timeout=timeout)

        return payload
    except (asyncio.TimeoutError, OSError, ConnectionError, ConnectionResetError) as error:
        fail(f"Не удалось получить сообщение типа: {ws_invoke_type}. Ошибка: {error}")


async def poll_balance_algorithm_diagnostic_areas(
    ws_client: WebSocketClient,
    ws_parser: ws_message_parser,
    imitator_start_time: datetime,
    end_time: datetime,
    poll_interval: float,
) -> list:
    """
    Опрашивает очередь ws_client на наличие BalanceAlgorithmResultsContent,
    собирает и возвращает все diagnosticAreas из flowAreas.
    """
    collected_diagnostic_areas = []
    ws_client.suppress_recv_logging = True
    ws_parser.suppress_recv_logging = True
    try:
        while datetime.now(tz=imitator_start_time.tzinfo) < end_time:
            await asyncio.sleep(poll_interval)

            latest_msg = None
            while not ws_client.recv_queue.empty():
                try:
                    msg = ws_client.recv_queue.get_nowait()
                except asyncio.QueueEmpty:
                    break
                if isinstance(msg, list) and is_desired_type(msg, "BalanceAlgorithmResultsContent"):
                    latest_msg = msg

            if latest_msg is None:
                continue

            parsed_payload = ws_message_parser.parse_balance_algorithm_msg(latest_msg)
            reply_content = parsed_payload.replyContent
            if reply_content and reply_content.flowAreas:
                for flow_area in reply_content.flowAreas:
                    if flow_area.diagnosticAreas:
                        collected_diagnostic_areas.extend(flow_area.diagnosticAreas)
    finally:
        ws_client.suppress_recv_logging = False
        ws_parser.suppress_recv_logging = False

    return collected_diagnostic_areas


def get_leak_diagnostic_area_samples(
    collected_diagnostic_areas: list,
    leak_diagnostic_area_name: str,
    total_wait: int,
) -> list:
    """
    Проверяет наличие diagnosticAreas и возвращает подмножество
    для ДУ с заданным leak_diagnostic_area_id. Падает, если данные не найдены.
    """
    if not collected_diagnostic_areas:
        fail(f"За {total_wait} секунд не пришло ни одной diagnosticArea в BalanceAlgorithmResultsContent")

    leak_diagnostic_area_samples = [
        diagnostic_area
        for diagnostic_area in collected_diagnostic_areas
        if diagnostic_area.name == leak_diagnostic_area_name
    ]
    if not leak_diagnostic_area_samples:
        fail(f"За {total_wait} секунд не пришло ни одного сообщения для ДУ с name={leak_diagnostic_area_name}.")
    return leak_diagnostic_area_samples


def moscow_now() -> datetime:
    """Текущее время в часовом поясе Europe/Moscow."""
    return datetime.now(ZoneInfo(TestConst.ZONE_INFO))
























conftest.py
import asyncio
import glob
import os
import shutil
import threading
import time
from typing import Optional

import allure
import pytest
import pytest_asyncio

from clients.http_client import StandHttpClient
from clients.keycloak_clients import KeycloakAuthError, KeycloakClient
from clients.testops_client import AllureResultsUploader, logger
from clients.websocket_client import WebSocketClient
from constants.architecture_constants import EnvKeyConstants as EnvConst
from constants.architecture_constants import HTTPClientConstants as HttpConst
from constants.architecture_constants import ImitatorConstants as ImConst
from constants.architecture_constants import KeycloakClientConstants as KCliConst
from constants.architecture_constants import WebSocketClientConstants as WSCliConst
from constants.enums import RejectionSensorTag
from constants.test_constants import BaseTN3Constants
from infra.stand_setup_manager import StandSetupManager
from test_config.datasets import get_config_by_name
from test_scenarios import lds_configurator_scenarios
from utils.helpers import lds_configurator_utils as lds_cfg_utils
from utils.helpers.ws_message_parser import ws_message_parser as lds_ws_parser


def pytest_addoption(parser):
    """
    Добавляет кастомные опции командной строки pytest.
    """
    parser.addoption(
        "--suites",
        action="store",
        default=None,
        help="Запустить только указанные наборы данных. Пример: --suites=select_4,select_19_20",
    )


def _find_config_by_suite_name(suite_name: str):
    """Находит конфиг по имени набора данных."""
    try:
        return get_config_by_name(suite_name)
    except ValueError:
        return None


@pytest.fixture(autouse=True)
def allure_suite_hierarchy(request):
    """
    Автоматически устанавливает иерархию Allure для группировки тестов по наборам данных.

    В Allure отчёте тесты группируются:
    - Parent Suite: SingleLeakSuite / MultiLeakSuite (тип набора)
    - Suite: select_4 / select_6 / ... (имя набора данных)

    Работает как с параметризованными тестами (config в параметрах),
    так и с обычными тестами (через маркер test_suite_name).
    """
    config = None
    suite_name = None

    # Пробуем получить конфиг из параметризации
    if hasattr(request, 'fixturenames') and 'config' in request.fixturenames:
        try:
            config = request.getfixturevalue('config')
            suite_name = config.suite_name
        except Exception:
            pass

    # Если не нашли, пробуем найти конфиг по маркеру test_suite_name
    if not config:
        marker = request.node.get_closest_marker('test_suite_name')
        if marker:
            suite_name = marker.args[0]
            config = _find_config_by_suite_name(suite_name)

    if config and suite_name:
        parent_suite = "MultiLeakSuite" if config.has_multiple_leaks else "SingleLeakSuite"
        allure.dynamic.parent_suite(parent_suite)
        allure.dynamic.suite(suite_name)


def pytest_configure(config):
    """
    Храним состояние сессии
    """
    config.addinivalue_line("markers", "critical_stop: если тест упал, останавливаем дальнейшее выполнение сессии")
    config.group_state = {
        "current_suite": None,
        "suite_start_time": None,
        "stand_manager": None,
        "imitator_start_time": None,  # datetime объект времени старта имитатора для расчёта интервалов утечек
        "use_lds_configurator": True,
        "resolved_tu_id": None,
        "admin_tu_name": None,
        "pre_run_running_tus": None,
        "suite_infra_ready": False,
    }


def _update_sensor_ids(stand_manager: StandSetupManager) -> None:
    """
    Для тестов датчиков обновляет sensor_id по address из конфигурации стенда.
    """
    sensor_ids_by_address = stand_manager.get_sensor_ids_by_address()
    BaseTN3Constants.SENSOR_IDS_BY_ADDRESS.update(sensor_ids_by_address)
    RejectionSensorTag.update_ids_from_config(sensor_ids_by_address)


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_makereport(item, call):
    """
    Делает падение критического теста с маркировкой critical_stop однозначным:
    - рисуем fail для теста
    - после него прекращаем запуск остальных тестов
    """
    outcome = yield
    report = outcome.get_result()
    if report.when == "call" and report.failed and item.get_closest_marker("critical_stop"):
        item.session.shouldstop = f"Критическая проверка упала: {item.nodeid}"


# ===== Маппинг имён тестов на атрибуты конфига для получения маркеров =====
# Используется для добавления offset и test_case_id маркеров во время сбора тестов

# Smoke-тесты уровня набора (маркеры из SmokeSuiteConfig)
SMOKE_SUITE_LEVEL_MAPPING = {
    'test_basic_info': 'basic_info_test',
    'test_journal_info': 'journal_info_test',
    'test_imitate_pressure_sensor_signal': 'imitate_pressure_sensor_signal_test',
    'test_imitate_flowmeter_signal': 'imitate_flowmeter_signal_test',
    'test_lds_status_initialization': 'lds_status_initialization_test',
    'test_lds_status_init_in_journal': 'lds_status_init_in_journal_test',
    'test_main_page_info': 'main_page_info_test',
    'test_main_page_info_signals': 'main_page_info_signals_test',
    'test_mask_signal': 'mask_signal_test',
    'test_mask_info_in_journal': 'mask_info_in_journal_test',
    'test_lds_status_initialization_out': 'lds_status_initialization_out_test',
    'test_lds_status_init_out_in_journal': 'lds_status_init_out_in_journal_test',
    'test_main_page_info_unstationary': 'main_page_info_unstationary_test',
    'test_mask_du_on_mini_scheme': 'mask_du_on_mini_scheme_test',
    'test_unmask_du_on_mini_scheme': 'unmask_du_on_mini_scheme_test',
    'test_lds_status_after_confirming_leak': 'lds_status_after_confirming_leak_test',
    'test_lds_status_completed_leak': 'lds_status_completed_leak_test',
    'test_diagnostics_of_signals_after_initialization': 'diagnostics_of_signals_after_initialization_test',
    'test_mode_mt_in_journal': 'mode_mt_in_journal_test',
    'test_export_lds_status_report': 'export_lds_status_report_test',
    'test_export_mt_mode_report': 'export_mt_mode_report_test',
}

# Regress-тесты режимов СОУ (маркеры из LDSStatusConfig)
LDS_STATUS_SUITE_LEVEL_MAPPING = {
    'test_lds_status_basic_info': 'lds_status_basic_info_test',
    'test_lds_status_init_accumulation_data': 'init_accumulation_data_test',
    'test_lds_status_init_accumulation_data_in_journal': 'init_accumulation_data_in_journal_test',
    'test_lds_status_init_cold_start': 'init_cold_start_test',
    'test_lds_status_init_cold_start_in_journal': 'init_cold_start_in_journal_test',
    'test_lds_status_init_exiting_faulty': 'init_exiting_faulty_test',
    'test_lds_status_init_switching_shut_off': 'init_switching_shut_off_test',
    'test_lds_status_init_switching_shut_off_in_journal': 'init_switching_shut_off_in_journal_test',
    'test_lds_status_serviceable_after_cold_start': 'serviceable_after_cold_start_test',
    'test_lds_status_serviceable_after_cold_start_in_journal': 'serviceable_after_cold_start_in_journal_test',
    'test_lds_status_serviceable_after_switching_shut_off': 'serviceable_after_switching_shut_off_test',
    'test_lds_status_serviceable_after_switching_shut_off_in_journal': 'serviceable_after_switching_shut_off_in_journal_test',  # noqa: E501
    'test_lds_status_serviceable_after_deg_absence_min_pressure_sensors': 'serviceable_after_deg_absence_min_pressure_sensors_test',  # noqa: E501
    'test_lds_status_serviceable_after_deg_additive_injectors_operation': 'serviceable_after_deg_additive_injectors_operation_test',  # noqa: E501
    'test_lds_status_serviceable_after_deg_exceeding_distance_between_flow_meters': 'serviceable_after_deg_exceeding_distance_between_flow_meters_test',  # noqa: E501
    'test_lds_status_serviceable_after_deg_starting_pumping_out_pumps': 'serviceable_after_deg_starting_pumping_out_pumps_test',  # noqa: E501
    'test_lds_status_serviceable_after_deg_faulty_pressure_sensors_at_pump': 'serviceable_after_deg_faulty_pressure_sensors_at_pump_test',  # noqa: E501
    'test_lds_status_serviceable_after_deg_faulty_pressure_sensors_at_pump_in_journal': 'serviceable_after_deg_faulty_pressure_sensors_at_pump_in_journal_test',  # noqa: E501
    'test_lds_status_serviceable_after_faulty': 'serviceable_after_faulty_test',
    'test_lds_status_degradation_additive_injectors_operation': 'deg_additive_injectors_operation_test',
    'test_lds_status_degradation_exceeding_distance_between_pressure_sensors': 'deg_exceeding_distance_between_pressure_sensors_test',  # noqa: E501
    'test_lds_status_degradation_exceeding_distance_between_pressure_sensors_in_journal': 'deg_exceeding_distance_between_pressure_sensors_in_journal_test',  # noqa: E501
    'test_lds_status_degradation_absence_min_pressure_sensors': 'deg_absence_min_pressure_sensors_test',
    'test_lds_status_degradation_faulty_pressure_sensors_at_pump_station': 'deg_faulty_pressure_sensors_at_pump_station_test',  # noqa: E501
    'test_lds_status_degradation_faulty_pressure_sensors_at_pump_station_in_journal': 'deg_faulty_pressure_sensors_at_pump_station_in_journal_test',  # noqa: E501
    'test_lds_status_degradation_gravity_section_pumping': 'deg_gravity_section_pumping_test',
    'test_lds_status_degradation_gravity_section_pumping_in_stopping': 'deg_gravity_section_pumping_in_stopping_test',
    'test_lds_status_degradation_gravity_section_pumping_in_stopping_in_journal': 'deg_gravity_section_pumping_in_stopping_in_journal_test',  # noqa: E501
    'test_lds_status_degradation_pig_sensor_passage': 'deg_pig_sensor_passage_test',
    'test_lds_status_degradation_starting_pumping_out_pumps': 'deg_starting_pumping_out_pumps_test',
    'test_lds_status_degradation_exceeding_distance_between_flow_meters': 'deg_exceeding_distance_between_flow_meters_test',  # noqa: E501
    'test_lds_status_degradation_rejection_temperature_sensor_on_du_2': 'deg_rejection_temperature_sensor_on_du_2_test',
    'test_lds_status_degradation_rejection_temperature_sensor_on_du_3': 'deg_rejection_temperature_sensor_on_du_3_test',
    'test_lds_status_degradation_rejection_temperature_sensor_on_du_5': 'deg_rejection_temperature_sensor_on_du_5_test',
    'test_lds_status_degradation_rejection_density_and_viscosity_on_du_2': 'deg_rejection_density_and_viscosity_on_du_2_test',  # noqa: E501
    'test_lds_status_degradation_rejection_density_and_viscosity_on_du_3': 'deg_rejection_density_and_viscosity_on_du_3_test',  # noqa: E501
    'test_lds_status_degradation_rejection_density_and_viscosity_on_du_5': 'deg_rejection_density_and_viscosity_on_du_5_test',  # noqa: E501
    'test_lds_status_faulty_absence_min_flow_meters_continuous': 'faulty_absence_min_flow_meters_continuous_test',
    'test_lds_status_faulty_absence_min_flow_meters': 'faulty_absence_min_flow_meters_test',
    'test_lds_status_faulty_absence_min_pressure_sensors': 'faulty_absence_min_pressure_sensors_test',
    'test_lds_status_faulty_absence_min_pressure_sensors_in_journal': 'faulty_absence_min_pressure_sensors_in_journal_test',  # noqa: E501
}

# Тесты уровня утечки (маркеры из LeakTestConfig - параметр leak)
LEAK_LEVEL_TEST_MAPPING = {
    'test_leaks_content': 'leaks_content_test',
    'test_all_leaks_info': 'all_leaks_info_test',
    'test_tu_leaks_info': 'tu_leaks_info_test',
    'test_leak_info_in_journal': 'leak_info_in_journal',
    'test_possible_leak_in_journal': 'possible_leak_in_journal_test',
    'test_acknowledge_leak_info': 'acknowledge_leak_test',
    'test_acknowledge_leak_in_journal': 'acknowledge_leak_in_journal_test',
    'test_output_signals': 'output_signals_test',
    'test_lds_status_during_leak': 'lds_status_during_leak_test',
    'test_balance_algorithm_leak_waiting': 'balance_algorithm_leak_waiting_test',
    'test_balance_algorithm_leak_detected': 'balance_algorithm_leak_detected_test',
    'test_the_leak_is_complete_on_kg': 'the_leak_is_complete_on_kg_test',
    'test_leak_is_complete_in_output_signals': 'leak_is_complete_in_output_signals_test',
    'test_complete_tu_leaks_info_content': 'complete_tu_leaks_info_content_test',
    'test_all_leaks_is_empty': 'all_leaks_is_empty_test',
    'test_leak_is_confirm_on_main_page': 'leak_is_confirm_on_main_page_test',
    'test_leak_is_complete_on_main_page': 'leak_is_complete_on_main_page_test',
    'test_balance_algorithm_leak_completed': 'balance_algorithm_leak_completed_test',
    'test_completed_leak_info_in_journal': 'completed_leak_info_in_journal_test',
    'test_export_leaks_report': 'export_leaks_report_test',
}

STATIONARY_STATUS_SUITE_LEVEL_MAPPING = {
    'test_stationary_status_basic_info': 'stationary_status_basic_info_test',
    'test_stationary_status_check_with_reasons': 'stationary_status_check_with_reasons_test',
    'test_stationary_status_in_journal': 'stationary_status_in_journal_test',
}

# Тесты уровня отбраковки (маркеры из RejectionTestCase - параметр rejection_case)
IS_REJECTED_LEVEL_TEST_MAPPING = {
    'test_rejection_input_signals': 'rejection_input_signals_test',
    'test_rejection_journal': 'rejection_journal_test',
    'test_rejection_main_page': 'rejection_main_page_test',
    'test_rejection_scheme_signals_state': 'rejection_scheme_signals_state_test',
}

# Suite-level тесты отбраковки (маркеры из IsRejectedConfig - параметр config)
IS_REJECTED_SUITE_LEVEL_MAPPING = {
    'test_rejection_report': 'rejection_report_test',
}

# Мержим все вместе чтобы не переписывать логику коллектора айтемов (тестов)
SUITE_LEVEL_TEST_MAPPING = {
    **SMOKE_SUITE_LEVEL_MAPPING,
    **LDS_STATUS_SUITE_LEVEL_MAPPING,
    **STATIONARY_STATUS_SUITE_LEVEL_MAPPING,
    **IS_REJECTED_SUITE_LEVEL_MAPPING,
}


def _get_test_markers_config(item, test_name):
    """
    Получает конфигурацию с маркерами для теста.

    Для leak-level тестов: маркеры берутся из параметра leak
    Для suite-level тестов: маркеры берутся из config

    :return: CaseMarkers объект или None
    """
    if not hasattr(item, 'callspec'):
        return None

    params = item.callspec.params

    # Проверяем, есть ли параметр leak (для leak-level тестов)
    if 'leak' in params and test_name in LEAK_LEVEL_TEST_MAPPING:
        leak = params['leak']
        attr_name = LEAK_LEVEL_TEST_MAPPING[test_name]
        return getattr(leak, attr_name, None)

    # Проверяем, есть ли параметр rejection_case для тестов отбраковки
    if 'rejection_case' in params and test_name in IS_REJECTED_LEVEL_TEST_MAPPING:
        rejection_case = params['rejection_case']
        attr_name = IS_REJECTED_LEVEL_TEST_MAPPING[test_name]
        return getattr(rejection_case, attr_name, None)

    # Suite-level тесты отбраковки (без rejection_case)
    if 'config' in params and 'rejection_case' not in params and test_name in IS_REJECTED_SUITE_LEVEL_MAPPING:
        suite_config = params['config']
        attr_name = IS_REJECTED_SUITE_LEVEL_MAPPING[test_name]
        return getattr(suite_config, attr_name, None)

    # Для suite-level тестов берём из config
    if 'config' in params:
        if test_name in SMOKE_SUITE_LEVEL_MAPPING:
            suite_config = params['config']
            attr_name = SMOKE_SUITE_LEVEL_MAPPING[test_name]
            return getattr(suite_config, attr_name, None)
        if test_name in LDS_STATUS_SUITE_LEVEL_MAPPING:
            suite_config = params['config']
            attr_name = LDS_STATUS_SUITE_LEVEL_MAPPING[test_name]
            return getattr(suite_config, attr_name, None)
        if test_name in STATIONARY_STATUS_SUITE_LEVEL_MAPPING:
            suite_config = params['config']
            attr_name = STATIONARY_STATUS_SUITE_LEVEL_MAPPING[test_name]
            return getattr(suite_config, attr_name, None)
    return None


def pytest_collection_modifyitems(session, config, items):
    """
    1. Фильтрует тесты по --suites (если указано)
    2. Исключает тесты, у которых конфиг = None (тест отключён для этого набора данных)
    3. Добавляет маркеры offset и test_case_id из конфига к каждому параметризованному тесту
    4. Сортирует тесты по test_suite_name для группировки по наборам данных
    """
    # Получаем список выбранных наборов из --suites
    suites_option = config.getoption("--suites")
    selected_suites = None
    if suites_option:
        # Парсим список наборов: "select_4,select_19_20" -> ["select_4", "select_19_20"]
        selected_suites = [s.strip().lower() for s in suites_option.split(",")]

    selected_items = []
    deselected_items = []

    for item in items:
        # Фильтрация по --suites
        if selected_suites:
            suite_marker = item.get_closest_marker("test_suite_name")
            if suite_marker:
                suite_name = suite_marker.args[0].lower()
                # Проверяем, содержит ли имя набора одну из выбранных подстрок
                if not any(selected in suite_name for selected in selected_suites):
                    deselected_items.append(item)
                    continue

        # Получаем имя функции теста (без параметров)
        test_name = item.originalname or item.name.split('[')[0]

        # Получаем конфиг с маркерами для теста
        test_config = _get_test_markers_config(item, test_name)

        if test_config is not None:
            # Добавляем маркер offset
            if hasattr(test_config, 'offset') and test_config.offset is not None:
                item.add_marker(pytest.mark.offset(test_config.offset))

            # Добавляем маркер test_case_id
            if hasattr(test_config, 'test_case_id') and test_config.test_case_id is not None:
                item.add_marker(pytest.mark.test_case_id(test_config.test_case_id))
        elif (
            test_name in SUITE_LEVEL_TEST_MAPPING
            or test_name in LEAK_LEVEL_TEST_MAPPING
            or test_name in IS_REJECTED_LEVEL_TEST_MAPPING
        ):
            # Конфиг теста = None - исключаем тест из прогона
            deselected_items.append(item)
            continue

        selected_items.append(item)

    # Уведомляем pytest об исключённых тестах
    if deselected_items:
        config.hook.pytest_deselected(items=deselected_items)

    # Заменяем список тестов на отфильтрованный
    items[:] = selected_items

    # Сортировка тестов по test_suite_name и offset
    # Цель: обеспечить запуск тестов строго по offset строго внутри набора данных
    # При равных offset сохраняем исходный порядок коллекции, чтобы порядок параметризации не перескакивал
    def suite_offset_key(item):
        """
        Сортировка тестов по test_suite_name и offset (без падения на None).
        """
        test_suite_name_marker = item.get_closest_marker("test_suite_name")
        test_suite_name = test_suite_name_marker.args[0] if test_suite_name_marker else ""

        offset_marker = item.get_closest_marker("offset")
        if offset_marker:
            try:
                offset_value = float(offset_marker.args[0])
            except Exception:
                offset_value = float("inf")
        else:
            offset_value = float("inf")

        original_index = getattr(item, "_collection_index", 0)
        # Возвращаем тройку ключей сортировки
        # 1) test_suite_name - группировка по набору
        # 2) offset_value - порядок внутри набора по времени
        # 3) original_index - стабильность при равных offset
        return test_suite_name, offset_value, original_index

    # Сохраняем исходный порядок коллекции для стабильной сортировки
    for index, item in enumerate(items):
        item._collection_index = index

    # по кортежу питон сортирует слева направо, благодаря этому сортировка по offset идет строго внутри test_suite_name
    items.sort(key=suite_offset_key)

    for item in items:
        if hasattr(item, "_collection_index"):
            delattr(item, "_collection_index")


@pytest.fixture(autouse=True)
def allure_tms_link(request):
    """
    Allure TMS‑линки по test_case_id
    """
    if test_case_id_marker := request.node.get_closest_marker("test_case_id"):
        test_case_id = test_case_id_marker.args[0]
        allure.dynamic.link(
            f"https://{os.environ['TESTOPS_BASE_URL']}/testcases?selected_id={test_case_id}",
            name=f"TestCase-{test_case_id}",
            link_type="tms",
        )


@pytest.fixture(autouse=True)
def offset_wait(request):
    """
    Offset‑ожидание перед каждым тестом относительно фактического старта core
    """
    if offset_marker := request.node.get_closest_marker("offset"):
        offset_sec = float(offset_marker.args[0]) * BaseTN3Constants.SEC_PER_MIN
        start = request.config.group_state["suite_start_time"] or 0
        elapsed = time.monotonic() - start
        to_wait = max(0, offset_sec - elapsed)
        if to_wait:
            time.sleep(to_wait)


def compute_imitator_duration(item, current_test_suite: str) -> float:
    """
    Вычисляет длительность для имитатора (в минутах).

    Правило:
      - Собирает все тесты (item.session.items) с меткой test_suite_name == current_test_suite
      - Извлекает все значения @pytest.mark.offset(...) (в минутах)
      - Если offsets найдены: возвращает max(offsets) + IMITATOR_FINISH_DELAY задержка остановки имитатора
      - Иначе: если у текущего item есть @pytest.mark.imitator_duration — используется как fallback и логируется
      - Если ничего не найдено — pytest.fail с понятным текстом
    """

    suite_items = [
        suite_item
        for suite_item in item.session.items
        if (marker := suite_item.get_closest_marker("test_suite_name")) and marker.args[0] == current_test_suite
    ]

    offsets = []
    for suite_item in suite_items:
        offset_marker = suite_item.get_closest_marker("offset")
        if offset_marker:
            try:
                offsets.append(float(offset_marker.args[0]))
            except Exception:
                continue

    if offsets:
        max_offset = max(offsets)
        imitator_duration = float(max_offset) + ImConst.IMITATOR_FINISH_DELAY_MINUTE
        return imitator_duration

    else:
        # fallback- если все еще задан старый маркер imitator_duration, то используем его
        if imitator_mark := item.get_closest_marker("imitator_duration"):
            imitator_duration = float(imitator_mark.args[0])
            logger.warning(
                "[DEPRECATED] использован pytest.mark.imitator_duration()"
                f"рекомендуется убрать и полагаться на max_offset + {ImConst.IMITATOR_FINISH_DELAY_MINUTE}"
            )
            return imitator_duration

        pytest.fail(
            "Не удалось вычислить imitator_duration: в тестовом модуле одновременно отсутствуют "
            "и @pytest.mark.offset(), и pytest.mark.imitator_duration()"
        )


@pytest.fixture(autouse=True)
def require_suite_infra(request):
    """
    Блокирует тесты набора, если infra-setup не завершился успешно.
    """
    if not request.node.get_closest_marker("test_suite_name"):
        return
    cfg = request.config.group_state
    if cfg.get("current_suite") and not cfg.get("suite_infra_ready"):
        pytest.exit(
            "[SETUP] [ERROR] Инфраструктура набора не готова (suite_infra_ready=False). " "Тесты не запускаются."
        )


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_setup(item):
    """
    Перезапуск имитатора при смене test_suite_name
    """
    cfg = item.config.group_state

    test_suite_marker = item.get_closest_marker("test_suite_name")
    if not test_suite_marker:
        pytest.fail("Тест без @pytest.mark.test_suite_name")
    current_test_suite = test_suite_marker.args[0]

    if current_test_suite != cfg["current_suite"]:
        # stop old
        _run_lds_configurator_teardown_if_needed(cfg)
        if stand_manager := cfg["stand_manager"]:
            stand_manager.stop_imitator_wrapper()
            try:
                stand_manager.restore_signal_unit_conversion_rules()
            except Exception:
                logger.exception(
                    "[ERROR] [SETUP] Ошибка при восстановлении signal_unit_conversion_rules.json "
                    "перед запуском нового набора"
                )
            if not os.environ.get("RUN_WITHOUT_TESTOPS", "False").lower() == "true":
                # При запуске с TestOps удаляет данные прогона
                stand_manager.server_test_data_remover()

        # start new
        cfg["current_suite"] = current_test_suite
        cfg["suite_start_time"] = None
        cfg["suite_infra_ready"] = False

        data_id = item.get_closest_marker("test_suite_data_id").args[0]
        test_data_name = item.get_closest_marker("test_data_name").args[0]
        # legacy: id из enum TU для имитатора (tn{id}_tags.txt), не resolved_tu_id из Администрирования
        tu_id = item.get_closest_marker("tu_id").args[0]

        imitator_duration = compute_imitator_duration(item, current_test_suite)

        suite_config = _find_config_by_suite_name(current_test_suite)
        measure_conversion_rules = suite_config.measure_conversion_rules if suite_config is not None else None

        if suite_config is not None and suite_config.use_lds_configurator:
            if not suite_config.admin_tu_name.strip():
                pytest.exit(
                    f"[SETUP] [ERROR] Набор '{suite_config.suite_name}': admin_tu_name обязателен "
                    "при use_lds_configurator=True"
                )

        stand_manager = StandSetupManager(
            duration_m=imitator_duration,
            test_data_id=data_id,
            test_data_name=test_data_name,
            tu_id=tu_id,
            measure_conversion_rules=measure_conversion_rules,
        )
        cfg["stand_manager"] = stand_manager
        try:
            stand_manager.check_opc_server_status()
        except RuntimeError as error:
            msg = (
                "[SETUP] [ERROR] OPC сервер недоступен. Имитатор и автотесты не запущены. "
                f"Ошибка при проверке статуса OPC: {error}"
            )
            allure.attach(msg, name="OPC сервер недоступен", attachment_type=allure.attachment_type.TEXT)
            pytest.exit(msg)
        try:
            stand_manager.setup_stand_for_imitator_run()
        except Exception as error:
            pytest.exit(f"[SETUP] [ERROR] ошибка при подготовке стенда: {error}")

        try:
            _update_sensor_ids(stand_manager)
        except Exception as error:
            pytest.exit(f"[SETUP] [ERROR] ошибка обновления id датчиков отбраковки из конфигурации: {error}")

        if suite_config is not None and suite_config.use_lds_configurator:
            try:
                time.sleep(30)
                _run_lds_admin_setup(suite_config, cfg)
            except BaseException as error:
                pytest.exit(f"[SETUP] [ERROR] LDS Configurator admin setup: {error}")

        imitator_thread = threading.Thread(
            target=stand_manager.start_imitator, name=f"imitator->{current_test_suite}", daemon=True
        )
        core_thread = threading.Thread(target=stand_manager.start_core)
        try:
            imitator_thread.start()
        except Exception as error:
            pytest.exit(f"[SETUP] [ERROR] ошибка запуска имитатора: {error}")
        time.sleep(ImConst.CORE_START_DELAY_S)
        try:
            cfg["suite_start_time"] = time.monotonic()
            core_thread.start()
            core_thread.join(timeout=5)
        except Exception as error:
            pytest.exit(f"[SETUP] [ERROR] ошибка запуска СORE контейнеров: {error}")

        # Сохраняем время старта имитатора для расчёта интервалов утечек в тестах
        cfg["imitator_start_time"] = stand_manager.start_time

        if suite_config is not None and suite_config.use_lds_configurator:
            if suite_config.admin_tu is None:
                pytest.exit(
                    f"[SETUP] [ERROR] Набор '{suite_config.suite_name}': admin_tu обязателен "
                    "при use_lds_configurator=True"
                )
            try:
                _run_lds_verify_after_core(suite_config)
            except BaseException as error:
                # Имитатор остановится в pytest_sessionfinish через stop_imitator_wrapper
                pytest.exit(f"[SETUP] [ERROR] LDS Configurator проверка после запуска lds-core: {error}")

        cfg["suite_infra_ready"] = True

    yield  # pytest продолжит выполнение теста


def _run_lds_configurator_ws(coro_factory) -> None:
    """Запускает async WS-сценарий lds-configurator"""
    lds_cfg_utils.set_configurator_flow_active(True)
    lds_ws_parser.suppress_recv_logging = True
    try:
        asyncio.run(coro_factory())
    finally:
        lds_ws_parser.suppress_recv_logging = False
        lds_cfg_utils.set_configurator_flow_active(False)


def _run_lds_admin_setup(suite_config, group_state: dict) -> None:
    """
    Setup СОУ через Администрирование до старта имитатора.
    """

    async def _admin_setup() -> None:
        http_client = init_http_stand_client()
        await lds_configurator_scenarios.lds_configurator_admin_setup(http_client, suite_config, group_state)

    _run_lds_configurator_ws(_admin_setup)


def _run_lds_verify_after_core(suite_config) -> None:
    """
    WS-проверка готовности стенда после запуска lds-core.
    """

    async def _verify() -> None:
        websocket_client = init_ws_stand_client()
        http_client = init_http_stand_client()
        async with websocket_client as ws_client:
            ws_client.suppress_recv_logging = True
            await lds_configurator_scenarios.lds_configurator_verify_after_core(ws_client, http_client, suite_config)

    _run_lds_configurator_ws(_verify)


def _run_lds_configurator_teardown_if_needed(cfg: dict) -> None:
    """
    Выполняет WS-teardown СОУ при смене набора или завершении сессии.

    Ошибки не прерывают pytest: логируются и оформляются как ALERT внутри сценария.
    Состояние group_state сбрасывается в finally.
    """
    if not cfg.get("use_lds_configurator") or not cfg.get("resolved_tu_id"):
        return

    tu_id = cfg["resolved_tu_id"]
    admin_tu_name = cfg.get("admin_tu_name") or ""
    pre_run_running_tus = cfg.get("pre_run_running_tus") or []

    async def _teardown() -> None:
        websocket_client = init_ws_stand_client()
        http_client = init_http_stand_client()
        async with websocket_client as ws_client:
            ws_client.suppress_recv_logging = True
            await lds_configurator_scenarios.lds_configurator_teardown(
                ws_client, http_client, tu_id, admin_tu_name, pre_run_running_tus
            )

    try:
        _run_lds_configurator_ws(_teardown)
    except BaseException as error:
        logger.warning(
            "[TEARDOWN] [ALERT] LDS Configurator teardown завершился с ошибкой для tuId=%s: %s",
            tu_id,
            error,
        )
    finally:
        cfg["resolved_tu_id"] = None
        cfg["use_lds_configurator"] = False
        cfg["admin_tu_name"] = None
        cfg["pre_run_running_tus"] = None


@pytest.hookimpl(hookwrapper=True)
def pytest_runtest_teardown(item, nextitem):
    """
    Teardown имитатора при выходе из группы
    """
    yield
    cfg = item.config.group_state

    next_marker = nextitem.get_closest_marker("test_suite_name") if nextitem else None
    next_suite = next_marker.args[0] if next_marker else None

    if next_suite != cfg["current_suite"]:
        if stand_manager := cfg["stand_manager"]:
            stand_manager.stop_imitator_wrapper()
            try:
                stand_manager.restore_signal_unit_conversion_rules()
            except Exception:
                logger.exception("[ERROR] [TEARDOWN] Ошибка при восстановлении signal_unit_conversion_rules.json")
            if not os.environ.get("RUN_WITHOUT_TESTOPS", "False").lower() == "true":
                # При запуске с TestOps удаляет данные прогона
                stand_manager.server_test_data_remover()
            cfg["stand_manager"] = None
        cfg["current_suite"] = None
        cfg["suite_start_time"] = None
        cfg["imitator_start_time"] = None
        cfg["suite_infra_ready"] = False

        # опционально дождаться завершения потока (если не daemon) — безопасный join
        imitator_thread = cfg.get("imitator_thread")
        if imitator_thread and not getattr(imitator_thread, "daemon", False):
            try:
                imitator_thread.join(timeout=5)
            except RuntimeError:
                logger.exception("Ошибка при join() фона имитатора")


def get_instance() -> str:
    """
    Получает имя стенда
    """
    instance = os.environ.get(EnvConst.STAND_NAME)
    if not instance:
        pytest.exit(f"Переменная окружения {EnvConst.STAND_NAME} не задана в .env")
    return instance


def build_stand_host() -> str:
    """
    Создает URL для ws и http запросов к стенду
    """
    instance = get_instance()
    ws_host = (
        f"{WSCliConst.SERVICE_NAME}.{WSCliConst.COMPONENT}-{instance}.{WSCliConst.ROOT_DOMAIN}/"
        f"{WSCliConst.API_GATEWAY_PATH_SEGMENT}"
    )

    return ws_host


def build_keycloak_host() -> str:
    """
    Создает URL для получения access_token
    """
    instance = get_instance()
    keycloak_host = (
        f"https://{KCliConst.KEYCLOAK_SUBDOMAIN}.{KCliConst.COMPONENT}-{instance}.{KCliConst.ROOT_DOMAIN}"
        f"{KCliConst.TOKEN_URL_PATH}"
    )
    return keycloak_host


def get_token(max_retries: int = 3, backoff: float = 5.0) -> str:
    """
    :param max_retries: сколько всего попыток (включая первую)
    :param backoff: время в секундах между попытками
    """
    last_exc = None
    keycloak_host = build_keycloak_host()
    keycloak = KeycloakClient(
        url=keycloak_host,
        client_id=os.environ.get(EnvConst.KEYCLOAK_CLIENT_ID),
        client_secret=os.environ.get(EnvConst.KEYCLOAK_SZI_CLIENT_SECRET),
        username=os.environ.get(EnvConst.KEYCLOAK_USERNAME),
        password=os.environ.get(EnvConst.KEYCLOAK_PASSWORD),
    )
    for attempt in range(1, max_retries + 1):
        try:

            token = keycloak.get_access_token()
            if not token:
                raise KeycloakAuthError("Получен пустой access token")
            return token

        except KeycloakAuthError as e:
            last_exc = e
            logger.warning(f"[{attempt}/{max_retries}] KeycloakAuthError: {e}. Повтор через {backoff} сек.")
        except Exception as e:
            last_exc = e
            logger.warning(f"[{attempt}/{max_retries}] Неожиданная ошибка: {e}. Повтор через {backoff} сек.")

        if attempt < max_retries:
            time.sleep(backoff)

    # все попытки исчерпаны
    logger.error(f"[KEYCLOAK] [ERROR] Не удалось получить токен после {max_retries} попыток: {last_exc}")
    pytest.fail(f"[KEYCLOAK][ERROR] Не удалось получить токен после {max_retries} попыток: {last_exc}")


def get_connection_params() -> tuple:
    """
    Получает адрес стенда и токен авторизации
    """
    stand_host = build_stand_host()
    auth_token = get_token()
    return stand_host, auth_token


def init_http_stand_client() -> StandHttpClient:
    """
    Создает экземпляр класса StandHttpClient для запросов к стенду
    """
    stand_host, auth_token = get_connection_params()
    http_client = StandHttpClient(stand_host, auth_token)
    return http_client


def get_x_user_id() -> Optional[str]:
    """
    Получает x-user-id из headers Ping запроса
    """
    http_client = init_http_stand_client()
    try:
        response = http_client.post_request(HttpConst.PING_URL_PATH, {})
        logger.info("[HTTP_CLIENT] [OK] Успешно получен x-user-id")
        return response.headers.get(HttpConst.X_USER_ID_KEY)
    except Exception as error:
        logger.error(f"[HTTP_CLIENT] [ERROR] Не удалось получить x-user-id: {error}")
        pytest.exit(f"[HTTP_CLIENT] [ERROR] Не удалось получить x-user-id: {error}")


def init_ws_stand_client() -> WebSocketClient:
    """
    Создает экземпляр класса WebSocketClient для запросов к стенду
    """
    ws_host, token = get_connection_params()
    x_user_id = get_x_user_id()
    ws_client = WebSocketClient(ws_host, token, x_user_id)
    return ws_client


@pytest_asyncio.fixture
async def ws_client():
    """
    Фикстура для работы с websocket клиентом
    :return: Объект wss соединения
    """
    ws_client = init_ws_stand_client()
    async with ws_client as client:
        yield client


@pytest.fixture
def http_client():
    """
    Фикстура для работы с http клиентом
    :return: экземпляр класса для выполнения http запросов к стенду
    """
    http_client = init_http_stand_client()
    yield http_client


@pytest.fixture
def imitator_start_time(request):
    """
    Фикстура для получения времени старта имитатора (datetime объект).
    Используется для точного расчёта времени обнаружения утечек:
    - leak_start_time = imitator_start_time + timedelta(seconds=LEAK_START_INTERVAL)
    - leak_end_time = imitator_start_time + timedelta(seconds=LEAK_START_INTERVAL + ALLOWED_TIME_DIFF_SECONDS)
    """
    start_time = request.config.group_state.get("imitator_start_time")
    if start_time is None:
        pytest.fail("imitator_start_time не установлен. Убедитесь что тест запущен после инициализации имитатора.")
    return start_time


def pytest_sessionfinish(session, exitstatus):
    """
    В завершении сессии — отправляем единый Allure‑отчёт в TestOps.
    """
    # 1) teardown стенда: LDS Configurator + остановка имитатора
    try:
        group_state = getattr(session.config, "group_state", {})
        _run_lds_configurator_teardown_if_needed(group_state)
        stand_manager = group_state.get("stand_manager")
        if stand_manager:
            try:
                stand_manager.stop_imitator_wrapper()
            except Exception:
                logger.exception("[ERROR] [TEARDOWN] Ошибка при остановке имитатора")
            try:
                stand_manager.restore_signal_unit_conversion_rules()
            except Exception:
                logger.exception("[ERROR] [TEARDOWN] Ошибка при восстановлении signal_unit_conversion_rules.json")
            try:
                stand_manager.server_test_data_remover()
            except Exception:
                logger.exception("[ERROR] [TEARDOWN] Ошибка при удалении тестового набора данных со стенда")
    except Exception:
        logger.exception("[ERROR] [TEARDOWN] Ошибка при получении stand_manager из group_state")

    # 2) Выгрузка allure-results в TestOps
    try:
        uploader = AllureResultsUploader()
        logger.info("[INFO] [TEARDOWN] Uploading Allure results to TestOps")
        uploader.upload_allure_results()
    except Exception:
        logger.exception("[ERROR] [TEARDOWN] Ошибка при выгрузке allure-results в TestOps")

    # 3) Удаление локальных архивов с данными
    shutil.rmtree("allure-results")
    project_root = os.path.dirname(os.path.abspath(__file__))
    files_for_drop = glob.glob(os.path.join(project_root, "*.tar.gz"))
    if not files_for_drop:
        logger.warning("[WARNING] [TEARDOWN] Не нашлось архивов .tar.gz с данным для удаления")
    else:
        for file in files_for_drop:
            os.remove(file)