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


clients/opc_ua_client.py
"""
OPC UA клиент для чтения выходных сигналов СОУ (обратный обмен СДКУ).

Подключается к OPC UA серверу по opc.tcp и читает значения тегов
(Value + StatusCode + SourceTimestamp). Используется как в setup-части
(проверка доступности OPC), так и в сценариях имитации выходных сигналов.
"""
import asyncio
import logging
from typing import Any, Optional

from asyncua import Client, ua

from constants.architecture_constants import OpcUaConstants as OpcConst

logger = logging.getLogger(__name__)


def build_opc_node_id(
    ost_name: str,
    stand_name: str,
    address: str,
    suffix: str,
    namespace_index: int = OpcConst.NAMESPACE_INDEX,
) -> str:
    """
    Собирает NodeId вида ns=2;s=CHTNTEST4:CHTN_linearParts_9.isLeakDetected.

    ost_name: имя ОСТ (например "CHTN").
    stand_name: имя стенда (например "TEST4").
    address: тег участка из конфигурации трубы (например "CHTN_linearParts_9").
    suffix: суффикс сигнала (например "isLeakDetected").
    namespace_index: номер пространства имён (по умолчанию NS=2).
    """
    return f"ns={namespace_index};s={ost_name}{stand_name}:{address}.{suffix}"


def read_data_value_sync(
    opc_url: str,
    node_id: str,
    timeout: float = OpcConst.CONNECT_TIMEOUT_S,
) -> ua.DataValue:
    """
    Синхронное подключение и чтение DataValue тега. Используется в setup-части
    (синхронный контекст pytest), чтобы понять, что OPC UA сервер жив и отдаёт данные.
    """
    async def _read() -> ua.DataValue:
        client = Client(url=opc_url, timeout=timeout)
        try:
            await client.connect()
            return await client.get_node(node_id).read_data_value()
        finally:
            await client.disconnect()

    return asyncio.run(_read())


class OpcUaClient:
    """Асинхронный OPC UA клиент с переподключением."""

    def __init__(self, url: str, timeout: float = OpcConst.OPERATION_TIMEOUT_S) -> None:
        self._url = url
        self._timeout = timeout
        self._client: Optional[Client] = None

    async def connect(self, attempts: int = OpcConst.RECONNECT_ATTEMPTS) -> None:
        """Подключается к OPC UA серверу с ограниченным числом попыток."""
        last_error: Optional[Exception] = None
        for attempt in range(1, attempts + 1):
            try:
                self._client = Client(url=self._url, timeout=self._timeout)
                await self._client.connect()
                logger.info("[OPC] [OK] Подключено к %s", self._url)
                return
            except Exception as error:
                last_error = error
                logger.warning(
                    "[OPC] [WARNING] Подключение не удалось (попытка %s/%s): %s", attempt, attempts, error
                )
                if attempt < attempts:
                    await asyncio.sleep(OpcConst.RECONNECT_INTERVAL_S)
        raise ConnectionError(f"Не удалось подключиться к OPC UA серверу {self._url}: {last_error}") from last_error

    async def disconnect(self) -> None:
        if self._client is not None:
            await self._client.disconnect()
            self._client = None

    async def read_data_value(self, node_id: str) -> ua.DataValue:
        """Читает DataValue (Value + StatusCode + SourceTimestamp) тега."""
        if self._client is None:
            raise RuntimeError("OPC UA клиент не подключён")
        return await self._client.get_node(node_id).read_data_value()

    async def read_value(self, node_id: str) -> Any:
        """Читает только значение тега."""
        if self._client is None:
            raise RuntimeError("OPC UA клиент не подключён")
        return await self._client.get_node(node_id).read_value()
































constants/architecture_constants.py
class OpcUaConstants:
    """Константы для OPC UA клиента (чтение выходных сигналов СОУ)."""

    # Номер пространства имён (namespace index) для тегов СОУ. На стенде всегда NS=2.
    NAMESPACE_INDEX: int = 2
    # Таймаут подключения и операций OPC UA (в секундах).
    CONNECT_TIMEOUT_S: float = 10.0
    OPERATION_TIMEOUT_S: float = 5.0
    # Число попыток переподключения и пауза между ними.
    RECONNECT_ATTEMPTS: int = 6
    RECONNECT_INTERVAL_S: float = 5.0
    # Пробный тег для setup-проверки доступности OPC UA (минимальный, не зависит от набора данных).
    PROBE_ADDRESS: str = "CHTN_linearParts_9"
    PROBE_SUFFIX: str = "isLeakDetected"
    # Допуск при проверке времени изменения сигнала (SourceTimestamp), в секундах.
    TIMESTAMP_DELTA_S: float = 60.0
































