Metadata-Version: 2.4
Name: makotest
Version: 0.16.0
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: Pytest
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Rust
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Topic :: Software Development :: Testing
Requires-Dist: pytest>=8.0 ; extra == 'dev'
Requires-Dist: ruff>=0.6 ; extra == 'dev'
Requires-Dist: makotest[hypothesis] ; extra == 'dev'
Requires-Dist: hypothesis>=6.100 ; extra == 'hypothesis'
Provides-Extra: dev
Provides-Extra: hypothesis
Summary: Test & simulation toolkit for German market-communication (MaKo) platforms
Keywords: energy,edifact,bdew,mako,testing,pytest,bo4e
License: MIT OR Apache-2.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown; charset=UTF-8; variant=GFM
Project-URL: Repository, https://github.com/hupe1980/mako

# makotest

**Test & simulation toolkit for German market-communication (MaKo) platforms.**

Builds regulator-conformant EDIFACT, simulates the counterparties a MaKo
platform talks to — in EDIFACT, so a test can feed the answer back — and asserts
on both wire contracts it exposes: the messages and the event stream.

`makotest` targets [mako](https://github.com/hupe1980/mako) first but is **not
mako-specific**. Everything it drives is public (EDIFACT over AS4, REST,
CloudEvents), so it can exercise any MaKo implementation.

```python
from makotest import antwort_obligation, malo_from_base, validate_edifact

malo_from_base("5123869601")  # '51238696012' — BDEW check digit applied

o = antwort_obligation(55001)  # what a Netzbetreiber owes on an Anmeldung
o.clock_time  # '11:00' — a clock time, not n × 24 h
o.due_at("2026-03-02T09:00:00Z")  # '2026-03-03T11:00:00+01:00'

validate_edifact(utilmd_bytes, "2026-10-01").is_valid  # MIG + AHB + semantic
```

```bash
pip install makotest                  # no runtime dependencies
pip install 'makotest[hypothesis]'    # + property-based strategies
```

Wheels are **abi3** (`abi3-py311`) — one wheel serves Python 3.11 and later.

The same answers are reachable from a shell, for whoever is holding a real
message rather than writing a test:

```console
$ makotest validate inbound.edi --on 2026-04-01
$ makotest frist 55001 --received 2026-03-02T09:00:00Z
$ makotest id 9900357000004      # → satisfies NEITHER check-digit procedure
```

---

## Why

Deadlines are legal obligations, message content is defined by AHB rule tables,
and a wrong Prüfidentifikator is a compliance defect rather than a bug. A test
has to express *"this is what the counterparty is entitled to send, and this is
what we owe them by when"* — which a `curl` script cannot.

Two failure modes shape the whole design, because both produce a **green suite
that proves nothing**:

- a message whose Prüfidentifikator has no AHB rules *validates* — having
  checked nothing;
- an assertion naming an event type the platform does not declare finds no such
  event — forever.

Every validation report therefore carries `rules_applied`, and every event
assertion resolves the type against the platform's own catalog first.

## Design

**One source of truth.** EDIFACT construction and validation, identifier check
digits, the Werktag calendar, the published answer Fristen and the CloudEvents
catalog come from the same Rust crates the platform runs, through PyO3 bindings.
None is reimplemented in Python: a second implementation drifts from the BDEW
documents at the first Formatumstellung, and a harness that disagrees with the
system under test about what is valid — or about when a Frist expires — is worse
than none.

The rule: **anything a regulator defines in a table is Rust; anything shaped by
test ergonomics is Python.**

| Concern | Home |
|---|---|
| EDIFACT build + MIG/AHB/semantic validation, release per format version | Rust — `edi-energy` |
| Identifier check digits (MaLo, MP-ID, EIC, §8.2 resources) | Rust — `rubo4e` |
| Werktag calendar, acknowledgement clocks, answer Fristen | Rust — `mako-fristen` |
| CloudEvents type catalog and subscription matcher | Rust — `mako-events` |
| Counterparty behaviour, EPEX curves, fixtures | Python |

Because validation runs the platform's own AHB engine, `makotest` proves
*process and integration* behaviour — not format conformance. The BDEW reference
examples remain the authority for that.

**Deterministic by construction.** Every generator is seeded, every clock is
injected, and the format version is an argument rather than a read of today's
date. Two runs of the same scenario produce byte-identical EDIFACT.

**Framework-agnostic core, pytest on top.** Only `makotest.plugin` imports
pytest, so a demo and a CI test drive the same code path.

**Two failures, two exceptions.** `AssertionError` means the system under test is
wrong; `ValueError` means the test is.

---

## A tour

**Fristen have three shapes, so ask the table.** "A Werktage Frist expires at
17:00 Berlin" is true of the WiM MSB-Wechsel windows and of nothing else — GPKE
states a clock time on the 1. Werktag after the ÜT, GeLi Gas the *end* of the
n-th Werktag.

```python
assert_deadline_is(response["deadline"], received=received, pid=55001)
assert_frist_met(55001, received=received, answered_at=answer["sent_at"])
```

**The send date picks the format version.** Pinning a release by hand and
validating on a date where another is in force reports the mismatch rather than
the message.

```python
msg = build_utilmd(
    55001,
    sender=LF,
    receiver=NB,
    on="2026-04-01",
    transactions=[
        UtilmdTransaction("VORGANG-1", locations=[("melo", melo)], dates=[("92", start)])
    ],
)
wire = build_interchange(
    sender=LF, receiver=NB, dar="REF1", messages=[msg], on="2026-04-01"
)
assert_edifact_valid(wire, on="2026-04-01")
```

**Counterparties answer in EDIFACT — or badly, or not at all.** The unhappy modes
are the ones worth having: a platform that only ever sees a punctual, conformant
partner has never had its Fristüberwachung exercised.

```python
def test_nb_bestaetigt(nb_sim, anmeldung):
    nb_sim.on(55001).bestaetigung(process_dates=[("92", "20260501")])
    reply = nb_sim.receive(anmeldung, received_at="2026-03-02T09:00:00Z")

    assert reply.pid == 55002  # the AHB answer PID — never Anfrage + 1
    platform.ingest(reply.business)  # a rendered interchange, not a dict


def test_frist_faellt(nb_sim, anmeldung):
    nb_sim.on(55001).timeout()  # no answer, not even a CONTRL
    assert not nb_sim.receive(anmeldung)


def test_verspaetete_antwort(nb_sim, anmeldung):
    nb_sim.on(55001).bestaetigung(delay_werktage=3)  # right message, wrong day
    reply = nb_sim.receive(anmeldung, received_at="2026-03-02T09:00:00Z")
    assert reply.answered_at > reply.due_at
```

An interchange carries several messages, each its own Vorgang — the reply answers
all of them, and `reply.pid` raises rather than speaking for one.

**Events are the other wire contract.**

```python
assert_event_emitted(webhook_bodies, "de.mako.process.*", subject=malo)
assert_no_event_emitted(webhook_bodies, "de.mako.aperak.timeout")
```

**Strategies draw values the platform accepts.** A random 11-digit string is a
valid MaLo one time in ten and a random 16-character string is essentially never
a valid EIC, so a hand-rolled strategy spends its budget on the rejection path.

```python
@given(malo=malo_ids(), pid=pruefidentifikatoren(message_type="UTILMD"))
def test_every_utilmd_roundtrips(malo, pid): ...
```

**EPEX days are Europe/Berlin days**, so the DST days carry 92 and 100
quarter-hourly MTUs rather than 96 — and negative prices are supported, which
§51 EEG and §41a EnWG dynamic tariffs both need.

```python
EpexGenerator(seed=42).day("2026-06-21", profile="solar_glut", negative_hours=6)
```

The full reference — every builder, assertion, strategy and simulator, with the
Fundstelle behind each rule — is in the
[mako documentation](https://hupe1980.github.io/mako/docs/reference/makotest/).

---

## pytest plugin

Registered through the `pytest11` entry point — no `conftest.py` wiring.

| | |
|---|---|
| **Fixtures** | `nb_sim`, `biko_sim`, `imsys_sim`, `epex`, `frozen_clock`, `makotest_seed`, `makotest_on`, `mako_endpoint` |
| **Markers** | `@pytest.mark.regulatory("GPKE Teil 2")`, `@pytest.mark.requires_docker` |
| **Options** | `--makotest-on ISO_DATE`, `--makotest-seed N`, `--mako-endpoint URL` |

`--makotest-on` pins the format version for the whole suite, so re-running on a
future date shows what the next Formatumstellung breaks. Every dated fixture
takes it, so one test is never about two days. `--mako-endpoint` names a running
deployment; bring your own HTTP client.

`--hypothesis-profile=makotest` selects a registered profile with `deadline=None`
and `derandomize=True` — a strategy here draws through the Rust core, and
Hypothesis' 200 ms per-example deadline is written for pure functions.

---

## Development

`makotest` is a member of the mako Cargo workspace and builds with
[maturin](https://www.maturin.rs/):

```bash
just test-makotest      # maturin develop + pytest
just lint-makotest      # ruff check + format check
just build-makotest     # release wheel
```

`pyo3/extension-module` is deliberately **not** a Cargo feature — maturin enables
it at build time. Declaring it would make `cargo test --workspace --all-features`
link the Rust test harness against it and fail on undefined Python symbols. CI
exercises both paths.

`abi3-py311` sets a floor of Python 3.11, so *every* workspace-wide cargo command
builds this crate against an interpreter. The workspace pins `PYO3_PYTHON` to
`.venv/bin/python` in [`.cargo/config.toml`](../.cargo/config.toml); create that
venv once and the rest of the workspace builds:

```bash
python3.11 -m venv .venv    # or any ≥ 3.11
```

Without it PyO3 falls back to the first `python3` on `PATH` — still 3.9 on macOS
— and the whole workspace fails to build on a message about a crate nobody was
working on.

`py.typed` ships with the wheel, so `_native.pyi` is the only thing a consumer's
type checker sees; a test pins it against the compiled module in both directions.

## Scope

The Marktpartner simulator carries no AS4 transport of its own: it is a plain
object with `receive()`, so a transport layers on top instead of being a
dependency of the many tests that do not need one.

A counterparty is modelled once it has a consumer. One written ahead of its
consumer encodes guesses about an interface nobody has implemented, and to the
next reader those guesses are indistinguishable from requirements.

The package version tracks `workspace.package.version` through Cargo.toml, so the
wheel and the crates it binds can never report different versions.

## Licence

MIT OR Apache-2.0, matching mako.

