# Test Wiring: From YAML to Test Cases

This document explains **how a test reaches a live `LinInterface`** (or PSU, or
LDF database). For a *static* catalog of components see
[`05_architecture_overview.md`](05_architecture_overview.md); for *what* the
config knobs do see [`02_configuration_resolution.md`](02_configuration_resolution.md).
This file focuses on the *dynamic resolution* — the fixture plumbing that
glues the framework to the test suite at session start.

## The big idea

**Tests never import a concrete adapter.** They never call `load_config()`
directly. The only thing test files import from `ecu_framework` is **types**
(`EcuTestConfig`, `LinFrame`, `LinInterface`, `OwonPSU`) for annotations.
Behavior arrives via pytest fixtures, which are the single seam between the
framework and the test suite.

Concretely: the choice of LIN adapter (mock / MUM / BabyLIN) is made by the
`lin` fixture at session start based on `config.interface.type`. A test that
writes `def test_x(lin):` works against all three with no per-test changes.

## End-to-end wiring

```mermaid
flowchart TB
  subgraph User_Inputs[User inputs]
    YAML["config/test_config.yaml<br/>+ optional config/owon_psu.yaml<br/>+ $ECU_TESTS_CONFIG / $OWON_PSU_CONFIG"]
  end

  subgraph Loader[ecu_framework.config]
    LC["load_config&#40;workspace_root&#41;<br/>YAML + env + overrides → EcuTestConfig"]
  end

  subgraph Fixtures_Top[tests/conftest.py - session-scoped]
    F_CONFIG["config<br/>→ EcuTestConfig"]
    F_LIN["lin<br/>→ LinInterface"]
    F_LDF["ldf<br/>→ LdfDatabase"]
    F_FLASH["flash_ecu<br/>→ runs HexFlasher"]
    F_RP["rp<br/>→ record_property helper"]
  end

  subgraph Fixtures_HW[tests/hardware/conftest.py - session-scoped]
    F_PSU_PRIV["_psu_or_none<br/>opens PSU once"]
    F_PSU_AUTO["_psu_powers_bench<br/>autouse=True"]
    F_PSU["psu<br/>public, skips when unavailable"]
  end

  subgraph Fixtures_MUM[tests/hardware/mum/conftest.py]
    F_REQ_MUM["_require_mum<br/>session, autouse"]
    F_FIO["fio<br/>session"]
    F_NAD["nad<br/>session"]
    F_ALM["alm<br/>session"]
    F_RESET["_reset_to_off<br/>function, autouse"]
  end

  subgraph Adapters[ecu_framework adapters]
    MOCK["MockBabyLinInterface"]
    MUM["MumLinInterface"]
    BABY["BabyLinInterface<br/>DEPRECATED"]
    OWON["OwonPSU"]
    HEX["HexFlasher"]
  end

  subgraph Tests[tests/]
    UNIT["tests/unit/*<br/>only config-level fixtures"]
    HW_PSU["tests/hardware/psu/*<br/>psu only (no fio/alm)"]
    HW_MUM["tests/hardware/mum/*<br/>fio + alm + psu (inherited)"]
    HW_BABY["tests/hardware/babylin/*<br/>legacy E2E"]
  end

  YAML --> LC
  LC --> F_CONFIG
  F_CONFIG --> F_LIN
  F_CONFIG --> F_LDF
  F_CONFIG --> F_FLASH
  F_CONFIG --> F_PSU_PRIV
  F_CONFIG --> F_REQ_MUM
  F_LIN --> F_FLASH
  F_LIN --> F_FIO
  F_LDF --> F_FIO
  F_FIO --> F_NAD
  F_FIO --> F_ALM
  F_NAD --> F_ALM
  F_ALM --> F_RESET

  F_LIN -.selects.-> MOCK
  F_LIN -.selects.-> MUM
  F_LIN -.selects.-> BABY
  F_FLASH --> HEX
  F_PSU_PRIV --> OWON
  F_PSU_PRIV --> F_PSU_AUTO
  F_PSU_PRIV --> F_PSU

  UNIT --> F_CONFIG
  HW_PSU --> F_PSU
  HW_MUM --> F_ALM
  HW_MUM --> F_FIO
  HW_MUM --> F_PSU
  HW_BABY --> F_LIN
```

