Skip to content

Pattern Cookbook

Focused recipes for common hardware-test patterns, all built around the fictional Widget Controller Board (WCB-1) used in the Starter Test Plan. Each recipe assumes the session-scoped interface fixture from The Test Interface and the f3ts fixtures.

The non-interactive recipes below are runnable offline; a complete, tested version lives in examples/cookbook_plan/.

1. Rail voltage against config limits

The canonical check: read a value, record it, assert against test_config limits from config.yml.

python
from pytest_f3ts.utils import log_vars


def test_5v0_rail(interface, test_config, record_property):
    meas = interface.measure_rail("5v0")
    log_vars(record_property)
    assert test_config.min_limit <= meas <= test_config.max_limit
yaml
test_cases:
  test_5v0_rail:
    test_id: "1.1"
    description: "5V0 rail within tolerance"
    min_limit: 4.90
    max_limit: 5.10

2. Many checks in one test (sub-results)

When one test covers several rails or channels, use f3ts_assert so each gets its own pass/fail row. Pass per-channel limits explicitly — test_config maps one entry per function, not per loop iteration.

python
RAIL_LIMITS = {"5v0": (4.90, 5.10), "3v3": (3.20, 3.40), "1v8": (1.71, 1.89)}


def test_all_rails(interface, f3ts_assert):
    for rail, (lo, hi) in RAIL_LIMITS.items():
        meas = interface.measure_rail(rail)
        f3ts_assert(
            assert_value=(lo <= meas <= hi),
            meas=meas,
            min_limit=lo,
            max_limit=hi,
            description=f"{rail} rail",
        )

A test with test_id 2.1 emits sub-results 2.1.1, 2.1.2, 2.1.3 — see Result Reporting.

3. Threshold tables

The same idea for any per-channel threshold map (LED intensities, tone levels, …):

python
LED_MIN = {"r": 200, "g": 200, "b": 200}


def test_led_channels(interface, f3ts_assert):
    for channel, floor in LED_MIN.items():
        intensity = interface.read_led(channel)
        f3ts_assert(
            assert_value=(intensity >= floor),
            meas=intensity,
            min_limit=floor,
            description=f"LED {channel} intensity",
        )

4. Tunable limits and delays

Keep settle times and limits adjustable without code changes — read them from environment variables and from per-slot fixture_config.user_vars:

python
import os
import time

from pytest_f3ts.utils import log_vars


def test_current_draw(interface, fixture_config, record_property):
    boot_delay = float(os.getenv("BOOT_DELAY_S", fixture_config.user_vars["boot_delay_s"]))
    max_ma = float(fixture_config.user_vars["max_current_ma"])
    time.sleep(boot_delay)  # settle after boot before measuring
    meas = interface.measure_current_ma("5v0")
    log_vars(record_property)
    assert meas <= max_ma
yaml
fixture_settings:
  slot_1:
    slot_id: "1"
    user_vars:
      boot_delay_s: 0.0
      max_current_ma: 200.0

5. Serial command / response

Wrap your console protocol in the interface and keep tests readable:

python
from pytest_f3ts.utils import log_vars


def test_firmware_version(interface, record_property):
    response = interface.serial_command("VER?")   # e.g. returns "VER 1.2.3"
    meas = response.removeprefix("VER ").strip()
    log_vars(record_property)
    assert meas == "1.2.3"

A real serial_command frames the payload (for example STX + payload + ETX) and reads until the terminator with a timeout — keep that logic in interface.py, not in the test.

6. Operator prompts and serial-number capture (needs the runner or -s)

Two-way operator interaction. On the Test Runner these are GUI dialogs; locally they fall back to the terminal, so run pytest with -s. Wrap input in a retry loop instead of failing fast:

python
import re

from pytest_f3ts import schemas

SERIAL_RE = re.compile(r"^WCB-\d{4}$")


def test_capture_serial(serial_number, user_dialog):
    for _ in range(3):
        resp = user_dialog.send(schemas.Dialog(
            title="Scan serial",
            message="Scan the board serial (WCB-####)",
            inputType="text",
        ))
        if resp.okClicked and SERIAL_RE.match(resp.inputText):
            serial_number.set(resp.inputText)
            return
    raise AssertionError("No valid serial number scanned")


def test_visual_inspection(user_dialog):
    resp = user_dialog.send(schemas.Dialog(
        title="Visual check",
        message="Are all three status LEDs lit?",
        okButtonText="Pass", cancelButtonText="Fail",
    ))
    assert resp.okClicked

7. Status banners for long steps

Show the operator what's happening, and always clear the banner afterwards:

python
def test_with_status_banner(interface, status_banner, f3ts_assert):
    status_banner.override(True, status="Measuring rails...", color="#f5a623")
    try:
        meas = interface.measure_rail("3v3")
        f3ts_assert(assert_value=(meas > 0), meas=meas, description="3V3 present")
    finally:
        status_banner.override(False)

Offline, status_banner prints the status to the terminal instead of driving the GUI.

8. Safe interface setup and teardown

Open the hardware once per session and always leave the DUT safe, even on failure, by registering teardown with addfinalizer:

python
# conftest.py
import pytest

from interface import WidgetInterface


@pytest.fixture(scope="session")
def interface(request):
    dut = WidgetInterface()
    request.addfinalizer(dut.close)  # safe power-down even if a test fails
    return dut

Put the safe-state logic (rails off in reverse order, GPIO to a known state) in the interface's close() so it runs no matter how the run ends. See The Test Interface.

9. LED and optical measurement

LEDs and indicators are tested by their light output, not just a voltage. Three approaches are common, trading accuracy against cost and fixturing effort:

  • Feasa LED Analyzer — optical fibers positioned next to each LED measure color and luminous intensity. Highest accuracy and repeatability, and it reads many LEDs at once; higher cost, and the fibers are fragile. Supported in f3ts-hardware-utils via f3ts_hardware_utils.feasa.Feasa.
  • I2C optical sensor (for example an ISL29125 breakout) — a sensor placed near the LED reports intensity over I2C. Low cost and simple, but less accurate and sensitive to sensor placement.
  • USB camera + OpenCV — a camera captures the board and software computes per-LED intensity. Good for many LEDs or screens at low cost, but it needs vision code and controlled ambient light.

The Feasa analyzer is the usual production choice. Wrap it in your interface, capture once, then read each channel:

python
# interface.py
from f3ts_hardware_utils.feasa import Feasa


class WidgetInterface:
    def __init__(self, feasa_port: str):
        self.feasa = Feasa(feasa_port)

    def capture_leds(self):
        """Trigger a reading on all Feasa channels."""
        self.feasa.capture()

    def read_led(self, channel):
        """Return the {r, g, b, i} reading for one Feasa channel."""
        return self.feasa.get_rgbi(channel)

Assert each indicator against per-channel color/intensity limits, one sub-result per indicator:

python
INDICATOR_MIN = {"r": 250, "g": 0, "b": 0, "i": 20000}
INDICATORS = {0: "D1", 1: "D2"}


def test_indicator_leds(interface, f3ts_assert):
    interface.capture_leds()  # one capture, then read each channel
    for channel, name in INDICATORS.items():
        reading = interface.read_led(channel)
        in_spec = all(reading[k] >= INDICATOR_MIN[k] for k in INDICATOR_MIN)
        f3ts_assert(
            assert_value=in_spec,
            meas=str(reading),
            min_limit=str(INDICATOR_MIN),
            description=f"{name} color and intensity",
        )