ecu-tests/tests/hardware/mum/test_mum_alm_animation_generated.py
Hosam-Eldin Mostafa 8fa4cf0be1 refactor(tests): layer fixtures by adapter type (mum/psu/babylin)
Restructures tests/hardware/ so that fixture access is controlled by
directory layout — pytest only walks upward through conftest.py files,
so a PSU test physically cannot request fio/alm/nad.

Layout:
- tests/hardware/conftest.py           (unchanged: PSU fixtures)
- tests/hardware/mum/conftest.py       NEW: _require_mum (session autouse),
                                       fio (session), nad (session),
                                       alm (session), _reset_to_off
                                       (function autouse)
- tests/hardware/mum/**                MUM tests + swe5/ + swe6/
- tests/hardware/psu/**                PSU-only tests
- tests/hardware/babylin/**            deprecated BabyLIN E2E

What this removes (was duplicated before):
- 7 verbatim copies of the `fio` fixture
- 6 copies of the `alm` fixture
- 6 copies of the `_reset_to_off` autouse
- 9 inline `if config.interface.type != "mum": pytest.skip(...)` gates

What this changes by design:
- fio / alm / nad scope: module → session. NAD discovery happens once
  per run instead of once per module. The helpers are immutable beyond
  their constructor args, so sharing them is safe; per-test state is
  reset by the autouse `_reset_to_off`.
- test_overvolt.py: `_park_at_nominal` is now `_reset_to_off`, which
  cleanly overrides the conftest's LED-only version (PSU + LED reset).
- test_mum_alm_animation_generated.py keeps a local `_reset_to_off` +
  `_force_off` so its "no AlmTester anywhere" demonstration is preserved
  via fixture override; the local `nad` is also retained because it
  uses the typed `AlmStatus.receive` API.

Docs:
- docs/24_test_wiring.md NEW — describes the three-layer fixture
  topology, lifecycle sequence diagram, helper class wiring, and the
  playbook for adding a new framework component.
- docs/05_architecture_overview.md: add MCF (mum conftest) node to the
  Mermaid diagram + mention it in the components list.
- docs/19_frame_io_and_alm_helpers.md: replace the per-module
  fixture-wiring example with a request-fixtures-by-name snippet plus
  the override pattern.
- Path references swept across docs/02, docs/14, docs/18, docs/20,
  docs/README to point at the new locations.

Verified: pytest --collect-only collects 93 tests with no errors;
30 unit tests and 10 mock-only smoke tests pass; fixture-per-test
output shows PSU tests cannot see fio/alm/nad.

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

537 lines
21 KiB
Python

"""Animation tests using only the generated LIN API + FrameIO.
Parallels :mod:`test_mum_alm_animation` but imports **nothing** from
``alm_helpers`` — frame and signal names, state values, encoding-type
constants, and tolerances all come from the generated ``_generated.lin_api``
module (or are declared locally in this file).
Why this file exists:
- It's a worked example of what tests look like when they go straight
through the generated layer.
- It makes the trade-off concrete. The patterns ``AlmTester`` provides
(``force_off``, ``wait_for_state``, ``measure_animating_window``,
``assert_pwm_matches_rgb``) reappear in this file as module-level
helpers because they can't be derived from the LDF — they're test
intent, not schema.
- It serves as a reference for "what does the generated layer give you
on its own" before deciding whether a future ECU needs its own
``<ecu>_helpers.py``.
If you're writing a *new* ALM test that needs these patterns, prefer the
``alm_helpers.AlmTester`` path — the patterns are reused across the suite
and belong in one place. This file deliberately duplicates them to
demonstrate the seam.
"""
from __future__ import annotations
import time
from typing import Optional
import pytest
from frame_io import FrameIO
from vendor.rgb_to_pwm import compute_pwm
from _generated.lin_api import (
AlmReqA,
AlmStatus,
ConfigFrame,
PwmFrame,
PwmWoComp,
TjFrame,
LedState,
Mode,
Update,
)
pytestmark = [pytest.mark.ANM]
# --- cadences / tolerances (not in the LDF) --------------------------------
# These are test-bench choices, not schema. They mirror the values in
# alm_helpers.py:40-53 and exist here only because this file is a worked
# example of avoiding the alm_helpers import.
STATE_POLL_INTERVAL = 0.05 # 50 ms (5 LIN periods)
STATE_TIMEOUT_DEFAULT = 1.0
PWM_SETTLE_SECONDS = 0.1 # 100 ms — TX-buffer refresh
FORCE_OFF_SETTLE_SECONDS = 0.4
KELVIN_TO_CELSIUS_OFFSET = 273.15
PWM_ABS_TOL = 3277 # ±5% of 16-bit full scale
PWM_REL_TOL = 0.05
# --- module-local semantic helpers -----------------------------------------
# These mirror AlmTester's methods. They live here only because this file
# is the "no alm_helpers" reference. New code should use AlmTester instead.
def _force_off(fio: FrameIO, nad: int) -> None:
AlmReqA.send(
fio,
AmbLightColourRed=0, AmbLightColourGreen=0, AmbLightColourBlue=0,
AmbLightIntensity=0,
AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
AmbLightMode=Mode.IMMEDIATE_SETPOINT,
AmbLightDuration=0,
AmbLightLIDFrom=nad, AmbLightLIDTo=nad,
)
time.sleep(FORCE_OFF_SETTLE_SECONDS)
def _read_led_state(fio: FrameIO, timeout: float = 0.2) -> int:
"""Read ALM_Status.ALMLEDState; -1 on timeout."""
decoded = AlmStatus.receive(fio, timeout=timeout)
if decoded is None:
return -1
return int(decoded.get("ALMLEDState", -1))
def _wait_for_state(
fio: FrameIO, target: int, timeout: float
) -> tuple[bool, float, list[int]]:
seen: list[int] = []
start = time.monotonic()
deadline = start + timeout
while time.monotonic() < deadline:
st = _read_led_state(fio)
if not seen or seen[-1] != st:
seen.append(st)
if st == target:
return True, time.monotonic() - start, seen
time.sleep(STATE_POLL_INTERVAL)
return False, time.monotonic() - start, seen
def _measure_animating_window(
fio: FrameIO, max_wait: float
) -> tuple[Optional[float], list[int]]:
seen: list[int] = []
started_at: Optional[float] = None
deadline = time.monotonic() + max_wait
while time.monotonic() < deadline:
st = _read_led_state(fio)
if not seen or seen[-1] != st:
seen.append(st)
if started_at is None and st == LedState.LED_ANIMATING:
started_at = time.monotonic()
elif started_at is not None and st != LedState.LED_ANIMATING:
return time.monotonic() - started_at, seen
time.sleep(STATE_POLL_INTERVAL)
return None, seen
def _pwm_within_tol(actual: int, expected: int) -> bool:
return abs(actual - expected) <= max(PWM_ABS_TOL, abs(expected) * PWM_REL_TOL)
def _band(expected: int) -> int:
return max(PWM_ABS_TOL, int(abs(expected) * PWM_REL_TOL))
def _assert_pwm_matches_rgb(fio: FrameIO, rp, r: int, g: int, b: int) -> None:
"""PWM_Frame_{Red,Green,Blue1,Blue2} match compute_pwm(...).pwm_comp."""
ntc_raw = TjFrame.read_signal(fio, "Tj_Frame_NTC")
assert ntc_raw is not None, "Tj_Frame not received within timeout"
temp_c = float(ntc_raw) - KELVIN_TO_CELSIUS_OFFSET
rp("ntc_raw_kelvin", int(ntc_raw))
rp("temp_c_used", round(temp_c, 2))
exp_r, exp_g, exp_b = compute_pwm(r, g, b, temp_c=temp_c).pwm_comp
rp("expected_pwm", {
"red": exp_r, "green": exp_g, "blue": exp_b,
"rgb_in": (r, g, b), "temp_c_used": round(temp_c, 2),
})
time.sleep(PWM_SETTLE_SECONDS)
decoded = PwmFrame.receive(fio)
assert decoded is not None, "PWM_Frame not received within timeout"
actual_r = int(decoded["PWM_Frame_Red"])
actual_g = int(decoded["PWM_Frame_Green"])
actual_b1 = int(decoded["PWM_Frame_Blue1"])
actual_b2 = int(decoded["PWM_Frame_Blue2"])
rp("actual_pwm", {
"red": actual_r, "green": actual_g,
"blue1": actual_b1, "blue2": actual_b2,
})
assert _pwm_within_tol(actual_r, exp_r), (
f"PWM_Frame_Red {actual_r} differs from expected {exp_r} "
f"by more than ±{_band(exp_r)} (rgb_in={(r, g, b)})"
)
assert _pwm_within_tol(actual_g, exp_g), (
f"PWM_Frame_Green {actual_g} differs from expected {exp_g} "
f"by more than ±{_band(exp_g)} (rgb_in={(r, g, b)})"
)
assert _pwm_within_tol(actual_b1, exp_b), (
f"PWM_Frame_Blue1 {actual_b1} differs from expected {exp_b} "
f"by more than ±{_band(exp_b)} (rgb_in={(r, g, b)})"
)
assert _pwm_within_tol(actual_b2, exp_b), (
f"PWM_Frame_Blue2 {actual_b2} differs from expected {exp_b} "
f"by more than ±{_band(exp_b)} (rgb_in={(r, g, b)})"
)
def _assert_pwm_wo_comp_matches_rgb(fio: FrameIO, rp, r: int, g: int, b: int) -> None:
"""PWM_wo_Comp_{Red,Green,Blue} match compute_pwm(...).pwm_no_comp."""
exp_r, exp_g, exp_b = compute_pwm(r, g, b).pwm_no_comp
rp("expected_pwm_wo_comp", {
"red": exp_r, "green": exp_g, "blue": exp_b, "rgb_in": (r, g, b),
})
rp("ntc_raw_kelvin", TjFrame.read_signal(fio, "Tj_Frame_NTC"))
time.sleep(PWM_SETTLE_SECONDS)
decoded = PwmWoComp.receive(fio)
assert decoded is not None, "PWM_wo_Comp not received within timeout"
actual_r = int(decoded["PWM_wo_Comp_Red"])
actual_g = int(decoded["PWM_wo_Comp_Green"])
actual_b = int(decoded["PWM_wo_Comp_Blue"])
rp("actual_pwm_wo_comp", {
"red": actual_r, "green": actual_g, "blue": actual_b,
})
assert _pwm_within_tol(actual_r, exp_r), (
f"PWM_wo_Comp_Red {actual_r} differs from expected {exp_r} "
f"by more than ±{_band(exp_r)} (rgb_in={(r, g, b)})"
)
assert _pwm_within_tol(actual_g, exp_g), (
f"PWM_wo_Comp_Green {actual_g} differs from expected {exp_g} "
f"by more than ±{_band(exp_g)} (rgb_in={(r, g, b)})"
)
assert _pwm_within_tol(actual_b, exp_b), (
f"PWM_wo_Comp_Blue {actual_b} differs from expected {exp_b} "
f"by more than ±{_band(exp_b)} (rgb_in={(r, g, b)})"
)
# --- fixtures --------------------------------------------------------------
#
# ``fio`` comes from ``tests/hardware/mum/conftest.py``. We deliberately
# keep local ``nad`` and ``_reset_to_off`` overrides here so that this
# module continues to demonstrate the "no AlmTester anywhere" path — the
# typed ``AlmStatus.receive`` / ``AlmReqA.send`` calls (via ``_force_off``)
# replace what AlmTester would do.
@pytest.fixture(scope="module")
def nad(fio: FrameIO) -> int:
"""Live NAD reported by ALM_Status; used as LIDFrom/LIDTo in unicast sends.
Overrides the conftest's stringly-typed ``nad`` fixture to use the
generated typed ``AlmStatus.receive`` API instead.
"""
decoded = AlmStatus.receive(fio, timeout=1.0)
if decoded is None:
pytest.skip("ECU not responding on ALM_Status — check wiring/power")
n = int(decoded["ALMNadNo"])
if not (0x01 <= n <= 0xFE):
pytest.skip(f"ECU reports invalid NAD {n:#x} — auto-addressing first")
return n
@pytest.fixture(autouse=True)
def _reset_to_off(fio: FrameIO, nad: int):
"""Force LED to OFF before and after each test using only the generated API.
Overrides the conftest's AlmTester-based ``_reset_to_off`` to keep this
module's "no AlmTester" demonstration intact.
"""
_force_off(fio, nad)
yield
_force_off(fio, nad)
# --- tests: AmbLightMode behavior ------------------------------------------
def test_mode0_immediate_setpoint_drives_led_on(fio: FrameIO, nad: int, rp):
"""
Title: Mode 0 - Immediate Setpoint reaches LED_ON and both PWM frames match RGB pipeline
Description:
With AmbLightMode=IMMEDIATE_SETPOINT the ECU jumps directly to the
requested color at full intensity. ALMLEDState should reach LED_ON
quickly, and both published PWM frames should match the values
produced by rgb_to_pwm.compute_pwm():
- PWM_Frame_{Red,Green,Blue1,Blue2} match .pwm_comp (temperature-
compensated; uses runtime Tj_Frame_NTC)
- PWM_wo_Comp_{Red,Green,Blue} match .pwm_no_comp (non-compensated;
temperature-independent)
Requirements: REQ-MODE0-IMMEDIATE
"""
r, g, b = 0, 180, 80
# ── PROCEDURE ──────────────────────────────────────────────────────
AlmReqA.send(
fio,
AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
AmbLightIntensity=255,
AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
AmbLightMode=Mode.IMMEDIATE_SETPOINT,
AmbLightDuration=10,
AmbLightLIDFrom=nad, AmbLightLIDTo=nad,
)
reached, elapsed, history = _wait_for_state(
fio, LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT
)
# ── ASSERT ─────────────────────────────────────────────────────────
rp("led_state_history", history)
rp("on_elapsed_s", round(elapsed, 3))
assert reached, f"LEDState never reached LED_ON (history: {history})"
_assert_pwm_matches_rgb(fio, rp, r, g, b)
_assert_pwm_wo_comp_matches_rgb(fio, rp, r, g, b)
def test_mode1_fade_passes_through_animating(fio: FrameIO, nad: int, rp):
"""
Title: Mode 1 - Fade RGB + Intensity passes through LED_ANIMATING and settles to expected PWM
Description:
AmbLightMode=FADING_EFFECT_1 requests a smooth fade. We try to
observe the LED_OFF -> LED_ANIMATING -> LED_ON transition (recorded
as ``animating_observed`` in report properties) but don't fail on
it — the firmware's ANIMATING window is short and easily missed by
bus polling. The primary expectation is that ALMLEDState reaches
LED_ON and that PWM_wo_Comp matches rgb_to_pwm.compute_pwm().pwm_no_comp
for the requested RGB at full intensity.
Requirements: REQ-MODE1-FADE
"""
r, g, b = 255, 40, 0
# ── SETUP ──────────────────────────────────────────────────────────
# Disable temperature compensation so the assertion can use PWM_wo_Comp
# (which is temperature-independent). Restore in finally.
ConfigFrame.send(
fio,
ConfigFrame_Calibration=0,
ConfigFrame_EnableDerating=1,
ConfigFrame_EnableCompensation=0,
ConfigFrame_MaxLM=3840,
)
time.sleep(0.2)
try:
# ── PROCEDURE ──────────────────────────────────────────────────
AlmReqA.send(
fio,
AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
AmbLightIntensity=255,
AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
AmbLightMode=Mode.FADING_EFFECT_1,
AmbLightDuration=10,
AmbLightLIDFrom=nad, AmbLightLIDTo=nad,
)
animating_s, history = _measure_animating_window(fio, max_wait=4.0)
reached_on, _, post_history = _wait_for_state(
fio, LedState.LED_ON, timeout=4.0
)
# ── ASSERT ─────────────────────────────────────────────────────
rp("led_state_history", history)
rp("animating_seconds", animating_s)
rp("animating_observed", LedState.LED_ON in history)
rp("post_history", post_history)
assert reached_on, (
f"LEDState did not reach LED_ON after Mode 1 fade ({post_history})"
)
_assert_pwm_wo_comp_matches_rgb(fio, rp, r, g, b)
finally:
# ── TEARDOWN ───────────────────────────────────────────────────
ConfigFrame.send(
fio,
ConfigFrame_Calibration=0,
ConfigFrame_EnableDerating=1,
ConfigFrame_EnableCompensation=1,
ConfigFrame_MaxLM=3840,
)
time.sleep(0.2)
# --- tests: AmbLightUpdate save / apply / discard --------------------------
def test_update1_save_does_not_apply_immediately(fio: FrameIO, nad: int, rp):
"""
Title: AmbLightUpdate=COLOR_MEMORIZATION does not change LED state
Description:
With AmbLightUpdate=COLOR_MEMORIZATION the ECU should buffer the
command without executing it. ALMLEDState therefore must remain at
the prior value (LED_OFF baseline) — no transition to LED_ON or
LED_ANIMATING.
Requirements: REQ-101
"""
# ── PROCEDURE ──────────────────────────────────────────────────────
AlmReqA.send(
fio,
AmbLightColourRed=0, AmbLightColourGreen=255, AmbLightColourBlue=0,
AmbLightIntensity=255,
AmbLightUpdate=Update.COLOR_MEMORIZATION,
AmbLightMode=Mode.FADING_EFFECT_1,
AmbLightDuration=10,
AmbLightLIDFrom=nad, AmbLightLIDTo=nad,
)
deadline = time.monotonic() + 1.0
history: list[int] = []
while time.monotonic() < deadline:
st = _read_led_state(fio)
if not history or history[-1] != st:
history.append(st)
time.sleep(STATE_POLL_INTERVAL)
# ── ASSERT ─────────────────────────────────────────────────────────
rp("led_state_history", history)
assert LedState.LED_ANIMATING not in history, (
f"Save (Update.COLOR_MEMORIZATION) unexpectedly triggered ANIMATING: {history}"
)
assert LedState.LED_ON not in history, (
f"Save (Update.COLOR_MEMORIZATION) unexpectedly drove LED ON: {history}"
)
# --- tests: LID range targeting --------------------------------------------
def test_lid_broadcast_targets_node(fio: FrameIO, nad: int, rp):
"""
Title: LIDFrom=0x00, LIDTo=0xFF (broadcast) reaches this node
Description:
A broadcast LID range should include any NAD, so this node should
react. We assert against LED_OFF here (matches the parallel test
in test_mum_alm_animation.py:447 — note that test compares against
OFF, not ON; preserving the same behavior).
Requirements: REQ-LID-BROADCAST, REQ-LID-LED-RESPONSE
"""
r, g, b = 120, 0, 255
# ── PROCEDURE ──────────────────────────────────────────────────────
AlmReqA.send(
fio,
AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
AmbLightIntensity=255,
AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
AmbLightMode=Mode.IMMEDIATE_SETPOINT,
AmbLightDuration=0,
AmbLightLIDFrom=0x00, AmbLightLIDTo=0xFF,
)
reached, elapsed, history = _wait_for_state(
fio, LedState.LED_OFF, timeout=STATE_TIMEOUT_DEFAULT
)
# ── ASSERT ─────────────────────────────────────────────────────────
rp("led_state_history", history)
rp("on_elapsed_s", round(elapsed, 3))
assert reached, f"Broadcast LID range failed to drive node OFF: {history}"
def test_lid_invalid_range_is_ignored(fio: FrameIO, nad: int, rp):
"""
Title: LIDFrom > LIDTo is rejected (no LED change)
Description:
An ill-formed LID range (From > To) should be ignored by the node;
ALMLEDState must remain at the LED_OFF baseline.
Requirements: REQ-LID-INVALID
"""
# ── PROCEDURE ──────────────────────────────────────────────────────
AlmReqA.send(
fio,
AmbLightColourRed=255, AmbLightColourGreen=255, AmbLightColourBlue=255,
AmbLightIntensity=255,
AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
AmbLightMode=Mode.IMMEDIATE_SETPOINT,
AmbLightDuration=0,
AmbLightLIDFrom=0x14, AmbLightLIDTo=0x0A, # From > To (invalid)
)
deadline = time.monotonic() + 1.0
history: list[int] = []
while time.monotonic() < deadline:
st = _read_led_state(fio)
if not history or history[-1] != st:
history.append(st)
time.sleep(STATE_POLL_INTERVAL)
# ── ASSERT ─────────────────────────────────────────────────────────
rp("led_state_history", history)
assert LedState.LED_ANIMATING not in history, (
f"Invalid LID range animated unexpectedly: {history}"
)
assert LedState.LED_ON not in history, (
f"Invalid LID range drove LED ON unexpectedly: {history}"
)
# --- tests: ConfigFrame compensation toggle --------------------------------
def test_disable_compensation_pwm_wo_comp_matches_uncompensated(fio: FrameIO, nad: int, rp):
"""
Title: ConfigFrame_EnableCompensation=0 -> PWM_wo_Comp matches non-compensated calculator output
Description:
Publishing ConfigFrame with ConfigFrame_EnableCompensation=0 turns
off the firmware's temperature-compensation pipeline. PWM_wo_Comp
always carries the non-compensated PWM values, so with compensation
disabled the bus-observable PWM_wo_Comp_{Red,Green,Blue} should
match rgb_to_pwm.compute_pwm(R,G,B).pwm_no_comp — which is
temperature-independent.
Requirements: REQ-CONFIG-COMP
"""
r, g, b = 0, 180, 80
# ── SETUP ──────────────────────────────────────────────────────────
ConfigFrame.send(
fio,
ConfigFrame_Calibration=0,
ConfigFrame_EnableDerating=1,
ConfigFrame_EnableCompensation=0,
ConfigFrame_MaxLM=3840,
)
time.sleep(0.2)
try:
# ── PROCEDURE ──────────────────────────────────────────────────
AlmReqA.send(
fio,
AmbLightColourRed=r, AmbLightColourGreen=g, AmbLightColourBlue=b,
AmbLightIntensity=255,
AmbLightUpdate=Update.IMMEDIATE_COLOR_UPDATE,
AmbLightMode=Mode.IMMEDIATE_SETPOINT,
AmbLightDuration=10,
AmbLightLIDFrom=nad, AmbLightLIDTo=nad,
)
reached, elapsed, history = _wait_for_state(
fio, LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT
)
# ── ASSERT ─────────────────────────────────────────────────────
rp("led_state_history", history)
rp("on_elapsed_s", round(elapsed, 3))
assert reached, (
f"LEDState never reached LED_ON with comp disabled (history: {history})"
)
_assert_pwm_wo_comp_matches_rgb(fio, rp, r, g, b)
finally:
# ── TEARDOWN ───────────────────────────────────────────────────
ConfigFrame.send(
fio,
ConfigFrame_Calibration=0,
ConfigFrame_EnableDerating=1,
ConfigFrame_EnableCompensation=1,
ConfigFrame_MaxLM=3840,
)
time.sleep(0.2)