ecu-tests/docs/05_architecture_overview.md
Hosam-Eldin Mostafa 8fa4cf0be1 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>
2026-05-14 19:43:09 +02:00

12 KiB
Raw Blame History

Architecture Overview

This document provides a high-level view of the frameworks components and how they interact, plus a Mermaid diagram for quick orientation.

For the dynamic wiring — how a test actually reaches a live LinInterface at session start, the fixture topology, and the playbook for adding a new framework component — see 24_test_wiring.md.

Components

Framework core (ecu_framework/)

  • Config Loader — ecu_framework/config/loader.py (YAML → dataclasses; re-exported via ecu_framework.config)
  • LIN Abstraction — ecu_framework/lin/base.py (LinInterface, LinFrame)
  • Mock LIN Adapter — ecu_framework/lin/mock.py
  • MUM LIN Adapter — ecu_framework/lin/mum.py (Melexis Universal Master via pylin + pymumclient)
  • BabyLIN Adapter — ecu_framework/lin/babylin.py (SDK wrapper → BabyLIN_library.py; DEPRECATED, kept for legacy rigs only)
  • LDF Database — ecu_framework/lin/ldf.py (LdfDatabase/Frame over ldfparser; per-frame pack/unpack). Runtime, dynamic. Loaded fresh each session from whatever LDF the config points at. See LDF Database vs Generated LIN API below for why this is paired with the generated layer.
  • Flasher — ecu_framework/flashing/hex_flasher.py
  • Power Supply (PSU) control — ecu_framework/power/owon_psu.py (serial SCPI + cross-platform port resolver)
  • PSU quick demo script — vendor/Owon/owon_psu_quick_demo.py

Hardware test layer (tests/hardware/)

  • Project-wide fixtures — tests/conftest.py (config, lin, ldf, flash_ecu, rp)
  • Hardware-suite fixtures — tests/hardware/conftest.py (session-scoped, autouse PSU; the bench is powered up once at session start and stays on for every test in the suite)
  • MUM-suite fixtures — tests/hardware/mum/conftest.py (session-scoped fio, nad, alm; autouse _require_mum gate and _reset_to_off per-test reset). Tests outside tests/hardware/mum/ cannot see these — that's how PSU-only and BabyLIN-only tests are kept from accidentally requesting MUM fixtures.
  • Generic LDF I/O — tests/hardware/frame_io.py (FrameIO — send/receive/pack/unpack for any LDF frame plus raw-bus escape hatches). Stringly-typed at this layer (fio.send("ALM_Req_A", …)); typed wrappers live one level up.
  • Generated LIN API — tests/hardware/_generated/lin_api.py (auto-emitted from an LDF by scripts/gen_lin_api.py; one class per frame, one IntEnum per encoding type with logical values). Build-time, static. Provides typed names so frame/signal typos become import errors. Design + generation rules in docs/22_generated_lin_api.md; relationship to ecu_framework/lin/ldf.py covered in LDF Database vs Generated LIN API.
  • ALM domain helpers — tests/hardware/alm_helpers.py (AlmTester — force_off / wait_for_state / measure_animating_window / assert_pwm_*). Imports typed frames + enums from the generated layer; keeps the non-generatable semantics (polling cadences, PWM tolerances, cross-frame test patterns).
  • PSU settle helpers — tests/hardware/psu_helpers.py (wait_until_settled, apply_voltage_and_settle — measured-rail-then-validation pattern shared by all voltage-changing tests)
  • RGB→PWM calculator — vendor/rgb_to_pwm.py (consumed by AlmTester.assert_pwm_*)
  • Test templates (not collected) — tests/hardware/_test_case_template.py, tests/hardware/_test_case_template_psu_lin.py

Tests, reporting, artifacts

  • Tests (pytest) — modules under tests/{,unit,plugin,hardware}/
  • Reporting Plugin — conftest_plugin.py (docstring → report metadata)
  • Reports — reports/report.html, reports/junit.xml, reports/summary.md, reports/requirements_coverage.json

Mermaid architecture diagram

