ecu-tests/tests/hardware/_test_case_template.py
Hosam-Eldin Mostafa 11b5402b14 tests/hardware: add copyable, heavily-commented test templates
Two starting-point files for new hardware tests. Leading underscore
in the filenames keeps pytest from collecting them.

- _test_case_template.py — for ALM_Node-touching MUM tests.
  Three flavors with full SETUP / PROCEDURE / ASSERT / TEARDOWN
  section markers:
    A) minimal: relies on the autouse _reset_to_off (LED OFF
       baseline) — no per-test setup/teardown
    B) with isolation: try/finally pattern for tests that mutate
       persistent ECU state (e.g. ConfigFrame)
    C) single-signal probe: fio.read_signal one-shot

  Inline comments explain pytest fundamentals (fixture, scope,
  autouse, yield, rp), the four-phase pattern, and the
  must/must-not contract.

- _test_case_template_psu_lin.py — for tests that drive the PSU
  AND observe the LIN bus (over/undervoltage tolerance, brown-out,
  supply transients). Three flavors:
    A) overvoltage: apply OV via apply_voltage_and_settle, single
       status read after validation hold, assert OverVoltage
    B) undervoltage: symmetric for UV
    C) parametrized voltage sweep
  Documents the three-layer safety guarantee (session
  safe_off_on_close / autouse _park_at_nominal / per-test
  try/finally) and the rule that tests never call set_output(False)
  or close() — the session fixture owns the PSU lifecycle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-08 19:02:13 +02:00

