From 2f876219264a1a410d9acc60e896012f002390c9 Mon Sep 17 00:00:00 2001 From: Yuzviak Date: Thu, 16 Apr 2026 21:41:58 +0300 Subject: [PATCH] test(e2e): add DatasetAdapter base interface + capability dataclass Co-Authored-By: Claude Opus 4.7 (1M context) --- src/gps_denied/testing/datasets/base.py | 74 +++++++++++++++++++++++++ tests/e2e/test_dataset_base.py | 63 +++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 src/gps_denied/testing/datasets/base.py create mode 100644 tests/e2e/test_dataset_base.py diff --git a/src/gps_denied/testing/datasets/base.py b/src/gps_denied/testing/datasets/base.py new file mode 100644 index 0000000..475e8e2 --- /dev/null +++ b/src/gps_denied/testing/datasets/base.py @@ -0,0 +1,74 @@ +"""DatasetAdapter — uniform interface for e2e harness over arbitrary UAV datasets.""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import Enum +from typing import Iterator + + +class PlatformClass(str, Enum): + FIXED_WING = "fixed_wing" + ROTARY = "rotary" + INDOOR = "indoor" + SYNTHETIC = "synthetic" + + +class DatasetNotAvailableError(RuntimeError): + """Raised when a dataset's expected files are missing. Tests should skip, not fail.""" + + +@dataclass(frozen=True) +class DatasetCapabilities: + has_raw_imu: bool + has_rtk_gt: bool + has_loop_closures: bool + platform_class: PlatformClass + + +@dataclass(frozen=True) +class DatasetFrame: + frame_idx: int + timestamp_ns: int + image_path: str + + +@dataclass(frozen=True) +class DatasetIMU: + timestamp_ns: int + accel: tuple[float, float, float] # m/s^2 in body frame + gyro: tuple[float, float, float] # rad/s in body frame + + +@dataclass(frozen=True) +class DatasetPose: + timestamp_ns: int + lat: float + lon: float + alt: float + qx: float + qy: float + qz: float + qw: float + + +class DatasetAdapter(ABC): + """Uniform read-only iteration over a UAV dataset.""" + + @property + @abstractmethod + def name(self) -> str: ... + + @property + @abstractmethod + def capabilities(self) -> DatasetCapabilities: ... + + @abstractmethod + def iter_frames(self) -> Iterator[DatasetFrame]: ... + + @abstractmethod + def iter_imu(self) -> Iterator[DatasetIMU]: ... + + @abstractmethod + def iter_ground_truth(self) -> Iterator[DatasetPose]: ... diff --git a/tests/e2e/test_dataset_base.py b/tests/e2e/test_dataset_base.py new file mode 100644 index 0000000..b5ef750 --- /dev/null +++ b/tests/e2e/test_dataset_base.py @@ -0,0 +1,63 @@ +"""Tests for DatasetAdapter base contract.""" + +import pytest + +from gps_denied.testing.datasets.base import ( + DatasetAdapter, + DatasetCapabilities, + DatasetFrame, + DatasetIMU, + DatasetPose, + DatasetNotAvailableError, + PlatformClass, +) + + +def test_capabilities_defaults(): + cap = DatasetCapabilities( + has_raw_imu=False, + has_rtk_gt=False, + has_loop_closures=False, + platform_class=PlatformClass.FIXED_WING, + ) + assert cap.has_raw_imu is False + assert cap.platform_class == PlatformClass.FIXED_WING + + +def test_adapter_is_abstract(): + with pytest.raises(TypeError): + DatasetAdapter() # type: ignore[abstract] + + +def test_dataset_not_available_error_is_exception(): + assert issubclass(DatasetNotAvailableError, Exception) + + +def test_dataset_frame_dataclass_fields(): + frame = DatasetFrame( + frame_idx=0, + timestamp_ns=1_000_000_000, + image_path="/tmp/x.jpg", + ) + assert frame.frame_idx == 0 + assert frame.timestamp_ns == 1_000_000_000 + + +def test_dataset_imu_dataclass_fields(): + imu = DatasetIMU( + timestamp_ns=1_000_000_000, + accel=(0.0, 0.0, -9.81), + gyro=(0.0, 0.0, 0.0), + ) + assert imu.accel == (0.0, 0.0, -9.81) + + +def test_dataset_pose_dataclass_fields(): + pose = DatasetPose( + timestamp_ns=1_000_000_000, + lat=49.0, + lon=32.0, + alt=100.0, + qx=0.0, qy=0.0, qz=0.0, qw=1.0, + ) + assert pose.lat == 49.0