The dotted edges from `lin` are the **polymorphism boundary**: which adapter
is wired in is decided at fixture instantiation time, by config alone.

## Three-layer conftest topology

Pytest discovers `conftest.py` files automatically by directory and walks
**upward** from each test file. A test only sees fixtures defined in its own
directory or any ancestor — which is how this codebase enforces "MUM tests
can use `fio`, PSU tests can't" without any runtime allow-list.

| File | Scope | Fixtures it provides | Why split |
|---|---|---|---|
| `tests/conftest.py` | Whole test suite | `config`, `lin`, `ldf`, `flash_ecu`, `rp` | Framework primitives every test type needs |
| `tests/hardware/conftest.py` | All hardware tests | `_psu_or_none`, `_psu_powers_bench` (autouse), `psu` | PSU powers the ECU on the bench, so any hardware test benefits |
| `tests/hardware/mum/conftest.py` | MUM-only tests | `_require_mum` (autouse), `fio`, `nad`, `alm`, `_reset_to_off` (autouse) | LDF I/O + ALM state are only meaningful when `interface.type == "mum"` |

The hardware directory is partitioned by adapter type:

```
tests/hardware/
├── conftest.py          # session: PSU fixtures
├── mum/                 # MUM-only tests
│   ├── conftest.py      # session: fio, alm, nad + autouse _require_mum / _reset_to_off
│   ├── test_mum_*.py
│   ├── test_overvolt.py # uses both PSU (inherited) and MUM fio/alm
│   ├── swe5/            # SWE.5 integration tests (all MUM-backed)
│   └── swe6/            # SWE.6 validation tests (all MUM-backed)
├── psu/                 # PSU-only tests; cannot see fio/alm
│   ├── test_owon_psu.py
│   └── test_psu_voltage_settling.py
└── babylin/             # legacy BabyLIN E2E (deprecated)
    └── test_e2e_power_on_lin_smoke.py
```

Each leaf directory carries an empty `__init__.py` so pytest's import
mechanism walks upward to `tests/hardware/` (which has no `__init__.py`)
and prepends it to `sys.path`. That keeps the bare imports
`from frame_io import FrameIO` / `from alm_helpers import AlmTester`
working from any subdirectory, without changes to the helper modules.

The split keeps unit tests fast and import-light: they don't transitively pull
in `pyserial` for an Owon driver they never use.

## Per-component wiring

### `config` — the root of the dependency tree

`tests/conftest.py:27-30`:

```python
@pytest.fixture(scope="session")
def config() -> EcuTestConfig:
    return load_config(str(WORKSPACE_ROOT))
```

- **Session-scoped** → `load_config()` runs **once per test run**.
- `WORKSPACE_ROOT` is derived from `__file__` so the same fixture works
  whether pytest is launched from the repo root, from `tests/`, or from a
  Pi deployment.
- Every other fixture downstream takes `config` as a parameter, so swapping
  YAML files (or `ECU_TESTS_CONFIG=...`) reroutes the entire stack.

### `lin` — the polymorphism boundary in action

`tests/conftest.py:33-87`:

```python
@pytest.fixture(scope="session")
def lin(config: EcuTestConfig) -> Iterator[LinInterface]:
    if   config.interface.type == "mock":    lin = MockBabyLinInterface(...)
    elif config.interface.type == "mum":     lin = MumLinInterface(...)
    elif config.interface.type == "babylin": lin = BabyLinInterface(...)   # deprecated
    ...
    lin.connect()
    yield lin
    lin.disconnect()
```

Two details that matter:

- **Conditional adapter imports at the top of the file** (`tests/conftest.py:13-21`)
  use `try/except`: MUM needs `pymumclient`, BabyLIN needs native DLLs —
  neither is present in CI. The `try` keeps mock-only environments importable;
  selecting a missing adapter `pytest.skip()`s cleanly.
- **LDF + frame_lengths merge** (`tests/conftest.py:62-73`): the LDF (if
  `interface.ldf_path` is set) provides default frame lengths, then YAML
  `frame_lengths` overrides per ID. This merge lives in the fixture, not in
  `MumLinInterface`, so the adapter doesn't depend on `ldfparser`.

