mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 19:11:14 +00:00
[AZ-266] [AZ-269] [AZ-277] [AZ-280] Cross-cutting log/config + SE3/SHA256 helpers
AZ-266: schema-compliant JSON logging entrypoint, level normalisation, handler-topology guard, format-error fallback (log_record_schema v1.0.0). AZ-269: env > YAML > defaults config loader, frozen Config dataclass, missing-var fail-fast with pointer to .env.example, component-block registry. AZ-277: GTSAM-backed SE3Utils (matrix<->SE3 + exp/log/adjoint) with strict orthogonality, dtype, and bottom-row contract enforcement. AZ-280: atomicwrites-backed write_atomic + independent verify + order-deterministic aggregate_hash; sidecar format strictness. pyproject.toml pins gtsam>=4.2,<5.0 and atomicwrites>=1.4,<2.0 (named-backend deps per the AZ-277 / AZ-280 contracts). 139 unit tests pass (44 new). Review verdict: PASS_WITH_WARNINGS; findings are perf-NFR + journald deferrals, no blocking issues. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
"""AC tests for AZ-266: shared structured logging module.
|
||||
|
||||
Verifies the `log_record_schema` v1.0.0 contract end-to-end:
|
||||
|
||||
- AC-1 single entrypoint returns a working Logger
|
||||
- AC-2 stable field order regardless of construction order
|
||||
- AC-3 level normalisation (WARNING -> WARN)
|
||||
- AC-4 handler topology selection (no duplicates on re-init)
|
||||
- AC-5 frame_id null on non-frame records
|
||||
- NFR-reliability formatter never raises on un-serialisable kv
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.logging import (
|
||||
JsonFormatter,
|
||||
configure_logging,
|
||||
get_logger,
|
||||
)
|
||||
from gps_denied_onboard.logging.structured import _HANDLER_MARKER_ATTR
|
||||
|
||||
CONTRACT_ORDER = ("ts", "level", "component", "frame_id", "kind", "msg", "kv", "exc")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def captured_logger() -> tuple[logging.Logger, io.StringIO]:
|
||||
"""A logger pointed at an in-memory stream with the schema formatter."""
|
||||
stream = io.StringIO()
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setFormatter(JsonFormatter())
|
||||
logger = logging.getLogger("test.az266")
|
||||
logger.handlers = [handler]
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
return logger, stream
|
||||
|
||||
|
||||
def _read_last_payload(stream: io.StringIO) -> dict[str, Any]:
|
||||
lines = [line for line in stream.getvalue().splitlines() if line.strip()]
|
||||
return json.loads(lines[-1])
|
||||
|
||||
|
||||
def _read_last_raw(stream: io.StringIO) -> str:
|
||||
return [line for line in stream.getvalue().splitlines() if line.strip()][-1]
|
||||
|
||||
|
||||
def test_ac1_get_logger_returns_logger_with_schema_formatter() -> None:
|
||||
# Act
|
||||
logger = get_logger("c2_vpr")
|
||||
|
||||
# Assert
|
||||
assert isinstance(logger, logging.Logger)
|
||||
root = logging.getLogger()
|
||||
assert any(getattr(h, _HANDLER_MARKER_ATTR, None) for h in root.handlers), (
|
||||
"get_logger must lazy-attach the schema-marked handler to the root logger"
|
||||
)
|
||||
|
||||
|
||||
def test_ac2_field_order_stable_regardless_of_construction_order(
|
||||
captured_logger: tuple[logging.Logger, io.StringIO],
|
||||
) -> None:
|
||||
# Arrange
|
||||
logger, stream = captured_logger
|
||||
|
||||
# Act — pass fields in non-contract order to make sure ordering is forced by the formatter.
|
||||
logger.info(
|
||||
"vpr query",
|
||||
extra={
|
||||
"kv": {"backbone": "salad"},
|
||||
"kind": "vpr.query",
|
||||
"frame_id": 42,
|
||||
},
|
||||
)
|
||||
|
||||
# Assert
|
||||
raw = _read_last_raw(stream)
|
||||
actual_order = tuple(json.loads(raw).keys())
|
||||
assert actual_order == CONTRACT_ORDER, (
|
||||
f"emitted JSON keys must follow contract order; got {actual_order}"
|
||||
)
|
||||
|
||||
|
||||
def test_ac3_level_warning_normalises_to_warn(
|
||||
captured_logger: tuple[logging.Logger, io.StringIO],
|
||||
) -> None:
|
||||
# Arrange
|
||||
logger, stream = captured_logger
|
||||
|
||||
# Act
|
||||
logger.warning("covariance spike", extra={"kind": "state.cov_spike"})
|
||||
|
||||
# Assert
|
||||
payload = _read_last_payload(stream)
|
||||
assert payload["level"] == "WARN"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"level_method,expected",
|
||||
[
|
||||
("debug", "DEBUG"),
|
||||
("info", "INFO"),
|
||||
("error", "ERROR"),
|
||||
],
|
||||
)
|
||||
def test_ac3_other_levels_pass_through_unchanged(
|
||||
captured_logger: tuple[logging.Logger, io.StringIO],
|
||||
level_method: str,
|
||||
expected: str,
|
||||
) -> None:
|
||||
# Arrange
|
||||
logger, stream = captured_logger
|
||||
|
||||
# Act
|
||||
getattr(logger, level_method)("msg", extra={"kind": "test"})
|
||||
|
||||
# Assert
|
||||
payload = _read_last_payload(stream)
|
||||
assert payload["level"] == expected
|
||||
|
||||
|
||||
def test_ac4_handler_topology_no_duplicates_on_reinit() -> None:
|
||||
# Arrange — start clean
|
||||
root = logging.getLogger()
|
||||
root.handlers = [h for h in root.handlers if not getattr(h, _HANDLER_MARKER_ATTR, None)]
|
||||
|
||||
# Act
|
||||
configure_logging(tier=1, level="INFO")
|
||||
handler_count_first = sum(
|
||||
1 for h in root.handlers if getattr(h, _HANDLER_MARKER_ATTR, None) == "tier1.stdout"
|
||||
)
|
||||
configure_logging(tier=1, level="DEBUG")
|
||||
handler_count_second = sum(
|
||||
1 for h in root.handlers if getattr(h, _HANDLER_MARKER_ATTR, None) == "tier1.stdout"
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert handler_count_first == 1
|
||||
assert handler_count_second == 1
|
||||
|
||||
|
||||
def test_ac5_non_frame_records_emit_explicit_null_frame_id(
|
||||
captured_logger: tuple[logging.Logger, io.StringIO],
|
||||
) -> None:
|
||||
# Arrange
|
||||
logger, stream = captured_logger
|
||||
|
||||
# Act — no frame_id passed in extras
|
||||
logger.info("startup", extra={"kind": "lifecycle.start", "kv": {"version": "0.1.0"}})
|
||||
|
||||
# Assert
|
||||
payload = _read_last_payload(stream)
|
||||
raw = _read_last_raw(stream)
|
||||
assert "frame_id" in payload, "frame_id key must always be present"
|
||||
assert payload["frame_id"] is None, "frame_id must be JSON null on non-frame records"
|
||||
assert '"frame_id":null' in raw, "raw JSON must emit literal null, not omit the key"
|
||||
|
||||
|
||||
def test_default_kind_for_records_without_explicit_kind(
|
||||
captured_logger: tuple[logging.Logger, io.StringIO],
|
||||
) -> None:
|
||||
# Arrange
|
||||
logger, stream = captured_logger
|
||||
|
||||
# Act
|
||||
logger.info("kindless")
|
||||
|
||||
# Assert — default kind is the documented diagnostic tag, not absent.
|
||||
payload = _read_last_payload(stream)
|
||||
assert payload["kind"] == "log.diag"
|
||||
|
||||
|
||||
def test_nfr_formatter_never_raises_on_unserialisable_kv(
|
||||
captured_logger: tuple[logging.Logger, io.StringIO],
|
||||
) -> None:
|
||||
# Arrange
|
||||
logger, stream = captured_logger
|
||||
|
||||
class _Unserialisable:
|
||||
pass
|
||||
|
||||
# Act — should NOT raise into the caller
|
||||
logger.error(
|
||||
"broken kv",
|
||||
extra={"kind": "log.format_error_test", "kv": {"obj": _Unserialisable()}},
|
||||
)
|
||||
|
||||
# Assert
|
||||
payload = _read_last_payload(stream)
|
||||
assert payload["level"] == "ERROR"
|
||||
assert "_format_error" in payload["kv"], (
|
||||
"un-serialisable kv must be replaced with a {_format_error: ...} payload"
|
||||
)
|
||||
|
||||
|
||||
def test_nfr_multi_line_msg_is_collapsed(
|
||||
captured_logger: tuple[logging.Logger, io.StringIO],
|
||||
) -> None:
|
||||
# Arrange
|
||||
logger, stream = captured_logger
|
||||
|
||||
# Act
|
||||
logger.info("line1\nline2", extra={"kind": "test"})
|
||||
|
||||
# Assert — schema requires one JSON object per line; embedded newlines stripped.
|
||||
raw = _read_last_raw(stream)
|
||||
payload = json.loads(raw)
|
||||
assert "\n" not in payload["msg"]
|
||||
# exactly one JSON object emitted, not two
|
||||
other_lines = [line for line in stream.getvalue().splitlines() if line.strip()]
|
||||
assert len(other_lines) == 1
|
||||
|
||||
|
||||
def test_kv_explicit_field_wins_over_auto_collected_extras(
|
||||
captured_logger: tuple[logging.Logger, io.StringIO],
|
||||
) -> None:
|
||||
# Arrange
|
||||
logger, stream = captured_logger
|
||||
|
||||
# Act — pass an explicit `kv` plus a free-standing extras key; explicit kv wins.
|
||||
logger.info(
|
||||
"explicit kv",
|
||||
extra={"kind": "test", "kv": {"a": 1}, "stray": "value"},
|
||||
)
|
||||
|
||||
# Assert
|
||||
payload = _read_last_payload(stream)
|
||||
assert payload["kv"] == {"a": 1}
|
||||
@@ -0,0 +1,166 @@
|
||||
"""AC tests for AZ-269: config loader + outer Config dataclass.
|
||||
|
||||
Verifies the `composition_root_protocol` contract v1.0.0:
|
||||
|
||||
- AC-1 env > YAML for the same key
|
||||
- AC-2 YAML > defaults when env is silent
|
||||
- AC-3 defaults fill gaps
|
||||
- AC-4 multi-file YAML: later path wins
|
||||
- AC-5 frozen end-to-end (mutation raises)
|
||||
- AC-6 missing required env var fails fast with a pointer
|
||||
- NFR-reliability load_config is pure
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import FrozenInstanceError
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.config import (
|
||||
Config,
|
||||
LogConfig,
|
||||
RequiredFieldMissingError,
|
||||
load_config,
|
||||
)
|
||||
|
||||
REQUIRED_ENV: dict[str, str] = {
|
||||
"GPS_DENIED_FC_PROFILE": "ardupilot_plane",
|
||||
"GPS_DENIED_TIER": "1",
|
||||
"DB_URL": "postgresql://gps_denied:dev@db:5432/gps_denied",
|
||||
"CAMERA_CALIBRATION_PATH": "/fixtures/calibration/adti26.json",
|
||||
"LOG_LEVEL": "INFO",
|
||||
"LOG_SINK": "console",
|
||||
"INFERENCE_BACKEND": "pytorch_fp16",
|
||||
"FDR_PATH": "/var/lib/gps-denied/fdr",
|
||||
"TILE_CACHE_PATH": "/var/lib/gps-denied/tiles",
|
||||
}
|
||||
|
||||
|
||||
def _yaml(tmp_path: Path, name: str, body: str) -> Path:
|
||||
path = tmp_path / name
|
||||
path.write_text(body)
|
||||
return path
|
||||
|
||||
|
||||
def test_ac1_env_beats_yaml(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
yaml_path = _yaml(tmp_path, "settings.yaml", "log:\n level: INFO\n")
|
||||
env = {**REQUIRED_ENV, "LOG_LEVEL": "DEBUG"}
|
||||
|
||||
# Act
|
||||
config = load_config(env, [yaml_path])
|
||||
|
||||
# Assert
|
||||
assert config.log.level == "DEBUG"
|
||||
|
||||
|
||||
def test_ac2_yaml_beats_default_when_env_silent(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
yaml_path = _yaml(tmp_path, "settings.yaml", "log:\n level: INFO\n")
|
||||
env_without_log_level = {k: v for k, v in REQUIRED_ENV.items() if k != "LOG_LEVEL"}
|
||||
# Re-introduce LOG_LEVEL only because it's in the required set; use a YAML-only field
|
||||
# to demonstrate YAML > defaults more cleanly.
|
||||
yaml_with_unique = _yaml(tmp_path, "queue.yaml", "fdr:\n queue_size: 16384\n")
|
||||
env = {**REQUIRED_ENV}
|
||||
|
||||
# Act
|
||||
config = load_config(env, [yaml_with_unique])
|
||||
|
||||
# Assert — defaults document queue_size=4096; YAML overrides to 16384; no env override.
|
||||
assert config.fdr.queue_size == 16384
|
||||
# Also verify the prior assertion about LOG_LEVEL: with LOG_LEVEL absent, YAML wins.
|
||||
env2 = {**env_without_log_level, "LOG_LEVEL": REQUIRED_ENV["LOG_LEVEL"]}
|
||||
cfg2 = load_config(env2, [yaml_path])
|
||||
assert cfg2.log.level in {"INFO", "DEBUG"} # env is set; either is acceptable per ordering
|
||||
|
||||
|
||||
def test_ac3_defaults_fill_gaps() -> None:
|
||||
# Arrange — no YAML paths, only required env
|
||||
env = {**REQUIRED_ENV}
|
||||
|
||||
# Act
|
||||
config = load_config(env, [])
|
||||
|
||||
# Assert — documented default for fdr.queue_size is 4096.
|
||||
assert config.fdr.queue_size == 4096
|
||||
assert isinstance(config.log, LogConfig)
|
||||
|
||||
|
||||
def test_ac4_later_yaml_path_wins(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
first = _yaml(tmp_path, "first.yaml", "fdr:\n queue_size: 4096\n")
|
||||
second = _yaml(tmp_path, "second.yaml", "fdr:\n queue_size: 8192\n")
|
||||
env = {**REQUIRED_ENV}
|
||||
|
||||
# Act
|
||||
config = load_config(env, [first, second])
|
||||
|
||||
# Assert
|
||||
assert config.fdr.queue_size == 8192
|
||||
|
||||
|
||||
def test_ac5_config_is_frozen_end_to_end() -> None:
|
||||
# Arrange
|
||||
env = {**REQUIRED_ENV}
|
||||
config = load_config(env, [])
|
||||
|
||||
# Assert
|
||||
with pytest.raises((FrozenInstanceError, AttributeError, TypeError)):
|
||||
config.log.level = "DEBUG" # type: ignore[misc]
|
||||
with pytest.raises((FrozenInstanceError, AttributeError, TypeError)):
|
||||
config.runtime.tier = 99 # type: ignore[misc]
|
||||
|
||||
|
||||
def test_ac6_missing_required_env_var_fails_with_pointer() -> None:
|
||||
# Arrange — DB_URL deliberately omitted
|
||||
env = {k: v for k, v in REQUIRED_ENV.items() if k != "DB_URL"}
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(RequiredFieldMissingError) as excinfo:
|
||||
load_config(env, [])
|
||||
message = str(excinfo.value)
|
||||
assert "DB_URL" in message, "error must name the offending env var"
|
||||
assert ".env.example" in message, "error must point at the documented variable set"
|
||||
|
||||
|
||||
def test_nfr_reliability_loader_is_pure(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
yaml_path = _yaml(tmp_path, "settings.yaml", "log:\n level: INFO\n")
|
||||
env = {**REQUIRED_ENV}
|
||||
|
||||
# Act
|
||||
a = load_config(env, [yaml_path])
|
||||
b = load_config(env, [yaml_path])
|
||||
|
||||
# Assert — same inputs -> deep-equal Configs.
|
||||
assert a == b
|
||||
assert a is not b
|
||||
|
||||
|
||||
def test_tier_is_coerced_to_int() -> None:
|
||||
# Arrange
|
||||
env = {**REQUIRED_ENV, "GPS_DENIED_TIER": "2"}
|
||||
|
||||
# Act
|
||||
config = load_config(env, [])
|
||||
|
||||
# Assert
|
||||
assert isinstance(config.runtime.tier, int)
|
||||
assert config.runtime.tier == 2
|
||||
|
||||
|
||||
def test_unknown_yaml_block_does_not_break_load(tmp_path: Path) -> None:
|
||||
"""Per contract: unregistered component slugs in YAML should not crash."""
|
||||
# Arrange
|
||||
yaml_path = _yaml(
|
||||
tmp_path,
|
||||
"extras.yaml",
|
||||
"log:\n level: INFO\nunknown_block:\n some_key: value\n",
|
||||
)
|
||||
env = {**REQUIRED_ENV}
|
||||
|
||||
# Act + Assert — loader does not crash; unknown_block is silently ignored.
|
||||
cfg = load_config(env, [yaml_path])
|
||||
assert isinstance(cfg, Config)
|
||||
@@ -0,0 +1,203 @@
|
||||
"""AC tests for AZ-277: SE3Utils helper module.
|
||||
|
||||
Verifies the `se3_utils` contract v1.0.0 — round-trips, Lie-algebra
|
||||
stability, strict orthogonality / dtype / block-layout rejection, and
|
||||
the no-upward-imports invariant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
import gtsam
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.helpers import (
|
||||
SE3,
|
||||
Se3InvalidMatrixError,
|
||||
adjoint,
|
||||
exp_map,
|
||||
is_valid_rotation,
|
||||
log_map,
|
||||
matrix_to_se3,
|
||||
se3_to_matrix,
|
||||
)
|
||||
|
||||
SEED = 12345
|
||||
RNG = np.random.default_rng(SEED)
|
||||
|
||||
|
||||
def _random_valid_T(rng: np.random.Generator) -> np.ndarray:
|
||||
"""Produce a valid SE(3) 4x4 matrix from a random twist."""
|
||||
xi = rng.standard_normal(6) * 0.5
|
||||
pose = gtsam.Pose3.Expmap(xi.astype(np.float64))
|
||||
return np.ascontiguousarray(pose.matrix(), dtype=np.float64)
|
||||
|
||||
|
||||
def test_ac1_matrix_se3_roundtrip_within_tolerance() -> None:
|
||||
# Arrange + Act + Assert
|
||||
for _ in range(20):
|
||||
T = _random_valid_T(RNG)
|
||||
pose = matrix_to_se3(T)
|
||||
recovered = se3_to_matrix(pose)
|
||||
assert np.allclose(recovered, T, atol=1e-9), (
|
||||
f"matrix_to_se3 -> se3_to_matrix round-trip diverged for T:\n{T}"
|
||||
)
|
||||
|
||||
|
||||
def test_ac2_lie_algebra_roundtrip() -> None:
|
||||
# Arrange + Act + Assert
|
||||
for _ in range(20):
|
||||
xi = RNG.standard_normal(6).astype(np.float64)
|
||||
norm = float(np.linalg.norm(xi))
|
||||
if norm == 0.0:
|
||||
continue
|
||||
xi *= 1.0 / norm # norm ~= 1.0
|
||||
recovered = log_map(exp_map(xi))
|
||||
assert np.allclose(recovered, xi, atol=1e-9), (
|
||||
f"log_map(exp_map(xi)) round-trip diverged for xi={xi}"
|
||||
)
|
||||
|
||||
|
||||
def test_ac3_near_identity_exp_map_is_stable() -> None:
|
||||
# Arrange
|
||||
xi = np.full(6, 1e-12, dtype=np.float64)
|
||||
|
||||
# Act
|
||||
pose = exp_map(xi)
|
||||
matrix = se3_to_matrix(pose)
|
||||
|
||||
# Assert
|
||||
identity = np.eye(4, dtype=np.float64)
|
||||
assert np.allclose(matrix, identity, atol=1e-9), (
|
||||
f"near-identity exp_map must return identity within atol=1e-9; got\n{matrix}"
|
||||
)
|
||||
assert not np.any(np.isnan(matrix)), "near-identity exp_map must not produce NaNs"
|
||||
|
||||
|
||||
def test_ac4_strict_orthogonality_rejection() -> None:
|
||||
# Arrange — drift R^T R away from I by 1e-3 in Frobenius norm
|
||||
T = _random_valid_T(RNG)
|
||||
T[:3, :3] += np.eye(3, dtype=np.float64) * 1e-3
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(Se3InvalidMatrixError) as excinfo:
|
||||
matrix_to_se3(T)
|
||||
msg = str(excinfo.value)
|
||||
assert "orthogonal" in msg.lower() or "R^T R" in msg, (
|
||||
"rejection message must explain the orthogonality drift"
|
||||
)
|
||||
|
||||
|
||||
def test_ac5_mirror_rotation_rejected() -> None:
|
||||
# Arrange — flip the sign of one column to produce det(R) = -1
|
||||
T = _random_valid_T(RNG)
|
||||
T[:3, 0] = -T[:3, 0]
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(Se3InvalidMatrixError) as excinfo:
|
||||
matrix_to_se3(T)
|
||||
msg = str(excinfo.value)
|
||||
assert "determinant" in msg.lower() or "negative" in msg.lower()
|
||||
|
||||
|
||||
def test_ac6_bottom_row_guard() -> None:
|
||||
# Arrange
|
||||
T = _random_valid_T(RNG)
|
||||
T[3] = np.array([0.0, 0.0, 0.0, 2.0], dtype=np.float64)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(Se3InvalidMatrixError) as excinfo:
|
||||
matrix_to_se3(T)
|
||||
assert "bottom row" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
def test_ac7_float32_dtype_rejected() -> None:
|
||||
# Arrange
|
||||
T = _random_valid_T(RNG).astype(np.float32)
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(Se3InvalidMatrixError) as excinfo:
|
||||
matrix_to_se3(T)
|
||||
assert "dtype" in str(excinfo.value).lower() or "float" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
def test_ac8_determinism_byte_equal_outputs() -> None:
|
||||
# Arrange
|
||||
T = _random_valid_T(RNG)
|
||||
|
||||
# Act
|
||||
pose_a = matrix_to_se3(T)
|
||||
pose_b = matrix_to_se3(T)
|
||||
matrix_a = se3_to_matrix(pose_a)
|
||||
matrix_b = se3_to_matrix(pose_b)
|
||||
|
||||
# Assert
|
||||
assert np.array_equal(matrix_a, matrix_b), (
|
||||
"matrix_to_se3 -> se3_to_matrix must be byte-equal across calls"
|
||||
)
|
||||
|
||||
|
||||
def test_ac9_no_upward_imports_to_components() -> None:
|
||||
"""The helper module MUST NOT import from gps_denied_onboard.components.*."""
|
||||
# Arrange
|
||||
module_path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src"
|
||||
/ "gps_denied_onboard"
|
||||
/ "helpers"
|
||||
/ "se3_utils.py"
|
||||
)
|
||||
tree = ast.parse(module_path.read_text())
|
||||
bad_imports: list[str] = []
|
||||
|
||||
# Act
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
module = node.module or ""
|
||||
if module.startswith("gps_denied_onboard.components"):
|
||||
bad_imports.append(module)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("gps_denied_onboard.components"):
|
||||
bad_imports.append(alias.name)
|
||||
|
||||
# Assert
|
||||
assert not bad_imports, (
|
||||
f"helpers.se3_utils must not import from components.*; found {bad_imports}"
|
||||
)
|
||||
|
||||
|
||||
def test_is_valid_rotation_predicate() -> None:
|
||||
# Arrange
|
||||
R_good = np.eye(3, dtype=np.float64)
|
||||
R_drift = np.eye(3, dtype=np.float64) + 1e-2 # off-orthogonal
|
||||
R_mirror = np.diag([-1.0, 1.0, 1.0]).astype(np.float64)
|
||||
R_f32 = np.eye(3, dtype=np.float32)
|
||||
|
||||
# Assert
|
||||
assert is_valid_rotation(R_good) is True
|
||||
assert is_valid_rotation(R_drift) is False
|
||||
assert is_valid_rotation(R_mirror) is False
|
||||
assert is_valid_rotation(R_f32) is False
|
||||
assert is_valid_rotation("not an array") is False # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_adjoint_shape() -> None:
|
||||
# Arrange
|
||||
T = _random_valid_T(RNG)
|
||||
pose = matrix_to_se3(T)
|
||||
|
||||
# Act
|
||||
ad = adjoint(pose)
|
||||
|
||||
# Assert
|
||||
assert ad.shape == (6, 6)
|
||||
assert ad.dtype == np.float64
|
||||
|
||||
|
||||
def test_se3_is_gtsam_pose3_alias() -> None:
|
||||
# Assert
|
||||
assert SE3 is gtsam.Pose3
|
||||
@@ -0,0 +1,214 @@
|
||||
"""AC tests for AZ-280: Sha256Sidecar helper.
|
||||
|
||||
Verifies the `sha256_sidecar` contract v1.0.0 — atomic write, independent
|
||||
verification, sidecar format strictness, aggregate determinism, and the
|
||||
no-upward-imports invariant.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from gps_denied_onboard.helpers import (
|
||||
SIDECAR_SUFFIX,
|
||||
Sha256Sidecar,
|
||||
Sha256SidecarError,
|
||||
)
|
||||
|
||||
|
||||
def _payload(seed: int, size: int = 1 << 20) -> bytes:
|
||||
"""1 MiB random-ish payload derived from a seed for reproducibility."""
|
||||
import random
|
||||
|
||||
rng = random.Random(seed)
|
||||
return bytes(rng.randint(0, 255) for _ in range(size))
|
||||
|
||||
|
||||
def _sidecar(path: Path) -> Path:
|
||||
return Path(str(path) + SIDECAR_SUFFIX)
|
||||
|
||||
|
||||
def test_ac1_roundtrip_write_and_verify(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
payload = _payload(seed=1)
|
||||
target = tmp_path / "artifact.bin"
|
||||
|
||||
# Act
|
||||
written_digest = Sha256Sidecar.write_atomic_and_sidecar(target, payload)
|
||||
|
||||
# Assert
|
||||
assert target.exists()
|
||||
assert _sidecar(target).exists()
|
||||
sidecar_text = _sidecar(target).read_text()
|
||||
expected_digest = hashlib.sha256(payload).hexdigest()
|
||||
assert sidecar_text == expected_digest
|
||||
assert written_digest == expected_digest
|
||||
assert Sha256Sidecar.verify(target) is True
|
||||
|
||||
|
||||
def test_ac2_atomicity_no_partial_file_on_fault(tmp_path: Path, monkeypatch) -> None:
|
||||
# Arrange
|
||||
payload = b"hello"
|
||||
target = tmp_path / "atomic.bin"
|
||||
|
||||
# Sabotage `os.replace` via the atomicwrites module so the rename step fails.
|
||||
import atomicwrites
|
||||
|
||||
real_replace = atomicwrites.replace_atomic
|
||||
|
||||
def _failing_replace(src: str, dst: str) -> None:
|
||||
raise OSError("simulated rename failure")
|
||||
|
||||
monkeypatch.setattr(atomicwrites, "replace_atomic", _failing_replace)
|
||||
|
||||
# Act + Assert — the write should raise; no half file should appear at the target.
|
||||
with pytest.raises(Sha256SidecarError):
|
||||
Sha256Sidecar.write_atomic(target, payload)
|
||||
assert not target.exists(), "no partial file may exist at the target name on fault"
|
||||
# Also confirm no orphaned `.tmp` siblings remain at the target name.
|
||||
siblings = [p for p in tmp_path.iterdir() if p.name.startswith("atomic.bin")]
|
||||
assert all(p == target for p in siblings if p.exists())
|
||||
|
||||
# Cleanup
|
||||
monkeypatch.setattr(atomicwrites, "replace_atomic", real_replace)
|
||||
|
||||
|
||||
def test_ac3_independent_verification_rejects_swapped_payload(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
payload = b"original payload"
|
||||
target = tmp_path / "swap.bin"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(target, payload)
|
||||
|
||||
# Act — replace the artifact bytes out-of-band without updating the sidecar.
|
||||
target.write_bytes(b"completely different bytes that are longer")
|
||||
|
||||
# Assert
|
||||
assert Sha256Sidecar.verify(target) is False
|
||||
|
||||
|
||||
def test_ac4_missing_sidecar_raises(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
payload = b"data"
|
||||
target = tmp_path / "missing_sidecar.bin"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(target, payload)
|
||||
_sidecar(target).unlink()
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(Sha256SidecarError) as excinfo:
|
||||
Sha256Sidecar.verify(target)
|
||||
assert "sidecar" in str(excinfo.value).lower()
|
||||
|
||||
|
||||
def test_ac5_malformed_sidecar_rejected(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
payload = b"data"
|
||||
target = tmp_path / "bad_sidecar.bin"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(target, payload)
|
||||
_sidecar(target).write_text("not a hex digest at all")
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(Sha256SidecarError):
|
||||
Sha256Sidecar.verify(target)
|
||||
|
||||
|
||||
def test_ac6_aggregate_hash_order_deterministic(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
paths = []
|
||||
for i in range(3):
|
||||
p = tmp_path / f"file_{i}.bin"
|
||||
Sha256Sidecar.write_atomic(p, f"payload-{i}".encode())
|
||||
paths.append(p)
|
||||
|
||||
# Act
|
||||
a = Sha256Sidecar.aggregate_hash(paths)
|
||||
b = Sha256Sidecar.aggregate_hash(list(reversed(paths)))
|
||||
c = Sha256Sidecar.aggregate_hash([paths[1], paths[0], paths[2]])
|
||||
|
||||
# Assert — same set, three permutations -> identical aggregate
|
||||
assert a == b == c
|
||||
|
||||
|
||||
def test_ac7_aggregate_hash_missing_path_raises(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
real = tmp_path / "real.bin"
|
||||
Sha256Sidecar.write_atomic(real, b"data")
|
||||
ghost = tmp_path / "ghost.bin" # never created
|
||||
|
||||
# Act + Assert
|
||||
with pytest.raises(Sha256SidecarError) as excinfo:
|
||||
Sha256Sidecar.aggregate_hash([real, ghost])
|
||||
assert "ghost.bin" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_ac8_sidecar_format_strictness(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
payload = b"hello world"
|
||||
target = tmp_path / "strict.bin"
|
||||
Sha256Sidecar.write_atomic_and_sidecar(target, payload)
|
||||
|
||||
# Act
|
||||
raw_bytes = _sidecar(target).read_bytes()
|
||||
text = _sidecar(target).read_text()
|
||||
|
||||
# Assert — exactly 64 hex chars, no newline, no JSON wrapper, no whitespace.
|
||||
assert len(raw_bytes) == 64, f"sidecar must be 64 bytes; got {len(raw_bytes)}"
|
||||
assert not raw_bytes.endswith(b"\n"), "sidecar must NOT have a trailing newline"
|
||||
assert text == text.strip(), "sidecar must contain no whitespace"
|
||||
int(text, 16) # raises if not pure hex
|
||||
assert text == hashlib.sha256(payload).hexdigest()
|
||||
|
||||
|
||||
def test_ac9_no_upward_imports_to_components() -> None:
|
||||
# Arrange
|
||||
module_path = (
|
||||
Path(__file__).resolve().parents[2]
|
||||
/ "src"
|
||||
/ "gps_denied_onboard"
|
||||
/ "helpers"
|
||||
/ "sha256_sidecar.py"
|
||||
)
|
||||
tree = ast.parse(module_path.read_text())
|
||||
bad_imports: list[str] = []
|
||||
|
||||
# Act
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.ImportFrom):
|
||||
module = node.module or ""
|
||||
if module.startswith("gps_denied_onboard.components"):
|
||||
bad_imports.append(module)
|
||||
elif isinstance(node, ast.Import):
|
||||
for alias in node.names:
|
||||
if alias.name.startswith("gps_denied_onboard.components"):
|
||||
bad_imports.append(alias.name)
|
||||
|
||||
# Assert
|
||||
assert not bad_imports, (
|
||||
f"helpers.sha256_sidecar must not import from components.*; found {bad_imports}"
|
||||
)
|
||||
|
||||
|
||||
def test_verify_on_missing_path_returns_false(tmp_path: Path) -> None:
|
||||
"""Contract: `verify` distinguishes 'missing path' (False) from 'missing sidecar' (raise)."""
|
||||
# Act + Assert
|
||||
assert Sha256Sidecar.verify(tmp_path / "never_created.bin") is False
|
||||
|
||||
|
||||
def test_aggregate_includes_all_file_digests(tmp_path: Path) -> None:
|
||||
"""The aggregate must be sensitive to file content, not only path names."""
|
||||
# Arrange
|
||||
a = tmp_path / "a.bin"
|
||||
b = tmp_path / "b.bin"
|
||||
Sha256Sidecar.write_atomic(a, b"alpha")
|
||||
Sha256Sidecar.write_atomic(b, b"beta")
|
||||
digest_initial = Sha256Sidecar.aggregate_hash([a, b])
|
||||
|
||||
# Act — mutate b's content and recompute
|
||||
Sha256Sidecar.write_atomic(b, b"gamma")
|
||||
digest_after = Sha256Sidecar.aggregate_hash([a, b])
|
||||
|
||||
# Assert
|
||||
assert digest_initial != digest_after
|
||||
@@ -1,9 +1,11 @@
|
||||
"""Structured logging smoke — AZ-263 AC-7."""
|
||||
"""Structured logging smoke — AZ-263 AC-7 (schema-compliant per AZ-266)."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
|
||||
from gps_denied_onboard.logging import get_logger
|
||||
from gps_denied_onboard.logging.structured import JsonFormatter
|
||||
|
||||
|
||||
def test_get_logger_returns_logger_instance() -> None:
|
||||
@@ -16,25 +18,24 @@ def test_get_logger_returns_logger_instance() -> None:
|
||||
|
||||
def test_log_lines_are_single_json_objects() -> None:
|
||||
# Arrange
|
||||
import io
|
||||
|
||||
from gps_denied_onboard.logging.structured import _JsonFormatter
|
||||
|
||||
stream = io.StringIO()
|
||||
handler = logging.StreamHandler(stream)
|
||||
handler.setFormatter(_JsonFormatter())
|
||||
handler.setFormatter(JsonFormatter())
|
||||
logger = logging.getLogger("test.json.unit")
|
||||
logger.handlers = [handler]
|
||||
logger.setLevel(logging.DEBUG)
|
||||
logger.propagate = False
|
||||
|
||||
# Act
|
||||
logger.error("hello world", extra={"event": "smoke", "value": 42})
|
||||
logger.error(
|
||||
"hello world",
|
||||
extra={"kind": "test.smoke", "kv": {"event": "smoke", "value": 42}},
|
||||
)
|
||||
|
||||
# Assert
|
||||
line = stream.getvalue().strip().splitlines()[-1]
|
||||
payload = json.loads(line)
|
||||
assert payload["level"] == "ERROR"
|
||||
assert payload["msg"] == "hello world"
|
||||
assert payload["event"] == "smoke"
|
||||
assert payload["value"] == 42
|
||||
assert payload["kind"] == "test.smoke"
|
||||
assert payload["kv"] == {"event": "smoke", "value": 42}
|
||||
|
||||
Reference in New Issue
Block a user