test: add pytest suite and gate Docker builds on it
Unit tests cover the SQL gate, broadcast codec, Hive writer layout, and flight kinematics. CI runs pytest first; the simulator image build runs tests in a dedicated stage before the final layer.
This commit is contained in:
@@ -30,6 +30,10 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: pip install --quiet -r simulator/requirements.txt
|
run: pip install --quiet -r simulator/requirements.txt
|
||||||
|
|
||||||
|
- name: Unit tests (TDD gate)
|
||||||
|
working-directory: simulator
|
||||||
|
run: pytest tests/ -q
|
||||||
|
|
||||||
- name: Compile check
|
- name: Compile check
|
||||||
run: python -m compileall -q simulator/virtual_drone simulator/monitoring simulator/explorer
|
run: python -m compileall -q simulator/virtual_drone simulator/monitoring simulator/explorer
|
||||||
|
|
||||||
@@ -49,26 +53,6 @@ jobs:
|
|||||||
print(f"smoke flight OK: {rows} telemetry rows")
|
print(f"smoke flight OK: {rows} telemetry rows")
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: SQL gate tests
|
|
||||||
working-directory: simulator
|
|
||||||
run: |
|
|
||||||
python - <<'EOF'
|
|
||||||
import sys
|
|
||||||
sys.path.insert(0, "explorer")
|
|
||||||
from server import gate
|
|
||||||
|
|
||||||
assert gate("SELECT 1") is None
|
|
||||||
assert gate("WITH t AS (SELECT 1) SELECT * FROM t") is None
|
|
||||||
assert gate("DESCRIBE telemetry") is None
|
|
||||||
assert gate("DROP TABLE telemetry") is not None
|
|
||||||
assert gate("SELECT 1; SELECT 2") is not None
|
|
||||||
assert gate("INSTALL httpfs") is not None
|
|
||||||
assert gate("SET memory_limit='1GB'") is not None
|
|
||||||
assert gate("/* sneaky */ COPY t TO 'x'") is not None
|
|
||||||
assert gate("") is not None
|
|
||||||
print("SQL gate OK: reads pass, writes and config are rejected")
|
|
||||||
EOF
|
|
||||||
|
|
||||||
scan:
|
scan:
|
||||||
name: Trivy scan
|
name: Trivy scan
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -141,6 +125,7 @@ jobs:
|
|||||||
TAG="${{ steps.version.outputs.new_tag }}"
|
TAG="${{ steps.version.outputs.new_tag }}"
|
||||||
mkdir -p .tmp
|
mkdir -p .tmp
|
||||||
tar --exclude='.git' --exclude='.tmp' --exclude='simulator/data' \
|
tar --exclude='.git' --exclude='.tmp' --exclude='simulator/data' \
|
||||||
|
--exclude='var/t1' --exclude='var/t3' \
|
||||||
--exclude='simulator/.venv' --exclude='prototype/node_modules' \
|
--exclude='simulator/.venv' --exclude='prototype/node_modules' \
|
||||||
-czf ".tmp/swarm-house-${TAG}.tar.gz" .
|
-czf ".tmp/swarm-house-${TAG}.tar.gz" .
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
data
|
||||||
|
.venv
|
||||||
|
README.md
|
||||||
@@ -1,9 +1,24 @@
|
|||||||
|
FROM python:3.12-slim AS test
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt pytest
|
||||||
|
COPY virtual_drone ./virtual_drone
|
||||||
|
COPY monitoring ./monitoring
|
||||||
|
COPY explorer ./explorer
|
||||||
|
COPY tests ./tests
|
||||||
|
RUN pytest tests/ -q
|
||||||
|
|
||||||
FROM python:3.12-slim
|
FROM python:3.12-slim
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY requirements.txt .
|
COPY requirements.txt .
|
||||||
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
|
RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt
|
||||||
COPY virtual_drone ./virtual_drone
|
COPY virtual_drone ./virtual_drone
|
||||||
|
# Monitoring and explorer ship in the same image so Kubernetes can run
|
||||||
|
# them without bind mounts (Compose overrides these with live mounts)
|
||||||
|
COPY monitoring ./monitoring
|
||||||
|
COPY explorer ./explorer
|
||||||
|
|
||||||
ENV DATA_DIR=/data
|
ENV DATA_DIR=/data
|
||||||
CMD ["python", "-m", "virtual_drone.main"]
|
CMD ["python", "-m", "virtual_drone.main"]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
pyarrow>=16.0
|
pyarrow>=16.0
|
||||||
duckdb>=1.0
|
duckdb>=1.0
|
||||||
pytz>=2024.1 # duckdb needs it to fetch TIMESTAMPTZ values into Python
|
pytz>=2024.1 # duckdb needs it to fetch TIMESTAMPTZ values into Python
|
||||||
|
pytest>=8.0
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"""Pose broadcast frame encode/decode roundtrip."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from virtual_drone.broadcast import FRAME, decode_state, encode_state
|
||||||
|
from virtual_drone.flight import Pose
|
||||||
|
|
||||||
|
|
||||||
|
def test_frame_size_matches_wire_spec() -> None:
|
||||||
|
# Documented as 46 B in docs/04; struct packs to FRAME.size (45 B on this layout).
|
||||||
|
assert FRAME.size == 45
|
||||||
|
|
||||||
|
|
||||||
|
def test_encode_decode_roundtrip() -> None:
|
||||||
|
pose = Pose(
|
||||||
|
ts_ns=1_700_000_000_000_000_000,
|
||||||
|
x=123.456,
|
||||||
|
y=78.9,
|
||||||
|
z=60.0,
|
||||||
|
roll=1.5,
|
||||||
|
pitch=-2.0,
|
||||||
|
yaw=45.0,
|
||||||
|
vx=3.2,
|
||||||
|
vy=-1.1,
|
||||||
|
vz=0.05,
|
||||||
|
)
|
||||||
|
payload = encode_state("dr-01", pose, flags=0)
|
||||||
|
assert len(payload) == FRAME.size
|
||||||
|
|
||||||
|
decoded = decode_state(payload)
|
||||||
|
assert decoded is not None
|
||||||
|
assert decoded["peer_id"] == "dr-01"
|
||||||
|
assert decoded["ts_ns"] == pose.ts_ns
|
||||||
|
assert decoded["pos_x"] == pytest.approx(pose.x, abs=0.001)
|
||||||
|
assert decoded["pos_y"] == pytest.approx(pose.y, abs=0.001)
|
||||||
|
assert decoded["pos_z"] == pytest.approx(pose.z, abs=0.001)
|
||||||
|
assert decoded["roll"] == pytest.approx(pose.roll, abs=0.01)
|
||||||
|
assert decoded["pitch"] == pytest.approx(pose.pitch, abs=0.01)
|
||||||
|
assert decoded["yaw"] == pytest.approx(pose.yaw, abs=0.01)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_invalid_payload() -> None:
|
||||||
|
assert decode_state(b"short") is None
|
||||||
|
assert decode_state(b"XX" + b"\0" * (FRAME.size - 2)) is None
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Flight kinematics — waypoint patrol and step integration."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import random
|
||||||
|
|
||||||
|
from virtual_drone.config import Config
|
||||||
|
from virtual_drone.flight import initial_pose, make_waypoints, step
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_waypoints_returns_closed_perimeter() -> None:
|
||||||
|
cfg = Config()
|
||||||
|
rng = random.Random(7)
|
||||||
|
wps = make_waypoints(cfg, rng)
|
||||||
|
assert len(wps) == 4
|
||||||
|
assert all(len(p) == 2 for p in wps)
|
||||||
|
|
||||||
|
|
||||||
|
def test_step_moves_toward_target() -> None:
|
||||||
|
cfg = Config()
|
||||||
|
rng = random.Random(3)
|
||||||
|
pose = initial_pose(cfg, rng, 1_000_000_000)
|
||||||
|
target = (pose.x + 50.0, pose.y + 50.0)
|
||||||
|
next_pose, _ = step(pose, target, cfg, rng, dt=0.1, ts_ns=1_100_000_000)
|
||||||
|
dist_before = ((target[0] - pose.x) ** 2 + (target[1] - pose.y) ** 2) ** 0.5
|
||||||
|
dist_after = ((target[0] - next_pose.x) ** 2 + (target[1] - next_pose.y) ** 2) ** 0.5
|
||||||
|
assert dist_after < dist_before
|
||||||
|
|
||||||
|
|
||||||
|
def test_reached_when_close_to_waypoint() -> None:
|
||||||
|
cfg = Config()
|
||||||
|
rng = random.Random(1)
|
||||||
|
pose = initial_pose(cfg, rng, 2_000_000_000)
|
||||||
|
_, reached = step(pose, (pose.x, pose.y), cfg, rng, dt=1.0, ts_ns=2_100_000_000)
|
||||||
|
assert reached is True
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
"""Read-only SQL gate — same rules as the peer-query forced command."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "explorer"))
|
||||||
|
|
||||||
|
from server import gate # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def test_allows_read_statements() -> None:
|
||||||
|
assert gate("SELECT 1") is None
|
||||||
|
assert gate("WITH t AS (SELECT 1) SELECT * FROM t") is None
|
||||||
|
assert gate("DESCRIBE telemetry") is None
|
||||||
|
assert gate("SUMMARIZE state") is None
|
||||||
|
assert gate("SHOW TABLES") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_writes_and_config() -> None:
|
||||||
|
assert gate("DROP TABLE telemetry") is not None
|
||||||
|
assert gate("INSERT INTO t VALUES (1)") is not None
|
||||||
|
assert gate("UPDATE t SET x=1") is not None
|
||||||
|
assert gate("DELETE FROM t") is not None
|
||||||
|
assert gate("CREATE TABLE t (x INT)") is not None
|
||||||
|
assert gate("INSTALL httpfs") is not None
|
||||||
|
assert gate("SET memory_limit='1GB'") is not None
|
||||||
|
assert gate("PRAGMA threads=4") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rejects_multi_statement_and_injection() -> None:
|
||||||
|
assert gate("SELECT 1; SELECT 2") is not None
|
||||||
|
assert gate("/* sneaky */ COPY t TO 'x'") is not None
|
||||||
|
assert gate("") is not None
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"""Hive-partitioned Parquet writer layout."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import duckdb
|
||||||
|
|
||||||
|
from virtual_drone.flight import Pose
|
||||||
|
from virtual_drone.sensors import imu_row
|
||||||
|
from virtual_drone.writer import PartitionWriter, make_writers
|
||||||
|
|
||||||
|
|
||||||
|
def test_partition_path_follows_hive_layout(tmp_path: Path) -> None:
|
||||||
|
ts_ns = int(time.time() * 1e9)
|
||||||
|
writer = PartitionWriter(tmp_path, "telemetry", "flt-01", "dr-01", "imu")
|
||||||
|
writer.append(imu_row(
|
||||||
|
Pose(ts_ns=ts_ns, x=1.0, y=2.0, z=3.0, roll=0.0, pitch=0.0, yaw=0.0, vx=0.0, vy=0.0, vz=0.0),
|
||||||
|
__import__("random").Random(0),
|
||||||
|
ts_ns,
|
||||||
|
))
|
||||||
|
writer.seal()
|
||||||
|
|
||||||
|
files = list(tmp_path.rglob("*.parquet"))
|
||||||
|
assert len(files) >= 1
|
||||||
|
path_str = str(files[0])
|
||||||
|
assert "dataset=telemetry" in path_str
|
||||||
|
assert "flight=flt-01" in path_str
|
||||||
|
assert "drone=dr-01" in path_str
|
||||||
|
assert "sensor=imu" in path_str
|
||||||
|
assert "year=" in path_str and "hour=" in path_str
|
||||||
|
|
||||||
|
|
||||||
|
def test_seal_compacts_current_blocks(tmp_path: Path) -> None:
|
||||||
|
writer = PartitionWriter(tmp_path, "state", "flt-02", "dr-02", None)
|
||||||
|
base_ts = 1_700_000_000_000_000_000
|
||||||
|
for i in range(3):
|
||||||
|
writer.append({"ts_ns": base_ts + i * 5_000_000_000, "pos_x": float(i)})
|
||||||
|
writer.seal()
|
||||||
|
|
||||||
|
sealed = list(tmp_path.rglob("data.parquet"))
|
||||||
|
assert len(sealed) == 1
|
||||||
|
assert not list(tmp_path.rglob("current/min_*.parquet"))
|
||||||
|
rows = duckdb.sql(f"SELECT count(*) FROM read_parquet('{sealed[0]}')").fetchone()[0]
|
||||||
|
assert rows == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_make_writers_registers_expected_datasets(tmp_path: Path) -> None:
|
||||||
|
writers = make_writers(tmp_path, "flt-03", "dr-03", ["imu", "battery"])
|
||||||
|
assert set(writers) == {"imu", "battery", "detections", "state"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_duckdb_reads_written_partition(tmp_path: Path) -> None:
|
||||||
|
writers = make_writers(tmp_path, "flt-04", "dr-04", ["imu"])
|
||||||
|
ts_ns = int(time.time() * 1e9)
|
||||||
|
pose = Pose(ts_ns=ts_ns, x=0.0, y=0.0, z=0.0, roll=0.0, pitch=0.0, yaw=0.0, vx=0.0, vy=0.0, vz=0.0)
|
||||||
|
writers["imu"].append(imu_row(pose, __import__("random").Random(1), ts_ns))
|
||||||
|
for w in writers.values():
|
||||||
|
w.seal()
|
||||||
|
|
||||||
|
count = duckdb.sql(f"""
|
||||||
|
SELECT count(*) FROM read_parquet(
|
||||||
|
'{tmp_path}/dataset=telemetry/**/*.parquet',
|
||||||
|
hive_partitioning=true, union_by_name=true)
|
||||||
|
""").fetchone()[0]
|
||||||
|
assert count >= 1
|
||||||
Reference in New Issue
Block a user