енам
class OutputSignalType(Enum):
    """
    Типы выходных сигналов СОУ, которые имитируются через api-gateway (обратный обмен СДКУ).

    Значение каждого элемента — кортеж:
      (signal_type, opc_suffix, section_type)

    - signal_type: значение поля signalType в imitateOutputSignalRequest (контракт asyncapi).
    - opc_suffix: суффикс OPC-тега, из которого собирается NodeId вида
      ns=2;s={ost}{stand}:{address}.{suffix}.
    - section_type: тип участка, к которому относится сигнал:
      "linearParts" (ДУ, НПС-НПС) или "controlledSites" (КП-КП, сегменты ДУ).
    """

    LEAK = ("leak", "isLeakDetected", "linearParts")
    LEAK_COORDINATE = ("leakCoordinate", "leakCoordinate", "linearParts")
    LEAK_VOLUME = ("leakVolume", "leakVolume", "linearParts")
    LEAK_TIME = ("leakTime", "leakDetectedAt", "linearParts")
    ACKNOWLEDGE = ("acknowledge", "isLeakAcknowledged", "linearParts")
    MASK = ("mask", "isMasked", "linearParts")
    MASK_REASON = ("maskReason", "maskReason", "linearParts")
    PUMPING_STATUS = ("pumpingStatus", "pumpingStatus", "controlledSites")
    LDS_STATUS = ("ldsStatus", "ldsStatus", "controlledSites")
    FREE_FLOW = ("freeFlow", "hasFreeFlow", "controlledSites")

    def __init__(self, signal_type: str, opc_suffix: str, section_type: str) -> None:
        self.signal_type = signal_type
        self.opc_suffix = opc_suffix
        self.section_type = section_type

    def __str__(self) -> str:
        return f"{self.signal_type} -> .{self.opc_suffix} ({self.section_type})"























стенд сет мен
        logger.info(f"[SETUP] [OK] OPC сервер {host}:{port} доступен")

    def check_opc_server_availability(self) -> None:
        """
        Проверяет доступность OPC UA сервера с runner'а: реальное подключение
        (asyncua) и чтение пробного тега выходного сигнала.
        """
        from clients.opc_ua_client import build_opc_node_id, read_data_value_sync
        from constants.architecture_constants import OpcUaConstants as OpcConst

        opc_url = os.environ.get(EnvKeyConstants.OPC_URL)
        if not opc_url:
            raise RuntimeError("[SETUP] [ERROR] OPC_URL не задан в переменных окружения")

        probe_node_id = build_opc_node_id(
            ost_name=self._ost_name,
            stand_name=self._stand_name,
            address=OpcConst.PROBE_ADDRESS,
            suffix=OpcConst.PROBE_SUFFIX,
        )

        try:
            data_value = read_data_value_sync(opc_url, probe_node_id)
        except Exception as error:
            raise RuntimeError(f"[SETUP] [ERROR] OPC UA сервер недоступен ({opc_url}): {error}") from error

        status = data_value.StatusCode
        if not status.is_good():
            raise RuntimeError(f"[SETUP] [ERROR] OPC UA тег {probe_node_id} вернул плохой статус: {status}")

        value = data_value.Value.Value if data_value.Value else None
        snapshot = (
            f"{probe_node_id}\n"
            f"Value={value}\n"
            f"StatusCode={status}\n"
            f"SourceTimestamp={data_value.SourceTimestamp}"
        )
        logger.info("[SETUP] [OK] OPC UA сервер доступен. Слепок выходного сигнала:\n%s", snapshot)
        allure.attach(
            snapshot,
            name="OPC UA выходной сигнал",
            attachment_type=allure.attachment_type.TEXT,
        )


