flowchart TB
  subgraph Tests_and_Pytest [Tests & Pytest]
    T[tests/* &#40;test bodies&#41;]
    CF[tests/conftest.py<br/>config, lin, ldf, flash_ecu, rp]
    HCF[tests/hardware/conftest.py<br/>SESSION psu &#40;autouse&#41;]
    MCF[tests/hardware/mum/conftest.py<br/>fio, alm, nad, _require_mum &#40;autouse&#41;,<br/>_reset_to_off &#40;autouse&#41;]
    PL[conftest_plugin.py]
  end

  subgraph Hardware_Helpers [Hardware-test helpers]
    FIO[tests/hardware/frame_io.py<br/>FrameIO &#40;stringly-typed&#41;]
    GEN[tests/hardware/_generated/lin_api.py<br/>AlmReqA, AlmStatus, ... &#40;typed&#41;<br/>LedState, Mode, Update IntEnums]
    ALM[tests/hardware/alm_helpers.py<br/>AlmTester]
    RGB[vendor/rgb_to_pwm.py]
    TPL[tests/hardware/_test_case_template*.py<br/>not collected]
  end

  subgraph Build_Time [Build-time tooling &#40;not run during tests&#41;]
    GENSCRIPT[scripts/gen_lin_api.py]
  end

  subgraph Framework
    CFG[ecu_framework/config/loader.py]
    BASE[ecu_framework/lin/base.py]
    MOCK[ecu_framework/lin/mock.py]
    MUM[ecu_framework/lin/mum.py]
    BABY[ecu_framework/lin/babylin.py<br/>DEPRECATED]
    LDF[ecu_framework/lin/ldf.py]
    FLASH[ecu_framework/flashing/hex_flasher.py]
    POWER[ecu_framework/power/owon_psu.py<br/>SerialParams, OwonPSU,<br/>resolve_port]
  end

  subgraph Artifacts
    REP[reports/report.html<br/>reports/junit.xml<br/>reports/summary.md]
    YAML[config/*.yaml<br/>test_config.yaml<br/>mum.example.yaml<br/>babylin.example.yaml — deprecated]
    PSU_YAML[config/owon_psu.yaml<br/>OWON_PSU_CONFIG]
    MELEXIS[Melexis pylin + pymumclient<br/>MUM @ 192.168.7.2]
    SDK[vendor/BabyLIN_library.py<br/>platform libs<br/>DEPRECATED]
    OWON[vendor/Owon/owon_psu_quick_demo.py]
    LDFFILE[vendor/*.ldf]
    LDFLIB[ldfparser PyPI]
  end

  T --> CF
  T --> HCF
  T --> MCF
  MCF --> FIO
  MCF --> ALM
  CF --> CFG
  CF --> BASE
  CF --> MOCK
  CF --> MUM
  CF --> BABY
  CF --> FLASH
  HCF --> POWER
  T --> FIO
  T --> GEN
  T --> ALM
  ALM --> FIO
  ALM --> GEN
  GEN -.calls at runtime.-> FIO
  GENSCRIPT -.reads LDF once.-> LDFFILE
  GENSCRIPT -.emits source.-> GEN
  ALM --> RGB
  TPL -.copy & edit.-> T

  PL --> REP

  CFG --> YAML
  CFG --> PSU_YAML
  MUM --> MELEXIS
  BABY --> SDK
  LDF --> LDFLIB
  LDF --> LDFFILE
  POWER --> PSU_YAML
  T --> OWON
  T --> REP

Data and control flow summary

  • Tests use fixtures to obtain config and a connected LIN adapter
  • Config loader reads YAML (or env override), returns typed dataclasses
  • LIN calls are routed through the interface abstraction to the selected adapter
  • Hardware tests sit on top of three helpers: FrameIO (LDF-driven send / receive / pack / unpack for any frame, stringly-typed by frame name), the generated lin_api.py (typed AlmReqA.send(fio, …) wrappers plus LedState/Mode/Update enums, so signal/frame typos become import errors), and AlmTester (ALM_Node domain patterns built on FrameIO and the generated enums). All three are imported as siblings from tests/hardware/ — see docs/19_frame_io_and_alm_helpers.md and docs/22_generated_lin_api.md
  • The hardware-suite tests/hardware/conftest.py defines a session-scoped, autouse psu fixture: on benches where the Owon PSU powers the ECU, the supply is opened once at session start, parked at config.power_supply.set_voltage / set_current, and left enabled for every test. Voltage-tolerance tests perturb voltage and restore in finally; they never toggle output. See docs/14_power_supply.md §5.
  • Flasher (optional) uses the same LinInterface to program the ECU
  • Power supply control (optional) uses ecu_framework/power/owon_psu.py and reads config.power_supply (merged with config/owon_psu.yaml or OWON_PSU_CONFIG when present). The quick demo script under vendor/Owon/ provides a quick manual flow
  • Reporting plugin parses docstrings and enriches the HTML report

LDF Database vs Generated LIN API: two layers, one purpose

There are two pieces of code in this repo whose names both sound like "the LDF module", and a recurring question is why both exist:

Aspect ecu_framework/lin/ldf.py (LdfDatabase/Frame) tests/hardware/_generated/lin_api.py
What it is Runtime wrapper around ldfparser Source file emitted by scripts/gen_lin_api.py
When it runs Every test session — parse_ldf(path) is called inside the ldf fixture (tests/conftest.py:92) Never runs as a parser; it is the parser's output, imported like any other module
What it produces Frame objects whose .pack(**kw) / .unpack(bytes) route through ldfparser's encode_raw / decode_raw class AlmReqA, class LedState(IntEnum), etc. — Python literals derived from one LDF
Source of truth The LDF file on disk at startup The LDF file at the time gen_lin_api.py was last run (SHA256 in the file header)
Typing model Stringly-typed (db.frame("ALM_Req_A").pack(AmbLight…=…)) Statically typed (AlmReqA.send(fio, AmbLight…=…))
Failure mode for a missing/renamed frame KeyError: 'Frame X not found' at test time ImportError: cannot import name 'X' at collection time, surfaced in CI
Failure mode for an LDF rev None — it parses whatever is on disk The in-sync unit test fails when the LDF SHA256 in the header drifts
Layer in the dependency tree Framework core (ecu_framework/) — knows nothing about specific frame names Test code (tests/hardware/) — bakes specific frame and signal names in
Lifecycle Re-parsed each pytest session Regenerated only on LDF change, then committed
Coupling to ldfparser Direct (from ldfparser import parse_ldf) None at runtime; the generator imports it, the generated file does not

The two answer orthogonal questions:

  • ecu_framework/lin/ldf.py answers "what bytes go on the wire for this frame right now?" — it has to be dynamic because bit offsets, widths, and init values are properties of whichever LDF the bench loaded, and must be re-validated against that LDF at startup.
  • tests/hardware/_generated/lin_api.py answers "what frame and signal names are valid for me to type in test code?" — it has to be static because that question is asked by the IDE, mypy, and pytest's collection step, all of which run before any LDF has been parsed.

If only ecu_framework/lin/ldf.py existed, every test would keep its stringly-typed fio.send("ALM_Req_A", …) calls and its hand-copied LED_STATE_OFF = 0 constants — both of which silently drift when the LDF changes. If only the generated lin_api.py existed, the runtime would have no path from a frame name to the actual byte layout for the currently loaded LDF — and worse, the test bench would happily ship bytes encoded against a stale LDF baked into the generator's last run.

Concretely, a single fio.send call traverses both layers:

test code
   |
   |  AlmReqA.send(fio, AmbLightColourRed=0, ...)
   v
tests/hardware/_generated/lin_api.py   <-- typed names, compile-time check
   |
   |  fio.send("ALM_Req_A", AmbLightColourRed=0, ...)
   v
tests/hardware/frame_io.py             <-- per-instance frame cache
   |
   |  ldf.frame("ALM_Req_A").pack(AmbLightColourRed=0, ...)
   v
ecu_framework/lin/ldf.py               <-- runtime pack/unpack
   |
   |  raw_frame.encode_raw({...})
   v
ldfparser                              <-- bit-level layout from LDF on disk

Each layer's responsibility is unique to that layer; removing either collapses a distinct kind of check (compile-time name validation, or runtime LDF-driven byte layout) that the other layer cannot provide.

Extending the architecture

  • Add new bus adapters by implementing LinInterface
  • Add new ECU-domain helpers next to AlmTester (e.g. BcmTester) on top of FrameIO 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 ecu_framework/power/ or a new ecu_framework/instruments/ package, expose them as session-scoped fixtures
  • Add new report sinks (e.g., JSON or a DB) by extending the plugin