Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions .github/workflows/pr-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,16 @@ jobs:
python-version: "3.12"
cache: pip

# Mosquitto is the MQTT broker for tests/integration (novaMock -> backend).
- name: Install
run: pip install -r requirements.txt pytest httpx
run: |
sudo apt-get update -q
sudo apt-get install -y -q mosquitto
pip install -r requirements.txt pytest httpx pytest-cov

# NOVA_REQUIRE_INTEGRATION makes the integration tests fail rather than
# skip if Mosquitto is missing, so they can't silently stop running.
- name: Tests
run: python -m pytest -q
env:
NOVA_REQUIRE_INTEGRATION: "1"
run: python -m pytest -q -rs --cov=app --cov-report=term-missing:skip-covered
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,8 @@ data/*

# Config history written when running without NOVA_CONFIG_HISTORY_DIR
config/history/

# pytest-cov output
.coverage
coverage.xml
htmlcov/
11 changes: 8 additions & 3 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ This file only covers what is specific to this repo.
## Before you open a PR

```bash
pip install -r requirements.txt pytest httpx
python -m pytest -q
pip install -r requirements.txt pytest httpx pytest-cov
python -m pytest -q --cov=app
```

CI runs the same tests on every PR.
CI runs the same tests on every PR and prints coverage.

`tests/integration/` starts a real Mosquitto broker and `tools/novaMock.py`
and checks that telemetry reaches a websocket client. It needs Mosquitto
installed (on PATH, or in `C:\Program Files\mosquitto`) and is skipped
without it. CI sets `NOVA_REQUIRE_INTEGRATION=1` so it fails instead.

## Simulator Test (level 2)

Expand Down
8 changes: 6 additions & 2 deletions app/routers/config.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations

from pathlib import Path
from pathlib import Path, PureWindowsPath

from fastapi import APIRouter, Depends, File, Header, HTTPException, UploadFile
from fastapi.responses import FileResponse
Expand All @@ -17,8 +17,12 @@ def _config_file(ctx: AppContext, name: str) -> Path:

Only bare *.yaml names are accepted, so a request can never read or activate
a file elsewhere on disk (e.g. "../../somewhere.yaml").

PureWindowsPath treats both / and \\ as separators (and C: as a drive) on
every OS, so a name is rejected the same way on Linux as on Windows.
"""
if not name or Path(name).name != name or Path(name).suffix.lower() != ".yaml":
as_path = PureWindowsPath(name)
if not name or as_path.name != name or as_path.suffix.lower() != ".yaml":
raise HTTPException(status_code=400, detail="Expected a bare .yaml file name")
return ctx.config_service.config_path.parent / name

Expand Down
4 changes: 4 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ dependencies = [
dev = [
"pytest>=8.3.0",
"httpx>=0.27.0",
"pytest-cov>=5.0.0",
"mkdocs>=1.6.0",
"pylint>=3.2.0",
]
Expand All @@ -30,6 +31,9 @@ include = ["app*"]
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
markers = [
"integration: starts Mosquitto and novaMock; skipped if Mosquitto isn't installed",
]

[tool.pylint.MASTER]
ignore-paths = [".venv", ".pytest_cache"]
103 changes: 103 additions & 0 deletions tests/integration/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Fixtures that run real processes: a Mosquitto broker and tools/novaMock.py.

Mosquitto must be installed. Without it these tests are skipped, unless
NOVA_REQUIRE_INTEGRATION is set (as it is in CI), in which case they fail.
"""
from __future__ import annotations

import os
import shutil
import socket
import subprocess
import sys
import time
from pathlib import Path

import pytest

REPO_ROOT = Path(__file__).resolve().parents[2]
NOVA_MOCK = REPO_ROOT / "tools" / "novaMock.py"

# Where the Windows installer puts it; it isn't on PATH by default.
_WINDOWS_MOSQUITTO = Path(r"C:\Program Files\mosquitto\mosquitto.exe")


def _find_mosquitto() -> str | None:
found = shutil.which("mosquitto")
if found:
return found
for candidate in (_WINDOWS_MOSQUITTO, Path("/usr/sbin/mosquitto")):
if candidate.is_file():
return str(candidate)
return None


def _free_port() -> int:
with socket.socket() as sock:
sock.bind(("127.0.0.1", 0))
return sock.getsockname()[1]


def _wait_for_port(port: int, proc: subprocess.Popen, timeout_s: float) -> None:
deadline = time.monotonic() + timeout_s
while time.monotonic() < deadline:
if proc.poll() is not None:
raise RuntimeError(f"mosquitto exited early with code {proc.returncode}")
try:
with socket.create_connection(("127.0.0.1", port), timeout=0.2):
return
except OSError:
time.sleep(0.05)
raise TimeoutError(f"mosquitto did not open port {port} within {timeout_s}s")


def _stop(proc: subprocess.Popen) -> None:
if proc.poll() is None:
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)


@pytest.fixture(scope="session")
def mqtt_broker(tmp_path_factory) -> tuple[str, int]:
"""A private Mosquitto on a free localhost port. Yields (host, port)."""
exe = _find_mosquitto()
if exe is None:
message = "Mosquitto is not installed"
if os.getenv("NOVA_REQUIRE_INTEGRATION"):
pytest.fail(message)
pytest.skip(message)

port = _free_port()
conf = tmp_path_factory.mktemp("mosquitto") / "mosquitto.conf"
conf.write_text(f"listener {port} 127.0.0.1\nallow_anonymous true\n", encoding="utf-8")

proc = subprocess.Popen(
[exe, "-c", str(conf)],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
_wait_for_port(port, proc, timeout_s=10)
yield "127.0.0.1", port
finally:
_stop(proc)


@pytest.fixture
def nova_mock(mqtt_broker) -> subprocess.Popen:
"""tools/novaMock.py publishing simulated telemetry to the test broker."""
host, port = mqtt_broker
proc = subprocess.Popen(
[sys.executable, str(NOVA_MOCK), "--broker", host, "--port", str(port), "--hz", "20"],
cwd=REPO_ROOT,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
yield proc
finally:
_stop(proc)
114 changes: 114 additions & 0 deletions tests/integration/test_novamock_e2e.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"""novaMock -> MQTT -> backend -> websocket client, with real processes.

The backend runs in-process (TestClient), but talks to a real Mosquitto
broker that novaMock publishes to, so the MQTT contract between the
simulator and the backend is exercised for real.
"""
from __future__ import annotations

import time

import pytest
from fastapi.testclient import TestClient

from app.main import create_app

pytestmark = pytest.mark.integration

# One sensor from each side that novaMock simulates:
# PGSO: GCS hat 0 / channel 0, raw 0.99-3.99 V, calibrated here to 99-399 psi.
# PCC: FAS EPB_0 / channel 1, raw 0.2-0.9, passed through unconverted.
TEST_CONFIG = """
Sensors:
- name: PGSO
type: PT
unit: psi
binding:
source: GCS
hat_id: 0
channel_id: 0
convert:
method: linear
calibration:
- [0.0, 0.0]
- [5.0, 500.0]
- name: PCC
type: PT
unit: V
binding:
source: FAS
node: EPB_0
channel: 1
convert:
method: none
Actuators: []
"""

TIMEOUT_S = 15


@pytest.fixture
def backend_env(tmp_path, monkeypatch, mqtt_broker):
host, port = mqtt_broker
config_dir = tmp_path / "config"
config_dir.mkdir()
(config_dir / "system.yaml").write_text(TEST_CONFIG, encoding="utf-8")

monkeypatch.setenv("NOVA_MQTT_BROKER", host)
monkeypatch.setenv("NOVA_MQTT_PORT", str(port))
monkeypatch.setenv("NOVA_CONFIG_DIR", str(config_dir))
monkeypatch.setenv("NOVA_CONFIG_HISTORY_DIR", str(tmp_path / "history"))
monkeypatch.setenv("NOVA_DATA_DIR", str(tmp_path / "data"))


def _wait_for_engine_data(ctx) -> None:
"""Poll until both test sensors have arrived over MQTT.

Polling first means a broken pipeline fails with a clear timeout instead
of hanging forever in websocket.receive_json().
"""
deadline = time.monotonic() + TIMEOUT_S
while time.monotonic() < deadline:
names = {row.get("name") for row in ctx.runtime.latest_engine_data or []}
if {"PGSO", "PCC"} <= names:
return
time.sleep(0.1)
raise AssertionError(
f"No engine_data for PGSO and PCC within {TIMEOUT_S}s; "
f"latest_engine_data={ctx.runtime.latest_engine_data!r}"
)


def _receive_until(ws, msg_type: str, max_messages: int = 500) -> dict:
for _ in range(max_messages):
msg = ws.receive_json()
if msg.get("type") == msg_type:
return msg
raise AssertionError(f"No {msg_type!r} message in {max_messages} websocket messages")


def _by_name(engine_data: dict) -> dict[str, dict]:
return {row["name"]: row for row in engine_data["data"]}


def test_websocket_client_receives_calibrated_engine_telemetry(backend_env, nova_mock) -> None:
app = create_app()
with TestClient(app) as client:
_wait_for_engine_data(app.state.ctx)

with client.websocket_connect("/ws") as ws:
# The connect-time snapshot, then a live broadcast from MQTT.
snapshot = _receive_until(ws, "engine_data")
live = _receive_until(ws, "engine_data")

for message in (snapshot, live):
sensors = _by_name(message)

pgso = sensors["PGSO"]
assert pgso["unit"] == "psi"
# Raw 0.99-3.99 V through the x100 calibration (plus a little noise).
assert 90.0 <= pgso["value"] <= 410.0, pgso

pcc = sensors["PCC"]
assert pcc["unit"] == "V"
assert 0.15 <= pcc["value"] <= 0.95, pcc
Loading
Loading