Extends ``tests/hardware/alm_helpers.py`` into the full surface that
hardware tests use, so contributors write intent (``alm.send_color``,
``alm.read_led_state``, ``alm.wait_for_led_on``) and never touch
``fio.send("ALM_Req_A", AmbLight…=…)`` or LDF schema details.
What landed:
- AlmTester gains ~16 methods:
read_nad, read_voltage_status, read_thermal_status, read_nvm_status,
read_sig_comm_err, read_ntc_kelvin, read_ntc_celsius, read_pwm,
read_pwm_wo_comp, send_color, send_color_broadcast, save_color,
apply_saved_color, discard_saved_color, send_config, plus
wait_for_led_on / wait_for_led_off / wait_for_animating wrappers.
- The six IntEnum classes that ALM tests need (LedState, Mode, Update,
NVMStatus, VoltageStatus, ThermalStatus) are defined directly in
alm_helpers.py — tests get them via `from alm_helpers import …`.
- All ALM test files migrated:
test_mum_alm_animation.py, test_mum_alm_cases.py, test_overvolt.py,
swe5/test_anm_management.py, swe5/test_com_management.py
each now go through AlmTester for every common pattern.
- swe6/test_com_management.py: stays on `fio` (these tests probe
schema features not in the current production LDF and skip when
the LDF doesn't declare them) — change limited to LedState enum.
- test_mum_alm_animation_generated.py deleted — its "no-AlmTester"
demonstration loses its point now that AlmTester is the
recommended path.
- docs/19_frame_io_and_alm_helpers.md reframed: AlmTester is the
contributor surface; FrameIO is implementation detail. New API
reference + Cookbook examples + a note that the maintenance pact
is "LDF changes → AlmTester updates".
Verified: pytest --collect-only collects 87 tests cleanly; 40 unit
+ mock smoke tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
620 lines
24 KiB
Markdown
620 lines
24 KiB
Markdown
# Hardware Test Helpers — `AlmTester` (and `FrameIO` underneath)
|
|
|
|
Hardware tests under `tests/hardware/mum/` go through **`AlmTester`** —
|
|
the contributor-facing API. Test bodies read like a sequence of intents
|
|
(`alm.send_color(red=255)`, `alm.wait_for_led_on()`, `alm.read_voltage_status()`)
|
|
and never need to know the LDF schema or how `FrameIO` works.
|
|
|
|
| Module | Scope | What it gives you |
|
|
| --- | --- | --- |
|
|
| [`tests/hardware/alm_helpers.py`](../tests/hardware/alm_helpers.py) | **Contributor-facing API** | `AlmTester` class — the only thing tests should reach for. Per-signal `read_*`, per-action `send_*`, cross-frame patterns (`wait_for_state`, `assert_pwm_matches_rgb`), and re-exported typed enums (`LedState`, `Mode`, `Update`, `VoltageStatus`, `ThermalStatus`, `NVMStatus`). |
|
|
| [`tests/hardware/frame_io.py`](../tests/hardware/frame_io.py) | **Implementation detail** | `FrameIO` class — generic LDF-driven send/receive used by `AlmTester` internally. Test bodies should not import this directly; framework-level tests (LDF schema introspection, MUM-only `send_raw`, end-to-end smoke) are the exception. |
|
|
|
|
**Maintenance pact:** when the LDF adds a signal or frame that tests
|
|
should use, the corresponding `read_*` / `send_*` method goes into
|
|
`alm_helpers.py`. Tests never look past that file.
|
|
|
|
The split lets the same `FrameIO` underlay be reused by future ECUs
|
|
while each ECU's domain vocabulary (the facade methods) lives in its
|
|
own `<ecu>_helpers.py`.
|
|
|
|
---
|
|
|
|
## 1. Three layers of access
|
|
|
|
`FrameIO` exposes the same bus three ways. A test picks whichever layer
|
|
matches its intent.
|
|
|
|
### 1.1 High level — by frame and signal name
|
|
|
|
This is the default for almost every test. The LDF carries the frame ID,
|
|
length, and signal layout, so the test code never mentions any of those.
|
|
|
|
```python
|
|
fio.send(
|
|
"ALM_Req_A",
|
|
AmbLightColourRed=255, AmbLightColourGreen=0, AmbLightColourBlue=0,
|
|
AmbLightIntensity=255,
|
|
AmbLightUpdate=0, AmbLightMode=0, AmbLightDuration=10,
|
|
AmbLightLIDFrom=alm.nad, AmbLightLIDTo=alm.nad,
|
|
)
|
|
|
|
decoded = fio.receive("ALM_Status") # full dict of decoded signals
|
|
nad = fio.read_signal("ALM_Status", "ALMNadNo") # one signal
|
|
```
|
|
|
|
### 1.2 Mid level — pack / unpack without I/O
|
|
|
|
Use this when you want to build a payload, inspect or modify it, and then
|
|
send it (often via the low-level path).
|
|
|
|
```python
|
|
data = bytearray(fio.pack("ALM_Req_A", AmbLightColourRed=255, ...))
|
|
data[7] |= 0x80 # tweak a bit by hand
|
|
fio.send_raw(fio.frame_id("ALM_Req_A"), bytes(data))
|
|
|
|
# Decode raw bytes you already have:
|
|
decoded = fio.unpack("PWM_Frame", b"\x12\x34..." )
|
|
```
|
|
|
|
### 1.3 Low level — raw bus, bypass the LDF
|
|
|
|
For cases the LDF doesn't describe, or when you need full control.
|
|
|
|
```python
|
|
fio.send_raw(0x12, bytes([0x00] * 8))
|
|
rx = fio.receive_raw(0x11, timeout=0.5) # returns LinFrame | None
|
|
```
|
|
|
|
### 1.4 Introspection
|
|
|
|
```python
|
|
fio.frame_id("PWM_Frame") # 0x12
|
|
fio.frame_length("PWM_Frame") # 8
|
|
fio.frame("PWM_Frame") # raw ldfparser Frame object (cached)
|
|
fio.lin # underlying LinInterface
|
|
fio.ldf # LdfDatabase
|
|
```
|
|
|
|
---
|
|
|
|
## 2. How frame names reach `FrameIO`
|
|
|
|
A common point of confusion when reading the API for the first time is
|
|
*"where does FrameIO get the list of frame names from?"* — looking at
|
|
`FrameIO.send("ALM_Req_A", ...)` it can seem like the class must hold a
|
|
registry of known frames somewhere.
|
|
|
|
**It doesn't.** `FrameIO` has zero pre-knowledge of any frame name. It's
|
|
a **broker** — the caller hands it a string, it forwards that string to
|
|
the LDF object it was constructed with, and it uses what comes back.
|
|
|
|
### 2.1 Where the names actually live
|
|
|
|
| Location | What it has | Example |
|
|
|---|---|---|
|
|
| The LDF file on disk (e.g. `vendor/4SEVEN_color_lib_test.ldf`) | Source of truth — frame definitions in LDF syntax | `Frames { ALM_Req_A: 10, Master_Node, 8 { ... } }` |
|
|
| `LdfDatabase` (`ecu_framework/lin/ldf.py`, parsed once per pytest session) | Dict-like access to the parsed frames | `db.frame("ALM_Req_A")` returns a `Frame` with `.id`, `.length`, `.pack(...)`, `.unpack(...)` |
|
|
| Caller's source code | A **string literal** in a test, or a class-level `NAME = "ALM_Req_A"` in the generated wrapper | `fio.send("ALM_Req_A", ...)` |
|
|
|
|
`FrameIO` itself is **not** in this table. The class stores only a
|
|
reference to the LDF object and an empty cache:
|
|
|
|
```python
|
|
# tests/hardware/frame_io.py:44
|
|
class FrameIO:
|
|
def __init__(self, lin: LinInterface, ldf) -> None:
|
|
self._lin = lin
|
|
self._ldf = ldf
|
|
self._frames: dict = {} # starts empty, fills on demand
|
|
```
|
|
|
|
It learns names exactly when a caller hands one over.
|
|
|
|
### 2.2 A concrete call trace
|
|
|
|
Watching the string flow through `fio.send("ALM_Req_A", AmbLightColourRed=255)`:
|
|
|
|
```
|
|
Test code (a hardware test in tests/hardware/mum/)
|
|
|
|
|
| fio.send("ALM_Req_A", AmbLightColourRed=255, ...)
|
|
v
|
|
FrameIO.send(frame_name="ALM_Req_A", **signals) # frame_io.py:77
|
|
|
|
|
| f = self.frame(frame_name)
|
|
v
|
|
FrameIO.frame(name="ALM_Req_A") # frame_io.py:61
|
|
|
|
|
| Not in cache? Look it up via the duck-typed ldf:
|
|
| f = self._ldf.frame("ALM_Req_A")
|
|
v
|
|
LdfDatabase.frame(name="ALM_Req_A") # ecu_framework/lin/ldf.py
|
|
|
|
|
| Returns the parsed Frame object
|
|
v
|
|
Frame(id=0x0A, length=8, signals={...}) # has .id, .pack, .unpack
|
|
|
|
|
| Back in FrameIO.frame: cache the result so the next call is O(1)
|
|
| self._frames["ALM_Req_A"] = f
|
|
| return f
|
|
v
|
|
Back in FrameIO.send:
|
|
data = f.pack(AmbLightColourRed=255, ...) # Frame builds the byte payload
|
|
self._lin.send(LinFrame(id=f.id, data=data)) # wire it out via LinInterface
|
|
```
|
|
|
|
At no point did `FrameIO` ever store, import, or hard-code the string
|
|
`"ALM_Req_A"`. The string travelled:
|
|
|
|
```
|
|
test source -> FrameIO -> LdfDatabase -> ldfparser -> byte layout
|
|
```
|
|
|
|
`FrameIO`'s only contribution was **caching the LDF lookup** so a second
|
|
`fio.send("ALM_Req_A", ...)` skips the re-parse.
|
|
|
|
### 2.3 Where the caller gets the name from
|
|
|
|
Two paths, your choice per test file:
|
|
|
|
**Path A — stringly-typed: caller writes the literal.**
|
|
|
|
```python
|
|
def test_red(fio):
|
|
fio.send("ALM_Req_A", AmbLightColourRed=255, ...) # string in test source
|
|
```
|
|
|
|
The string `"ALM_Req_A"` lives in the test file. Typo it and you get a
|
|
`KeyError` (or `FrameNotFound`) at runtime when
|
|
`self._ldf.frame("ALM_Req_a")` fails.
|
|
|
|
**Path B — hidden inside `AlmTester` (the recommended path).**
|
|
|
|
```python
|
|
# tests/hardware/alm_helpers.py — hand-maintained facade
|
|
class AlmTester:
|
|
...
|
|
def send_color(self, *, red, green, blue, ...) -> None:
|
|
self._fio.send("ALM_Req_A", AmbLightColourRed=red, ...) # string lives here
|
|
|
|
# Your test:
|
|
def test_red(alm):
|
|
alm.send_color(red=255, green=0, blue=0) # zero strings in the test body
|
|
```
|
|
|
|
Same underlying call. Where the string lives is the only difference: in
|
|
your test (Path A) vs in `alm_helpers.py` (Path B). Path B catches a
|
|
signal-name typo as a `TypeError` at the call site (signal names are
|
|
real kwarg names of the facade) and a frame-name typo never appears at
|
|
the test level because the facade hides it. This is the path **new
|
|
contributors should use**; Path A is reserved for low-level tests
|
|
(schema introspection, MUM-only `send_raw`, end-to-end smoke). A
|
|
previous Path B variant — an auto-generated typed-wrapper module —
|
|
was retired in favor of the hand-maintained facade; see
|
|
[`22_generated_lin_api.md`](22_generated_lin_api.md) and
|
|
[`../deprecated/`](../deprecated/) for the history.
|
|
|
|
Either way, `FrameIO` itself sees only an incoming string and forwards it.
|
|
|
|
### 2.4 The cache lifecycle
|
|
|
|
`self._frames` starts empty. Each unique `frame_name` passed in adds one
|
|
entry on its first use. So after a test session that touched
|
|
`ALM_Req_A`, `ALM_Status`, and `PWM_Frame`, the cache is:
|
|
|
|
```python
|
|
{
|
|
"ALM_Req_A": Frame(id=0x0A, ...),
|
|
"ALM_Status": Frame(id=0x11, ...),
|
|
"PWM_Frame": Frame(id=0x12, ...),
|
|
}
|
|
```
|
|
|
|
…and `FrameIO` still has no idea those names exist *until* a caller
|
|
asked for them. The LDF object had them all along; `FrameIO` just
|
|
learned them lazily.
|
|
|
|
### 2.5 The mental model
|
|
|
|
`FrameIO` is a broker with two contracts:
|
|
|
|
1. *"Give me a name, I'll look it up in whatever LDF you gave me at
|
|
construction time, then I'll send the resulting frame over whatever
|
|
`LinInterface` you gave me."*
|
|
2. *"…and I'll cache the lookup so you don't pay for it twice."*
|
|
|
|
That's the whole class. It doesn't know your ECU, your project, your
|
|
frame names, or your LDF revision. It's a generic glue layer. Swap the
|
|
LDF (different ECU project), the same `FrameIO` works without
|
|
modification — that's the architectural payoff for keeping it
|
|
name-agnostic.
|
|
|
|
---
|
|
|
|
## 3. `FrameIO` API reference
|
|
|
|
```python
|
|
class FrameIO:
|
|
def __init__(self, lin: LinInterface, ldf): ...
|
|
|
|
# high level
|
|
def send(self, frame_name: str, **signals) -> None
|
|
def receive(self, frame_name: str, timeout: float = 1.0) -> dict | None
|
|
def read_signal(self, frame_name: str, signal_name: str, *,
|
|
timeout: float = 1.0, default=None) -> Any
|
|
|
|
# mid level
|
|
def pack(self, frame_name: str, **signals) -> bytes
|
|
def unpack(self, frame_name: str, data: bytes) -> dict
|
|
|
|
# low level
|
|
def send_raw(self, frame_id: int, data: bytes) -> None
|
|
def receive_raw(self, frame_id: int, timeout: float = 1.0) -> LinFrame | None
|
|
|
|
# introspection
|
|
def frame(self, name: str)
|
|
def frame_id(self, name: str) -> int
|
|
def frame_length(self, name: str) -> int
|
|
|
|
# injected refs
|
|
@property
|
|
def lin(self) -> LinInterface
|
|
@property
|
|
def ldf(self)
|
|
```
|
|
|
|
Notes:
|
|
|
|
- `send()` / `pack()` require **every** signal in the frame; ldfparser
|
|
raises if one is missing. Use `receive()` first if you want to merge a
|
|
change into the current state.
|
|
- `receive()` returns `None` on timeout (rather than raising), so polling
|
|
loops stay simple.
|
|
- All frame lookups are cached per `FrameIO` instance — repeated calls to
|
|
`send`/`receive`/`frame` for the same name don't re-walk the LDF.
|
|
|
|
---
|
|
|
|
## 4. `AlmTester` API reference
|
|
|
|
`AlmTester` is the contributor-facing surface. Built by the
|
|
`tests/hardware/mum/conftest.py` `alm` fixture; tests just request it.
|
|
|
|
```python
|
|
class AlmTester:
|
|
def __init__(self, fio: FrameIO, nad: int): ...
|
|
|
|
@property
|
|
def fio(self) -> FrameIO # the underlying FrameIO (rarely needed)
|
|
@property
|
|
def nad(self) -> int # bound node NAD
|
|
|
|
# ─── Per-signal readers (ALM_Status, Tj_Frame, PWM_Frame, PWM_wo_Comp) ─
|
|
def read_led_state(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int
|
|
def read_nad(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
|
|
def read_voltage_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
|
|
def read_thermal_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
|
|
def read_nvm_status(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
|
|
def read_sig_comm_err(self, timeout: float = STATE_RECEIVE_TIMEOUT) -> int | None
|
|
def read_ntc_kelvin(self) -> int | None
|
|
def read_ntc_celsius(self) -> float | None
|
|
def read_pwm(self) -> tuple[int, int, int, int] | None # (R, G, B1, B2)
|
|
def read_pwm_wo_comp(self) -> tuple[int, int, int] | None # (R, G, B)
|
|
|
|
# ─── Wait helpers ──────────────────────────────────────────────────────
|
|
def wait_for_state(self, target: int, timeout: float
|
|
) -> tuple[bool, float, list[int]]
|
|
def wait_for_led_on(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool
|
|
def wait_for_led_off(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool
|
|
def wait_for_animating(self, timeout: float = STATE_TIMEOUT_DEFAULT) -> bool
|
|
def measure_animating_window(self, max_wait: float
|
|
) -> tuple[float | None, list[int]]
|
|
|
|
# ─── Per-action senders (ALM_Req_A) ────────────────────────────────────
|
|
def send_color(self, *, red, green, blue, intensity=255,
|
|
mode=Mode.IMMEDIATE_SETPOINT,
|
|
update=Update.IMMEDIATE_COLOR_UPDATE,
|
|
duration=0,
|
|
lid_from=None, lid_to=None) -> None # unicast (defaults to alm.nad)
|
|
def send_color_broadcast(self, *, red, green, blue, ...) -> None # LID 0x00..0xFF
|
|
def save_color(self, *, red, green, blue, ...) -> None # Update.COLOR_MEMORIZATION
|
|
def apply_saved_color(self) -> None # Update.APPLY_MEMORIZED_COLOR
|
|
def discard_saved_color(self) -> None # Update.DISCARD_MEMORIZED_COLOR
|
|
|
|
# ─── ConfigFrame sender ────────────────────────────────────────────────
|
|
def send_config(self, *, calibration=0,
|
|
enable_derating=1, enable_compensation=1,
|
|
max_lm=3840) -> None
|
|
|
|
# ─── LED state control ─────────────────────────────────────────────────
|
|
def force_off(self) -> None # drives intensity=0; sleeps to settle
|
|
|
|
# ─── Cross-frame PWM assertions ────────────────────────────────────────
|
|
def assert_pwm_matches_rgb(self, rp, r, g, b, *, label: str = "") -> None
|
|
def assert_pwm_wo_comp_matches_rgb(self, rp, r, g, b, *, label: str = "") -> None
|
|
```
|
|
|
|
Quick notes:
|
|
|
|
- **Enum parameters accept `int` too** — both `Mode.IMMEDIATE_SETPOINT` and
|
|
plain `0` work because the generated enums inherit from `IntEnum`.
|
|
- **`lid_from` / `lid_to` default to `alm.nad`** — pass them only for
|
|
range targeting (or use `send_color_broadcast`).
|
|
- **`assert_pwm_*` helpers** read `Tj_Frame_NTC` (Kelvin), convert to °C,
|
|
and pass it to `compute_pwm` so temperature compensation matches
|
|
what the ECU applies. They sleep `PWM_SETTLE_SECONDS` (10 LIN frame
|
|
periods) before sampling the PWM frame. The optional `label`
|
|
parameter lets you append a suffix when asserting PWM more than once
|
|
in the same test.
|
|
|
|
---
|
|
|
|
## 5. Constants and utilities (in `alm_helpers`)
|
|
|
|
```python
|
|
# ALMLEDState (from LDF Signal_encoding_types: LED_State)
|
|
LED_STATE_OFF = 0
|
|
LED_STATE_ANIMATING = 1
|
|
LED_STATE_ON = 2
|
|
|
|
# Test pacing — chosen against the 10 ms LIN frame periodicity
|
|
STATE_POLL_INTERVAL = 0.05 # 50 ms between polls (5 LIN periods)
|
|
STATE_RECEIVE_TIMEOUT = 0.2 # per-poll receive timeout
|
|
STATE_TIMEOUT_DEFAULT = 1.0 # default wait_for_state ceiling
|
|
PWM_SETTLE_SECONDS = 0.1 # let the slave refresh PWM_Frame TX buffer
|
|
DURATION_LSB_SECONDS = 0.2 # AmbLightDuration scale: 1 LSB = 200 ms
|
|
FORCE_OFF_SETTLE_SECONDS = 0.4 # pause after the OFF command
|
|
|
|
# PWM tolerances
|
|
KELVIN_TO_CELSIUS_OFFSET = 273.15
|
|
PWM_ABS_TOL = 3277 # ±5% of 16-bit full scale
|
|
PWM_REL_TOL = 0.05 # ±5% of expected, whichever is larger
|
|
|
|
# Pure utilities
|
|
def ntc_kelvin_to_celsius(ntc_raw: int) -> float
|
|
def pwm_within_tol(actual: int, expected: int) -> bool
|
|
```
|
|
|
|
---
|
|
|
|
## 6. Fixture wiring
|
|
|
|
`fio`, `alm`, `nad`, and the autouse `_reset_to_off` are provided by
|
|
`tests/hardware/mum/conftest.py` — session-scoped (except `_reset_to_off`,
|
|
which must be function-scoped) and shared by every MUM test. A new MUM test
|
|
just lists them in its signature:
|
|
|
|
```python
|
|
def test_red_at_full(fio, alm, rp):
|
|
fio.send("ALM_Req_A", ...)
|
|
alm.assert_pwm_matches_rgb(rp, 255, 0, 0)
|
|
```
|
|
|
|
The MUM gate (`if config.interface.type != "mum": pytest.skip(...)`) is a
|
|
session-scoped autouse `_require_mum` in the same conftest — no per-test
|
|
opt-in needed.
|
|
|
|
The `lin`, `ldf`, and `config` fixtures are provided globally by
|
|
`tests/conftest.py`; see [`24_test_wiring.md`](24_test_wiring.md) for the
|
|
full three-layer fixture topology and the rationale behind the access
|
|
control.
|
|
|
|
### Overriding the autouse reset
|
|
|
|
A module that needs a richer baseline (e.g. `tests/hardware/mum/test_overvolt.py`
|
|
restores the PSU rail in addition to the LED) overrides `_reset_to_off`
|
|
locally — the local definition shadows the conftest's:
|
|
|
|
```python
|
|
@pytest.fixture(autouse=True)
|
|
def _reset_to_off(psu, alm):
|
|
apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2)
|
|
alm.force_off()
|
|
yield
|
|
apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2)
|
|
alm.force_off()
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Cookbook
|
|
|
|
### Drive the LED to a color and verify both PWM frames
|
|
|
|
```python
|
|
from alm_helpers import AlmTester, LedState
|
|
|
|
def test_red_at_full(alm: AlmTester, rp):
|
|
r, g, b = 255, 0, 0
|
|
alm.send_color(red=r, green=g, blue=b, duration=10)
|
|
|
|
assert alm.wait_for_led_on(timeout=1.0)
|
|
alm.assert_pwm_matches_rgb(rp, r, g, b)
|
|
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
|
|
```
|
|
|
|
### Toggle a single ConfigFrame bit and restore it
|
|
|
|
```python
|
|
def test_with_compensation_off(alm: AlmTester, rp):
|
|
try:
|
|
alm.send_config(enable_compensation=0)
|
|
time.sleep(0.2)
|
|
# ... drive the LED, observe non-compensated PWM ...
|
|
finally:
|
|
alm.send_config(enable_compensation=1)
|
|
time.sleep(0.2)
|
|
```
|
|
|
|
### Read one signal periodically
|
|
|
|
```python
|
|
nad = alm.read_nad(timeout=0.5)
|
|
if nad is None:
|
|
pytest.skip("ECU silent")
|
|
```
|
|
|
|
### Save / apply / discard a color (AmbLightUpdate semantics)
|
|
|
|
```python
|
|
# Buffer a colour without applying — LED state must not change yet.
|
|
alm.save_color(red=0, green=255, blue=0, mode=Mode.FADING_EFFECT_1, duration=10)
|
|
assert alm.read_led_state() == LedState.LED_OFF
|
|
|
|
# Commit later (semantics depend on firmware; not all builds support this).
|
|
alm.apply_saved_color()
|
|
assert alm.wait_for_led_on(timeout=2.0)
|
|
```
|
|
|
|
### Drop down to `fio` for cases the facade doesn't model
|
|
|
|
Schema validation, MUM-only `send_raw`, or frames AlmTester doesn't yet
|
|
know about — these legitimately need the low-level surface. The fixture
|
|
makes `fio` available alongside `alm`:
|
|
|
|
```python
|
|
def test_schema_has_frame(fio, alm, rp):
|
|
# AlmTester doesn't expose LDF introspection by design — fio does.
|
|
try:
|
|
fio.frame("ALM_Req_B")
|
|
except Exception:
|
|
pytest.skip("ALM_Req_B not in current LDF")
|
|
|
|
# ... continue with raw fio.send / receive ...
|
|
```
|
|
|
|
If the rare cases become common, add a facade method to `alm_helpers.py`
|
|
and use it from then on. The maintenance pact (see §1) keeps test bodies
|
|
short.
|
|
|
|
---
|
|
|
|
## 8. Writing a new test
|
|
|
|
### 8.1 Starting point
|
|
|
|
A heavily-annotated, copyable template lives at
|
|
[`tests/hardware/_test_case_template.py`](../tests/hardware/_test_case_template.py).
|
|
The leading underscore stops pytest from collecting it, so the example
|
|
bodies don't run on the bench.
|
|
|
|
Copy it to a new file named `test_<feature>.py` under `tests/hardware/`
|
|
and edit. The template includes:
|
|
|
|
- The standard imports for `frame_io` and `alm_helpers`
|
|
- The three module-level fixtures (`fio`, `alm`, `_reset_to_off`) with
|
|
inline explanations of fixture scope, `autouse`, and `yield`
|
|
- Three skeleton bodies (one per common shape — see §7.3)
|
|
- An appendix listing the most-reached-for patterns
|
|
|
|
### 8.2 The four-phase test pattern
|
|
|
|
Every hardware test that mutates ECU state beyond just the LED should
|
|
follow a **SETUP / PROCEDURE / ASSERT / TEARDOWN** structure with a
|
|
`try`/`finally` so the teardown runs even when an assertion fails.
|
|
|
|
```python
|
|
def test_xyz(fio, alm, rp):
|
|
"""..."""
|
|
# ── SETUP ──────────────────────────────────────
|
|
# Bring the ECU to the exact state THIS test needs, beyond what the
|
|
# autouse reset already gave us. Anything you change here MUST be
|
|
# undone in TEARDOWN below.
|
|
fio.send("ConfigFrame", ConfigFrame_EnableCompensation=0, ...)
|
|
time.sleep(0.2)
|
|
|
|
try:
|
|
# ── PROCEDURE ──────────────────────────────
|
|
# The actions whose effects you are validating.
|
|
fio.send("ALM_Req_A", ...)
|
|
reached, _, history = alm.wait_for_state(LED_STATE_ON, timeout=1.0)
|
|
|
|
# ── ASSERT ─────────────────────────────────
|
|
# Bus-observable expectations. Use `rp("key", value)` to attach
|
|
# diagnostics to the report, then assert.
|
|
rp("led_state_history", history)
|
|
assert reached, history
|
|
alm.assert_pwm_wo_comp_matches_rgb(rp, r, g, b)
|
|
|
|
finally:
|
|
# ── TEARDOWN ───────────────────────────────
|
|
# Always runs. Restores anything SETUP perturbed.
|
|
fio.send("ConfigFrame", ConfigFrame_EnableCompensation=1, ...)
|
|
time.sleep(0.2)
|
|
```
|
|
|
|
### Why this gives you test independence
|
|
|
|
Pytest runs tests in a deterministic order (the order they appear in the
|
|
file). Without strict teardown, a failure midway through one test can
|
|
leave the ECU in a non-default state that breaks every subsequent test
|
|
— turning a single bug into a cascade. The four-phase pattern prevents
|
|
that with two layers:
|
|
|
|
| Layer | What it covers | Where it lives |
|
|
|---|---|---|
|
|
| Common baseline | LED → OFF | autouse `_reset_to_off` fixture |
|
|
| Per-test specifics | ConfigFrame, schedules, mode flags, anything else | the test's own `try`/`finally` |
|
|
|
|
The autouse fixture handles the universal baseline so individual tests
|
|
don't have to think about it; the per-test `try`/`finally` handles
|
|
whatever that specific test mutated.
|
|
|
|
### When you can skip the four phases
|
|
|
|
If your test only sends a frame and observes the LED state (i.e. the
|
|
*only* mutable state involved is something the autouse reset already
|
|
restores), the explicit SETUP/TEARDOWN sections are dead weight — just
|
|
write the procedure straight through. Flavor A in the template
|
|
illustrates this minimal shape.
|
|
|
|
### 8.3 Three flavors in the template
|
|
|
|
| Flavor | When to use it |
|
|
|---|---|
|
|
| **A — minimal** | Test only drives the LED and asserts on PWM/state. The autouse reset is enough. |
|
|
| **B — with isolation** | Test changes any persistent ECU state (ConfigFrame, schedules, NAD, …). Use the `try`/`finally` pattern. |
|
|
| **C — single-signal probe** | "Ask the ECU one thing and check the answer." Uses `fio.read_signal(...)`, no state mutation. |
|
|
|
|
Pick the closest one, delete the others, rename the function and fill
|
|
in the docstring.
|
|
|
|
### 8.4 Tests that drive the PSU and observe the LIN bus
|
|
|
|
For *combined* PSU + LIN scenarios (overvoltage / undervoltage
|
|
tolerance, brown-out behaviour, supply transients) there is a
|
|
dedicated template at
|
|
[`tests/hardware/_test_case_template_psu_lin.py`](../tests/hardware/_test_case_template_psu_lin.py).
|
|
It adds a `psu` fixture (cross-platform port resolution + safe-off
|
|
on close), an autouse `_park_at_nominal` fixture, a
|
|
`wait_for_voltage_status` polling helper, and three flavors:
|
|
|
|
| Flavor | Demonstrates |
|
|
|---|---|
|
|
| A — overvoltage | Drive PSU above the OV threshold, expect `ALMVoltageStatus = 0x02`, restore. |
|
|
| B — undervoltage | Symmetric for UV (`0x01`). |
|
|
| C — sweep | Parametrized walk over `(V, expected_status)` tuples. |
|
|
|
|
For the *settling time* characterization that feeds these tests'
|
|
detect timeouts, see `tests/hardware/psu/test_psu_voltage_settling.py`
|
|
(opt-in via `pytest -m psu_settling`).
|
|
|
|
See [`docs/14_power_supply.md` §6](14_power_supply.md#6-run-the-hardware-test) and [§5 (session-managed power)](14_power_supply.md#5-session-managed-power-the-bench-powers-the-ecu-through-the-psu)
|
|
for the full reference and the constants to tune for your firmware.
|
|
|
|
---
|
|
|
|
## 9. Related docs
|
|
|
|
- [`04_lin_interface_call_flow.md`](04_lin_interface_call_flow.md) — what
|
|
`LinInterface.send`/`receive` does under the hood for each adapter.
|
|
- [`16_mum_internals.md`](16_mum_internals.md) — MUM-specific behaviour
|
|
the helpers rely on (master-driven receive, frame-length map, …).
|
|
- [`17_ldf_parser.md`](17_ldf_parser.md) — how the LDF is loaded and how
|
|
`pack` / `unpack` are implemented.
|
|
- [`13_unit_testing_guide.md`](13_unit_testing_guide.md) — unit-test
|
|
conventions, markers, coverage.
|
|
- [`15_report_properties_cheatsheet.md`](15_report_properties_cheatsheet.md)
|
|
— the standard `rp("key", value)` keys these helpers emit.
|