# Refactoring: conftest.py

This commit is contained in:
Dmitry Akimov 2026-04-06 18:22:58 +03:00
parent 4b190eba79
commit ab6d2f1393
4 changed files with 252 additions and 112 deletions

View file

@ -28,7 +28,6 @@ TARGET_CFG=target/imxrt.cfg
GDB_PORT=3333
GDB_EXECUTABLE=arm-none-eabi-gdb
# pyOCD — таргет и частота для gdbserver и flash_swd.py
PYOCD_TARGET=mimxrt1050_quadspi
PYOCD_FREQUENCY=4000000
@ -37,16 +36,23 @@ PYOCD_FREQUENCY=4000000
FCB_PATH=tools/host/dcd/w25q128_fdcb.bin
# --- HIL (аппаратный стенд) ---
# Порты: macOS = /dev/cu.usbmodem*, Linux = /dev/ttyACM*
HIL_VCOM_PORT=/dev/cu.usbmodemGUXFBWDJBWTGQ3
HIL_M5_PORT=/dev/cu.usbmodem211101
HIL_VCOM_BAUD=115200
HIL_M5_BAUD=115200
HIL_READY_TIMEOUT=5.0
HIL_M5_TIMEOUT=3.0
HIL_PYOCD_FREQUENCY=1000000
HIL_BUILD_DIR=build/target-debug
# Настройки порта USB CDC ACM на таргете
HIL_USB_CDC_PORT=/dev/cu.usbmodemZZZZ
# Задержка после включения питания таргета перед очередным тестом (мс стабилизации + POR)
HIL_TARGET_POWER_SETTLE_S=1.5
# VCOM на программаторе NXP MCU-Link
HIL_VCOM_PORT=/dev/cu.usbmodemGUXFBWDJBWTGQ3
HIL_VCOM_BAUD=115200
HIL_READY_TIMEOUT=5.0
# M5Stack StamPLC (промежуточная платформа для HIL)
HIL_M5_PORT=/dev/cu.usbmodem211101
HIL_M5_BAUD=115200
HIL_M5_TIMEOUT=3.0
# VCOM на плате таргета
HIL_USB_CDC_PORT=/dev/cu.usbmodem21201
HIL_USB_CDC_BAUD=115200
HIL_USB_CDC_TIMEOUT=5.0

145
tools/hil/REFACTORED.md Normal file
View file

