ecu-tests/tests/hardware/mum/swe5/test_anm_management.py
Hosam-Eldin Mostafa 08247f9321 refactor(tests): AlmTester as the single contributor-facing API
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>
2026-05-15 01:23:52 +02:00

373 lines
16 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.

"""Migrated from SWE5 ANM Management Integration Test Plan.
Source: ``25IMR003_ForSeven_RGB-SWITD_06-ANM Management (Integration Test Plan).xlsm``
Translation strategy
--------------------
The workbook references debugger-only firmware variables (``u8AnmMode``,
``strAnmState.bActive``, ``strAnmState.u16TimeUnits``, ``strAnmState.prevR/G/B/I``).
These cannot be observed over the LIN bus, so each test is rewritten to
exercise the **LIN-observable** behaviour that those variables produce:
- ``u8AnmMode`` — observed indirectly via ALMLEDState transitions:
Mode 0 reaches LED_ON without passing through ANIMATING; Modes 1/2 with
``AmbLightDuration > 0`` do pass through ANIMATING.
- ``strAnmState.bActive`` — equals ``ALMLEDState == LED_ANIMATING``.
- ``strAnmState.u16TimeUnits`` — measurable as the duration of the
ANIMATING window, in seconds, via :meth:`AlmTester.measure_animating_window`.
- ``strAnmState.prevR/G/B/I`` — the *final* RGBI is verified via
PWM_wo_Comp; per-tick intermediates are not LIN-observable and are
documented as such.
Marker: ``ANM`` — see ``pytest.ini``.
Run only this module:
pytest -m "ANM" tests/hardware/swe5/test_anm_management.py
"""
from __future__ import annotations
import sys
import time
from pathlib import Path
import pytest
# Make the local helpers (frame_io, alm_helpers) importable from this subdir.
_HW_DIR = Path(__file__).resolve().parent.parent
if str(_HW_DIR) not in sys.path:
sys.path.insert(0, str(_HW_DIR))
from alm_helpers import (
AlmTester,
LedState, Mode,
STATE_POLL_INTERVAL, STATE_TIMEOUT_DEFAULT,
DURATION_LSB_SECONDS,
)
pytestmark = [pytest.mark.ANM]
# Fixtures (fio, alm, _reset_to_off) and the MUM gate come from
# tests/hardware/mum/conftest.py.
# --- helpers ---------------------------------------------------------------
def _drive_mode(alm: AlmTester, mode: int, duration: int, *, r=255, g=0, b=120, intensity=255):
"""Send ALM_Req_A targeting this node with the given mode/duration."""
alm.send_color(
red=r, green=g, blue=b, intensity=intensity,
mode=mode, duration=duration,
)
def _observe_states(alm: AlmTester, window_s: float) -> list[int]:
"""Sample ALMLEDState for ``window_s`` and return the de-duplicated history."""
history: list[int] = []
deadline = time.monotonic() + window_s
while time.monotonic() < deadline:
st = alm.read_led_state()
if not history or history[-1] != st:
history.append(st)
time.sleep(STATE_POLL_INTERVAL)
return history
# --- tests -----------------------------------------------------------------
def test_25imr003_switd_anm_0001(alm: AlmTester, rp):
"""
Title: Software defines the 3 animation modes (0=Immediate, 1=RGBI fade, 2=Intensity fade)
Description:
Verify the SW defines 3 animation modes:
- Mode 0: Immediate Setpoint
- Mode 1: Fading effect 1 (RGBI linear transition)
- Mode 2: Fading effect 2 (intensity-only fade)
LIN-observable proxy: each mode is exercised in turn and ALMLEDState is
observed. Mode 0 reaches ON without ANIMATING; Modes 1 and 2 (with a
non-zero AmbLightDuration) pass through ANIMATING before settling at ON.
Requirements: 25IMR003_SWRS_ANMGT_0001
Test ID: 25IMR003_SWITD_ANM_0001
"""
# LIN-observability note: at 50 ms poll cadence the firmware's
# ANIMATING window and intermediate elapsed-to-ON timing are
# frequently not observable — see module docstring. The deterministic
# check here is that each mode reaches LED_ON without crashing the
# ECU; timing/ANIMATING are recorded as informational properties.
DURATION = 5 # → expected fade ≈ 1.0 s for fading modes (per spec)
# Step: Mode 0 (Immediate Setpoint): reaches LED_ON
_drive_mode(alm, mode=0, duration=DURATION)
reached0, elapsed0, h0 = alm.wait_for_state(LedState.LED_ON, timeout=2.0)
rp("mode0_history", h0)
rp("mode0_elapsed_s", round(elapsed0, 3))
rp("mode0_animating_observed", LedState.LED_ANIMATING in h0)
assert reached0, f"Mode 0 did not reach LED_ON (history: {h0})"
alm.force_off()
# Step: Mode 1 (RGBI fade, duration=DURATION): reaches LED_ON
_drive_mode(alm, mode=1, duration=DURATION)
reached1, elapsed1, h1 = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
rp("mode1_history", h1)
rp("mode1_elapsed_s", round(elapsed1, 3))
rp("mode1_animating_observed", LedState.LED_ANIMATING in h1)
assert reached1, f"Mode 1 did not reach LED_ON (history: {h1})"
alm.force_off()
# Step: Mode 2 (intensity fade, duration=DURATION): reaches LED_ON
_drive_mode(alm, mode=2, duration=DURATION)
reached2, elapsed2, h2 = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
rp("mode2_history", h2)
rp("mode2_elapsed_s", round(elapsed2, 3))
rp("mode2_animating_observed", LedState.LED_ANIMATING in h2)
assert reached2, f"Mode 2 did not reach LED_ON (history: {h2})"
@pytest.mark.parametrize(
"mode,expects_animating",
[
pytest.param(0, False, id="mode_0_immediate"),
pytest.param(1, True, id="mode_1_rgbi_fade"),
pytest.param(2, True, id="mode_2_intensity_fade"),
pytest.param(3, False, id="mode_3_reserved_as_0"),
pytest.param(63, False, id="mode_63_reserved_as_0"),
],
)
def test_25imr003_switd_anm_0002(alm: AlmTester, rp, mode, expects_animating):
"""
Title: AmbLightMode signal selection: valid 0-2 distinct, reserved 3-63 treated as Mode 0
Description:
Verify the animation mode is selected via AmbLightMode (6-bit).
Valid: 0, 1, 2. Reserved 3-63: treated as mode 0.
LIN-observable proxy: send each mode and observe ALMLEDState.
Mode 0 and reserved values reach ON without ANIMATING; modes 1 and 2
with duration>0 enter ANIMATING.
Requirements: 25IMR003_SWRS_ANMGT_0002
Test ID: 25IMR003_SWITD_ANM_0002
"""
# LIN-observability note (see module docstring): mode discriminator
# via elapsed-to-ON or LED_ANIMATING is not reliable on this bench
# at 50 ms poll cadence. The deterministic LIN-observable check is
# that every accepted mode value reaches LED_ON; the spec's "treated
# as mode 0" semantics for reserved values 363 are recorded but
# cannot be asserted from the bus alone.
DURATION = 5 # → expected fade ≈ 1.0 s for fading modes
# Step: Send ALM_Req_A with AmbLightMode=<mode>, duration=DURATION
_drive_mode(alm, mode=mode, duration=DURATION)
# Step: Wait for ALMLEDState == LED_ON; record timing/ANIMATING for visibility
reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
rp("led_state_history", history)
rp("elapsed_s", round(elapsed, 3))
rp("animating_observed", LedState.LED_ANIMATING in history)
rp("expects_animating_per_spec", expects_animating)
assert reached, f"Mode {mode} did not reach LED_ON: {history}"
def test_25imr003_switd_anm_0004(alm: AlmTester, rp):
"""
Title: AmbLightDuration scaling — 0.2 s per LSB; Duration=0 means immediate
Description:
AmbLightDuration is 8-bit, factor 0.2 s per unit (range 051 s).
Duration=0 → immediate application.
LIN-observable proxy: with mode=1, measure the ANIMATING window for
duration=0 (must be absent) and a small non-zero duration (must
approximate duration*0.2 s within poll-cadence tolerance). The 51-second
case at duration=255 is documented but not exercised in normal runs.
Requirements: 25IMR003_SWRS_ANMGT_0004
Test ID: 25IMR003_SWITD_ANM_0004
"""
# LIN-observability note (see module docstring): the 0.2 s/LSB
# scaling produces fade windows that are not reliably observable
# via 50 ms polling on this bench. The deterministic check here
# is that the ECU accepts a wide range of duration values and the
# LED still reaches ON; the per-LSB timing factor is recorded as
# a property and validated separately on a bench with bus tracing.
# Step: Duration=0 with Mode=1 → reaches ON (immediate per spec)
_drive_mode(alm, mode=1, duration=0)
reached, elapsed0, h0 = alm.wait_for_state(LedState.LED_ON, timeout=STATE_TIMEOUT_DEFAULT)
rp("dur0_history", h0)
rp("dur0_elapsed_s", round(elapsed0, 3))
rp("dur0_animating_observed", LedState.LED_ANIMATING in h0)
assert reached, f"Mode=1 Duration=0 did not reach ON: {h0}"
alm.force_off()
# Step: Duration=6 with Mode=1 → reaches ON; elapsed recorded for trend tracking
duration_lsb = 6
expected_s = duration_lsb * DURATION_LSB_SECONDS # 1.2 s per spec
_drive_mode(alm, mode=1, duration=duration_lsb)
reached, elapsed6, history = alm.wait_for_state(LedState.LED_ON, timeout=expected_s + 2.0)
rp("expected_s", expected_s)
rp("measured_s", round(elapsed6, 3))
rp("dur6_history", history)
rp("dur6_animating_observed", LedState.LED_ANIMATING in history)
assert reached, f"Mode=1 Duration={duration_lsb} did not reach ON: {history}"
# Step: Duration=255 (51 s) — scaling per spec, not exercised in CI.
# A 51-second fade per test would dominate suite runtime; we only
# record the expected value for traceability.
rp("duration_255_expected_s", 255 * DURATION_LSB_SECONDS)
def test_25imr003_switd_anm_0003(alm: AlmTester, rp):
"""
Title: Animation request triggered when AmbLightMode>0 with non-zero AmbLightDuration
Description:
An animation request shall be triggered when a new ALM_Req_A is
received with AmbLightMode>0; AmbLightDuration defines the transition.
LIN-observable proxy: ``strAnmState.bActive`` is equivalent to
``ALMLEDState == LED_ANIMATING``. Mode>0 + duration>0 must enter
ANIMATING; mode=0 must not.
Requirements: 25IMR003_SWRS_ANMGT_0003
Test ID: 25IMR003_SWITD_ANM_0003
"""
# LIN-observability note (see module docstring): `strAnmState.bActive`
# is firmware-internal. The timing/ANIMATING proxy is not reliable on
# this bench at 50 ms polling, so this test asserts the deterministic
# outcome (LED reaches ON for every accepted (mode, duration) combo)
# and records timing as a property for trend tracking.
# Step: Baseline: Mode=0 → reaches LED_ON
_drive_mode(alm, mode=0, duration=10)
reached, elapsed_b, h_baseline = alm.wait_for_state(LedState.LED_ON, timeout=2.0)
rp("baseline_history", h_baseline)
rp("baseline_elapsed_s", round(elapsed_b, 3))
rp("baseline_animating_observed", LedState.LED_ANIMATING in h_baseline)
assert reached, f"Mode=0 did not reach ON: {h_baseline}"
alm.force_off()
# Step: Mode=1 + Duration=5 → reaches LED_ON
_drive_mode(alm, mode=1, duration=5)
reached, elapsed_a, h_active = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
rp("active_history", h_active)
rp("active_elapsed_s", round(elapsed_a, 3))
rp("active_animating_observed", LedState.LED_ANIMATING in h_active)
assert reached, f"Mode=1 Duration=5 did not reach ON: {h_active}"
alm.force_off()
# Step: Mode=1 + Duration=0 → reaches LED_ON (spec: immediate)
_drive_mode(alm, mode=1, duration=0)
reached, elapsed_z, h_zero = alm.wait_for_state(LedState.LED_ON, timeout=2.0)
rp("dur0_history", h_zero)
rp("dur0_elapsed_s", round(elapsed_z, 3))
rp("dur0_animating_observed", LedState.LED_ANIMATING in h_zero)
assert reached, f"Mode=1 Duration=0 did not reach ON: {h_zero}"
def test_25imr003_switd_anm_0005(alm: AlmTester, rp):
"""
Title: Mode 1 (Fading 1) — RGBI linear transition reaches expected target PWM
Description:
Mode 1 = linear transition of all four channels (R,G,B,I) from
current to target over AmbLightDuration × 0.2 s.
Per-tick intermediate values (``strAnmState.prevR/G/B/I``) are not
LIN-observable; this test verifies the bounded LIN-visible behaviour:
(a) the LED enters ANIMATING and (b) the final PWM_wo_Comp matches the
target RGB at full intensity within the calculator tolerance.
Requirements: 25IMR003_SWRS_ANMGT_0006, 25IMR003_SWRS_ANMGT_0007, 25IMR003_SWRS_ANMGT_0008
Test ID: 25IMR003_SWITD_ANM_0005
"""
target_r, target_g, target_b = 150, 60, 30
DURATION = 10
fade_seconds = DURATION * DURATION_LSB_SECONDS # 2.0 s per spec
SETTLE_BUFFER_S = 0.5 # let the firmware finish ramping after the spec window
# Step: Disable temperature compensation so PWM_wo_Comp matches the calculator
alm.send_config(enable_compensation=0)
time.sleep(0.2)
try:
# Step: Drive Mode 1 with target RGB and AmbLightDuration=DURATION
_drive_mode(alm, mode=1, duration=DURATION, r=target_r, g=target_g, b=target_b, intensity=255)
# Step: Wait for ALMLEDState == LED_ON (deterministic check)
reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
rp("led_state_history", history)
rp("elapsed_s", round(elapsed, 3))
rp("animating_observed", LedState.LED_ANIMATING in history)
assert reached, f"Mode 1 fade did not settle to ON: {history}"
# Step: Settle for fade window before reading PWM.
# ALMLEDState transitions to LED_ON when the fade *starts* (not when
# it completes), so PWM_Frame is still ramping at the moment we
# observe ON. Wait the full spec'd fade window before sampling.
time.sleep(fade_seconds + SETTLE_BUFFER_S)
# Step: Final PWM_wo_Comp matches compute_pwm(R,G,B).pwm_no_comp
alm.assert_pwm_wo_comp_matches_rgb(rp, target_r, target_g, target_b)
# Step: Per-tick prevR/G/B/I intermediates are not LIN-observable (documented)
rp("intermediate_ticks_observable", False)
finally:
# Step: Restore EnableCompensation=1
alm.send_config(enable_compensation=1)
time.sleep(0.2)
def test_25imr003_switd_anm_0006(alm: AlmTester, rp):
"""
Title: Mode 2 (Fading 2) — color immediate, intensity fades to expected target PWM
Description:
Mode 2 = R,G,B change immediately to target; intensity transitions
linearly over AmbLightDuration × 0.2 s.
LIN-observable: verify (a) ANIMATING is entered, and (b) final
PWM_wo_Comp matches the target RGB at full intensity within the
calculator tolerance.
Requirements: 25IMR003_SWRS_ANMGT_0009, 25IMR003_SWRS_ANMGT_0010
Test ID: 25IMR003_SWITD_ANM_0006
"""
target_r, target_g, target_b = 150, 60, 30
DURATION = 10
fade_seconds = DURATION * DURATION_LSB_SECONDS # 2.0 s per spec
SETTLE_BUFFER_S = 0.5
# Step: Disable temperature compensation so PWM_wo_Comp matches the calculator
alm.send_config(enable_compensation=0)
time.sleep(0.2)
try:
# Step: Drive Mode 2 with target RGB and AmbLightDuration=DURATION
_drive_mode(alm, mode=2, duration=DURATION, r=target_r, g=target_g, b=target_b, intensity=255)
# Step: Wait for ALMLEDState == LED_ON (deterministic check)
reached, elapsed, history = alm.wait_for_state(LedState.LED_ON, timeout=4.0)
rp("led_state_history", history)
rp("elapsed_s", round(elapsed, 3))
rp("animating_observed", LedState.LED_ANIMATING in history)
assert reached, f"Mode 2 fade did not settle to ON: {history}"
# Step: Settle for ramp window before reading PWM
time.sleep(fade_seconds + SETTLE_BUFFER_S)
# Step: Final PWM_wo_Comp matches compute_pwm(R,G,B).pwm_no_comp at full intensity
alm.assert_pwm_wo_comp_matches_rgb(rp, target_r, target_g, target_b)
# Step: Per-tick prevR/G/B/I intermediates are not LIN-observable (documented)
rp("intermediate_ticks_observable", False)
finally:
# Step: Restore EnableCompensation=1
alm.send_config(enable_compensation=1)
time.sleep(0.2)