test_config/datasets/__init__.py
        "ALL_IS_REJECTED_CONFIGS",
        "ALL_OUTPUT_SIGNAL_IMITATION_CONFIGS",




    "ALL_IS_REJECTED_CONFIGS",
    "ALL_OUTPUT_SIGNAL_IMITATION_CONFIGS",






















test_config/datasets/output_signal_imitation.py
"""
Конфигурация тестового набора для имитации выходных сигналов СОУ (обратный обмен СДКУ).

Особенности набора:
- Имитация выходных сигналов (не входных, как imitate_sensor_signal).
- Проверка изменений выполняется напрямую через OPC UA (ns=2, string NodeId).

Пример:
- ДУ (НПС-НПС): CHTN_linearParts_9
- КП-КП (сегменты ДУ): CHTN_controlledSites_188

Запуск:
- Все наборы: pytest tests/test_output_signal_imitation.py
- Один набор: pytest tests/test_output_signal_imitation.py --suites=output_signal_imitation
"""

from constants.enums import TU, OutputSignalType
from constants.test_constants import BaseTN3Constants as TestConst
from test_config.models_for_tests import (
    CaseMarkers,
    OutputSignalImitationCase,
    OutputSignalImitationConfig,
)

# ===== Константы набора =====
SUITE_NAME = "OutputSignalImitation_tn3"
SUITE_DATA_ID = 0
ARCHIVE_NAME = f"{SUITE_NAME}.tar.gz"

# Технологический участок (ЧТН)
TECHNOLOGICAL_UNIT = TU.TIKHORETSK_NOVOROSSIYSK_3

# ===== Адреса участков для имитации (примеры из конфигурации трубы) =====
LINEAR_PART_ADDRESS = "CHTN_linearParts_9"
CONTROLLED_SITE_ADDRESS = "CHTN_controlledSites_188"

# ===== Кейсы имитации выходных сигналов =====
# ДУ (НПС-НПС): утечка, координата, объём, время, квитирование, маскирование
LINEAR_PART_CASES = [
    OutputSignalImitationCase(signal_type=OutputSignalType.LEAK, address=LINEAR_PART_ADDRESS, imitate_value="true"),
    OutputSignalImitationCase(
        signal_type=OutputSignalType.LEAK_COORDINATE, address=LINEAR_PART_ADDRESS, imitate_value="35000"
    ),
    OutputSignalImitationCase(
        signal_type=OutputSignalType.LEAK_VOLUME, address=LINEAR_PART_ADDRESS, imitate_value="100.5"
    ),
    OutputSignalImitationCase(
        signal_type=OutputSignalType.LEAK_TIME, address=LINEAR_PART_ADDRESS, imitate_value="2026-01-01T00:00:00Z"
    ),
    OutputSignalImitationCase(
        signal_type=OutputSignalType.ACKNOWLEDGE, address=LINEAR_PART_ADDRESS, imitate_value="true"
    ),
    OutputSignalImitationCase(signal_type=OutputSignalType.MASK, address=LINEAR_PART_ADDRESS, imitate_value="true"),
    OutputSignalImitationCase(
        signal_type=OutputSignalType.MASK_REASON, address=LINEAR_PART_ADDRESS, imitate_value="test_reason"
    ),
]

# КП-КП (сегменты ДУ): режим МТ, режим СОУ, самотеки
CONTROLLED_SITE_CASES = [
    OutputSignalImitationCase(
        signal_type=OutputSignalType.PUMPING_STATUS, address=CONTROLLED_SITE_ADDRESS, imitate_value="1"
    ),
    OutputSignalImitationCase(
        signal_type=OutputSignalType.LDS_STATUS, address=CONTROLLED_SITE_ADDRESS, imitate_value="4"
    ),
    OutputSignalImitationCase(
        signal_type=OutputSignalType.FREE_FLOW, address=CONTROLLED_SITE_ADDRESS, imitate_value="true"
    ),
]