434 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Copyable starting point for new MUM hardware tests.
WHY THE NAME STARTS WITH AN UNDERSCORE
--------------------------------------
pytest only collects files whose name matches ``test_*.py`` (configured
in ``pytest.ini``). Because this file is named ``_test_case_template.py``
(leading underscore), pytest skips it — so the example bodies below
won't accidentally run on your bench.
HOW TO USE THIS FILE
--------------------
1. Copy this file to ``tests/hardware/test_<feature>.py``.
2. Rename ``test_template_*`` functions to describe what they verify
(e.g. ``test_blue_at_full_intensity_drives_pwm``).
3. Fill in each docstring's ``Title / Description / Test Steps /
Expected Result`` block — the conftest plugin parses those into the
HTML report's metadata columns.
4. Decide which template body matches your test (see TEST FLAVORS below)
and delete the others.
5. Use ``fio`` for generic LDF-driven I/O; use ``alm`` for ALM_Node
patterns. Full reference: ``docs/19_frame_io_and_alm_helpers.md``.
TEST FLAVORS PROVIDED BELOW
---------------------------
Three example bodies cover the most common shapes:
A) ``test_template_minimal`` — relies on the autouse reset; no
per-test setup or teardown. Use this when the test only sends
a frame, observes a state change, and asserts on PWM.
B) ``test_template_with_isolation`` — uses the explicit four-phase
SETUP / PROCEDURE / ASSERT / TEARDOWN pattern with try/finally so
the test stays independent of the others even if it mutates
persistent ECU state (e.g. ConfigFrame). **Use this for any test
that changes a value the autouse reset doesn't restore.**
C) ``test_template_signal_probe`` — short pattern for "read one
signal, assert something about it" cases.
THE FOUR-PHASE PATTERN (read this once, the comments below assume it)
---------------------------------------------------------------------
Each test body in flavor B is split into four labelled sections:
• SETUP — bring the ECU to the *exact* state this test needs
beyond the common baseline already provided by the
autouse fixture. Anything you change here MUST be
undone in TEARDOWN.
• PROCEDURE — the actions under test (sending a frame, waiting for
a state, etc.). Should be readable top-to-bottom as
the steps of the requirement you are verifying.
• ASSERT — bus-observable expectations. Use ``rp("key", value)``
to attach data to the report, then ``assert ...`` for
the actual check.
• TEARDOWN — runs in a ``finally`` so it executes even when an
assertion fails. Restores any state that SETUP
perturbed. This is what guarantees test independence.
Tests in flavor A skip SETUP/TEARDOWN because the autouse
``_reset_to_off`` fixture is enough — the LED is forced OFF before and
after every test.
"""
from __future__ import annotations
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ IMPORTS ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Standard library: ``time`` is used for short delays where we wait for
# the ECU to apply a new ConfigFrame or for the slave to refresh its TX
# buffer. Test code generally prefers ``alm.wait_for_state(...)`` over
# raw sleeps, but a short ``time.sleep(...)`` is fine for "let the ECU
# latch this command" pauses.
import time
# pytest itself: ``@pytest.fixture``, ``@pytest.mark.*``, ``pytest.skip``.
import pytest
# Project framework: ``EcuTestConfig`` holds the merged YAML config (so we
# can guard against running this suite on a non-MUM bench), and
# ``LinInterface`` is the abstract LIN adapter the ``lin`` session
# fixture provides.
from ecu_framework.config import EcuTestConfig
from ecu_framework.lin.base import LinInterface
# The two test-helper modules. Sibling imports work because pytest's
# default rootdir mode puts the test file's directory on ``sys.path``.
# • ``frame_io.FrameIO`` — generic, LDF-driven send/receive/pack/unpack
# • ``alm_helpers`` — ALM_Node domain helpers + constants
from frame_io import FrameIO
from alm_helpers import (
AlmTester,
LED_STATE_OFF, LED_STATE_ANIMATING, LED_STATE_ON,
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
PWM_SETTLE_SECONDS, DURATION_LSB_SECONDS,
)
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ MODULE MARKERS ║
# ╚══════════════════════════════════════════════════════════════════════╝
# ``pytestmark`` applies the listed markers to every test in this file.
#
# • ``pytest.mark.hardware`` — needs a real LIN master + ECU.
# Excluded from default mock-only runs (``pytest -m "not hardware"``).
# • ``pytest.mark.mum`` — uses the Melexis Universal Master
# adapter. Pair with ``hardware`` when running:
# pytest -m "hardware and mum"
#
# Add per-test markers (e.g. ``@pytest.mark.smoke`` or ``@pytest.mark.req_001``)
# directly above individual test functions.
pytestmark = [pytest.mark.hardware, pytest.mark.mum]
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ FIXTURES — the wiring that gives every test its tools ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# A "fixture" in pytest is a function decorated with ``@pytest.fixture``
# that prepares (and optionally cleans up) something tests need. A test
# requests a fixture by listing its name as a parameter — pytest matches
# the parameter name to the fixture name and injects the return value.
#
# WHAT EACH SCOPE MEANS
# scope="function" (default) → fixture re-runs for every test
# scope="module" → runs once per file; same value reused
# across all tests in this file
# scope="session" → runs once per pytest invocation
#
# ``module`` scope is the right default for ``fio`` and ``alm`` because
# building a FrameIO and resolving the NAD only need to happen once
# per file — they don't change between tests.
#
# ``autouse=True`` on a fixture means tests don't have to request it by
# name; pytest applies it to every test in scope automatically. We use
# this for ``_reset_to_off`` so the LED reset is mechanical, not
# something each test author has to remember.
#
# ``yield`` inside a fixture splits it into setup (before yield) and
# teardown (after yield). The teardown runs even when the test fails.
@pytest.fixture(scope="module")
def fio(config: EcuTestConfig, lin: LinInterface, ldf) -> FrameIO:
"""Generic LDF-driven I/O for any frame in the project's LDF.
Built once per file. The test asks pytest for ``fio`` by name and
receives this single ``FrameIO`` instance, with three layers of
access available:
• ``fio.send("FrameName", **signals)`` — high level, by name
• ``fio.pack(...)`` / ``fio.unpack(...)`` — bytes ↔ signals, no I/O
• ``fio.send_raw(id, data)`` — bypass the LDF entirely
SKIP IF NOT ON MUM: this whole suite is meaningless on a mock or
deprecated-BabyLIN bench, so we skip cleanly rather than letting
later asserts fail in confusing ways.
"""
if config.interface.type != "mum":
pytest.skip("interface.type must be 'mum' for this suite")
return FrameIO(lin, ldf)
@pytest.fixture(scope="module")
def alm(fio: FrameIO) -> AlmTester:
"""ALM_Node domain helper bound to the live NAD reported by ALM_Status.
Reads ALM_Status once, picks the NAD out, and constructs an
``AlmTester`` carrying ``(fio, nad)``. From there every test can
do ``alm.force_off()``, ``alm.wait_for_state(...)``, etc., without
re-deriving the NAD or re-discovering frames.
SKIPS we want to be loud about:
• The slave didn't respond at all → wiring/power issue
• The reported NAD is outside 0x01..0xFE → auto-addressing issue
These belong as skips (not failures) because they indicate the bench
isn't ready, which is independent of any logic this file checks.
"""
decoded = fio.receive("ALM_Status", timeout=1.0)
if decoded is None:
pytest.skip("ECU not responding on ALM_Status — check wiring/power")
nad = int(decoded["ALMNadNo"])
if not (0x01 <= nad <= 0xFE):
pytest.skip(f"ECU reports invalid NAD {nad:#x} — auto-addressing first")
return AlmTester(fio, nad)
@pytest.fixture(autouse=True)
def _reset_to_off(alm: AlmTester):
"""The COMMON baseline: LED is OFF before AND after every test.
Why this matters:
Without it, a test that left the LED in some state (mid-fade,
ON, etc.) would bleed into the next test, and a failure could
cascade across the whole file. Forcing OFF before and after
guarantees that whatever happens inside a test, the next test
starts from the same place — that is *test independence*.
The leading underscore is just a hint that this fixture isn't
meant to be requested directly by a test; ``autouse=True`` already
pulls it in automatically.
Note this only handles the LED state. If your test also writes
something the reset doesn't undo (e.g. ConfigFrame), you must
restore it yourself in the test's TEARDOWN block — see flavor B.
"""
alm.force_off() # SETUP (runs before the test body)
yield # ←── the test runs here
alm.force_off() # TEARDOWN (runs after, even if the test failed)
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR A — minimal, no per-test setup/teardown ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Use this shape when the autouse ``_reset_to_off`` is enough — i.e. the
# only mutable state the test touches is the LED itself.
def test_template_minimal(fio: FrameIO, alm: AlmTester, rp):
"""
Title: <one-line summary; appears as a column in the HTML report>
Description:
<23 sentences explaining what this test validates and why it
matters. Avoid mentioning implementation details that change
often — focus on the requirement.>
Requirements: REQ-XXX
Test Steps:
1. <step>
2. <step>
3. <step>
Expected Result:
- <bus-observable expectation>
- <bus-observable expectation>
"""
# The colour we want to drive the LED to. Using locals (r, g, b)
# makes the assertion below read naturally.
r, g, b = 0, 180, 80
# ── PROCEDURE ──────────────────────────────────────────────────────
# ``fio.send`` packs the frame against the LDF and pushes it on the
# bus. Every signal the LDF defines for the frame must be supplied;
# ldfparser raises if you forget one.
fio.send(
"ALM_Req_A",
AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
AmbLightIntensity=255, # full brightness
AmbLightUpdate=0, # 0 = immediate (no save buffer)
AmbLightMode=0, # 0 = immediate setpoint, no fade
AmbLightDuration=10, # ignored for mode=0; harmless
AmbLightLIDFrom=alm.nad, # target THIS node
AmbLightLIDTo=alm.nad,
)
# Poll ALM_Status until ALMLEDState reports ON (or timeout).
# ``wait_for_state`` returns three things:
# reached — True if we saw the target state in time
# elapsed — seconds it took (for diagnostics)
# history — distinct LED states observed during the wait
reached, elapsed, history = alm.wait_for_state(
LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT
)
# ── ASSERT ─────────────────────────────────────────────────────────
# ``rp("key", value)`` attaches a property to the JUnit XML and HTML
# report. The conftest plugin renders these in the report row, so
# we get useful per-test diagnostics even without re-running.
rp("led_state_history", history)
rp("on_elapsed_s", round(elapsed, 3))
assert reached, f"LEDState never reached ON (history: {history})"
# Assert the published PWM matches what rgb_to_pwm.compute_pwm()
# predicts for these RGB inputs — at the live ECU temperature.
# ``alm.assert_pwm_matches_rgb`` reads Tj_Frame_NTC, converts it
# to °C, and feeds it into the calculator before comparing.
alm.assert_pwm_matches_rgb(rp, r, g, b)
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR B — explicit SETUP / PROCEDURE / ASSERT / TEARDOWN ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Use this shape any time the test mutates state the autouse reset
# doesn't put back. The four sections are clearly labelled and the
# try/finally guarantees TEARDOWN runs even on assertion failure —
# which is what keeps the suite independent across runs.
def test_template_with_isolation(fio: FrameIO, alm: AlmTester, rp):
"""
Title: <verifies a behaviour that requires touching ConfigFrame>
Description:
<Same docstring shape as flavor A. The plugin reads it.>
Requirements: REQ-XXX
Test Steps:
1. SETUP: disable temperature compensation
2. PROCEDURE: drive LED, wait for ON
3. ASSERT: PWM_wo_Comp matches the non-compensated calculator
4. TEARDOWN: re-enable compensation so other tests see defaults
Expected Result:
- LED reaches ON
- PWM_wo_Comp_{Red,Green,Blue} match compute_pwm(R,G,B).pwm_no_comp
"""
r, g, b = 0, 180, 80
# ── SETUP ──────────────────────────────────────────────────────────
# The autouse fixture has already forced the LED OFF for us. Here
# we make any *additional* changes this test specifically needs.
# Anything we change here gets undone in TEARDOWN below.
fio.send(
"ConfigFrame",
ConfigFrame_Calibration=0,
ConfigFrame_EnableDerating=1,
ConfigFrame_EnableCompensation=0, # ← the change under test
ConfigFrame_MaxLM=3840,
)
# Brief pause so the ECU latches the new config before the next
# frame. 200 ms is comfortable on a 10 ms LIN bus.
time.sleep(0.2)
try:
# ── PROCEDURE ─────────────────────────────────────────────────
# The actions whose effects we are validating.
fio.send(
"ALM_Req_A",
AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
AmbLightIntensity=255,
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=10,
AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
)
reached, elapsed, history = alm.wait_for_state(
LED_STATE_ON, timeout=STATE_TIMEOUT_DEFAULT
)
# ── ASSERT ────────────────────────────────────────────────────
rp("led_state_history", history)
rp("on_elapsed_s", round(elapsed, 3))
assert reached, (
f"LEDState never reached ON with comp disabled "
f"(history: {history})"
)
# PWM_wo_Comp is temperature-independent, so we only check it
# here (the comp PWM would still be temperature-corrected).
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
finally:
# ── TEARDOWN ──────────────────────────────────────────────────
# ALWAYS runs, even if an assertion above failed. This is what
# keeps the suite independent: by the time the next test starts,
# ConfigFrame is back at its default and ``_reset_to_off`` has
# taken the LED OFF.
fio.send(
"ConfigFrame",
ConfigFrame_Calibration=0,
ConfigFrame_EnableDerating=1,
ConfigFrame_EnableCompensation=1, # ← restore default
ConfigFrame_MaxLM=3840,
)
time.sleep(0.2)
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ TEST FLAVOR C — single-signal probe ║
# ╚══════════════════════════════════════════════════════════════════════╝
# Quick shape for "ask the ECU one thing and check the answer".
# ``fio.read_signal`` is the convenience reader: it receives a frame
# and pulls one signal out, returning ``default`` on timeout.
def test_template_signal_probe(fio: FrameIO, alm: AlmTester, rp):
"""
Title: Tj_Frame_NTC reports a sensible junction temperature
Description:
Probes a single signal on a slave-published frame. Fast and
useful for sanity-checking that a sensor is alive without
decoding the rest of the frame.
Expected Result:
Tj_Frame_NTC is received and falls within a plausible range
(200..400 K covers anything from a cold lab to a hot bench).
"""
# No SETUP needed: the autouse reset already gave us OFF baseline,
# and this test doesn't perturb anything.
# ── PROCEDURE ──────────────────────────────────────────────────────
ntc_kelvin = fio.read_signal(
"Tj_Frame", "Tj_Frame_NTC",
timeout=0.5, # fail fast if the slave is silent
default=None, # what to return on timeout (so we can branch)
)
# ── ASSERT ─────────────────────────────────────────────────────────
rp("ntc_raw_kelvin", ntc_kelvin)
assert ntc_kelvin is not None, "Tj_Frame did not respond"
assert 200 <= ntc_kelvin <= 400, (
f"NTC reading {ntc_kelvin}K outside plausible range; "
f"check the firmware's encoding"
)
# No TEARDOWN needed: nothing was perturbed.
# ╔══════════════════════════════════════════════════════════════════════╗
# ║ APPENDIX — handy patterns you'll reach for ║
# ╚══════════════════════════════════════════════════════════════════════╝
#
# Send raw bytes (bypass the LDF):
# fio.send_raw(0x12, bytes([0x00] * 8))
# rx = fio.receive_raw(0x11, timeout=0.5)
#
# Pack with the LDF, hand-edit, then send raw:
# data = bytearray(fio.pack("ALM_Req_A", AmbLightColourRed=255, ...))
# data[7] |= 0x80 # twiddle a bit
# fio.send_raw(fio.frame_id("ALM_Req_A"), bytes(data))
#
# Decode bytes you already captured:
# decoded = fio.unpack("PWM_Frame", b"\x12\x34\x56\x78\x9A\xBC\xDE\xF0")
#
# Inspect a frame's metadata:
# fio.frame_id("PWM_Frame") # 0x12
# fio.frame_length("PWM_Frame") # 8
#
# Wait for an arbitrary state with custom timeout:
# reached, elapsed, hist = alm.wait_for_state(LED_STATE_ANIMATING, timeout=2.0)
#
# Per-test marker for the requirements matrix:
# @pytest.mark.req_005
# def test_something(...): ...