diff --git a/docs/02_configuration_resolution.md b/docs/02_configuration_resolution.md index 40c0ca5..f229684 100644 --- a/docs/02_configuration_resolution.md +++ b/docs/02_configuration_resolution.md @@ -2,6 +2,10 @@ This document explains how configuration is loaded, merged, and provided to tests and interfaces. +> Looking for the implementation deep-dive — merge semantics, type coercion, +> the forward-reference quirk in `EcuTestConfig`, and the PSU side-channel? +> See [`23_config_loader_internals.md`](23_config_loader_internals.md). + ## Sources and precedence From highest to lowest precedence: @@ -136,7 +140,7 @@ central defaults in `config/test_config.yaml`. - PSU-related tests or utilities read `config.power_supply` for serial parameters and optional actions (IDN assertions, on/off toggle, set/measure). The reference implementation is `ecu_framework/power/owon_psu.py`, with a hardware test in - `tests/hardware/test_owon_psu.py` and a quick demo script in `vendor/Owon/owon_psu_quick_demo.py`. + `tests/hardware/psu/test_owon_psu.py` and a quick demo script in `vendor/Owon/owon_psu_quick_demo.py`. ## Tips diff --git a/docs/05_architecture_overview.md b/docs/05_architecture_overview.md index faa9c1e..081e470 100644 --- a/docs/05_architecture_overview.md +++ b/docs/05_architecture_overview.md @@ -2,15 +2,20 @@ This document provides a high-level view of the framework’s components and how they interact, plus a Mermaid diagram for quick orientation. +> For the **dynamic wiring** — how a test actually reaches a live +> `LinInterface` at session start, the fixture topology, and the playbook +> for adding a new framework component — see +> [`24_test_wiring.md`](24_test_wiring.md). + ## Components ### Framework core (`ecu_framework/`) -- Config Loader — `ecu_framework/config.py` (YAML → dataclasses) +- Config Loader — `ecu_framework/config/loader.py` (YAML → dataclasses; re-exported via `ecu_framework.config`) - LIN Abstraction — `ecu_framework/lin/base.py` (`LinInterface`, `LinFrame`) - Mock LIN Adapter — `ecu_framework/lin/mock.py` - MUM LIN Adapter — `ecu_framework/lin/mum.py` (Melexis Universal Master via `pylin` + `pymumclient`) - BabyLIN Adapter — `ecu_framework/lin/babylin.py` (SDK wrapper → BabyLIN_library.py; **DEPRECATED**, kept for legacy rigs only) -- LDF Database — `ecu_framework/lin/ldf.py` (`LdfDatabase`/`Frame` over `ldfparser`; per-frame `pack`/`unpack`) +- LDF Database — `ecu_framework/lin/ldf.py` (`LdfDatabase`/`Frame` over `ldfparser`; per-frame `pack`/`unpack`). **Runtime, dynamic.** Loaded fresh each session from whatever LDF the config points at. See [LDF Database vs Generated LIN API](#ldf-database-vs-generated-lin-api-two-layers-one-purpose) below for why this is paired with the generated layer. - Flasher — `ecu_framework/flashing/hex_flasher.py` - Power Supply (PSU) control — `ecu_framework/power/owon_psu.py` (serial SCPI + cross-platform port resolver) - PSU quick demo script — `vendor/Owon/owon_psu_quick_demo.py` @@ -18,8 +23,10 @@ This document provides a high-level view of the framework’s components and how ### Hardware test layer (`tests/hardware/`) - Project-wide fixtures — `tests/conftest.py` (config, lin, ldf, flash_ecu, rp) - Hardware-suite fixtures — `tests/hardware/conftest.py` (session-scoped, autouse PSU; the bench is powered up once at session start and stays on for every test in the suite) -- Generic LDF I/O — `tests/hardware/frame_io.py` (`FrameIO` — send/receive/pack/unpack for any LDF frame plus raw-bus escape hatches) -- ALM domain helpers — `tests/hardware/alm_helpers.py` (`AlmTester` — force_off / wait_for_state / measure_animating_window / assert_pwm_*) +- MUM-suite fixtures — `tests/hardware/mum/conftest.py` (session-scoped `fio`, `nad`, `alm`; autouse `_require_mum` gate and `_reset_to_off` per-test reset). Tests outside `tests/hardware/mum/` cannot see these — that's how PSU-only and BabyLIN-only tests are kept from accidentally requesting MUM fixtures. +- Generic LDF I/O — `tests/hardware/frame_io.py` (`FrameIO` — send/receive/pack/unpack for any LDF frame plus raw-bus escape hatches). Stringly-typed at this layer (`fio.send("ALM_Req_A", …)`); typed wrappers live one level up. +- Generated LIN API — `tests/hardware/_generated/lin_api.py` (auto-emitted from an LDF by `scripts/gen_lin_api.py`; one class per frame, one `IntEnum` per encoding type with logical values). **Build-time, static.** Provides typed names so frame/signal typos become import errors. Design + generation rules in `docs/22_generated_lin_api.md`; relationship to `ecu_framework/lin/ldf.py` covered in [LDF Database vs Generated LIN API](#ldf-database-vs-generated-lin-api-two-layers-one-purpose). +- ALM domain helpers — `tests/hardware/alm_helpers.py` (`AlmTester` — force_off / wait_for_state / measure_animating_window / assert_pwm_*). Imports typed frames + enums from the generated layer; keeps the non-generatable semantics (polling cadences, PWM tolerances, cross-frame test patterns). - PSU settle helpers — `tests/hardware/psu_helpers.py` (`wait_until_settled`, `apply_voltage_and_settle` — measured-rail-then-validation pattern shared by all voltage-changing tests) - RGB→PWM calculator — `vendor/rgb_to_pwm.py` (consumed by `AlmTester.assert_pwm_*`) - Test templates (not collected) — `tests/hardware/_test_case_template.py`, `tests/hardware/_test_case_template_psu_lin.py` @@ -37,18 +44,24 @@ flowchart TB T[tests/* (test bodies)] CF[tests/conftest.py
config, lin, ldf, flash_ecu, rp] HCF[tests/hardware/conftest.py
SESSION psu (autouse)] + MCF[tests/hardware/mum/conftest.py
fio, alm, nad, _require_mum (autouse),
_reset_to_off (autouse)] PL[conftest_plugin.py] end subgraph Hardware_Helpers [Hardware-test helpers] - FIO[tests/hardware/frame_io.py
FrameIO] + FIO[tests/hardware/frame_io.py
FrameIO (stringly-typed)] + GEN[tests/hardware/_generated/lin_api.py
AlmReqA, AlmStatus, ... (typed)
LedState, Mode, Update IntEnums] ALM[tests/hardware/alm_helpers.py
AlmTester] RGB[vendor/rgb_to_pwm.py] TPL[tests/hardware/_test_case_template*.py
not collected] end + subgraph Build_Time [Build-time tooling (not run during tests)] + GENSCRIPT[scripts/gen_lin_api.py] + end + subgraph Framework - CFG[ecu_framework/config.py] + CFG[ecu_framework/config/loader.py] BASE[ecu_framework/lin/base.py] MOCK[ecu_framework/lin/mock.py] MUM[ecu_framework/lin/mum.py] @@ -71,6 +84,9 @@ flowchart TB T --> CF T --> HCF + T --> MCF + MCF --> FIO + MCF --> ALM CF --> CFG CF --> BASE CF --> MOCK @@ -79,8 +95,13 @@ flowchart TB CF --> FLASH HCF --> POWER T --> FIO + T --> GEN T --> ALM ALM --> FIO + ALM --> GEN + GEN -.calls at runtime.-> FIO + GENSCRIPT -.reads LDF once.-> LDFFILE + GENSCRIPT -.emits source.-> GEN ALM --> RGB TPL -.copy & edit.-> T @@ -102,10 +123,14 @@ flowchart TB - Tests use fixtures to obtain config and a connected LIN adapter - Config loader reads YAML (or env override), returns typed dataclasses - LIN calls are routed through the interface abstraction to the selected adapter -- Hardware tests sit on top of two helpers: `FrameIO` (LDF-driven send / - receive / pack / unpack for any frame) and `AlmTester` (ALM_Node domain - patterns built on `FrameIO`). Both are imported as siblings from - `tests/hardware/` — see `docs/19_frame_io_and_alm_helpers.md` +- Hardware tests sit on top of three helpers: `FrameIO` (LDF-driven send / + receive / pack / unpack for any frame, stringly-typed by frame name), + the generated `lin_api.py` (typed `AlmReqA.send(fio, …)` wrappers plus + `LedState`/`Mode`/`Update` enums, so signal/frame typos become import + errors), and `AlmTester` (ALM_Node domain patterns built on `FrameIO` + and the generated enums). All three are imported as siblings from + `tests/hardware/` — see `docs/19_frame_io_and_alm_helpers.md` and + `docs/22_generated_lin_api.md` - The hardware-suite `tests/hardware/conftest.py` defines a **session-scoped, autouse** `psu` fixture: on benches where the Owon PSU powers the ECU, the supply is opened once at session start, parked at @@ -119,11 +144,80 @@ flowchart TB `vendor/Owon/` provides a quick manual flow - Reporting plugin parses docstrings and enriches the HTML report +## LDF Database vs Generated LIN API: two layers, one purpose + +There are two pieces of code in this repo whose names both sound like +"the LDF module", and a recurring question is why both exist: + +| Aspect | `ecu_framework/lin/ldf.py` (`LdfDatabase`/`Frame`) | `tests/hardware/_generated/lin_api.py` | +| --- | --- | --- | +| **What it is** | Runtime wrapper around `ldfparser` | Source file emitted by `scripts/gen_lin_api.py` | +| **When it runs** | Every test session — `parse_ldf(path)` is called inside the `ldf` fixture (`tests/conftest.py:92`) | Never runs as a parser; it *is* the parser's output, imported like any other module | +| **What it produces** | `Frame` objects whose `.pack(**kw)` / `.unpack(bytes)` route through `ldfparser`'s `encode_raw` / `decode_raw` | `class AlmReqA`, `class LedState(IntEnum)`, etc. — Python literals derived from one LDF | +| **Source of truth** | The LDF file on disk at startup | The LDF file at the time `gen_lin_api.py` was last run (SHA256 in the file header) | +| **Typing model** | Stringly-typed (`db.frame("ALM_Req_A").pack(AmbLight…=…)`) | Statically typed (`AlmReqA.send(fio, AmbLight…=…)`) | +| **Failure mode for a missing/renamed frame** | `KeyError: 'Frame X not found'` at test time | `ImportError: cannot import name 'X'` at collection time, surfaced in CI | +| **Failure mode for an LDF rev** | None — it parses whatever is on disk | The in-sync unit test fails when the LDF SHA256 in the header drifts | +| **Layer in the dependency tree** | Framework core (`ecu_framework/`) — knows nothing about specific frame names | Test code (`tests/hardware/`) — bakes specific frame and signal names in | +| **Lifecycle** | Re-parsed each pytest session | Regenerated only on LDF change, then committed | +| **Coupling to `ldfparser`** | Direct (`from ldfparser import parse_ldf`) | None at runtime; the generator imports it, the generated file does not | + +The two answer **orthogonal** questions: + +- `ecu_framework/lin/ldf.py` answers *"what bytes go on the wire for this + frame right now?"* — it has to be dynamic because bit offsets, widths, + and init values are properties of whichever LDF the bench loaded, and + must be re-validated against that LDF at startup. +- `tests/hardware/_generated/lin_api.py` answers *"what frame and signal + names are valid for me to type in test code?"* — it has to be static + because that question is asked by the IDE, mypy, and pytest's + collection step, all of which run before any LDF has been parsed. + +If only `ecu_framework/lin/ldf.py` existed, every test would keep its +stringly-typed `fio.send("ALM_Req_A", …)` calls and its hand-copied +`LED_STATE_OFF = 0` constants — both of which silently drift when the LDF +changes. If only the generated `lin_api.py` existed, the runtime would +have no path from a frame name to the actual byte layout for the currently +loaded LDF — and worse, the test bench would happily ship bytes encoded +against a *stale* LDF baked into the generator's last run. + +Concretely, a single `fio.send` call traverses both layers: + +``` +test code + | + | AlmReqA.send(fio, AmbLightColourRed=0, ...) + v +tests/hardware/_generated/lin_api.py <-- typed names, compile-time check + | + | fio.send("ALM_Req_A", AmbLightColourRed=0, ...) + v +tests/hardware/frame_io.py <-- per-instance frame cache + | + | ldf.frame("ALM_Req_A").pack(AmbLightColourRed=0, ...) + v +ecu_framework/lin/ldf.py <-- runtime pack/unpack + | + | raw_frame.encode_raw({...}) + v +ldfparser <-- bit-level layout from LDF on disk +``` + +Each layer's responsibility is unique to that layer; removing either +collapses a distinct kind of check (compile-time name validation, or +runtime LDF-driven byte layout) that the other layer cannot provide. + ## Extending the architecture - Add new bus adapters by implementing `LinInterface` - Add new ECU-domain helpers next to `AlmTester` (e.g. `BcmTester`) - on top of `FrameIO`; share fixtures via `tests/hardware/conftest.py` + on top of `FrameIO` and the generated `lin_api.py`; share fixtures via + `tests/hardware/conftest.py` +- When the LDF changes (new frame, renamed signal, new encoding-type row): + re-run `python scripts/gen_lin_api.py `, commit the updated + `tests/hardware/_generated/lin_api.py` alongside the LDF change. The + in-sync unit test in `tests/unit/test_generated_lin_api_in_sync.py` + fails CI if the two ever drift - Add new bench instrument controllers next to `OwonPSU` under `ecu_framework/power/` or a new `ecu_framework/instruments/` package, expose them as session-scoped fixtures diff --git a/docs/14_power_supply.md b/docs/14_power_supply.md index 7c4a2aa..d0a14d7 100644 --- a/docs/14_power_supply.md +++ b/docs/14_power_supply.md @@ -15,7 +15,7 @@ guarantees the controller class provides. | Artifact | Path | |---|---| | Controller library | [`ecu_framework/power/owon_psu.py`](../ecu_framework/power/owon_psu.py) | -| Hardware test | [`tests/hardware/test_owon_psu.py`](../tests/hardware/test_owon_psu.py) | +| Hardware test | [`tests/hardware/psu/test_owon_psu.py`](../tests/hardware/psu/test_owon_psu.py) | | Quick demo script | [`vendor/Owon/owon_psu_quick_demo.py`](../vendor/Owon/owon_psu_quick_demo.py) | | Central config | [`config/test_config.yaml`](../config/test_config.yaml) → `power_supply` | | Per-machine override | `config/owon_psu.yaml` or env `OWON_PSU_CONFIG` | @@ -187,7 +187,7 @@ Tests **must not**: ### What changed in the existing tests -- **`tests/hardware/test_owon_psu.py`** is now read-only: it queries `*IDN?`, `output?`, and the parsed measurement helpers, but doesn't toggle the output. The previous toggle-and-restore cycle has been deleted because it would brown out the bench mid-session. +- **`tests/hardware/psu/test_owon_psu.py`** is now read-only: it queries `*IDN?`, `output?`, and the parsed measurement helpers, but doesn't toggle the output. The previous toggle-and-restore cycle has been deleted because it would brown out the bench mid-session. - **`tests/hardware/_test_case_template_psu_lin.py`** drops its local `psu` fixture and uses the conftest's. Its autouse `_park_at_nominal` only restores voltage between tests — it never toggles output. --- @@ -275,7 +275,7 @@ numbers, run the dedicated characterization test: pytest -m psu_settling -s ``` -`tests/hardware/test_psu_voltage_settling.py` walks four +`tests/hardware/psu/test_psu_voltage_settling.py` walks four transitions (`13 V↔18 V`, `13 V↔7 V`), polls `measure_voltage_v()` every 50 ms until the rail is within ±100 mV of target, and records `settling_time_s` plus a downsampled voltage trace per case. The @@ -478,7 +478,7 @@ doesn't. | File | Purpose | |---|---| | `ecu_framework/power/owon_psu.py` | Controller library (`SerialParams`, `OwonPSU`, resolver helpers). | -| `tests/hardware/test_owon_psu.py` | Hardware test wired to central config. | +| `tests/hardware/psu/test_owon_psu.py` | Hardware test wired to central config. | | `vendor/Owon/owon_psu_quick_demo.py` | Quick demo runner. | | `config/owon_psu.example.yaml` | Example per-machine YAML. | | `tests/hardware/_test_case_template.py` | Copyable starting point for new hardware tests. | diff --git a/docs/18_test_catalog.md b/docs/18_test_catalog.md index 878ae74..cff04ad 100644 --- a/docs/18_test_catalog.md +++ b/docs/18_test_catalog.md @@ -196,7 +196,7 @@ Tests gated on `interface.type == "mum"`. All require: ### 4.1 `test_e2e_mum_led_activate.py` -Source: [tests/hardware/test_e2e_mum_led_activate.py](tests/hardware/test_e2e_mum_led_activate.py) +Source: [tests/hardware/mum/test_e2e_mum_led_activate.py](tests/hardware/mum/test_e2e_mum_led_activate.py) | Test | Markers | Purpose | | --- | --- | --- | @@ -209,7 +209,7 @@ Source: [tests/hardware/test_e2e_mum_led_activate.py](tests/hardware/test_e2e_mu ### 4.2 `test_mum_alm_animation.py` -Source: [tests/hardware/test_mum_alm_animation.py](tests/hardware/test_mum_alm_animation.py) +Source: [tests/hardware/mum/test_mum_alm_animation.py](tests/hardware/mum/test_mum_alm_animation.py) Suite of automated checks for the four behaviour buckets in `vendor/automated_lin_test/test_animation.py`. A module-scoped fixture @@ -234,7 +234,7 @@ before and after every test so cases don't bleed state into each other. ### 4.3 `test_mum_auto_addressing.py` -Source: [tests/hardware/test_mum_auto_addressing.py](tests/hardware/test_mum_auto_addressing.py) +Source: [tests/hardware/mum/test_mum_auto_addressing.py](tests/hardware/mum/test_mum_auto_addressing.py) | Test | Markers | Purpose | | --- | --- | --- | @@ -247,7 +247,7 @@ Source: [tests/hardware/test_mum_auto_addressing.py](tests/hardware/test_mum_aut ### 4.4 `test_e2e_power_on_lin_smoke.py` *(DEPRECATED, BabyLIN-marked)* -Source: [tests/hardware/test_e2e_power_on_lin_smoke.py](tests/hardware/test_e2e_power_on_lin_smoke.py) +Source: [tests/hardware/babylin/test_e2e_power_on_lin_smoke.py](tests/hardware/babylin/test_e2e_power_on_lin_smoke.py) Despite living in `tests/hardware/`, this file targets the **deprecated BabyLIN** adapter (it predates the MUM migration). See section 5.4. @@ -289,7 +289,7 @@ Source: [tests/test_hardware_placeholder.py](tests/test_hardware_placeholder.py) ### 5.4 `test_e2e_power_on_lin_smoke.py` -Source: [tests/hardware/test_e2e_power_on_lin_smoke.py](tests/hardware/test_e2e_power_on_lin_smoke.py) +Source: [tests/hardware/babylin/test_e2e_power_on_lin_smoke.py](tests/hardware/babylin/test_e2e_power_on_lin_smoke.py) | Test | Markers | Purpose | | --- | --- | --- | @@ -306,7 +306,7 @@ Source: [tests/hardware/test_e2e_power_on_lin_smoke.py](tests/hardware/test_e2e_ ### 6.1 `test_owon_psu.py` -Source: [tests/hardware/test_owon_psu.py](tests/hardware/test_owon_psu.py) +Source: [tests/hardware/psu/test_owon_psu.py](tests/hardware/psu/test_owon_psu.py) | Test | Markers | Purpose | | --- | --- | --- | @@ -323,7 +323,7 @@ Source: [tests/hardware/test_owon_psu.py](tests/hardware/test_owon_psu.py) ### 7.1 `test_overvolt.py` -Source: [tests/hardware/test_overvolt.py](tests/hardware/test_overvolt.py) +Source: [tests/hardware/mum/test_overvolt.py](tests/hardware/mum/test_overvolt.py) Drives the bench supply through known thresholds and observes `ALM_Status.ALMVoltageStatus` on the LIN bus. All cases use the @@ -362,7 +362,7 @@ status. After that, a single deterministic read of ### 7.2 `test_psu_voltage_settling.py` *(opt-in: `-m psu_settling`)* -Source: [tests/hardware/test_psu_voltage_settling.py](tests/hardware/test_psu_voltage_settling.py) +Source: [tests/hardware/psu/test_psu_voltage_settling.py](tests/hardware/psu/test_psu_voltage_settling.py) Characterization test — extracts how long the bench Owon PSU takes to actually deliver a new voltage at its terminals after a setpoint diff --git a/docs/19_frame_io_and_alm_helpers.md b/docs/19_frame_io_and_alm_helpers.md index f36f2c6..9eb1b9b 100644 --- a/docs/19_frame_io_and_alm_helpers.md +++ b/docs/19_frame_io_and_alm_helpers.md @@ -187,47 +187,41 @@ def pwm_within_tol(actual: int, expected: int) -> bool ## 5. Fixture wiring -`tests/hardware/test_mum_alm_animation.py` defines two module-scoped -fixtures plus an autouse reset. The same pattern applies to any new -hardware test file targeting MUM. +`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 -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 - - -@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 test so state doesn't leak.""" - alm.force_off() - yield - alm.force_off() +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 [docs/02_configuration_resolution.md](02_configuration_resolution.md) -for how they are wired. +`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() +``` --- @@ -400,7 +394,7 @@ on close), an autouse `_park_at_nominal` fixture, a | C — sweep | Parametrized walk over `(V, expected_status)` tuples. | For the *settling time* characterization that feeds these tests' -detect timeouts, see `tests/hardware/test_psu_voltage_settling.py` +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) diff --git a/docs/20_docker_image.md b/docs/20_docker_image.md index 488b634..4c064cd 100644 --- a/docs/20_docker_image.md +++ b/docs/20_docker_image.md @@ -312,7 +312,7 @@ docker run --rm -it \ Inside the container: ```bash -pytest tests/hardware/test_mum_alm_animation.py -v +pytest tests/hardware/mum/test_mum_alm_animation.py -v ``` --- diff --git a/docs/24_test_wiring.md b/docs/24_test_wiring.md new file mode 100644 index 0000000..8a00d30 --- /dev/null +++ b/docs/24_test_wiring.md @@ -0,0 +1,383 @@ +# Test Wiring: From YAML to Test Cases + +This document explains **how a test reaches a live `LinInterface`** (or PSU, or +LDF database). For a *static* catalog of components see +[`05_architecture_overview.md`](05_architecture_overview.md); for *what* the +config knobs do see [`02_configuration_resolution.md`](02_configuration_resolution.md). +This file focuses on the *dynamic resolution* — the fixture plumbing that +glues the framework to the test suite at session start. + +## The big idea + +**Tests never import a concrete adapter.** They never call `load_config()` +directly. The only thing test files import from `ecu_framework` is **types** +(`EcuTestConfig`, `LinFrame`, `LinInterface`, `OwonPSU`) for annotations. +Behavior arrives via pytest fixtures, which are the single seam between the +framework and the test suite. + +Concretely: the choice of LIN adapter (mock / MUM / BabyLIN) is made by the +`lin` fixture at session start based on `config.interface.type`. A test that +writes `def test_x(lin):` works against all three with no per-test changes. + +## End-to-end wiring + +```mermaid +flowchart TB + subgraph User_Inputs[User inputs] + YAML["config/test_config.yaml
+ optional config/owon_psu.yaml
+ $ECU_TESTS_CONFIG / $OWON_PSU_CONFIG"] + end + + subgraph Loader[ecu_framework.config] + LC["load_config(workspace_root)
YAML + env + overrides → EcuTestConfig"] + end + + subgraph Fixtures_Top[tests/conftest.py - session-scoped] + F_CONFIG["config
→ EcuTestConfig"] + F_LIN["lin
→ LinInterface"] + F_LDF["ldf
→ LdfDatabase"] + F_FLASH["flash_ecu
→ runs HexFlasher"] + F_RP["rp
→ record_property helper"] + end + + subgraph Fixtures_HW[tests/hardware/conftest.py - session-scoped] + F_PSU_PRIV["_psu_or_none
opens PSU once"] + F_PSU_AUTO["_psu_powers_bench
autouse=True"] + F_PSU["psu
public, skips when unavailable"] + end + + subgraph Fixtures_MUM[tests/hardware/mum/conftest.py] + F_REQ_MUM["_require_mum
session, autouse"] + F_FIO["fio
session"] + F_NAD["nad
session"] + F_ALM["alm
session"] + F_RESET["_reset_to_off
function, autouse"] + end + + subgraph Adapters[ecu_framework adapters] + MOCK["MockBabyLinInterface"] + MUM["MumLinInterface"] + BABY["BabyLinInterface
DEPRECATED"] + OWON["OwonPSU"] + HEX["HexFlasher"] + end + + subgraph Tests[tests/] + UNIT["tests/unit/*
only config-level fixtures"] + HW_PSU["tests/hardware/psu/*
psu only (no fio/alm)"] + HW_MUM["tests/hardware/mum/*
fio + alm + psu (inherited)"] + HW_BABY["tests/hardware/babylin/*
legacy E2E"] + end + + YAML --> LC + LC --> F_CONFIG + F_CONFIG --> F_LIN + F_CONFIG --> F_LDF + F_CONFIG --> F_FLASH + F_CONFIG --> F_PSU_PRIV + F_CONFIG --> F_REQ_MUM + F_LIN --> F_FLASH + F_LIN --> F_FIO + F_LDF --> F_FIO + F_FIO --> F_NAD + F_FIO --> F_ALM + F_NAD --> F_ALM + F_ALM --> F_RESET + + F_LIN -.selects.-> MOCK + F_LIN -.selects.-> MUM + F_LIN -.selects.-> BABY + F_FLASH --> HEX + F_PSU_PRIV --> OWON + F_PSU_PRIV --> F_PSU_AUTO + F_PSU_PRIV --> F_PSU + + UNIT --> F_CONFIG + HW_PSU --> F_PSU + HW_MUM --> F_ALM + HW_MUM --> F_FIO + HW_MUM --> F_PSU + HW_BABY --> F_LIN +``` + +The dotted edges from `lin` are the **polymorphism boundary**: which adapter +is wired in is decided at fixture instantiation time, by config alone. + +## Three-layer conftest topology + +Pytest discovers `conftest.py` files automatically by directory and walks +**upward** from each test file. A test only sees fixtures defined in its own +directory or any ancestor — which is how this codebase enforces "MUM tests +can use `fio`, PSU tests can't" without any runtime allow-list. + +| File | Scope | Fixtures it provides | Why split | +|---|---|---|---| +| `tests/conftest.py` | Whole test suite | `config`, `lin`, `ldf`, `flash_ecu`, `rp` | Framework primitives every test type needs | +| `tests/hardware/conftest.py` | All hardware tests | `_psu_or_none`, `_psu_powers_bench` (autouse), `psu` | PSU powers the ECU on the bench, so any hardware test benefits | +| `tests/hardware/mum/conftest.py` | MUM-only tests | `_require_mum` (autouse), `fio`, `nad`, `alm`, `_reset_to_off` (autouse) | LDF I/O + ALM state are only meaningful when `interface.type == "mum"` | + +The hardware directory is partitioned by adapter type: + +``` +tests/hardware/ +├── conftest.py # session: PSU fixtures +├── mum/ # MUM-only tests +│ ├── conftest.py # session: fio, alm, nad + autouse _require_mum / _reset_to_off +│ ├── test_mum_*.py +│ ├── test_overvolt.py # uses both PSU (inherited) and MUM fio/alm +│ ├── swe5/ # SWE.5 integration tests (all MUM-backed) +│ └── swe6/ # SWE.6 validation tests (all MUM-backed) +├── psu/ # PSU-only tests; cannot see fio/alm +│ ├── test_owon_psu.py +│ └── test_psu_voltage_settling.py +└── babylin/ # legacy BabyLIN E2E (deprecated) + └── test_e2e_power_on_lin_smoke.py +``` + +Each leaf directory carries an empty `__init__.py` so pytest's import +mechanism walks upward to `tests/hardware/` (which has no `__init__.py`) +and prepends it to `sys.path`. That keeps the bare imports +`from frame_io import FrameIO` / `from alm_helpers import AlmTester` +working from any subdirectory, without changes to the helper modules. + +The split keeps unit tests fast and import-light: they don't transitively pull +in `pyserial` for an Owon driver they never use. + +## Per-component wiring + +### `config` — the root of the dependency tree + +`tests/conftest.py:27-30`: + +```python +@pytest.fixture(scope="session") +def config() -> EcuTestConfig: + return load_config(str(WORKSPACE_ROOT)) +``` + +- **Session-scoped** → `load_config()` runs **once per test run**. +- `WORKSPACE_ROOT` is derived from `__file__` so the same fixture works + whether pytest is launched from the repo root, from `tests/`, or from a + Pi deployment. +- Every other fixture downstream takes `config` as a parameter, so swapping + YAML files (or `ECU_TESTS_CONFIG=...`) reroutes the entire stack. + +### `lin` — the polymorphism boundary in action + +`tests/conftest.py:33-87`: + +```python +@pytest.fixture(scope="session") +def lin(config: EcuTestConfig) -> Iterator[LinInterface]: + if config.interface.type == "mock": lin = MockBabyLinInterface(...) + elif config.interface.type == "mum": lin = MumLinInterface(...) + elif config.interface.type == "babylin": lin = BabyLinInterface(...) # deprecated + ... + lin.connect() + yield lin + lin.disconnect() +``` + +Two details that matter: + +- **Conditional adapter imports at the top of the file** (`tests/conftest.py:13-21`) + use `try/except`: MUM needs `pymumclient`, BabyLIN needs native DLLs — + neither is present in CI. The `try` keeps mock-only environments importable; + selecting a missing adapter `pytest.skip()`s cleanly. +- **LDF + frame_lengths merge** (`tests/conftest.py:62-73`): the LDF (if + `interface.ldf_path` is set) provides default frame lengths, then YAML + `frame_lengths` overrides per ID. This merge lives in the fixture, not in + `MumLinInterface`, so the adapter doesn't depend on `ldfparser`. + +`yield lin` then `disconnect()` means **one shared connection** for the whole +session, with deterministic teardown. + +### `flash_ecu` — built on top of `lin` + +`tests/conftest.py:113-126`: + +```python +@pytest.fixture(scope="session", autouse=False) +def flash_ecu(config, lin): + if not config.flash.enabled: pytest.skip("Flashing disabled in config") + from ecu_framework.flashing import HexFlasher # lazy import + flasher = HexFlasher(lin) # ← reuses the lin fixture + ... +``` + +- `autouse=False` — only runs when a test explicitly requests it. +- `HexFlasher(lin)` reuses the `lin` fixture, so flashing automatically + inherits the chosen adapter. One config switch (`interface.type`) reroutes + both LIN traffic and flashing. +- Import is **lazy** — pulling `HexFlasher` only when needed keeps unit-test + collection time low. + +### `power` — the three-tier PSU fixture ladder + +`tests/hardware/conftest.py:61-154`: + +| Fixture | Scope | Visibility | Purpose | +|---|---|---|---| +| `_psu_or_none` | session | private (`_` prefix) | Open PSU once, park at nominal V/I, leave output ON | +| `_psu_powers_bench` | session, **`autouse=True`** | private | Forces `_psu_or_none` to materialize even for tests that don't ask for PSU | +| `psu` | session | public | Tests that read measurements / perturb voltage request this; skips cleanly when PSU isn't configured | + +The autouse fixture is load-bearing. On a bench where the Owon **powers the +ECU**, a pure MUM test (which never names `psu`) would run with no power on +the ECU and fail mysteriously. The autouse forces PSU setup at session start +even when no test references it by name. Comments in +`tests/hardware/conftest.py:3-10` document exactly this incident. + +## Session lifecycle + +```mermaid +sequenceDiagram + autonumber + participant Pytest + participant Conftest as tests/conftest.py + participant HWConftest as tests/hardware/conftest.py + participant Loader as ecu_framework.config + participant LIN as ecu_framework.lin + participant PSU as ecu_framework.power + participant Test as test function + + Pytest->>Conftest: collect + resolve fixtures + Pytest->>HWConftest: (only for tests/hardware/**) + + Note over Conftest,Loader: session start + Conftest->>Loader: load_config(WORKSPACE_ROOT) + Loader-->>Conftest: EcuTestConfig + + Conftest->>LIN: build adapter from config.interface.type + LIN-->>Conftest: LinInterface + Conftest->>LIN: lin.connect() + + HWConftest->>PSU: resolve port, open, park at nominal V + PSU-->>HWConftest: OwonPSU (or None) + + Note over Test: per test + Pytest->>Test: invoke test_x(lin, psu, ldf, ...) + Test->>LIN: send / receive frames + Test->>PSU: optional perturb voltage + Test-->>Pytest: pass / fail + + Note over Conftest,PSU: session end + HWConftest->>PSU: close (sends output 0) + Conftest->>LIN: lin.disconnect() +``` + +Two invariants this diagram shows: + +- **Setup happens once, teardown happens once.** Session-scoped fixtures only + build and tear down at the session boundary, not per test. +- **Teardown is LIFO.** PSU closes before LIN disconnects, matching the + reverse of construction order — which is what you want, since on this + bench the LIN ECU is powered by the PSU. + +## Helpers — where they sit + +The helper *classes* `FrameIO` (`tests/hardware/frame_io.py`) and `AlmTester` +(`tests/hardware/alm_helpers.py`) are plain classes — not fixtures. They take +a `LinInterface` and an LDF (and a NAD, for `AlmTester`) as constructor +arguments. **Instances are exposed as session-scoped fixtures** in +`tests/hardware/mum/conftest.py`, so MUM tests just request them by name: + +```python +def test_alm_status(fio, alm, rp): + fio.send("ALM_Req_A", AmbLightColourRed=255) + status = fio.receive("ALM_Status") + alm.force_off() +``` + +The fixtures are session-scoped because `FrameIO` and `AlmTester` are +immutable beyond their constructor args, and per-test state hygiene is +handled by the autouse `_reset_to_off` (also in `mum/conftest.py`). A test +that genuinely needs a fresh instance can still build one locally: +`FrameIO(lin, ldf)` works inside any test body. They are a convenience +layer, not a required indirection. + +```mermaid +flowchart LR + LIN[LinInterface
session fixture] --> FIO + LDFDB[LdfDatabase
ldf fixture] --> FIO[FrameIO
per test, local] + FIO --> ALM[AlmTester
per test, local] + ALM --> T[test body] + FIO --> T + LIN -.also direct access.-> T +``` + +## The pytest plugin — orthogonal + +`conftest_plugin.py` (registered by the root `conftest.py:13-32`) is +**independent of the framework wiring above**. It parses test docstrings for +`Title:`, `Description:`, `Requirements:`, `Steps:` and attaches them as +`user_properties` on JUnit / HTML reports. It writes +`reports/requirements_coverage.json` and `reports/summary.md`. It does not +touch `ecu_framework` at all — it operates purely on test metadata. + +See [`11_conftest_plugin_overview.md`](11_conftest_plugin_overview.md) for +the plugin's hooks and outputs. + +## Why this shape works + +Five invariants make the wiring durable: + +1. **Tests depend on abstractions only.** Every `ecu_framework` import in a + test file is either a dataclass (`EcuTestConfig`, `LinFrame`) or an ABC + (`LinInterface`). Concrete adapters are selected by *configuration*, never + by import path. +2. **One YAML switch flips the whole stack.** Changing `interface.type` + reroutes LIN, flashing (via `HexFlasher(lin)`), and any helper built on + `lin` — without touching a single test. +3. **Fixture scope = lifecycle.** Session-scoped fixtures (`config`, `lin`, + `psu`) mean expensive bench setup happens once per run. Cleanup is + centralized in fixture teardowns. +4. **Optional features fail gracefully via `pytest.skip`.** No PSU → `psu` + skips. No `pymumclient` → MUM-typed config skips. No LDF → `ldf` skips. + The same conftest runs on a developer laptop, a CI runner, and a wired-up + Pi bench. +5. **Plugin metadata is orthogonal.** The reporting plugin reads test + docstrings, not the framework state. Adding/removing framework features + doesn't touch report generation. + +## Adding a new framework component + +The playbook is fixed. To add e.g. a CAN adapter: + +1. **Add the implementation** under `ecu_framework/can/`: + - `base.py` with a `CanInterface` ABC + `CanFrame` dataclass + - One or more adapter modules (`mock.py`, `vector.py`, ...) + - `__init__.py` re-exporting the public surface +2. **Add a config section** in `ecu_framework/config/loader.py`: + - A `CanConfig` dataclass + - A `can: CanConfig` field on `EcuTestConfig` + - A matching entry in the `base` defaults dict + - A coercion line in `_to_dataclass` +3. **Add a fixture** in `tests/conftest.py` mirroring `lin`: + ```python + @pytest.fixture(scope="session") + def can(config: EcuTestConfig) -> Iterator[CanInterface]: + if config.can.type == "mock": can = MockCan(...) + elif config.can.type == "vector": can = VectorCan(...) + ... + can.connect(); yield can; can.disconnect() + ``` +4. **Write tests** that take `can` as a parameter. Done. + +Helpers, reporting, and unit-test infrastructure inherit the wiring for free. +The playbook is the closest thing this framework has to a "you must read +this first" contract for contributors. + +## See also + +- [`05_architecture_overview.md`](05_architecture_overview.md) — static + component catalog and Mermaid architecture diagram +- [`02_configuration_resolution.md`](02_configuration_resolution.md) — what + YAML knobs exist and how they merge +- [`23_config_loader_internals.md`](23_config_loader_internals.md) — how the + loader is implemented under the hood +- [`11_conftest_plugin_overview.md`](11_conftest_plugin_overview.md) — the + reporting plugin (orthogonal to fixture wiring) +- [`04_lin_interface_call_flow.md`](04_lin_interface_call_flow.md) — what + each LIN adapter does once selected by the `lin` fixture +- [`19_frame_io_and_alm_helpers.md`](19_frame_io_and_alm_helpers.md) — the + helpers `FrameIO` and `AlmTester` covered above diff --git a/docs/README.md b/docs/README.md index 90e92e5..9ab0182 100644 --- a/docs/README.md +++ b/docs/README.md @@ -24,6 +24,8 @@ A guided tour of the ECU testing framework. Start here: 20. `19_frame_io_and_alm_helpers.md` — Hardware-test helpers: `FrameIO` (generic LDF I/O) and `AlmTester` (ALM_Node domain), plus the `tests/hardware/_test_case_template.py` starting point 21. `20_docker_image.md` — Containerizing the framework: mock-only CI image, hardware-passthrough image, the Melexis-package obstacle, compose & CI examples 22. `21_yocto_image_for_raspberry_pi.md` — Building a Yocto image that turns a Raspberry Pi into a self-contained test bench (BSP layout, recipes, network/USB config, deploy & maintenance) +23. `23_config_loader_internals.md` — How `ecu_framework/config/loader.py` is implemented: merge semantics, type coercion, schema quirks, and the PSU side-channel +24. `24_test_wiring.md` — How tests are wired to the framework: fixture topology, session lifecycle, the polymorphism boundary on `lin`, and the playbook for adding a new framework component Related references: diff --git a/tests/hardware/swe5/__init__.py b/tests/hardware/babylin/__init__.py similarity index 100% rename from tests/hardware/swe5/__init__.py rename to tests/hardware/babylin/__init__.py diff --git a/tests/hardware/test_e2e_power_on_lin_smoke.py b/tests/hardware/babylin/test_e2e_power_on_lin_smoke.py similarity index 100% rename from tests/hardware/test_e2e_power_on_lin_smoke.py rename to tests/hardware/babylin/test_e2e_power_on_lin_smoke.py diff --git a/tests/hardware/swe6/__init__.py b/tests/hardware/mum/__init__.py similarity index 100% rename from tests/hardware/swe6/__init__.py rename to tests/hardware/mum/__init__.py diff --git a/tests/hardware/mum/conftest.py b/tests/hardware/mum/conftest.py new file mode 100644 index 0000000..ea4330f --- /dev/null +++ b/tests/hardware/mum/conftest.py @@ -0,0 +1,143 @@ +"""Shared fixtures for the MUM hardware test suite. + +WHY THIS FILE EXISTS +-------------------- +Every test under ``tests/hardware/mum/**`` needs the same three things: + + 1. The session to be a MUM session (``config.interface.type == "mum"``). + 2. A live ``FrameIO`` bound to the session ``lin`` + ``ldf``. + 3. The ECU's live NAD, discovered by reading ``ALM_Status``. + +Before this conftest existed, each test module repeated those fixtures +verbatim — 9 copies of ``fio``, 8 of ``alm``, 8 of ``_reset_to_off``, +and 8 inline ``if config.interface.type != "mum": pytest.skip(...)`` +gates. They are all consolidated here. + +SCOPE STRATEGY +-------------- +``FrameIO``, ``AlmTester``, and the discovered ``nad`` are immutable +relative to a session connection. Keeping them at ``scope="session"`` +means one NAD discovery per run instead of one per module, and a single +shared cache of LDF frame lookups across the whole suite. The only +function-scoped fixture is ``_reset_to_off`` — it MUST be per-test so +each test starts with the LED in a known state. + +ACCESS CONTROL +-------------- +This conftest is at ``tests/hardware/mum/`` deliberately: tests under +``tests/hardware/psu/`` and ``tests/hardware/babylin/`` cannot see +``fio``/``alm``/``nad`` because pytest only walks **upward** through +``conftest.py`` files. A PSU-only test that accidentally requests +``fio`` will fail at collection with "fixture not found" — that is +the access-control mechanism. + +OVERRIDE NOTES +-------------- +Two files override fixtures here for documented reasons: + +- ``test_mum_alm_animation_generated.py`` keeps a local ``_reset_to_off`` + + ``_force_off`` so its "no AlmTester anywhere" demonstration stays + true. The local ``_reset_to_off`` shadows this conftest's. + +- ``test_overvolt.py`` defines its own ``_reset_to_off`` that ALSO + parks the PSU at the nominal voltage. Its override is necessary — + without it, both autouse fixtures would run and the LED would be + toggled twice per test (harmless but wasteful). +""" +from __future__ import annotations + +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 + + +# --------------------------------------------------------------------------- +# Session-wide gate +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session", autouse=True) +def _require_mum(config: EcuTestConfig) -> None: + """Single skip point for the whole MUM suite. + + Replaces the inline ``if config.interface.type != "mum": pytest.skip(...)`` + that used to live inside every ``fio`` fixture. ``autouse=True`` means + every test under ``tests/hardware/mum/**`` honors this without having + to opt in. + """ + if config.interface.type != "mum": + pytest.skip("interface.type must be 'mum' for tests under tests/hardware/mum/") + + +# --------------------------------------------------------------------------- +# Shared MUM-suite fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def fio(lin: LinInterface, ldf) -> FrameIO: + """LDF-driven I/O over the session LIN connection. + + Session-scoped because ``FrameIO`` only holds ``(lin, ldf)`` and caches + frame lookups — sharing it across the whole suite is a feature, not a + risk. Tests that need a fresh cache can build their own ``FrameIO(lin, ldf)`` + inside the test body. + """ + return FrameIO(lin, ldf) + + +@pytest.fixture(scope="session") +def nad(fio: FrameIO) -> int: + """Live NAD reported by the ECU's ALM_Status frame. + + Used as ``LIDFrom`` / ``LIDTo`` in unicast sends and as the slave + address bound into ``AlmTester``. Discovered once per session because + the address doesn't change while the ECU is powered. + + Skips cleanly when: + - The ECU isn't responding (no ``ALM_Status`` within 1 s) — likely + a wiring or power problem. + - The reported NAD is outside the valid 0x01-0xFE range — usually + means auto-addressing hasn't been performed yet. + """ + decoded = fio.receive("ALM_Status", 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(scope="session") +def alm(fio: FrameIO, nad: int) -> AlmTester: + """ALM_Node domain helper bound to the live NAD. + + Session-scoped because ``AlmTester`` is stateless beyond ``(fio, nad)``; + per-test state hygiene is handled by ``_reset_to_off`` below, not by + rebuilding the helper. + """ + return AlmTester(fio, nad) + + +# --------------------------------------------------------------------------- +# Per-test state reset +# --------------------------------------------------------------------------- + +@pytest.fixture(autouse=True) +def _reset_to_off(alm: AlmTester): + """Drive the LED to OFF before AND after every test. + + Function-scoped + autouse so that state cannot leak between tests. + The post-test ``force_off()`` runs even when the test body fails — + that is the contract: regardless of how the test exits, the next + one starts on a known baseline. + + Override this fixture locally in a test module to change the reset + semantics (see the OVERRIDE NOTES in the module docstring). + """ + alm.force_off() + yield + alm.force_off() diff --git a/tests/hardware/mum/swe5/__init__.py b/tests/hardware/mum/swe5/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/hardware/swe5/test_anm_management.py b/tests/hardware/mum/swe5/test_anm_management.py similarity index 94% rename from tests/hardware/swe5/test_anm_management.py rename to tests/hardware/mum/swe5/test_anm_management.py index eb897bf..3b80db4 100644 --- a/tests/hardware/swe5/test_anm_management.py +++ b/tests/hardware/mum/swe5/test_anm_management.py @@ -37,9 +37,6 @@ _HW_DIR = Path(__file__).resolve().parent.parent if str(_HW_DIR) not in sys.path: sys.path.insert(0, str(_HW_DIR)) -from ecu_framework.config import EcuTestConfig -from ecu_framework.lin.base import LinInterface - from frame_io import FrameIO from alm_helpers import ( AlmTester, @@ -52,33 +49,8 @@ from alm_helpers import ( pytestmark = [pytest.mark.ANM] -# --- fixtures -------------------------------------------------------------- - - -@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): - """Drive LED to OFF before/after each test so state doesn't leak.""" - alm.force_off() - yield - alm.force_off() +# Fixtures (fio, alm, _reset_to_off) and the MUM gate come from +# tests/hardware/mum/conftest.py. # --- helpers --------------------------------------------------------------- diff --git a/tests/hardware/swe5/test_com_management.py b/tests/hardware/mum/swe5/test_com_management.py similarity index 92% rename from tests/hardware/swe5/test_com_management.py rename to tests/hardware/mum/swe5/test_com_management.py index 14a8b67..3cdd2c0 100644 --- a/tests/hardware/swe5/test_com_management.py +++ b/tests/hardware/mum/swe5/test_com_management.py @@ -40,9 +40,6 @@ _HW_DIR = Path(__file__).resolve().parent.parent if str(_HW_DIR) not in sys.path: sys.path.insert(0, str(_HW_DIR)) -from ecu_framework.config import EcuTestConfig -from ecu_framework.lin.base import LinInterface - from frame_io import FrameIO from alm_helpers import ( AlmTester, @@ -54,32 +51,8 @@ from alm_helpers import ( pytestmark = [pytest.mark.COM] -# --- fixtures -------------------------------------------------------------- - - -@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): - alm.force_off() - yield - alm.force_off() +# Fixtures (fio, alm, _reset_to_off) and the MUM gate come from +# tests/hardware/mum/conftest.py. # --- tests ----------------------------------------------------------------- diff --git a/tests/hardware/mum/swe6/__init__.py b/tests/hardware/mum/swe6/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/hardware/swe6/test_com_management.py b/tests/hardware/mum/swe6/test_com_management.py similarity index 89% rename from tests/hardware/swe6/test_com_management.py rename to tests/hardware/mum/swe6/test_com_management.py index 54f29ae..2e7f94d 100644 --- a/tests/hardware/swe6/test_com_management.py +++ b/tests/hardware/mum/swe6/test_com_management.py @@ -40,9 +40,6 @@ _HW_DIR = Path(__file__).resolve().parent.parent if str(_HW_DIR) not in sys.path: sys.path.insert(0, str(_HW_DIR)) -from ecu_framework.config import EcuTestConfig -from ecu_framework.lin.base import LinInterface - from frame_io import FrameIO from alm_helpers import ( AlmTester, @@ -54,32 +51,8 @@ from alm_helpers import ( pytestmark = [pytest.mark.COM_VTD] -# --- fixtures -------------------------------------------------------------- - - -@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): - alm.force_off() - yield - alm.force_off() +# Fixtures (fio, alm, _reset_to_off) and the MUM gate come from +# tests/hardware/mum/conftest.py. def _require_signals_in_frame(fio: FrameIO, frame_name: str, signal_names: list[str]) -> None: diff --git a/tests/hardware/test_e2e_mum_led_activate.py b/tests/hardware/mum/test_e2e_mum_led_activate.py similarity index 93% rename from tests/hardware/test_e2e_mum_led_activate.py rename to tests/hardware/mum/test_e2e_mum_led_activate.py index 5959f2f..518c2d2 100644 --- a/tests/hardware/test_e2e_mum_led_activate.py +++ b/tests/hardware/mum/test_e2e_mum_led_activate.py @@ -11,16 +11,13 @@ from __future__ import annotations import pytest -from ecu_framework.config import EcuTestConfig from ecu_framework.lin.base import LinFrame, LinInterface pytestmark = [pytest.mark.hardware, pytest.mark.mum] -def test_mum_e2e_power_on_then_led_activate( - config: EcuTestConfig, lin: LinInterface, ldf, rp -): +def test_mum_e2e_power_on_then_led_activate(lin: LinInterface, ldf, rp): """ Title: MUM E2E - Power ECU, Read NAD, Activate RGB LED @@ -48,9 +45,7 @@ def test_mum_e2e_power_on_then_led_activate( - lin.send() of the LDF-packed frame succeeds - Second ALM_Status read returns a frame (bus still alive after Tx) """ - if config.interface.type != "mum": - pytest.skip("interface.type must be 'mum' for this test") - + # MUM gate is enforced by tests/hardware/mum/conftest.py::_require_mum req_a = ldf.frame("ALM_Req_A") status = ldf.frame("ALM_Status") rp("ldf_path", str(ldf.path)) diff --git a/tests/hardware/test_mum_alm_animation.py b/tests/hardware/mum/test_mum_alm_animation.py similarity index 95% rename from tests/hardware/test_mum_alm_animation.py rename to tests/hardware/mum/test_mum_alm_animation.py index 47a7865..270be32 100644 --- a/tests/hardware/test_mum_alm_animation.py +++ b/tests/hardware/mum/test_mum_alm_animation.py @@ -25,9 +25,6 @@ import time 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, @@ -40,35 +37,8 @@ from alm_helpers import ( pytestmark = [pytest.mark.ANM] -# --- fixtures -------------------------------------------------------------- - - -@pytest.fixture(scope="module") -def fio(config: EcuTestConfig, lin: LinInterface, ldf) -> FrameIO: - """Generic LDF-driven I/O helper 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 _reset_to_off(alm: AlmTester): - """Force LED to OFF before and after each test so state doesn't leak.""" - alm.force_off() - yield - alm.force_off() +# Fixtures (fio, alm, _reset_to_off) and the MUM gate come from +# tests/hardware/mum/conftest.py — see that file for scope rationale. # --- tests: AmbLightMode behavior ------------------------------------------ diff --git a/tests/hardware/mum/test_mum_alm_animation_generated.py b/tests/hardware/mum/test_mum_alm_animation_generated.py new file mode 100644 index 0000000..3a4b181 --- /dev/null +++ b/tests/hardware/mum/test_mum_alm_animation_generated.py @@ -0,0 +1,536 @@ +"""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 + ``_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) diff --git a/tests/hardware/test_mum_alm_cases.py b/tests/hardware/mum/test_mum_alm_cases.py similarity index 89% rename from tests/hardware/test_mum_alm_cases.py rename to tests/hardware/mum/test_mum_alm_cases.py index 65dc258..c4d9149 100644 --- a/tests/hardware/test_mum_alm_cases.py +++ b/tests/hardware/mum/test_mum_alm_cases.py @@ -38,9 +38,6 @@ 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, @@ -276,35 +273,8 @@ ALM_CASES: list[AlmCase] = [ ] -# ╔══════════════════════════════════════════════════════════════════════╗ -# ║ 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() +# Fixtures (fio, alm, _reset_to_off) and the MUM gate come from +# tests/hardware/mum/conftest.py. # ╔══════════════════════════════════════════════════════════════════════╗ diff --git a/tests/hardware/test_mum_auto_addressing.py b/tests/hardware/mum/test_mum_auto_addressing.py similarity index 96% rename from tests/hardware/test_mum_auto_addressing.py rename to tests/hardware/mum/test_mum_auto_addressing.py index 0858746..18a05e5 100644 --- a/tests/hardware/test_mum_auto_addressing.py +++ b/tests/hardware/mum/test_mum_auto_addressing.py @@ -35,7 +35,6 @@ from typing import Iterable import pytest -from ecu_framework.config import EcuTestConfig from ecu_framework.lin.base import LinInterface @@ -113,7 +112,7 @@ def _run_bsm_sequence(lin: LinInterface, target_nad: int) -> None: def test_bsm_auto_addressing_changes_nad( - config: EcuTestConfig, lin: LinInterface, ldf, rp + lin: LinInterface, ldf, rp ): """ Title: BSM-SNPD auto-addressing assigns a new NAD and ALM_Status reflects it @@ -140,9 +139,7 @@ def test_bsm_auto_addressing_changes_nad( - After BSM sequence, ALM_Status.ALMNadNo == target_nad - After restore sequence, ALM_Status.ALMNadNo == initial_nad """ - if config.interface.type != "mum": - pytest.skip("interface.type must be 'mum' for this test") - + # MUM gate is enforced by tests/hardware/mum/conftest.py::_require_mum # send_raw is MUM-only; gate on capability so the failure mode is clean if not hasattr(lin, "send_raw"): pytest.skip("LIN adapter does not expose send_raw() (need MumLinInterface)") diff --git a/tests/hardware/test_overvolt.py b/tests/hardware/mum/test_overvolt.py similarity index 93% rename from tests/hardware/test_overvolt.py rename to tests/hardware/mum/test_overvolt.py index 5a85945..cab9b0b 100644 --- a/tests/hardware/test_overvolt.py +++ b/tests/hardware/mum/test_overvolt.py @@ -53,8 +53,6 @@ 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 @@ -105,40 +103,23 @@ ECU_VALIDATION_TIME_S = 1.0 # 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) +# ``fio`` and ``alm`` come from ``tests/hardware/mum/conftest.py``. +# This module overrides ``_reset_to_off`` because parking the PSU at the +# nominal voltage is part of every test's baseline here, not just the +# LED state — see the docstring below. @pytest.fixture(autouse=True) -def _park_at_nominal(psu: OwonPSU, alm: AlmTester): +def _reset_to_off(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 will - do its own settle+validation in the PROCEDURE). + Overrides the conftest's LED-only ``_reset_to_off`` because over/under- + voltage tests need both the rail and the LED restored. 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 will do its own + settle+validation in the PROCEDURE). """ # SETUP — nominal voltage, then LED off apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2) diff --git a/tests/hardware/psu/__init__.py b/tests/hardware/psu/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/hardware/test_owon_psu.py b/tests/hardware/psu/test_owon_psu.py similarity index 100% rename from tests/hardware/test_owon_psu.py rename to tests/hardware/psu/test_owon_psu.py diff --git a/tests/hardware/test_psu_voltage_settling.py b/tests/hardware/psu/test_psu_voltage_settling.py similarity index 100% rename from tests/hardware/test_psu_voltage_settling.py rename to tests/hardware/psu/test_psu_voltage_settling.py