"""POC — data-driven ALM_Req_A tests via an :class:`AlmCase` dataclass. A single test function (:func:`test_alm`) is parametrized over a list of :class:`AlmCase` instances. Each instance carries: - identity & reporting metadata (id, title, description, requirements, severity, story, tags) - inputs to ``ALM_Req_A`` (RGB, intensity, mode, update, duration, LID range) - expected outcome (state to reach OR "must not transition", PWM-check flags, timeouts) - a ``run(fio, alm, rp)`` method that executes the case end-to-end Compared with :mod:`test_mum_alm_animation` (one ``def`` per case): - **Adding a new case is one Python literal**, not a new function + duplicated boilerplate. - The shape of every case is *visible* on the page — easy to scan a coverage matrix at a glance. - Cross-cutting changes (e.g. "all cases should also assert the measured NTC is plausible") happen in one place, the runner. - Trade-off: less freedom for a single case to do something one-of-a-kind. When a case needs custom behaviour the dataclass can be subclassed, or that case stays as a hand-written ``def test_xyz`` in the original file. The fixtures and the docstring-derived metadata mirror what ``test_mum_alm_animation.py`` does — this is purely a re-arrangement of the same domain logic. Per-case identity/severity attributes are recorded via ``rp(...)`` so they show up in the JUnit XML and the HTML report's metadata columns. """ from __future__ import annotations import time from dataclasses import dataclass, field from typing import Optional import pytest from ecu_framework.config import EcuTestConfig from ecu_framework.lin.base import LinInterface 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, ) pytestmark = [pytest.mark.ANM] # ╔══════════════════════════════════════════════════════════════════════╗ # ║ AlmCase — attributes + methods that describe ONE test scenario ║ # ╚══════════════════════════════════════════════════════════════════════╝ @dataclass class AlmCase: """One end-to-end ALM_Req_A scenario. Attribute groups (matching the four-phase pattern): Identity : ``id``, ``title``, ``description``, ``requirements``, ``severity``, ``story``, ``tags`` ALM_Req_A inputs : ``r``, ``g``, ``b``, ``intensity``, ``update``, ``mode``, ``duration``, ``lid_from``, ``lid_to`` (``None`` LID values default to ``alm.nad``) Expectations : ``expect_transition``, ``expected_led_state``, ``state_timeout_s``, ``check_pwm_comp``, ``check_pwm_wo_comp`` """ # ── Identity / reporting ──────────────────────────────────────────── id: str title: str description: str requirements: list[str] = field(default_factory=list) severity: str = "normal" story: str = "AmbLightMode" tags: list[str] = field(default_factory=list) # ── Inputs to ALM_Req_A ───────────────────────────────────────────── r: int = 0 g: int = 0 b: int = 0 intensity: int = 0 update: int = 0 mode: int = 0 duration: int = 0 lid_from: Optional[int] = None # None → use alm.nad lid_to: Optional[int] = None # None → use alm.nad # ── Expected outcome ──────────────────────────────────────────────── # When True: wait until ALMLEDState reaches `expected_led_state`. # When False: poll for `state_timeout_s` and assert the state never # entered ANIMATING or ON (the "Save / invalid LID" # pattern: the request must be ignored). expect_transition: bool = True expected_led_state: int = LED_STATE_ON state_timeout_s: float = STATE_TIMEOUT_DEFAULT # PWM checks only meaningful when expect_transition=True and we # reached LED_ON — they validate the rgb_to_pwm calculator output. check_pwm_comp: bool = False check_pwm_wo_comp: bool = False # ── Methods (the four phases live here) ───────────────────────────── def record_metadata(self, rp) -> None: """Stamp the per-case identity attributes onto the report. Recorded as JUnit ```` entries via the ``rp(...)`` helper from ``tests/conftest.py``. The HTML report's metadata columns pick these up. """ rp("case_id", self.id) rp("case_title", self.title) rp("case_story", self.story) rp("case_severity", self.severity) if self.tags: rp("case_tags", ", ".join(self.tags)) if self.requirements: rp("case_requirements", ", ".join(self.requirements)) def send(self, fio: FrameIO, default_nad: int) -> None: """Issue ALM_Req_A for this case; resolves None LIDs to ``default_nad``.""" lid_from = self.lid_from if self.lid_from is not None else default_nad lid_to = self.lid_to if self.lid_to is not None else default_nad fio.send( "ALM_Req_A", AmbLightColourRed=self.r, AmbLightColourGreen=self.g, AmbLightColourBlue=self.b, AmbLightIntensity=self.intensity, AmbLightUpdate=self.update, AmbLightMode=self.mode, AmbLightDuration=self.duration, AmbLightLIDFrom=lid_from, AmbLightLIDTo=lid_to, ) def assert_state(self, alm: AlmTester, rp) -> None: """Either wait for the target state, or watch that nothing happens.""" if self.expect_transition: reached, elapsed, history = alm.wait_for_state( self.expected_led_state, timeout=self.state_timeout_s ) rp("led_state_history", history) rp("on_elapsed_s", round(elapsed, 3)) assert reached, ( f"LEDState never reached {self.expected_led_state} " f"(history: {history})" ) else: deadline = time.monotonic() + self.state_timeout_s history: list[int] = [] while time.monotonic() < deadline: st = alm.read_led_state() if not history or history[-1] != st: history.append(st) time.sleep(STATE_POLL_INTERVAL) rp("led_state_history", history) assert LED_STATE_ANIMATING not in history, ( f"State unexpectedly entered ANIMATING: {history}" ) assert LED_STATE_ON not in history, ( f"State unexpectedly drove LED ON: {history}" ) def assert_pwm(self, alm: AlmTester, rp) -> None: """Run whichever PWM assertions the case enabled.""" if self.check_pwm_comp: alm.assert_pwm_matches_rgb(rp, self.r, self.g, self.b) if self.check_pwm_wo_comp: alm.assert_pwm_wo_comp_matches_rgb(rp, self.r, self.g, self.b) def run(self, fio: FrameIO, alm: AlmTester, rp) -> None: """Full case execution. Called from the parametrized test body.""" self.record_metadata(rp) rp("rgb_in", (self.r, self.g, self.b)) rp("intensity", self.intensity) rp("mode", self.mode) rp("update", self.update) self.send(fio, default_nad=alm.nad) self.assert_state(alm, rp) # PWM checks only meaningful for cases that reach LED_ON if (self.expect_transition and self.expected_led_state == LED_STATE_ON and (self.check_pwm_comp or self.check_pwm_wo_comp)): self.assert_pwm(alm, rp) # ╔══════════════════════════════════════════════════════════════════════╗ # ║ The case matrix ║ # ╚══════════════════════════════════════════════════════════════════════╝ # Each entry is one test row in the report. Adding a new case is just # appending another AlmCase(...) literal here — no new function body # needed. Inputs and expectations sit side by side so reviewers can # scan a coverage matrix at a glance. ALM_CASES: list[AlmCase] = [ AlmCase( id="VTD_ANM_0001", title="Mode 0 — Immediate setpoint reaches LED_ON; PWM matches calculator", description=( "AmbLightMode=0 jumps directly to the requested colour at " "full intensity. ALMLEDState should reach LED_ON quickly " "and both PWM frames should match rgb_to_pwm.compute_pwm()." ), requirements=["REQ-ANM-00001"], severity="critical", story="AmbLightMode", tags=["AmbLightMode", "Mode0", "PWM"], r=0, g=180, b=80, intensity=255, update=0, mode=0, duration=10, expected_led_state=LED_STATE_ON, check_pwm_comp=True, check_pwm_wo_comp=True, ), AlmCase( id="VTD_LID_0002", title="LID broadcast (0x00..0xFF) reaches this node", description=( "A broadcast LID range should include any NAD; this node " "should react and drive the LED ON." ), requirements=["REQ-LID-00002"], severity="normal", story="LID range", tags=["LID", "Broadcast"], r=120, g=0, b=255, intensity=255, update=0, mode=0, duration=0, lid_from=0x00, lid_to=0xFF, expected_led_state=LED_STATE_ON, ), AlmCase( id="VTD_LID_0003", title="LID From > To is rejected (no LED change)", description=( "An ill-formed LID range (From > To) should be ignored; " "ALMLEDState must remain at the OFF baseline for the watch " "window." ), requirements=["REQ-LID-00003"], severity="normal", story="LID range", tags=["LID", "Negative"], r=255, g=255, b=255, intensity=255, update=0, mode=0, duration=0, lid_from=0x14, lid_to=0x0A, expect_transition=False, state_timeout_s=1.0, ), AlmCase( id="VTD_LID_0004", title="Update=1 (Save) does not change LED state", description=( "With AmbLightUpdate=1 the ECU should buffer the command " "without executing it; ALMLEDState must remain at OFF." ), requirements=["REQ-UPDATE-00004"], severity="normal", story="AmbLightUpdate", tags=["AmbLightUpdate", "Save"], r=0, g=255, b=0, intensity=255, update=1, mode=1, duration=10, expect_transition=False, state_timeout_s=1.0, ), ] # ╔══════════════════════════════════════════════════════════════════════╗ # ║ Fixtures (mirror test_mum_alm_animation.py) ║ # ╚══════════════════════════════════════════════════════════════════════╝ @pytest.fixture(scope="module") def fio(config: EcuTestConfig, lin: LinInterface, ldf) -> FrameIO: 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: 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): """Force LED OFF before and after each case so state doesn't leak.""" alm.force_off() yield alm.force_off() # ╔══════════════════════════════════════════════════════════════════════╗ # ║ The single parametrized runner ║ # ╚══════════════════════════════════════════════════════════════════════╝ @pytest.mark.parametrize( "case", ALM_CASES, ids=[c.id for c in ALM_CASES], # nice short IDs in the pytest CLI ) def test_alm(case: AlmCase, fio: FrameIO, alm: AlmTester, rp): """Execute one :class:`AlmCase` end-to-end. The body is intentionally a one-liner — every per-case decision (which signals to send, what to assert, which PWM checks to run) lives on the case object itself. Adding new coverage means appending another AlmCase to ALM_CASES; no new test function needed. """ case.run(fio, alm, rp)