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:
parent
032866bba0
commit
8fa4cf0be1
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
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
|
## 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 framework’s 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/* (test bodies)]
|
T[tests/* (test bodies)]
|
||||||
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 (autouse)]
|
HCF[tests/hardware/conftest.py<br/>SESSION psu (autouse)]
|
||||||
|
MCF[tests/hardware/mum/conftest.py<br/>fio, alm, nad, _require_mum (autouse),<br/>_reset_to_off (autouse)]
|
||||||
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 (stringly-typed)]
|
||||||
|
GEN[tests/hardware/_generated/lin_api.py<br/>AlmReqA, AlmStatus, ... (typed)<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 (not run during tests)]
|
||||||
|
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
|
||||||
|
|||||||
@ -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. |
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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
383
docs/24_test_wiring.md
Normal 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(workspace_root)<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
|
||||||
@ -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:
|
||||||
|
|
||||||
|
|||||||
143
tests/hardware/mum/conftest.py
Normal file
143
tests/hardware/mum/conftest.py
Normal 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()
|
||||||
0
tests/hardware/mum/swe5/__init__.py
Normal file
0
tests/hardware/mum/swe5/__init__.py
Normal 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 ---------------------------------------------------------------
|
||||||
@ -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 -----------------------------------------------------------------
|
||||||
0
tests/hardware/mum/swe6/__init__.py
Normal file
0
tests/hardware/mum/swe6/__init__.py
Normal 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:
|
||||||
@ -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))
|
||||||
@ -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 ------------------------------------------
|
||||||
536
tests/hardware/mum/test_mum_alm_animation_generated.py
Normal file
536
tests/hardware/mum/test_mum_alm_animation_generated.py
Normal 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)
|
||||||
@ -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()
|
|
||||||
|
|
||||||
|
|
||||||
# ╔══════════════════════════════════════════════════════════════════════╗
|
# ╔══════════════════════════════════════════════════════════════════════╗
|
||||||
@ -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)")
|
||||||
@ -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)
|
||||||
0
tests/hardware/psu/__init__.py
Normal file
0
tests/hardware/psu/__init__.py
Normal file
Loading…
x
Reference in New Issue
Block a user