`yield lin` then `disconnect()` means **one shared connection** for the whole
session, with deterministic teardown.

### `flash_ecu` — built on top of `lin`

`tests/conftest.py:113-126`:

```python
@pytest.fixture(scope="session", autouse=False)
def flash_ecu(config, lin):
    if not config.flash.enabled: pytest.skip("Flashing disabled in config")
    from ecu_framework.flashing import HexFlasher   # lazy import
    flasher = HexFlasher(lin)                       # ← reuses the lin fixture
    ...
```

- `autouse=False` — only runs when a test explicitly requests it.
- `HexFlasher(lin)` reuses the `lin` fixture, so flashing automatically
  inherits the chosen adapter. One config switch (`interface.type`) reroutes
  both LIN traffic and flashing.
- Import is **lazy** — pulling `HexFlasher` only when needed keeps unit-test
  collection time low.

### `power` — the three-tier PSU fixture ladder

`tests/hardware/conftest.py:61-154`:

| Fixture | Scope | Visibility | Purpose |
|---|---|---|---|
| `_psu_or_none` | session | private (`_` prefix) | Open PSU once, park at nominal V/I, leave output ON |
| `_psu_powers_bench` | session, **`autouse=True`** | private | Forces `_psu_or_none` to materialize even for tests that don't ask for PSU |
| `psu` | session | public | Tests that read measurements / perturb voltage request this; skips cleanly when PSU isn't configured |

The autouse fixture is load-bearing. On a bench where the Owon **powers the
ECU**, a pure MUM test (which never names `psu`) would run with no power on
the ECU and fail mysteriously. The autouse forces PSU setup at session start
even when no test references it by name. Comments in
`tests/hardware/conftest.py:3-10` document exactly this incident.

## Session lifecycle

```mermaid
sequenceDiagram
  autonumber
  participant Pytest
  participant Conftest as tests/conftest.py
  participant HWConftest as tests/hardware/conftest.py
  participant Loader as ecu_framework.config
  participant LIN as ecu_framework.lin
  participant PSU as ecu_framework.power
  participant Test as test function

  Pytest->>Conftest: collect + resolve fixtures
  Pytest->>HWConftest: (only for tests/hardware/**)

  Note over Conftest,Loader: session start
  Conftest->>Loader: load_config(WORKSPACE_ROOT)
  Loader-->>Conftest: EcuTestConfig

  Conftest->>LIN: build adapter from config.interface.type
  LIN-->>Conftest: LinInterface
  Conftest->>LIN: lin.connect()

  HWConftest->>PSU: resolve port, open, park at nominal V
  PSU-->>HWConftest: OwonPSU (or None)

  Note over Test: per test
  Pytest->>Test: invoke test_x(lin, psu, ldf, ...)
  Test->>LIN: send / receive frames
  Test->>PSU: optional perturb voltage
  Test-->>Pytest: pass / fail

  Note over Conftest,PSU: session end
  HWConftest->>PSU: close (sends output 0)
  Conftest->>LIN: lin.disconnect()
```

Two invariants this diagram shows:

- **Setup happens once, teardown happens once.** Session-scoped fixtures only
  build and tear down at the session boundary, not per test.
- **Teardown is LIFO.** PSU closes before LIN disconnects, matching the
  reverse of construction order — which is what you want, since on this
  bench the LIN ECU is powered by the PSU.

## Helpers — where they sit

The helper *classes* `FrameIO` (`tests/hardware/frame_io.py`) and `AlmTester`
(`tests/hardware/alm_helpers.py`) are plain classes — not fixtures. They take
a `LinInterface` and an LDF (and a NAD, for `AlmTester`) as constructor
arguments. **Instances are exposed as session-scoped fixtures** in
`tests/hardware/mum/conftest.py`, so MUM tests just request them by name:

```python
def test_alm_status(fio, alm, rp):
    fio.send("ALM_Req_A", AmbLightColourRed=255)
    status = fio.receive("ALM_Status")
    alm.force_off()
```

The fixtures are session-scoped because `FrameIO` and `AlmTester` are
immutable beyond their constructor args, and per-test state hygiene is
handled by the autouse `_reset_to_off` (also in `mum/conftest.py`). A test
that genuinely needs a fresh instance can still build one locally:
`FrameIO(lin, ldf)` works inside any test body. They are a convenience
layer, not a required indirection.

