mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 12:11:13 +00:00
8e71f6c002
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>
167 lines
5.1 KiB
Python
167 lines
5.1 KiB
Python
"""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)
|