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>
This commit is contained in:
parent
29a7a44c8b
commit
11b5402b14
433
tests/hardware/_test_case_template.py
Normal file
433
tests/hardware/_test_case_template.py
Normal file
@ -0,0 +1,433 @@
|
||||
"""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:
|
||||
<2–3 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(...): ...
|
||||
419
tests/hardware/_test_case_template_psu_lin.py
Normal file
419
tests/hardware/_test_case_template_psu_lin.py
Normal file
@ -0,0 +1,419 @@
|
||||
"""Copyable template for tests that drive the PSU and observe the LIN bus.
|
||||
|
||||
WHEN TO USE THIS TEMPLATE
|
||||
-------------------------
|
||||
Voltage-tolerance, brown-out, over-voltage, and "supply transient"
|
||||
tests can't be done from either side alone — you need to *perturb*
|
||||
the bench supply (Owon PSU) and *observe* the ECU's reaction on the
|
||||
LIN bus. This template wires both ends together with the
|
||||
SETUP / PROCEDURE / ASSERT / TEARDOWN pattern so the test stays
|
||||
independent of the others even when it raises mid-flight.
|
||||
|
||||
THE CANONICAL PATTERN — settle then validate
|
||||
--------------------------------------------
|
||||
The Owon PSU does NOT slew instantaneously, and the slew time is
|
||||
**bench-dependent** (PSU model, load, cable drop). Don't sleep a
|
||||
fixed amount and assume the rail is there — *measure*. Every voltage
|
||||
change in this template goes through
|
||||
:func:`apply_voltage_and_settle` from ``psu_helpers``, which:
|
||||
|
||||
1. Issues the setpoint.
|
||||
2. **Polls** ``measure_voltage_v()`` until the rail is actually at
|
||||
the target (within ``DEFAULT_VOLTAGE_TOL_V``, or raises on
|
||||
timeout).
|
||||
3. Holds for ``ECU_VALIDATION_TIME_S`` so the firmware-side voltage
|
||||
monitor can detect, debounce, and republish status.
|
||||
|
||||
After that, a **single read** of ``ALM_Status.ALMVoltageStatus``
|
||||
gives an unambiguous answer — no polling-on-the-bus race.
|
||||
|
||||
THREE FLAVORS PROVIDED
|
||||
----------------------
|
||||
A) ``test_template_overvoltage_status`` — overvoltage detection.
|
||||
B) ``test_template_undervoltage_status`` — undervoltage detection.
|
||||
C) ``test_template_voltage_status_parametrized`` — sweep.
|
||||
|
||||
WHY THE NAME STARTS WITH AN UNDERSCORE
|
||||
--------------------------------------
|
||||
pytest only collects ``test_*.py``; this file's leading underscore
|
||||
keeps the example bodies out of the suite. Copy to
|
||||
``test_<feature>.py`` and edit.
|
||||
|
||||
SAFETY — three layers keep the bench safe
|
||||
-----------------------------------------
|
||||
1. The session-scoped ``psu`` fixture (in
|
||||
``tests/hardware/conftest.py``) parks the supply at nominal
|
||||
voltage with output ON at session start, and closes with
|
||||
``output 0`` at session end (``safe_off_on_close=True``).
|
||||
2. The autouse ``_park_at_nominal`` fixture in this file restores
|
||||
nominal voltage before AND after every test in this module —
|
||||
also via ``apply_voltage_and_settle`` so the rail is *measurably*
|
||||
back at nominal before the next test runs.
|
||||
3. Every test wraps its voltage change in ``try``/``finally`` that
|
||||
restores nominal so an assertion failure cannot leave the bench
|
||||
at an over/undervoltage rail.
|
||||
|
||||
WHY ``set_output`` IS NEVER CALLED HERE
|
||||
---------------------------------------
|
||||
On this bench the Owon PSU **powers the ECU**. Calling
|
||||
``psu.set_output(False)`` mid-session would brown out the ECU and
|
||||
break every test that runs afterwards. The session fixture enables
|
||||
the output once at session start; tests perturb voltage but never
|
||||
toggle the output state.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from ecu_framework.config import EcuTestConfig
|
||||
from ecu_framework.lin.base import LinInterface
|
||||
from ecu_framework.power import OwonPSU
|
||||
|
||||
from frame_io import FrameIO
|
||||
from alm_helpers import AlmTester
|
||||
from psu_helpers import apply_voltage_and_settle, downsample_trace
|
||||
|
||||
|
||||
# ╔══════════════════════════════════════════════════════════════════════╗
|
||||
# ║ MODULE MARKERS ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════╝
|
||||
# ``hardware`` excludes from default mock-only runs; ``mum`` selects the
|
||||
# Melexis Universal Master adapter for the LIN side.
|
||||
pytestmark = [pytest.mark.hardware, pytest.mark.mum]
|
||||
|
||||
|
||||
# ╔══════════════════════════════════════════════════════════════════════╗
|
||||
# ║ CONSTANTS ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════╝
|
||||
#
|
||||
# ALM_Status.ALMVoltageStatus values, taken verbatim from the LDF's
|
||||
# Signal_encoding_types: VoltageStatus block. Named constants make the
|
||||
# assertions self-explanatory and give readers something to grep for.
|
||||
VOLTAGE_STATUS_NORMAL = 0x00 # 'Normal Voltage'
|
||||
VOLTAGE_STATUS_UNDER = 0x01 # 'Power UnderVoltage'
|
||||
VOLTAGE_STATUS_OVER = 0x02 # 'Power OverVoltage'
|
||||
|
||||
# Bench voltage profile. **TUNE THESE TO YOUR ECU'S DATASHEET** before
|
||||
# running on real hardware. Values shown are conservative automotive
|
||||
# ranges; many ECUs trip earlier.
|
||||
NOMINAL_VOLTAGE = 13.0 # V — typical 12 V automotive nominal
|
||||
OVERVOLTAGE_V = 18.0 # V — comfortably above the OV threshold
|
||||
UNDERVOLTAGE_V = 7.0 # V — below most brown-out points
|
||||
|
||||
# Time to hold the rail steady AFTER the PSU has reached the target,
|
||||
# before reading ``ALMVoltageStatus``. This is the **firmware-dependent**
|
||||
# budget — the ECU's voltage monitor needs to sample, debounce, and
|
||||
# republish on its 10 ms LIN cycle. **Tune to your firmware spec.**
|
||||
# 1.0 s is a conservative starting point.
|
||||
ECU_VALIDATION_TIME_S = 1.0
|
||||
|
||||
|
||||
# ╔══════════════════════════════════════════════════════════════════════╗
|
||||
# ║ FIXTURES ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════╝
|
||||
#
|
||||
# ``psu`` is provided by ``tests/hardware/conftest.py`` at SESSION
|
||||
# scope (autouse) — the bench is powered up once at session start and
|
||||
# stays on. Tests in this file just READ the psu fixture and perturb
|
||||
# voltage; they MUST NOT close it or toggle output.
|
||||
#
|
||||
# ``fio`` and ``alm`` are module-scoped here. As soon as a third test
|
||||
# file needs them, move both to ``tests/hardware/conftest.py``.
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def fio(config: EcuTestConfig, lin: LinInterface, ldf) -> FrameIO:
|
||||
"""Generic LDF-driven LIN I/O for any frame in the project's LDF."""
|
||||
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."""
|
||||
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 _park_at_nominal(psu: OwonPSU, alm: AlmTester):
|
||||
"""Per-test baseline: PSU voltage at NOMINAL_VOLTAGE + LED off.
|
||||
|
||||
Uses :func:`apply_voltage_and_settle` so the rail is *measurably*
|
||||
at nominal before the test body runs — and afterwards, even on
|
||||
assertion failure. Validation time is short here: we just need
|
||||
the rail steady, not the ECU to react to it (the test body does
|
||||
its own settle+validation in PROCEDURE).
|
||||
"""
|
||||
# SETUP — nominal voltage (measured), LED off
|
||||
apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2)
|
||||
alm.force_off()
|
||||
yield
|
||||
# TEARDOWN — back to nominal even on test failure
|
||||
apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2)
|
||||
alm.force_off()
|
||||
|
||||
|
||||
# ╔══════════════════════════════════════════════════════════════════════╗
|
||||
# ║ TEST FLAVOR A — overvoltage detection ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
|
||||
def test_template_overvoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, rp):
|
||||
"""
|
||||
Title: ECU reports OverVoltage when supply exceeds the threshold
|
||||
|
||||
Description:
|
||||
Apply OVERVOLTAGE_V via :func:`apply_voltage_and_settle`, hold
|
||||
for ECU_VALIDATION_TIME_S, then read ALM_Status.ALMVoltageStatus
|
||||
once and assert it equals VOLTAGE_STATUS_OVER (0x02). Restore
|
||||
nominal supply on the way out.
|
||||
|
||||
Requirements: REQ-OVP-001
|
||||
|
||||
Test Steps:
|
||||
1. SETUP: confirm baseline ALMVoltageStatus == Normal
|
||||
2. PROCEDURE: apply OVERVOLTAGE_V, wait for the rail to be
|
||||
there, hold ECU_VALIDATION_TIME_S
|
||||
3. ASSERT: single read of ALMVoltageStatus == OverVoltage
|
||||
4. TEARDOWN: restore NOMINAL_VOLTAGE via the same helper
|
||||
and verify recovery to Normal
|
||||
|
||||
Expected Result:
|
||||
- Baseline status is Normal
|
||||
- After settle + validation hold at OVERVOLTAGE_V,
|
||||
ALMVoltageStatus reads OverVoltage
|
||||
- After restoring nominal, ALMVoltageStatus returns to Normal
|
||||
"""
|
||||
# ── SETUP ─────────────────────────────────────────────────────────
|
||||
baseline = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
|
||||
rp("baseline_voltage_status", int(baseline))
|
||||
assert int(baseline) == VOLTAGE_STATUS_NORMAL, (
|
||||
f"Expected Normal at nominal supply but got {baseline!r}; "
|
||||
f"check PSU output and ECU power rail before continuing."
|
||||
)
|
||||
|
||||
try:
|
||||
# ── PROCEDURE ─────────────────────────────────────────────────
|
||||
result = apply_voltage_and_settle(
|
||||
psu, OVERVOLTAGE_V,
|
||||
validation_time=ECU_VALIDATION_TIME_S,
|
||||
)
|
||||
# Single read after the rail is steady AND the ECU has had its
|
||||
# validation budget. No polling, no race.
|
||||
status = fio.read_signal(
|
||||
"ALM_Status", "ALMVoltageStatus", default=-1,
|
||||
)
|
||||
|
||||
# ── ASSERT ────────────────────────────────────────────────────
|
||||
rp("psu_setpoint_v", OVERVOLTAGE_V)
|
||||
rp("psu_settled_s", round(result["settled_s"], 4))
|
||||
rp("psu_final_v", result["final_v"])
|
||||
rp("validation_time_s", result["validation_s"])
|
||||
rp("voltage_status_after", int(status))
|
||||
rp("voltage_trace", downsample_trace(result["trace"]))
|
||||
assert int(status) == VOLTAGE_STATUS_OVER, (
|
||||
f"ALMVoltageStatus = 0x{int(status):02X} after applying "
|
||||
f"{OVERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, "
|
||||
f"held {result['validation_s']} s). Expected "
|
||||
f"0x{VOLTAGE_STATUS_OVER:02X} (OverVoltage)."
|
||||
)
|
||||
|
||||
finally:
|
||||
# ── TEARDOWN ──────────────────────────────────────────────────
|
||||
# ALWAYS runs, even on assertion failure.
|
||||
apply_voltage_and_settle(
|
||||
psu, NOMINAL_VOLTAGE,
|
||||
validation_time=ECU_VALIDATION_TIME_S,
|
||||
)
|
||||
|
||||
# Regression check after the try/finally: status returned to Normal.
|
||||
recovery_status = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
|
||||
rp("voltage_status_recovery", int(recovery_status))
|
||||
assert int(recovery_status) == VOLTAGE_STATUS_NORMAL, (
|
||||
f"ECU did not return to Normal after restoring nominal supply. "
|
||||
f"Got 0x{int(recovery_status):02X}."
|
||||
)
|
||||
|
||||
|
||||
# ╔══════════════════════════════════════════════════════════════════════╗
|
||||
# ║ TEST FLAVOR B — undervoltage detection ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════╝
|
||||
|
||||
|
||||
def test_template_undervoltage_status(psu: OwonPSU, fio: FrameIO, alm: AlmTester, rp):
|
||||
"""
|
||||
Title: ECU reports UnderVoltage when supply drops below the threshold
|
||||
|
||||
Description:
|
||||
Symmetric counterpart to flavor A — apply UNDERVOLTAGE_V via
|
||||
:func:`apply_voltage_and_settle`, hold for the validation
|
||||
window, then assert ALMVoltageStatus = 0x01.
|
||||
|
||||
Note that at very low voltages the ECU may stop publishing
|
||||
ALM_Status entirely (full brown-out). Pick UNDERVOLTAGE_V high
|
||||
enough to keep the LIN node alive but low enough to trip the
|
||||
UV flag — your firmware spec defines the right value.
|
||||
|
||||
Test Steps:
|
||||
1. SETUP: confirm baseline ALMVoltageStatus == Normal
|
||||
2. PROCEDURE: apply UNDERVOLTAGE_V via apply_voltage_and_settle
|
||||
3. ASSERT: single read of ALMVoltageStatus == UnderVoltage
|
||||
4. TEARDOWN: restore NOMINAL_VOLTAGE and verify recovery
|
||||
"""
|
||||
# ── SETUP ─────────────────────────────────────────────────────────
|
||||
baseline = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
|
||||
rp("baseline_voltage_status", int(baseline))
|
||||
assert int(baseline) == VOLTAGE_STATUS_NORMAL, (
|
||||
f"Expected Normal at nominal supply but got {baseline!r}"
|
||||
)
|
||||
|
||||
try:
|
||||
# ── PROCEDURE ─────────────────────────────────────────────────
|
||||
result = apply_voltage_and_settle(
|
||||
psu, UNDERVOLTAGE_V,
|
||||
validation_time=ECU_VALIDATION_TIME_S,
|
||||
)
|
||||
status = fio.read_signal(
|
||||
"ALM_Status", "ALMVoltageStatus", default=-1,
|
||||
)
|
||||
|
||||
# ── ASSERT ────────────────────────────────────────────────────
|
||||
rp("psu_setpoint_v", UNDERVOLTAGE_V)
|
||||
rp("psu_settled_s", round(result["settled_s"], 4))
|
||||
rp("psu_final_v", result["final_v"])
|
||||
rp("validation_time_s", result["validation_s"])
|
||||
rp("voltage_status_after", int(status))
|
||||
rp("voltage_trace", downsample_trace(result["trace"]))
|
||||
assert int(status) == VOLTAGE_STATUS_UNDER, (
|
||||
f"ALMVoltageStatus = 0x{int(status):02X} after applying "
|
||||
f"{UNDERVOLTAGE_V} V (settled in {result['settled_s']:.3f} s, "
|
||||
f"held {result['validation_s']} s). Expected "
|
||||
f"0x{VOLTAGE_STATUS_UNDER:02X} (UnderVoltage). "
|
||||
f"If status == -1 the slave likely browned out — raise "
|
||||
f"UNDERVOLTAGE_V toward the trip point so the node stays alive."
|
||||
)
|
||||
|
||||
finally:
|
||||
# ── TEARDOWN ──────────────────────────────────────────────────
|
||||
apply_voltage_and_settle(
|
||||
psu, NOMINAL_VOLTAGE,
|
||||
validation_time=ECU_VALIDATION_TIME_S,
|
||||
)
|
||||
|
||||
recovery_status = fio.read_signal("ALM_Status", "ALMVoltageStatus", default=-1)
|
||||
rp("voltage_status_recovery", int(recovery_status))
|
||||
assert int(recovery_status) == VOLTAGE_STATUS_NORMAL, (
|
||||
f"ECU did not return to Normal after restoring nominal supply. "
|
||||
f"Got 0x{int(recovery_status):02X}."
|
||||
)
|
||||
|
||||
|
||||
# ╔══════════════════════════════════════════════════════════════════════╗
|
||||
# ║ TEST FLAVOR C — parametrized voltage sweep ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════╝
|
||||
#
|
||||
# A single function that walks several (voltage, expected_status)
|
||||
# pairs. ``@pytest.mark.parametrize`` repeats the body once per tuple,
|
||||
# generating one independent test per row in the report. Each
|
||||
# invocation goes through the autouse fixture again, so they remain
|
||||
# isolated from each other.
|
||||
|
||||
_VOLTAGE_SCENARIOS = [
|
||||
# (psu_voltage, expected_alm_status, label)
|
||||
(NOMINAL_VOLTAGE, VOLTAGE_STATUS_NORMAL, "nominal"),
|
||||
(OVERVOLTAGE_V, VOLTAGE_STATUS_OVER, "overvoltage"),
|
||||
(UNDERVOLTAGE_V, VOLTAGE_STATUS_UNDER, "undervoltage"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"voltage,expected,label",
|
||||
_VOLTAGE_SCENARIOS,
|
||||
ids=[s[2] for s in _VOLTAGE_SCENARIOS], # nice IDs in the report
|
||||
)
|
||||
def test_template_voltage_status_parametrized(
|
||||
psu: OwonPSU,
|
||||
fio: FrameIO,
|
||||
rp,
|
||||
voltage: float,
|
||||
expected: int,
|
||||
label: str,
|
||||
):
|
||||
"""
|
||||
Title: ECU voltage status tracks the supply (sweep)
|
||||
|
||||
Description:
|
||||
Walks a small matrix of supply levels and asserts the ECU
|
||||
reports the corresponding ``ALMVoltageStatus``. Each row uses
|
||||
:func:`apply_voltage_and_settle` so the supply is *measurably*
|
||||
at the target before the validation hold and the status read.
|
||||
|
||||
Expected Result:
|
||||
For each (voltage, expected) tuple: a single ALMVoltageStatus
|
||||
read after settle + validation equals ``expected``.
|
||||
"""
|
||||
try:
|
||||
# ── PROCEDURE ─────────────────────────────────────────────────
|
||||
result = apply_voltage_and_settle(
|
||||
psu, voltage,
|
||||
validation_time=ECU_VALIDATION_TIME_S,
|
||||
)
|
||||
status = fio.read_signal(
|
||||
"ALM_Status", "ALMVoltageStatus", default=-1,
|
||||
)
|
||||
|
||||
# ── ASSERT ────────────────────────────────────────────────────
|
||||
rp("scenario", label)
|
||||
rp("psu_setpoint_v", voltage)
|
||||
rp("expected_status", expected)
|
||||
rp("psu_settled_s", round(result["settled_s"], 4))
|
||||
rp("psu_final_v", result["final_v"])
|
||||
rp("validation_time_s", result["validation_s"])
|
||||
rp("voltage_status_after", int(status))
|
||||
assert int(status) == expected, (
|
||||
f"[{label}] ALMVoltageStatus = 0x{int(status):02X} after "
|
||||
f"applying {voltage} V (settled in {result['settled_s']:.3f} s, "
|
||||
f"held {result['validation_s']} s). Expected 0x{expected:02X}."
|
||||
)
|
||||
|
||||
finally:
|
||||
# ── TEARDOWN ──────────────────────────────────────────────────
|
||||
apply_voltage_and_settle(
|
||||
psu, NOMINAL_VOLTAGE,
|
||||
validation_time=ECU_VALIDATION_TIME_S,
|
||||
)
|
||||
|
||||
|
||||
# ╔══════════════════════════════════════════════════════════════════════╗
|
||||
# ║ APPENDIX — patterns you'll reach for ║
|
||||
# ╚══════════════════════════════════════════════════════════════════════╝
|
||||
#
|
||||
# Read the parsed measured voltage / current at any time:
|
||||
# v = psu.measure_voltage_v() # float | None
|
||||
# i = psu.measure_current_a() # float | None
|
||||
# rp("psu_measured_v", v)
|
||||
#
|
||||
# Apply a setpoint and just settle (no firmware-side wait):
|
||||
# from psu_helpers import apply_voltage_and_settle
|
||||
# apply_voltage_and_settle(psu, 13.0, validation_time=0.2)
|
||||
#
|
||||
# Decode the entire ALM_Status frame (all signals at once):
|
||||
# decoded = fio.receive("ALM_Status")
|
||||
# # decoded → {'ALMNadNo': 1, 'ALMVoltageStatus': 0,
|
||||
# # 'ALMThermalStatus': 0, 'ALMNVMStatus': 0,
|
||||
# # 'ALMLEDState': 0, 'SigCommErr': 0}
|
||||
#
|
||||
# Verify the LED also turns OFF in undervoltage (some firmwares do):
|
||||
# reached, _, hist = alm.wait_for_state(LED_STATE_OFF, timeout=2.0)
|
||||
# assert reached, hist
|
||||
#
|
||||
# Add a per-test marker for the requirements matrix:
|
||||
# @pytest.mark.req_007
|
||||
# def test_xxx(...): ...
|
||||
Loading…
x
Reference in New Issue
Block a user