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

View file

@ -20,12 +20,22 @@ def _get(key: str, default: str) -> str:
BUILD_DIR: str = _get("HIL_BUILD_DIR", BUILD_DIR: str = _get("HIL_BUILD_DIR",
str(_REPO_ROOT / "build" / "target-debug")) 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_PORT: str = _get("HIL_VCOM_PORT", "/dev/ttyACM0")
VCOM_BAUD: int = int(_get("HIL_VCOM_BAUD", "115200")) VCOM_BAUD: int = int(_get("HIL_VCOM_BAUD", "115200"))
READY_TIMEOUT: float = float(_get("HIL_READY_TIMEOUT", "5.0")) READY_TIMEOUT: float = float(_get("HIL_READY_TIMEOUT", "5.0"))
PYOCD_FREQUENCY: int = int(_get("HIL_PYOCD_FREQUENCY", "1000000"))
# M5Stack StamPLC (промежуточная платформа для HIL) # M5Stack StamPLC (промежуточная платформа для HIL)
M5_PORT: str = _get("HIL_M5_PORT", "/dev/ttyACM1") M5_PORT: str = _get("HIL_M5_PORT", "/dev/ttyACM1")
M5_BAUD: int = int(_get("HIL_M5_BAUD", "115200")) M5_BAUD: int = int(_get("HIL_M5_BAUD", "115200"))
M5_TIMEOUT: float = float(_get("HIL_M5_TIMEOUT", "3.0")) 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"))