@ -0,0 +1,145 @@
# Рефакторинг `conftest.py` — HIL-тесты MIMXRT1052
## Обзор
В ходе code review были выявлены и устранены 3 проблемы:
утечка ресурсов при исключениях, дублирование кода фикстур UART,
несогласованное чтение конфигурации.
---
## 1. Утечка UART-порта при исключении
**Проблема.** Функция `_open_uart_and_wait_ready` открывала `serial.Serial`,
но при исключении внутри цикла ожидания `READY` (например, `SerialException`)
порт не закрывался — операционная система удерживала дескриптор до завершения
процесса.
**Решение.** Функция заменена на контекстный менеджер `_uart_context`,
реализованный через `@contextmanager`. Блок `finally` гарантирует вызов
`ser.close()` при любом исходе.
```python
# До
def _open_uart_and_wait_ready(request) -> serial.Serial:
ser = serial.Serial(port=port, ...)
# ... если исключение здесь — ser не закрыт
return ser
# После
```python
def _uart_context(port: str, baud: int, ready_timeout: float):
ser = serial.Serial(port=port, baudrate=baud, timeout=2.0, write_timeout=1.0)
try:
# … ожидание READY
yield ser
finally:
ser.close() # выполняется всегда
```
Аналогичная правка применена к фикстуре `usb_cdc_port` — добавлен
`try/finally` вокруг `yield ser`.
---
## 2. Дублирование `uart_*` фикстур
**Проблема.** Пять фикстур (`uart`, `uart_opto`, `uart_can`, `uart_button`,
`uart_hil_usb_cdc`) имели идентичное тело и отличались только зависимостью
`loaded_*`. При добавлении нового теста требовалось вручную копировать
очередную фикстуру.
**Решение.** Введена фабричная функция `_make_uart_fixture` и словарь
`_UART_FIXTURE_MAP`. Все фикстуры генерируются в одну строку через `globals()`.
Зависимость `loaded_*` активируется через `request.getfixturevalue()`
официальный pytest API (доступен с pytest 3.x).
```python
# До — пять одинаковых блоков
def uart_opto(request, loaded_hil_opto):
ser = _open_uart_and_wait_ready(request)
yield ser
ser.close()
def uart_can(request, loaded_hil_can):
ser = _open_uart_and_wait_ready(request)
yield ser\
ser.close()
# и т.д.
# После Фабрика + словарь
_UART_FIXTURE_MAP = {
"uart": "loaded_host_uart",
"uart_opto": "loaded_hil_opto",
"uart_can": "loaded_hil_can",
"uart_button": "loaded_hil_button",
"uart_hil_usb_cdc": "loaded_hil_usb_cdc",
}
def _make_uart_fixture(loaded_name: str):
@pytest.fixture(scope="module")
def _fixture(request: pytest.FixtureRequest) -> Generator[serial.Serial, None, None]:
request.getfixturevalue(loaded_name) # триггерит зависимость явно
port = request.config.getoption("--vcom")
with _uart_context(port, cfg.VCOM_BAUD, cfg.READY_TIMEOUT) as ser:
yield ser
return _fixture
# Регистрируем все фикстуры в пространстве имён модуля одной строкой
for _name, _dep in _UART_FIXTURE_MAP.items():
globals()[_name] = _make_uart_fixture(_dep)
```
Для добавления поддержки нового теста теперь достаточно одной строки
в `_UART_FIXTURE_MAP`.
---
## 3. Несогласованное чтение конфигурации
**Проблема.** Большинство параметров читались через `env_config` (`cfg.*`),
но фикстура `usb_cdc_port` обращалась к `os.environ.get()` напрямую.
Это создавало три источника истины, рассыпало дефолтные значения по коду
и лишало возможности переопределить параметр через CLI pytest.
```python
# До — нарушает единообразие конфигурации
port = os.environ.get("HIL_USB_CDC_PORT", "")
baud = int(os.environ.get("HIL_USB_CDC_BAUD", "115200"))
timeout = float(os.environ.get("HIL_USB_CDC_TIMEOUT", "5.0"))
# После
@pytest.fixture(scope="module")
def usb_cdc_port(
uart_hil_usb_cdc,
) -> Generator[serial.Serial, None, None]:
"""Открыть USB CDC порт таргета. Ждёт появления порта и DTR ready."""
import time
if not cfg.TARGET_VCOM_PORT:
pytest.skip("HIL_USB_CDC_PORT not set")
# Ждём появления USB CDC порта (enumeration после загрузки ELF).
deadline = time.monotonic() + cfg.TARGET_VCOM_TIMEOUT
ser = None
while time.monotonic() < deadline:
try:
ser = serial.Serial(port=cfg.TARGET_VCOM_PORT, baudrate=cfg.TARGET_VCOM_BAUD, timeout=0.5)
break
except serial.SerialException:
time.sleep(0.3)
if ser is None:
pytest.fail(f"USB CDC port {cfg.TARGET_VCOM_PORT} not available after {cfg.TARGET_VCOM_TIMEOUT}s")
try:
# Установить DTR чтобы firmware увидела DTE presence.
ser.dtr = True
time.sleep(0.3)
# Сбросить входной буфер — могут быть мусорные байты от enumeration.
ser.reset_input_buffer()
yield ser
finally:
ser.close()
```

View file

@ -11,17 +11,14 @@ import logging
import time
from pathlib import Path
from typing import Generator
from contextlib import contextmanager
import pytest
import serial
import env_config as cfg
from pyocd_utils import flexram_init, load_elf, open_target, run_from_vectors
import os
log = logging.getLogger(__name__)
# Задержка после включения питания таргета (мс стабилизации + POR)
_POWER_ON_SETTLE_S = 1.5
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
@ -56,20 +53,11 @@ def _load_elf(request: pytest.FixtureRequest, default_elf: Path) -> None:
# ---------------------------------------------------------------------------
# Общая логика UART: открыть порт, дождаться READY
# ---------------------------------------------------------------------------
def _open_uart_and_wait_ready(request: pytest.FixtureRequest) -> serial.Serial:
"""Открыть VCOM, дождаться READY от прошивки."""
port = request.config.getoption("--vcom")
log.info("UART %s @ %d baud", port, cfg.VCOM_BAUD)
ser = serial.Serial(
port=port,
baudrate=cfg.VCOM_BAUD,
timeout=2.0,
write_timeout=1.0,
)
# Ждём READY
deadline = time.monotonic() + cfg.READY_TIMEOUT
@contextmanager
def _uart_context(port: str, baud: int, ready_timeout: float):
ser = serial.Serial(port=port, baudrate=baud, timeout=2.0, write_timeout=1.0)
try:
deadline = time.monotonic() + ready_timeout
ready = False
while time.monotonic() < deadline:
line = ser.readline().decode("ascii", errors="replace").strip()
@ -79,14 +67,12 @@ def _open_uart_and_wait_ready(request: pytest.FixtureRequest) -> serial.Serial:
break
if not ready:
ser.close()
pytest.fail(
f"Прошивка не отправила READY за {cfg.READY_TIMEOUT} с"
"проверьте VCOM-порт и bsp_uart_host_init()."
)
pytest.fail(f"Прошивка не отправила READY за {ready_timeout} с")
ser.reset_input_buffer()
return ser
yield ser
finally:
ser.close() # гарантированно, всегда
# ---------------------------------------------------------------------------
@ -196,8 +182,8 @@ def m5(request: pytest.FixtureRequest) -> Generator[M5Agent, None, None]:
# Включаем питание таргета и ждём стабилизации
agent.power(True)
log.info("Питание таргета включено, ждём %.1f с", _POWER_ON_SETTLE_S)
time.sleep(_POWER_ON_SETTLE_S)
log.info("Питание таргета включено, ждём %.1f с", cfg.POWER_ON_SETTLE_S)
time.sleep(cfg.POWER_ON_SETTLE_S)
agent.opto_all_off() # безопасное начальное состояние
yield agent
@ -221,6 +207,13 @@ def m5(request: pytest.FixtureRequest) -> Generator[M5Agent, None, None]:
@pytest.fixture(scope="module")
def loaded_host_uart(request: pytest.FixtureRequest, m5: M5Agent) -> None:
"""
Загрузить test_hil_button.elf на таргет по SWD
Явная зависимость от m5 гарантирует порядок:
1. m5 создаётся первым питание таргета включено
2. только потом pyOCD подключается и грузит ELF
"""
_load_elf(
request,
Path(cfg.BUILD_DIR) / "tests/target/host_uart/test_host_uart.elf",
@ -228,6 +221,13 @@ def loaded_host_uart(request: pytest.FixtureRequest, m5: M5Agent) -> None:
@pytest.fixture(scope="module")
def loaded_hil_button(request: pytest.FixtureRequest, m5: M5Agent) -> None:
"""
Загрузить test_hil_button.elf на таргет по SWD
Явная зависимость от m5 гарантирует порядок:
1. m5 создаётся первым питание таргета включено
2. только потом pyOCD подключается и грузит ELF
"""
_load_elf(
request,
Path(cfg.BUILD_DIR) / "tests/target/hil_button/test_hil_button.elf",
@ -236,7 +236,7 @@ def loaded_hil_button(request: pytest.FixtureRequest, m5: M5Agent) -> None:
@pytest.fixture(scope="module")
def loaded_hil_opto(request: pytest.FixtureRequest, m5: M5Agent) -> None:
"""
Загрузить test_hil_opto.elf.
Загрузить test_hil_opto.elf на таргет по SWD
Явная зависимость от фикстуры m5 гарантирует порядок:
1. m5 создаётся первым питание таргета включено
@ -252,7 +252,7 @@ def loaded_hil_opto(request: pytest.FixtureRequest, m5: M5Agent) -> None:
@pytest.fixture(scope="module")
def loaded_hil_can(request: pytest.FixtureRequest, m5: M5Agent) -> None:
"""
Загрузить test_hil_can.elf.
Загрузить test_hil_can.elf на таргет по SWD
Явная зависимость от m5 гарантирует порядок:
1. m5 создаётся первым питание таргета включено
@ -265,60 +265,43 @@ def loaded_hil_can(request: pytest.FixtureRequest, m5: M5Agent) -> None:
@pytest.fixture(scope="module")
def loaded_hil_usb_cdc(request: pytest.FixtureRequest, m5: M5Agent) -> None:
"""
Загрузить test_hil_usb_cdc.elf на таргет по SWD
Явная зависимость от m5 гарантирует порядок:
1. m5 создаётся первым питание таргета включено
2. только потом pyOCD подключается и грузит ELF
"""
_load_elf(
request,
Path(cfg.BUILD_DIR) / "tests/target/hil_usb_cdc/test_hil_usb_cdc.elf",
)
# ---------------------------------------------------------------------------
# Фикстуры UART
# Фикстуры UART (установление соединения с VCOM программатора NXP MCU-Link)
# Выполняется один раз на тест
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def uart(
request: pytest.FixtureRequest,
loaded_host_uart,
) -> Generator[serial.Serial, None, None]:
ser = _open_uart_and_wait_ready(request)
_UART_FIXTURE_MAP = {
"uart": "loaded_host_uart",
"uart_opto": "loaded_hil_opto",
"uart_can": "loaded_hil_can",
"uart_button": "loaded_hil_button",
"uart_hil_usb_cdc": "loaded_hil_usb_cdc",
}
def _make_uart_fixture(loaded_name: str):
@pytest.fixture(scope="module")
def _fixture(request: pytest.FixtureRequest) -> Generator[serial.Serial, None, None]:
request.getfixturevalue(loaded_name) # триггерит зависимость явно
port = request.config.getoption("--vcom")
with _uart_context(port, cfg.VCOM_BAUD, cfg.READY_TIMEOUT) as ser:
yield ser
ser.close()
return _fixture
@pytest.fixture(scope="module")
def uart_opto(
request: pytest.FixtureRequest,
loaded_hil_opto,
) -> Generator[serial.Serial, None, None]:
ser = _open_uart_and_wait_ready(request)
yield ser
ser.close()
@pytest.fixture(scope="module")
def uart_can(
request: pytest.FixtureRequest,
loaded_hil_can,
) -> Generator[serial.Serial, None, None]:
ser = _open_uart_and_wait_ready(request)
yield ser
ser.close()
@pytest.fixture(scope="module")
def uart_button(
request: pytest.FixtureRequest,
loaded_hil_button,
) -> Generator[serial.Serial, None, None]:
ser = _open_uart_and_wait_ready(request)
yield ser
ser.close()
@pytest.fixture(scope="module")
def uart_hil_usb_cdc(
request: pytest.FixtureRequest,
loaded_hil_usb_cdc,
) -> Generator[serial.Serial, None, None]:
ser = _open_uart_and_wait_ready(request)
yield ser
ser.close()
# Регистрируем все фикстуры в пространстве имён модуля одной строкой
for _name, _dep in _UART_FIXTURE_MAP.items():
globals()[_name] = _make_uart_fixture(_dep)
# ---------------------------------------------------------------------------
# hil_usb_cdc — USB CDC ACM тест (два канала: UART + USB CDC)
# Фикстуры USB CDC (открытие порта, установка DTR)
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def usb_cdc_port(
@ -327,34 +310,30 @@ def usb_cdc_port(
"""Открыть USB CDC порт таргета. Ждёт появления порта и DTR ready."""
import time
port = os.environ.get("HIL_USB_CDC_PORT", "")
if not port:
if not cfg.TARGET_VCOM_PORT:
pytest.skip("HIL_USB_CDC_PORT not set")
baud = int(os.environ.get("HIL_USB_CDC_BAUD", "115200"))
timeout_s = float(os.environ.get("HIL_USB_CDC_TIMEOUT", "5.0"))
# Ждём появления USB CDC порта (enumeration после загрузки ELF).
deadline = time.monotonic() + timeout_s
deadline = time.monotonic() + cfg.TARGET_VCOM_TIMEOUT
ser = None
while time.monotonic() < deadline:
try:
ser = serial.Serial(port, baud, timeout=0.5)
ser = serial.Serial(port=cfg.TARGET_VCOM_PORT, baudrate=cfg.TARGET_VCOM_BAUD, timeout=0.5)
break
except serial.SerialException:
time.sleep(0.3)
if ser is None:
pytest.fail(f"USB CDC port {port} not available after {timeout_s}s")
pytest.fail(f"USB CDC port {cfg.TARGET_VCOM_PORT} not available after {cfg.TARGET_VCOM_TIMEOUT}s")
try:
# Установить DTR чтобы firmware увидела DTE presence.
ser.dtr = True
time.sleep(0.3)
# Сбросить входной буфер — могут быть мусорные байты от enumeration.
ser.reset_input_buffer()
yield ser
finally:
ser.close()

View file

@ -20,12 +20,22 @@ def _get(key: str, default: str) -> str:
BUILD_DIR: str = _get("HIL_BUILD_DIR",
str(_REPO_ROOT / "build" / "target-debug"))
PYOCD_FREQUENCY: int = int(_get("HIL_PYOCD_FREQUENCY", "1000000"))
POWER_ON_SETTLE_S = float(_get("HIL_TARGET_POWER_SETTLE_S", "1.5"))
# VCOM на программаторе NXP MCU-Link
VCOM_PORT: str = _get("HIL_VCOM_PORT", "/dev/ttyACM0")
VCOM_BAUD: int = int(_get("HIL_VCOM_BAUD", "115200"))
READY_TIMEOUT: float = float(_get("HIL_READY_TIMEOUT", "5.0"))
PYOCD_FREQUENCY: int = int(_get("HIL_PYOCD_FREQUENCY", "1000000"))
# M5Stack StamPLC (промежуточная платформа для HIL)
M5_PORT: str = _get("HIL_M5_PORT", "/dev/ttyACM1")
M5_BAUD: int = int(_get("HIL_M5_BAUD", "115200"))
M5_TIMEOUT: float = float(_get("HIL_M5_TIMEOUT", "3.0"))
# VCOM на плате таргета
TARGET_VCOM_PORT:str = _get("HIL_USB_CDC_PORT", "/dev/ttyACM2")
TARGET_VCOM_BAUD:int = int(_get("HIL_USB_CDC_BAUD", "115200"))
TARGET_VCOM_TIMEOUT:float = float(_get("HIL_USB_CDC_TIMEOUT", "3.0"))