# ===== Конфигурация набора =====
OUTPUT_SIGNAL_IMITATION_CONFIG = OutputSignalImitationConfig(
    ost_name=TestConst.CHTN_OST_NAME,
    suite_name=SUITE_NAME,
    suite_data_id=SUITE_DATA_ID,
    archive_name=ARCHIVE_NAME,
    technological_unit=TECHNOLOGICAL_UNIT,
    cases=LINEAR_PART_CASES + CONTROLLED_SITE_CASES,
    imitate_leak_test=CaseMarkers(test_case_id="0", offset=0),
    imitate_leak_coordinate_test=CaseMarkers(test_case_id="0", offset=2),
    imitate_leak_volume_test=CaseMarkers(test_case_id="0", offset=4),
    imitate_leak_time_test=CaseMarkers(test_case_id="0", offset=6),
    imitate_acknowledge_test=CaseMarkers(test_case_id="0", offset=8),
    imitate_mask_test=CaseMarkers(test_case_id="0", offset=10),
    imitate_mask_reason_test=CaseMarkers(test_case_id="0", offset=12),
    imitate_pumping_status_test=CaseMarkers(test_case_id="0", offset=14),
    imitate_lds_status_test=CaseMarkers(test_case_id="0", offset=16),
    imitate_free_flow_test=CaseMarkers(test_case_id="0", offset=18),
)

























модели тестов
    actual_parsed_report: Any = None


@dataclass(frozen=True)
class OutputSignalImitationCase:
    """
    Кейс имитации выходного сигнала СОУ (обратный обмен СДКУ).

    - signal_type: OutputSignalType - связывает signalType контракта имитации,
      OPC-суффикс и тип участка (linearParts / controlledSites).
    - address: тег участка в OPC (например "CHTN_linearParts_9").
    - imitate_value: имитируемое значение (строкой, как imitateInfo.value в контракте).
    - object_id: идентификатор объекта для запроса имитации (заглушка до готовности бэка).
    """

    signal_type: OutputSignalType
    address: str
    imitate_value: str
    object_id: Optional[int] = None

    @property
    def opc_suffix(self) -> str:
        return self.signal_type.opc_suffix

    @property
    def section_type(self) -> str:
        return self.signal_type.section_type


@dataclass
class OutputSignalImitationConfig(BaseSuiteConfig):
    """
    Конфигурация набора данных для имитации выходных сигналов СОУ.
    """

    cases: list[OutputSignalImitationCase] = field(default_factory=list)
    imitate_leak_test: Optional[CaseMarkers] = None
    imitate_leak_coordinate_test: Optional[CaseMarkers] = None
    imitate_leak_volume_test: Optional[CaseMarkers] = None
    imitate_leak_time_test: Optional[CaseMarkers] = None
    imitate_acknowledge_test: Optional[CaseMarkers] = None
    imitate_mask_test: Optional[CaseMarkers] = None
    imitate_mask_reason_test: Optional[CaseMarkers] = None
    imitate_pumping_status_test: Optional[CaseMarkers] = None
    imitate_lds_status_test: Optional[CaseMarkers] = None
    imitate_free_flow_test: Optional[CaseMarkers] = None

























test_scenarios/output_signal_imitation_scenarios.py
"""
Сценарий имитации выходных сигналов СОУ (обратный обмен СДКУ) с проверкой через OPC UA.

Порядок выполнения (чтобы не блочить сценарий):
1. Сначала читаем текущие значения всех тегов (снапшот "до").
2. Затем выполняем все действия имитации (заглушка до готовности api-gateway).
3. Затем читаем значения всех тегов (снапшот "после").
4. В конце — все проверки (мягкие, через SoftAssertions).
"""
import logging
import os
from datetime import datetime
from typing import Optional

import allure

from clients.opc_ua_client import OpcUaClient
from constants.architecture_constants import EnvKeyConstants
from test_config.models_for_tests import OutputSignalImitationCase, OutputSignalImitationConfig
from utils.helpers.asserts import SoftAssertions, StepCheck
from utils.helpers.opc_imitation_utils import (
    build_case_node_id,
    build_imitate_request,
    coerce_expected_value,
    format_opc_snapshot,
)

logger = logging.getLogger(__name__)