```mermaid
flowchart LR
  LIN[LinInterface<br/>session fixture] --> FIO
  LDFDB[LdfDatabase<br/>ldf fixture] --> FIO[FrameIO<br/>per test, local]
  FIO --> ALM[AlmTester<br/>per test, local]
  ALM --> T[test body]
  FIO --> T
  LIN -.also direct access.-> T
```

## The pytest plugin — orthogonal

`conftest_plugin.py` (registered by the root `conftest.py:13-32`) is
**independent of the framework wiring above**. It parses test docstrings for
`Title:`, `Description:`, `Requirements:`, `Steps:` and attaches them as
`user_properties` on JUnit / HTML reports. It writes
`reports/requirements_coverage.json` and `reports/summary.md`. It does not
touch `ecu_framework` at all — it operates purely on test metadata.

See [`11_conftest_plugin_overview.md`](11_conftest_plugin_overview.md) for
the plugin's hooks and outputs.

## Why this shape works

Five invariants make the wiring durable:

1. **Tests depend on abstractions only.** Every `ecu_framework` import in a
   test file is either a dataclass (`EcuTestConfig`, `LinFrame`) or an ABC
   (`LinInterface`). Concrete adapters are selected by *configuration*, never
   by import path.
2. **One YAML switch flips the whole stack.** Changing `interface.type`
   reroutes LIN, flashing (via `HexFlasher(lin)`), and any helper built on
   `lin` — without touching a single test.
3. **Fixture scope = lifecycle.** Session-scoped fixtures (`config`, `lin`,
   `psu`) mean expensive bench setup happens once per run. Cleanup is
   centralized in fixture teardowns.
4. **Optional features fail gracefully via `pytest.skip`.** No PSU → `psu`
   skips. No `pymumclient` → MUM-typed config skips. No LDF → `ldf` skips.
   The same conftest runs on a developer laptop, a CI runner, and a wired-up
   Pi bench.
5. **Plugin metadata is orthogonal.** The reporting plugin reads test
   docstrings, not the framework state. Adding/removing framework features
   doesn't touch report generation.

## Adding a new framework component

The playbook is fixed. To add e.g. a CAN adapter:

1. **Add the implementation** under `ecu_framework/can/`:
   - `base.py` with a `CanInterface` ABC + `CanFrame` dataclass
   - One or more adapter modules (`mock.py`, `vector.py`, ...)
   - `__init__.py` re-exporting the public surface
2. **Add a config section** in `ecu_framework/config/loader.py`:
   - A `CanConfig` dataclass
   - A `can: CanConfig` field on `EcuTestConfig`
   - A matching entry in the `base` defaults dict
   - A coercion line in `_to_dataclass`
3. **Add a fixture** in `tests/conftest.py` mirroring `lin`:
   ```python
   @pytest.fixture(scope="session")
   def can(config: EcuTestConfig) -> Iterator[CanInterface]:
       if config.can.type == "mock": can = MockCan(...)
       elif config.can.type == "vector": can = VectorCan(...)
       ...
       can.connect(); yield can; can.disconnect()
   ```
4. **Write tests** that take `can` as a parameter. Done.

Helpers, reporting, and unit-test infrastructure inherit the wiring for free.
The playbook is the closest thing this framework has to a "you must read
this first" contract for contributors.

## See also

- [`05_architecture_overview.md`](05_architecture_overview.md) — static
  component catalog and Mermaid architecture diagram
- [`02_configuration_resolution.md`](02_configuration_resolution.md) — what
  YAML knobs exist and how they merge
- [`23_config_loader_internals.md`](23_config_loader_internals.md) — how the
  loader is implemented under the hood
- [`11_conftest_plugin_overview.md`](11_conftest_plugin_overview.md) — the
  reporting plugin (orthogonal to fixture wiring)
- [`04_lin_interface_call_flow.md`](04_lin_interface_call_flow.md) — what
  each LIN adapter does once selected by the `lin` fixture
- [`19_frame_io_and_alm_helpers.md`](19_frame_io_and_alm_helpers.md) — the
  helpers `FrameIO` and `AlmTester` covered above
