refactor(tests): layer fixtures by adapter type (mum/psu/babylin)

Restructures tests/hardware/ so that fixture access is controlled by
directory layout — pytest only walks upward through conftest.py files,
so a PSU test physically cannot request fio/alm/nad.

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

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

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

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hosam-Eldin Mostafa 2026-05-14 19:43:09 +02:00
parent 032866bba0
commit 8fa4cf0be1
26 changed files with 1244 additions and 257 deletions

View File

@ -2,6 +2,10 @@
This document explains how configuration is loaded, merged, and provided to tests and interfaces. 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 ## Sources and precedence
From highest to lowest 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 - PSU-related tests or utilities read `config.power_supply` for serial parameters
and optional actions (IDN assertions, on/off toggle, set/measure). The reference 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 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 ## Tips

View File

@ -2,15 +2,20 @@
This document provides a high-level view of the frameworks components and how they interact, plus a Mermaid diagram for quick orientation. This document provides a high-level view of the frameworks 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 ## Components
### Framework core (`ecu_framework/`) ### 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`) - LIN Abstraction — `ecu_framework/lin/base.py` (`LinInterface`, `LinFrame`)
- Mock LIN Adapter — `ecu_framework/lin/mock.py` - Mock LIN Adapter — `ecu_framework/lin/mock.py`
- MUM LIN Adapter — `ecu_framework/lin/mum.py` (Melexis Universal Master via `pylin` + `pymumclient`) - 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) - 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` - Flasher — `ecu_framework/flashing/hex_flasher.py`
- Power Supply (PSU) control — `ecu_framework/power/owon_psu.py` (serial SCPI + cross-platform port resolver) - 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` - PSU quick demo script — `vendor/Owon/owon_psu_quick_demo.py`
@ -18,8 +23,10 @@ This document provides a high-level view of the frameworks components and how
### Hardware test layer (`tests/hardware/`) ### Hardware test layer (`tests/hardware/`)
- Project-wide fixtures — `tests/conftest.py` (config, lin, ldf, flash_ecu, rp) - 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) - 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) - 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.
- ALM domain helpers — `tests/hardware/alm_helpers.py` (`AlmTester` — force_off / wait_for_state / measure_animating_window / assert_pwm_*) - 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) - 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_*`) - 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` - 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/* &#40;test bodies&#41;] T[tests/* &#40;test bodies&#41;]
CF[tests/conftest.py<br/>config, lin, ldf, flash_ecu, rp] CF[tests/conftest.py<br/>config, lin, ldf, flash_ecu, rp]
HCF[tests/hardware/conftest.py<br/>SESSION psu &#40;autouse&#41;] HCF[tests/hardware/conftest.py<br/>SESSION psu &#40;autouse&#41;]
MCF[tests/hardware/mum/conftest.py<br/>fio, alm, nad, _require_mum &#40;autouse&#41;,<br/>_reset_to_off &#40;autouse&#41;]
PL[conftest_plugin.py] PL[conftest_plugin.py]
end end
subgraph Hardware_Helpers [Hardware-test helpers] subgraph Hardware_Helpers [Hardware-test helpers]
FIO[tests/hardware/frame_io.py<br/>FrameIO] FIO[tests/hardware/frame_io.py<br/>FrameIO &#40;stringly-typed&#41;]
GEN[tests/hardware/_generated/lin_api.py<br/>AlmReqA, AlmStatus, ... &#40;typed&#41;<br/>LedState, Mode, Update IntEnums]
ALM[tests/hardware/alm_helpers.py<br/>AlmTester] ALM[tests/hardware/alm_helpers.py<br/>AlmTester]
RGB[vendor/rgb_to_pwm.py] RGB[vendor/rgb_to_pwm.py]
TPL[tests/hardware/_test_case_template*.py<br/>not collected] TPL[tests/hardware/_test_case_template*.py<br/>not collected]
end end
subgraph Build_Time [Build-time tooling &#40;not run during tests&#41;]
GENSCRIPT[scripts/gen_lin_api.py]
end
subgraph Framework subgraph Framework
CFG[ecu_framework/config.py] CFG[ecu_framework/config/loader.py]
BASE[ecu_framework/lin/base.py] BASE[ecu_framework/lin/base.py]
MOCK[ecu_framework/lin/mock.py] MOCK[ecu_framework/lin/mock.py]
MUM[ecu_framework/lin/mum.py] MUM[ecu_framework/lin/mum.py]
@ -71,6 +84,9 @@ flowchart TB
T --> CF T --> CF
T --> HCF T --> HCF
T --> MCF
MCF --> FIO
MCF --> ALM
CF --> CFG CF --> CFG
CF --> BASE CF --> BASE
CF --> MOCK CF --> MOCK
@ -79,8 +95,13 @@ flowchart TB
CF --> FLASH CF --> FLASH
HCF --> POWER HCF --> POWER
T --> FIO T --> FIO
T --> GEN
T --> ALM T --> ALM
ALM --> FIO ALM --> FIO
ALM --> GEN
GEN -.calls at runtime.-> FIO
GENSCRIPT -.reads LDF once.-> LDFFILE
GENSCRIPT -.emits source.-> GEN
ALM --> RGB ALM --> RGB
TPL -.copy & edit.-> T TPL -.copy & edit.-> T
@ -102,10 +123,14 @@ flowchart TB
- Tests use fixtures to obtain config and a connected LIN adapter - Tests use fixtures to obtain config and a connected LIN adapter
- Config loader reads YAML (or env override), returns typed dataclasses - Config loader reads YAML (or env override), returns typed dataclasses
- LIN calls are routed through the interface abstraction to the selected adapter - LIN calls are routed through the interface abstraction to the selected adapter
- Hardware tests sit on top of two helpers: `FrameIO` (LDF-driven send / - Hardware tests sit on top of three helpers: `FrameIO` (LDF-driven send /
receive / pack / unpack for any frame) and `AlmTester` (ALM_Node domain receive / pack / unpack for any frame, stringly-typed by frame name),
patterns built on `FrameIO`). Both are imported as siblings from the generated `lin_api.py` (typed `AlmReqA.send(fio, …)` wrappers plus
`tests/hardware/` — see `docs/19_frame_io_and_alm_helpers.md` `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, - The hardware-suite `tests/hardware/conftest.py` defines a **session-scoped,
autouse** `psu` fixture: on benches where the Owon PSU powers the ECU, autouse** `psu` fixture: on benches where the Owon PSU powers the ECU,
the supply is opened once at session start, parked at the supply is opened once at session start, parked at
@ -119,11 +144,80 @@ flowchart TB
`vendor/Owon/` provides a quick manual flow `vendor/Owon/` provides a quick manual flow
- Reporting plugin parses docstrings and enriches the HTML report - 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 ## Extending the architecture
- Add new bus adapters by implementing `LinInterface` - Add new bus adapters by implementing `LinInterface`
- Add new ECU-domain helpers next to `AlmTester` (e.g. `BcmTester`) - 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 <ldf-path>`, 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 - Add new bench instrument controllers next to `OwonPSU` under
`ecu_framework/power/` or a new `ecu_framework/instruments/` package, `ecu_framework/power/` or a new `ecu_framework/instruments/` package,
expose them as session-scoped fixtures expose them as session-scoped fixtures

View File

@ -15,7 +15,7 @@ guarantees the controller class provides.
| Artifact | Path | | Artifact | Path |
|---|---| |---|---|
| Controller library | [`ecu_framework/power/owon_psu.py`](../ecu_framework/power/owon_psu.py) | | 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) | | 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` | | Central config | [`config/test_config.yaml`](../config/test_config.yaml) → `power_supply` |
| Per-machine override | `config/owon_psu.yaml` or env `OWON_PSU_CONFIG` | | 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 ### 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. - **`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 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()` 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 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 `settling_time_s` plus a downsampled voltage trace per case. The
@ -478,7 +478,7 @@ doesn't.
| File | Purpose | | File | Purpose |
|---|---| |---|---|
| `ecu_framework/power/owon_psu.py` | Controller library (`SerialParams`, `OwonPSU`, resolver helpers). | | `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. | | `vendor/Owon/owon_psu_quick_demo.py` | Quick demo runner. |
| `config/owon_psu.example.yaml` | Example per-machine YAML. | | `config/owon_psu.example.yaml` | Example per-machine YAML. |
| `tests/hardware/_test_case_template.py` | Copyable starting point for new hardware tests. | | `tests/hardware/_test_case_template.py` | Copyable starting point for new hardware tests. |

View File

@ -196,7 +196,7 @@ Tests gated on `interface.type == "mum"`. All require:
### 4.1 `test_e2e_mum_led_activate.py` ### 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 | | 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` ### 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 Suite of automated checks for the four behaviour buckets in
`vendor/automated_lin_test/test_animation.py`. A module-scoped fixture `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` ### 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 | | 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)* ### 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. 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` ### 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 | | 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` ### 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 | | 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` ### 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 Drives the bench supply through known thresholds and observes
`ALM_Status.ALMVoltageStatus` on the LIN bus. All cases use the `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`)* ### 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 Characterization test — extracts how long the bench Owon PSU takes
to actually deliver a new voltage at its terminals after a setpoint to actually deliver a new voltage at its terminals after a setpoint

View File

@ -187,47 +187,41 @@ def pwm_within_tol(actual: int, expected: int) -> bool
## 5. Fixture wiring ## 5. Fixture wiring
`tests/hardware/test_mum_alm_animation.py` defines two module-scoped `fio`, `alm`, `nad`, and the autouse `_reset_to_off` are provided by
fixtures plus an autouse reset. The same pattern applies to any new `tests/hardware/mum/conftest.py` — session-scoped (except `_reset_to_off`,
hardware test file targeting MUM. which must be function-scoped) and shared by every MUM test. A new MUM test
just lists them in its signature:
```python ```python
import pytest def test_red_at_full(fio, alm, rp):
from ecu_framework.config import EcuTestConfig fio.send("ALM_Req_A", ...)
from ecu_framework.lin.base import LinInterface alm.assert_pwm_matches_rgb(rp, 255, 0, 0)
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()
``` ```
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 The `lin`, `ldf`, and `config` fixtures are provided globally by
`tests/conftest.py` — see [docs/02_configuration_resolution.md](02_configuration_resolution.md) `tests/conftest.py`; see [`24_test_wiring.md`](24_test_wiring.md) for the
for how they are wired. 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. | | C — sweep | Parametrized walk over `(V, expected_status)` tuples. |
For the *settling time* characterization that feeds these tests' 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`). (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) 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)

View File

@ -312,7 +312,7 @@ docker run --rm -it \
Inside the container: Inside the container:
```bash ```bash
pytest tests/hardware/test_mum_alm_animation.py -v pytest tests/hardware/mum/test_mum_alm_animation.py -v
``` ```
--- ---

383
docs/24_test_wiring.md Normal file
View File

@ -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<br/>+ optional config/owon_psu.yaml<br/>+ $ECU_TESTS_CONFIG / $OWON_PSU_CONFIG"]
end
subgraph Loader[ecu_framework.config]
LC["load_config&#40;workspace_root&#41;<br/>YAML + env + overrides → EcuTestConfig"]
end
subgraph Fixtures_Top[tests/conftest.py - session-scoped]
F_CONFIG["config<br/>→ EcuTestConfig"]
F_LIN["lin<br/>→ LinInterface"]
F_LDF["ldf<br/>→ LdfDatabase"]
F_FLASH["flash_ecu<br/>→ runs HexFlasher"]
F_RP["rp<br/>→ record_property helper"]
end
subgraph Fixtures_HW[tests/hardware/conftest.py - session-scoped]
F_PSU_PRIV["_psu_or_none<br/>opens PSU once"]
F_PSU_AUTO["_psu_powers_bench<br/>autouse=True"]
F_PSU["psu<br/>public, skips when unavailable"]
end
subgraph Fixtures_MUM[tests/hardware/mum/conftest.py]
F_REQ_MUM["_require_mum<br/>session, autouse"]
F_FIO["fio<br/>session"]
F_NAD["nad<br/>session"]
F_ALM["alm<br/>session"]
F_RESET["_reset_to_off<br/>function, autouse"]
end
subgraph Adapters[ecu_framework adapters]
MOCK["MockBabyLinInterface"]
MUM["MumLinInterface"]
BABY["BabyLinInterface<br/>DEPRECATED"]
OWON["OwonPSU"]
HEX["HexFlasher"]
end
subgraph Tests[tests/]
UNIT["tests/unit/*<br/>only config-level fixtures"]
HW_PSU["tests/hardware/psu/*<br/>psu only (no fio/alm)"]
HW_MUM["tests/hardware/mum/*<br/>fio + alm + psu (inherited)"]
HW_BABY["tests/hardware/babylin/*<br/>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<br/>session fixture] --> FIO
LDFDB[LdfDatabase<br/>ldf fixture] --> FIO[FrameIO<br/>per test, local]
FIO --> ALM[AlmTester<br/>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

View File

@ -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 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 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) 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: Related references:

View File

@ -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()

View File

View File

@ -37,9 +37,6 @@ _HW_DIR = Path(__file__).resolve().parent.parent
if str(_HW_DIR) not in sys.path: if str(_HW_DIR) not in sys.path:
sys.path.insert(0, str(_HW_DIR)) 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 frame_io import FrameIO
from alm_helpers import ( from alm_helpers import (
AlmTester, AlmTester,
@ -52,33 +49,8 @@ from alm_helpers import (
pytestmark = [pytest.mark.ANM] pytestmark = [pytest.mark.ANM]
# --- fixtures -------------------------------------------------------------- # Fixtures (fio, alm, _reset_to_off) and the MUM gate come from
# tests/hardware/mum/conftest.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):
"""Drive LED to OFF before/after each test so state doesn't leak."""
alm.force_off()
yield
alm.force_off()
# --- helpers --------------------------------------------------------------- # --- helpers ---------------------------------------------------------------

View File

@ -40,9 +40,6 @@ _HW_DIR = Path(__file__).resolve().parent.parent
if str(_HW_DIR) not in sys.path: if str(_HW_DIR) not in sys.path:
sys.path.insert(0, str(_HW_DIR)) 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 frame_io import FrameIO
from alm_helpers import ( from alm_helpers import (
AlmTester, AlmTester,
@ -54,32 +51,8 @@ from alm_helpers import (
pytestmark = [pytest.mark.COM] pytestmark = [pytest.mark.COM]
# --- fixtures -------------------------------------------------------------- # Fixtures (fio, alm, _reset_to_off) and the MUM gate come from
# tests/hardware/mum/conftest.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):
alm.force_off()
yield
alm.force_off()
# --- tests ----------------------------------------------------------------- # --- tests -----------------------------------------------------------------

View File

View File

@ -40,9 +40,6 @@ _HW_DIR = Path(__file__).resolve().parent.parent
if str(_HW_DIR) not in sys.path: if str(_HW_DIR) not in sys.path:
sys.path.insert(0, str(_HW_DIR)) 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 frame_io import FrameIO
from alm_helpers import ( from alm_helpers import (
AlmTester, AlmTester,
@ -54,32 +51,8 @@ from alm_helpers import (
pytestmark = [pytest.mark.COM_VTD] pytestmark = [pytest.mark.COM_VTD]
# --- fixtures -------------------------------------------------------------- # Fixtures (fio, alm, _reset_to_off) and the MUM gate come from
# tests/hardware/mum/conftest.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):
alm.force_off()
yield
alm.force_off()
def _require_signals_in_frame(fio: FrameIO, frame_name: str, signal_names: list[str]) -> None: def _require_signals_in_frame(fio: FrameIO, frame_name: str, signal_names: list[str]) -> None:

View File

@ -11,16 +11,13 @@ from __future__ import annotations
import pytest import pytest
from ecu_framework.config import EcuTestConfig
from ecu_framework.lin.base import LinFrame, LinInterface from ecu_framework.lin.base import LinFrame, LinInterface
pytestmark = [pytest.mark.hardware, pytest.mark.mum] pytestmark = [pytest.mark.hardware, pytest.mark.mum]
def test_mum_e2e_power_on_then_led_activate( def test_mum_e2e_power_on_then_led_activate(lin: LinInterface, ldf, rp):
config: EcuTestConfig, lin: LinInterface, ldf, rp
):
""" """
Title: MUM E2E - Power ECU, Read NAD, Activate RGB LED 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 - lin.send() of the LDF-packed frame succeeds
- Second ALM_Status read returns a frame (bus still alive after Tx) - Second ALM_Status read returns a frame (bus still alive after Tx)
""" """
if config.interface.type != "mum": # MUM gate is enforced by tests/hardware/mum/conftest.py::_require_mum
pytest.skip("interface.type must be 'mum' for this test")
req_a = ldf.frame("ALM_Req_A") req_a = ldf.frame("ALM_Req_A")
status = ldf.frame("ALM_Status") status = ldf.frame("ALM_Status")
rp("ldf_path", str(ldf.path)) rp("ldf_path", str(ldf.path))

View File

@ -25,9 +25,6 @@ import time
import pytest import pytest
from ecu_framework.config import EcuTestConfig
from ecu_framework.lin.base import LinInterface
from frame_io import FrameIO from frame_io import FrameIO
from alm_helpers import ( from alm_helpers import (
AlmTester, AlmTester,
@ -40,35 +37,8 @@ from alm_helpers import (
pytestmark = [pytest.mark.ANM] pytestmark = [pytest.mark.ANM]
# --- fixtures -------------------------------------------------------------- # Fixtures (fio, alm, _reset_to_off) and the MUM gate come from
# tests/hardware/mum/conftest.py — see that file for scope rationale.
@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()
# --- tests: AmbLightMode behavior ------------------------------------------ # --- tests: AmbLightMode behavior ------------------------------------------

View File

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

View File

@ -38,9 +38,6 @@ from typing import Optional
import pytest import pytest
from ecu_framework.config import EcuTestConfig
from ecu_framework.lin.base import LinInterface
from frame_io import FrameIO from frame_io import FrameIO
from alm_helpers import ( from alm_helpers import (
AlmTester, AlmTester,
@ -276,35 +273,8 @@ ALM_CASES: list[AlmCase] = [
] ]
# ╔══════════════════════════════════════════════════════════════════════╗ # Fixtures (fio, alm, _reset_to_off) and the MUM gate come from
# ║ Fixtures (mirror test_mum_alm_animation.py) ║ # tests/hardware/mum/conftest.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()
# ╔══════════════════════════════════════════════════════════════════════╗ # ╔══════════════════════════════════════════════════════════════════════╗

View File

@ -35,7 +35,6 @@ from typing import Iterable
import pytest import pytest
from ecu_framework.config import EcuTestConfig
from ecu_framework.lin.base import LinInterface 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( 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 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 BSM sequence, ALM_Status.ALMNadNo == target_nad
- After restore sequence, ALM_Status.ALMNadNo == initial_nad - After restore sequence, ALM_Status.ALMNadNo == initial_nad
""" """
if config.interface.type != "mum": # MUM gate is enforced by tests/hardware/mum/conftest.py::_require_mum
pytest.skip("interface.type must be 'mum' for this test")
# send_raw is MUM-only; gate on capability so the failure mode is clean # send_raw is MUM-only; gate on capability so the failure mode is clean
if not hasattr(lin, "send_raw"): if not hasattr(lin, "send_raw"):
pytest.skip("LIN adapter does not expose send_raw() (need MumLinInterface)") pytest.skip("LIN adapter does not expose send_raw() (need MumLinInterface)")

View File

@ -53,8 +53,6 @@ from __future__ import annotations
import pytest import pytest
from ecu_framework.config import EcuTestConfig
from ecu_framework.lin.base import LinInterface
from ecu_framework.power import OwonPSU from ecu_framework.power import OwonPSU
from frame_io import FrameIO 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 # 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 # stays on. Tests in this file just READ the psu fixture and perturb
# voltage; they MUST NOT close it or toggle output. # voltage; they MUST NOT close it or toggle output.
# # ``fio`` and ``alm`` come from ``tests/hardware/mum/conftest.py``.
# ``fio`` and ``alm`` are module-scoped here. As soon as a third test # This module overrides ``_reset_to_off`` because parking the PSU at the
# file needs them, move both to ``tests/hardware/conftest.py``. # nominal voltage is part of every test's baseline here, not just the
# LED state — see the docstring below.
@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)
@pytest.fixture(autouse=True) @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. """Per-test baseline: PSU voltage at NOMINAL_VOLTAGE + LED off.
Uses :func:`apply_voltage_and_settle` so the rail is *measurably* Overrides the conftest's LED-only ``_reset_to_off`` because over/under-
at nominal before the test body runs and afterwards, even on voltage tests need both the rail and the LED restored. Uses
assertion failure. Validation time is short here: we just need :func:`apply_voltage_and_settle` so the rail is *measurably* at
the rail steady, not the ECU to react to it (the test body will nominal before the test body runs and afterwards, even on assertion
do its own settle+validation in the PROCEDURE). 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 # SETUP — nominal voltage, then LED off
apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2) apply_voltage_and_settle(psu, NOMINAL_VOLTAGE, validation_time=0.2)

View File