diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 54dd0ed..ba20cd3 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -30,6 +30,10 @@ jobs: - name: Install dependencies run: pip install --quiet -r simulator/requirements.txt + - name: Unit tests (TDD gate) + working-directory: simulator + run: pytest tests/ -q + - name: Compile check 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") 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: name: Trivy scan runs-on: ubuntu-latest @@ -141,6 +125,7 @@ jobs: TAG="${{ steps.version.outputs.new_tag }}" mkdir -p .tmp tar --exclude='.git' --exclude='.tmp' --exclude='simulator/data' \ + --exclude='var/t1' --exclude='var/t3' \ --exclude='simulator/.venv' --exclude='prototype/node_modules' \ -czf ".tmp/swarm-house-${TAG}.tar.gz" . diff --git a/simulator/.dockerignore b/simulator/.dockerignore new file mode 100644 index 0000000..329a32e --- /dev/null +++ b/simulator/.dockerignore @@ -0,0 +1,3 @@ +data +.venv +README.md diff --git a/simulator/Dockerfile b/simulator/Dockerfile index 1ad3e60..85d235e 100644 --- a/simulator/Dockerfile +++ b/simulator/Dockerfile @@ -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 WORKDIR /app COPY requirements.txt . RUN --mount=type=cache,target=/root/.cache/pip pip install -r requirements.txt 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 CMD ["python", "-m", "virtual_drone.main"] diff --git a/simulator/requirements.txt b/simulator/requirements.txt index 8e2a041..3912343 100644 --- a/simulator/requirements.txt +++ b/simulator/requirements.txt @@ -1,3 +1,4 @@ pyarrow>=16.0 duckdb>=1.0 pytz>=2024.1 # duckdb needs it to fetch TIMESTAMPTZ values into Python +pytest>=8.0 diff --git a/simulator/tests/__init__.py b/simulator/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/simulator/tests/test_broadcast.py b/simulator/tests/test_broadcast.py new file mode 100644 index 0000000..ff560fe --- /dev/null +++ b/simulator/tests/test_broadcast.py @@ -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 diff --git a/simulator/tests/test_flight.py b/simulator/tests/test_flight.py new file mode 100644 index 0000000..f02751e --- /dev/null +++ b/simulator/tests/test_flight.py @@ -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 diff --git a/simulator/tests/test_sql_gate.py b/simulator/tests/test_sql_gate.py new file mode 100644 index 0000000..a20c71e --- /dev/null +++ b/simulator/tests/test_sql_gate.py @@ -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 diff --git a/simulator/tests/test_writer.py b/simulator/tests/test_writer.py new file mode 100644 index 0000000..c78c027 --- /dev/null +++ b/simulator/tests/test_writer.py @@ -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