async def imitate_output_signal(
    opc_client: OpcUaClient, cfg: OutputSignalImitationConfig, case: OutputSignalImitationCase
) -> None:
    """
    Универсальный сценарий: имитирует один выходной сигнал и проверяет изменение
    через OPC UA.
    """
    stand_name = os.environ.get(EnvKeyConstants.STAND_NAME) or ""
    node_id = build_case_node_id(cfg, case, stand_name)

    # ===== 1. Слепок значений ноды до имитации =====
    with allure.step("Чтение значения выходного сигнала до имитации (OPC UA)"):
        before_dv = await opc_client.read_data_value(node_id)
        allure.attach(
            format_opc_snapshot(node_id, before_dv),
            name=f"до: {case.address}.{case.opc_suffix}",
            attachment_type=allure.attachment_type.TEXT,
        )

    # ===== 2. Действие имитации =====
    with allure.step("Имитация выходного сигнала через api-gateway (заглушка)"):
        request = build_imitate_request(case, cfg.tu_id)
        allure.attach(
            str(request),
            name=f"imitateOutputSignalRequest: {case.address}.{case.opc_suffix}",
            attachment_type=allure.attachment_type.JSON,
        )
        # TODO: реальный вызов имитации выходного сигнала через api-gateway.
        logger.info(
            "TODO: имитация выходного сигнала signalType=%s value=%s",
            case.signal_type.signal_type,
            case.imitate_value,
        )

    # ===== 3. Слепок значений ноды после имитации =====
    with allure.step("Чтение значения выходного сигнала после имитации (OPC UA)"):
        after_dv = await opc_client.read_data_value(node_id)
        allure.attach(
            format_opc_snapshot(node_id, after_dv),
            name=f"после: {case.address}.{case.opc_suffix}",
            attachment_type=allure.attachment_type.TEXT,
        )

    # ===== 4. Проверки =====
    with allure.step("Проверки"):
        with SoftAssertions() as soft_failures:
            after_value = after_dv.Value.Value if after_dv and after_dv.Value else None
            expected_value = coerce_expected_value(case.imitate_value, after_value)

            StepCheck(
                f"Проверка имитации {case.signal_type.signal_type} ({node_id})", "value", soft_failures
            ).actual(after_value).expected(expected_value).equal_to()

            StepCheck(
                f"Проверка качества сигнала {case.signal_type.signal_type} ({node_id})", "status", soft_failures
            ).actual(after_dv.StatusCode.is_good() if after_dv else False).expected(True).equal_to()

            before_ts: Optional[datetime] = before_dv.SourceTimestamp if before_dv else None
            after_ts: Optional[datetime] = after_dv.SourceTimestamp if after_dv else None
            ts_changed = after_ts is not None and (before_ts is None or after_ts >= before_ts)
            StepCheck(
                f"Проверка времени изменения сигнала {case.signal_type.signal_type} ({node_id})",
                "SourceTimestamp",
                soft_failures,
            ).actual(ts_changed).expected(True).equal_to()

            logger.info(
                "Имитация %s: до=%s, после=%s",
                node_id,
                before_dv.Value.Value if before_dv and before_dv.Value else None,
                after_value,
            )

































tests/test_output_signal_imitation.py
"""
Тесты имитации выходных сигналов СОУ (обратный обмен СДКУ).

Каждый тест имитирует ровно один выходной сигнал. Offset между тестами задан
с шагом 2 минуты, чтобы имитации не пересекались.

Запуск:
- Все наборы: pytest tests/test_output_signal_imitation.py
- Один набор: pytest tests/test_output_signal_imitation.py --suites=output_signal_imitation
"""
from typing import Any, List

import allure
import pytest

from clients.opc_ua_client import OpcUaClient
from constants.enums import OutputSignalType
from test_config.datasets import ALL_OUTPUT_SIGNAL_IMITATION_CONFIGS
from test_config.models_for_tests import OutputSignalImitationConfig
from test_scenarios import output_signal_imitation_scenarios as scenarios
from utils.helpers.opc_imitation_utils import find_case_by_signal_type


def _get_suite_markers(config: OutputSignalImitationConfig) -> List[pytest.MarkDecorator]:
    """Маркеры тестового набора для группировки и setup."""
    return [
        pytest.mark.test_suite_name(config.suite_name),
        pytest.mark.test_suite_data_id(config.suite_data_id),
        pytest.mark.test_data_name(config.archive_name),
        pytest.mark.tu_id(config.technological_unit.id),
        pytest.mark.ost_name(config.ost_name),
    ]


def _generate_suite_params() -> List[Any]:
    """Один параметр на каждый набор данных имитации выходных сигналов."""
    return [
        pytest.param(config, id=config.suite_name, marks=_get_suite_markers(config))
        for config in ALL_OUTPUT_SIGNAL_IMITATION_CONFIGS
    ]


@pytest.mark.parametrize("config", _generate_suite_params())
class TestOutputSignalImitation:
    """Тесты имитации выходных сигналов СОУ (по одному сигналу на тест)."""

    async def _imitate(
        self, opc_client: OpcUaClient, config: OutputSignalImitationConfig, signal_type: OutputSignalType
    ) -> None:
        case = find_case_by_signal_type(config.cases, signal_type)
        allure.dynamic.tag("imitateOutputSignal")
        allure.dynamic.tag("OPC_UA")
        allure.dynamic.title(
            f"[imitateOutputSignal] Имитация {case.signal_type.signal_type} ({case.address}.{case.opc_suffix})"
        )
        allure.dynamic.description(
            f"Проверка имитации выходного сигнала {case.signal_type.signal_type} "
            f"на наборе {config.suite_name}.\n"
            "Проверки выполняются напрямую через OPC UA: значение, качество и время изменения сигнала."
        )
        await scenarios.imitate_output_signal(opc_client, config, case)

    @pytest.mark.asyncio
    async def test_imitate_leak(self, opc_client: OpcUaClient, config: OutputSignalImitationConfig) -> None:
        await self._imitate(opc_client, config, OutputSignalType.LEAK)

    @pytest.mark.asyncio
    async def test_imitate_leak_coordinate(self, opc_client: OpcUaClient, config: OutputSignalImitationConfig) -> None:
        await self._imitate(opc_client, config, OutputSignalType.LEAK_COORDINATE)

    @pytest.mark.asyncio
    async def test_imitate_leak_volume(self, opc_client: OpcUaClient, config: OutputSignalImitationConfig) -> None:
        await self._imitate(opc_client, config, OutputSignalType.LEAK_VOLUME)

    @pytest.mark.asyncio
    async def test_imitate_leak_time(self, opc_client: OpcUaClient, config: OutputSignalImitationConfig) -> None:
        await self._imitate(opc_client, config, OutputSignalType.LEAK_TIME)

    @pytest.mark.asyncio
    async def test_imitate_acknowledge(self, opc_client: OpcUaClient, config: OutputSignalImitationConfig) -> None:
        await self._imitate(opc_client, config, OutputSignalType.ACKNOWLEDGE)

    @pytest.mark.asyncio
    async def test_imitate_mask(self, opc_client: OpcUaClient, config: OutputSignalImitationConfig) -> None:
        await self._imitate(opc_client, config, OutputSignalType.MASK)

    @pytest.mark.asyncio
    async def test_imitate_mask_reason(self, opc_client: OpcUaClient, config: OutputSignalImitationConfig) -> None:
        await self._imitate(opc_client, config, OutputSignalType.MASK_REASON)

    @pytest.mark.asyncio
    async def test_imitate_pumping_status(self, opc_client: OpcUaClient, config: OutputSignalImitationConfig) -> None:
        await self._imitate(opc_client, config, OutputSignalType.PUMPING_STATUS)

    @pytest.mark.asyncio
    async def test_imitate_lds_status(self, opc_client: OpcUaClient, config: OutputSignalImitationConfig) -> None:
        await self._imitate(opc_client, config, OutputSignalType.LDS_STATUS)

    @pytest.mark.asyncio
    async def test_imitate_free_flow(self, opc_client: OpcUaClient, config: OutputSignalImitationConfig) -> None:
        await self._imitate(opc_client, config, OutputSignalType.FREE_FLOW)



























utils/helpers/opc_imitation_utils.py
"""
Хелперы для сценария имитации выходных сигналов СОУ (обратный обмен СДКУ).
"""
from typing import Any

from clients.opc_ua_client import build_opc_node_id
from constants.enums import OutputSignalType
from test_config.models_for_tests import OutputSignalImitationCase, OutputSignalImitationConfig


def build_imitate_request(case: OutputSignalImitationCase, tu_id: int) -> dict[str, Any]:
    """
    Формирует тело запроса imitateOutputSignalRequest по контракту (asyncapi).

    TODO: заменить на реальный вызов имитации через api-gateway (WebSocket invocation),
    когда бэк реализует метод imitateOutputSignal.
    """
    return {
        'objectId': case.object_id,
        'signalType': case.signal_type.signal_type,
        'tuId': tu_id,
        'imitateInfo': {'value': case.imitate_value},
    }


def coerce_expected_value(expected: str, actual: Any) -> Any:
    """
    Приводит строковое ожидаемое значение (imitateInfo.value) к типу фактического
    значения OPC-тега, чтобы корректно сравнивать bool/int/float.
    """
    if actual is None:
        return expected
    if isinstance(actual, bool):
        return expected.strip().lower() in ("true", "1")
    if isinstance(actual, int):
        return int(float(expected))
    if isinstance(actual, float):
        return float(expected)
    return expected


def build_case_node_id(cfg: OutputSignalImitationConfig, case: OutputSignalImitationCase, stand_name: str) -> str:
    """Собирает NodeId для кейса имитации выходного сигнала."""
    return build_opc_node_id(cfg.ost_name, stand_name, case.address, case.opc_suffix)


def format_opc_snapshot(node_id: str, data_value: Any) -> str:
    """Текстовое описание значения/статуса/времени для allure-вложений и логов."""
    value = data_value.Value.Value if data_value and data_value.Value else None
    status = data_value.StatusCode if data_value else None
    source_ts = data_value.SourceTimestamp if data_value else None
    return f"{node_id}\nValue={value}\nStatusCode={status}\nSourceTimestamp={source_ts}"


def find_case_by_signal_type(
    cases: list[OutputSignalImitationCase], signal_type: OutputSignalType
) -> OutputSignalImitationCase:
    """Находит кейс имитации в наборе данных по типу сигнала."""
    for case in cases:
        if case.signal_type == signal_type:
            return case
    raise ValueError(f"Не найден кейс имитации для типа сигнала {signal_type}")























конфтест
IS_REJECTED_SUITE_LEVEL_MAPPING = {
    'test_rejection_report': 'rejection_report_test',
}

# Suite-level тесты имитации выходных сигналов (маркеры из OutputSignalImitationConfig - параметр config)
OUTPUT_SIGNAL_IMITATION_SUITE_LEVEL_MAPPING = {
    'test_imitate_leak': 'imitate_leak_test',
    'test_imitate_leak_coordinate': 'imitate_leak_coordinate_test',
    'test_imitate_leak_volume': 'imitate_leak_volume_test',
    'test_imitate_leak_time': 'imitate_leak_time_test',
    'test_imitate_acknowledge': 'imitate_acknowledge_test',
    'test_imitate_mask': 'imitate_mask_test',
    'test_imitate_mask_reason': 'imitate_mask_reason_test',
    'test_imitate_pumping_status': 'imitate_pumping_status_test',
    'test_imitate_lds_status': 'imitate_lds_status_test',
    'test_imitate_free_flow': 'imitate_free_flow_test',
}

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








            attr_name = STATIONARY_STATUS_SUITE_LEVEL_MAPPING[test_name]
            return getattr(suite_config, attr_name, None)
        if test_name in OUTPUT_SIGNAL_IMITATION_SUITE_LEVEL_MAPPING:
            suite_config = params['config']
            attr_name = OUTPUT_SIGNAL_IMITATION_SUITE_LEVEL_MAPPING[test_name]









        cfg["stand_manager"] = stand_manager
        try:
            stand_manager.check_opc_server_availability()










@pytest_asyncio.fixture
async def opc_client(request):
    """
    Фикстура OPC UA клиента для чтения выходных сигналов СОУ.

    Подключается к серверу, указанному в OPC_URL (opc.tcp://host:port).
    Ошибка подключения помечает тест как Failed (красный), а не Error (жёлтый).
    """
    from clients.opc_ua_client import OpcUaClient

    opc_url = os.environ.get(EnvKeyConstants.OPC_URL)
    if not opc_url:
        pytest.fail("OPC_URL не задан в переменных окружения")

    client = OpcUaClient(opc_url)
    try:
        await client.connect()
    except Exception as error:
        pytest.fail(f"Не удалось подключиться к OPC UA серверу ({opc_url}): {error}")

    try:
        yield client
    finally:
        try:
            await client.disconnect()
        except Exception as error:
            logger.warning("[OPC] [WARNING] Ошибка при отключении OPC UA клиента: %s", error)


def pytest_sessionfinish(session, exitstatus):