ria-toolkit-oss/tests/orchestration/test_campaign.py

490 lines
16 KiB
Python
Raw Normal View History

2026-03-11 10:27:18 -04:00
"""Tests for orchestration campaign schema and YAML parsing."""
import os
import tempfile
import pytest
import yaml
from ria_toolkit_oss.orchestration.campaign import (
CampaignConfig,
CaptureStep,
QAConfig,
RecorderConfig,
parse_bandwidth_mhz,
parse_duration,
parse_frequency,
parse_gain,
)
# ---------------------------------------------------------------------------
# parse_duration
# ---------------------------------------------------------------------------
class TestParseDuration:
def test_seconds_suffix(self):
assert parse_duration("30s") == 30.0
def test_seconds_suffix_long(self):
assert parse_duration("30sec") == 30.0
def test_minutes_suffix(self):
assert parse_duration("1.5m") == 90.0
def test_minutes_suffix_long(self):
assert parse_duration("2min") == 120.0
def test_hours_suffix(self):
assert parse_duration("2h") == 7200.0
def test_hours_suffix_long(self):
assert parse_duration("1hr") == 3600.0
def test_numeric_int(self):
assert parse_duration(45) == 45.0
def test_numeric_float(self):
assert parse_duration(1.5) == 1.5
def test_bare_number_string(self):
# No unit → treated as seconds
assert parse_duration("60") == 60.0
def test_invalid_raises(self):
with pytest.raises(ValueError):
parse_duration("two minutes")
# ---------------------------------------------------------------------------
# parse_frequency
# ---------------------------------------------------------------------------
class TestParseFrequency:
def test_ghz(self):
assert parse_frequency("2.45GHz") == pytest.approx(2.45e9)
def test_mhz(self):
assert parse_frequency("40MHz") == pytest.approx(40e6)
def test_khz(self):
assert parse_frequency("433k") == pytest.approx(433e3)
def test_scientific_notation_string(self):
assert parse_frequency("915e6") == pytest.approx(915e6)
def test_numeric_float(self):
assert parse_frequency(2.45e9) == pytest.approx(2.45e9)
def test_numeric_int(self):
assert parse_frequency(1000000) == pytest.approx(1e6)
def test_hz_suffix_optional(self):
# "40M" and "40MHz" should both work
assert parse_frequency("40M") == pytest.approx(40e6)
assert parse_frequency("40MHz") == pytest.approx(40e6)
def test_invalid_raises(self):
with pytest.raises(ValueError):
parse_frequency("two point four gigs")
# ---------------------------------------------------------------------------
# parse_gain
# ---------------------------------------------------------------------------
class TestParseGain:
def test_db_suffix(self):
assert parse_gain("40dB") == pytest.approx(40.0)
def test_db_suffix_lowercase(self):
assert parse_gain("32db") == pytest.approx(32.0)
def test_auto(self):
assert parse_gain("auto") == "auto"
def test_auto_case_insensitive(self):
assert parse_gain("AUTO") == "auto"
def test_numeric_int(self):
assert parse_gain(32) == pytest.approx(32.0)
def test_numeric_float(self):
assert parse_gain(32.5) == pytest.approx(32.5)
def test_invalid_raises(self):
with pytest.raises(ValueError):
parse_gain("high")
# ---------------------------------------------------------------------------
# parse_bandwidth_mhz
# ---------------------------------------------------------------------------
class TestParseBandwidthMhz:
def test_mhz_suffix(self):
assert parse_bandwidth_mhz("20MHz") == pytest.approx(20.0)
def test_numeric(self):
assert parse_bandwidth_mhz(40) == pytest.approx(40.0)
def test_none(self):
assert parse_bandwidth_mhz(None) is None
def test_invalid_raises(self):
with pytest.raises(ValueError):
parse_bandwidth_mhz("wide")
# ---------------------------------------------------------------------------
# CaptureStep.from_dict
# ---------------------------------------------------------------------------
class TestCaptureStep:
def test_wifi_step_auto_label(self):
d = {"channel": 6, "bandwidth": "20MHz", "traffic": "iperf_udp", "duration": "30s"}
step = CaptureStep.from_dict(d)
assert step.duration == 30.0
assert step.channel == 6
assert step.bandwidth_mhz == 20.0
assert step.traffic == "iperf_udp"
assert step.label == "ch06_20mhz_iperf_udp"
def test_explicit_label(self):
d = {"channel": 1, "bandwidth": "20MHz", "traffic": "idle", "duration": "30s", "label": "my_label"}
step = CaptureStep.from_dict(d)
assert step.label == "my_label"
def test_fallback_label(self):
# No channel/bandwidth/traffic → label falls back to "capture"
d = {"duration": "10s"}
step = CaptureStep.from_dict(d)
assert step.label == "capture"
def test_power_parsed(self):
d = {"channel": 6, "bandwidth": "20MHz", "traffic": "idle", "duration": "30s", "power": "15dBm"}
step = CaptureStep.from_dict(d)
assert step.power_dbm == pytest.approx(15.0)
# ---------------------------------------------------------------------------
# RecorderConfig.from_dict
# ---------------------------------------------------------------------------
class TestRecorderConfig:
def test_basic(self):
d = {"device": "usrp_b210", "center_freq": "2.45GHz", "sample_rate": "40MHz", "gain": "40dB"}
rec = RecorderConfig.from_dict(d)
assert rec.device == "usrp_b210"
assert rec.center_freq == pytest.approx(2.45e9)
assert rec.sample_rate == pytest.approx(40e6)
assert rec.gain == pytest.approx(40.0)
assert rec.bandwidth is None
def test_auto_gain(self):
d = {"device": "pluto", "center_freq": "2.45GHz", "sample_rate": "20MHz", "gain": "auto"}
rec = RecorderConfig.from_dict(d)
assert rec.gain == "auto"
def test_bandwidth_set(self):
d = {"device": "pluto", "center_freq": "2.45GHz", "sample_rate": "20MHz", "gain": 32, "bandwidth": "20MHz"}
rec = RecorderConfig.from_dict(d)
assert rec.bandwidth == pytest.approx(20e6)
# ---------------------------------------------------------------------------
# QAConfig.from_dict
# ---------------------------------------------------------------------------
class TestQAConfig:
def test_defaults(self):
qa = QAConfig.from_dict({})
assert qa.snr_threshold_db == pytest.approx(10.0)
assert qa.min_duration_s == pytest.approx(25.0)
assert qa.flag_for_review is True
def test_custom_values(self):
d = {"snr_threshold": "15dB", "min_duration": "28s", "flag_for_review": False}
qa = QAConfig.from_dict(d)
assert qa.snr_threshold_db == pytest.approx(15.0)
assert qa.min_duration_s == pytest.approx(28.0)
assert qa.flag_for_review is False
# ---------------------------------------------------------------------------
# CampaignConfig.from_device_profile
# ---------------------------------------------------------------------------
def _write_device_profile(d: dict) -> str:
"""Write a dict as YAML to a temp file and return the path."""
f = tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False)
yaml.dump(d, f)
f.close()
return f.name
WIFI_PROFILE = {
"device": {"name": "iPhone_13_WiFi", "type": "wifi"},
"capture": {
"channels": [1, 6, 11],
"bandwidth": "20MHz",
"traffic_patterns": ["idle", "ping", "iperf_udp"],
"duration_per_config": "30s",
"script": "./scripts/wifi_control.sh",
},
"recorder": {
"device": "usrp_b210",
"center_freq": "2.45GHz",
"sample_rate": "40MHz",
"gain": "auto",
},
"output": {"path": "/tmp/test_recordings", "device_id": "iphone13_wifi_001"},
}
BT_PROFILE = {
"device": {"name": "AirPods_Pro", "type": "bluetooth"},
"capture": {
"traffic_patterns": ["idle", "audio_stream", "data_transfer"],
"duration_per_config": "30s",
},
"recorder": {
"device": "usrp_b210",
"center_freq": "2.45GHz",
"sample_rate": "40MHz",
"gain": "auto",
},
"output": {"path": "/tmp/test_recordings", "device_id": "airpods_pro_bt_001"},
}
class TestDeviceProfileParsing:
def test_wifi_schedule_count(self):
"""WiFi: 3 channels × 3 traffic = 9 steps."""
path = _write_device_profile(WIFI_PROFILE)
try:
cfg = CampaignConfig.from_device_profile(path)
finally:
os.unlink(path)
assert len(cfg.transmitters) == 1
assert len(cfg.transmitters[0].schedule) == 9
def test_wifi_campaign_name(self):
path = _write_device_profile(WIFI_PROFILE)
try:
cfg = CampaignConfig.from_device_profile(path)
finally:
os.unlink(path)
assert cfg.name == "enroll_iphone13_wifi_001"
def test_wifi_step_labels(self):
path = _write_device_profile(WIFI_PROFILE)
try:
cfg = CampaignConfig.from_device_profile(path)
finally:
os.unlink(path)
labels = [s.label for s in cfg.transmitters[0].schedule]
assert "ch01_20mhz_idle" in labels
assert "ch06_20mhz_ping" in labels
assert "ch11_20mhz_iperf_udp" in labels
def test_wifi_step_ordering(self):
"""Steps iterate channels first, then traffic."""
path = _write_device_profile(WIFI_PROFILE)
try:
cfg = CampaignConfig.from_device_profile(path)
finally:
os.unlink(path)
steps = cfg.transmitters[0].schedule
assert steps[0].channel == 1 and steps[0].traffic == "idle"
assert steps[1].channel == 1 and steps[1].traffic == "ping"
assert steps[3].channel == 6 and steps[3].traffic == "idle"
assert steps[8].channel == 11 and steps[8].traffic == "iperf_udp"
def test_wifi_step_duration(self):
path = _write_device_profile(WIFI_PROFILE)
try:
cfg = CampaignConfig.from_device_profile(path)
finally:
os.unlink(path)
for step in cfg.transmitters[0].schedule:
assert step.duration == pytest.approx(30.0)
def test_wifi_bandwidth(self):
path = _write_device_profile(WIFI_PROFILE)
try:
cfg = CampaignConfig.from_device_profile(path)
finally:
os.unlink(path)
for step in cfg.transmitters[0].schedule:
assert step.bandwidth_mhz == pytest.approx(20.0)
def test_wifi_recorder(self):
path = _write_device_profile(WIFI_PROFILE)
try:
cfg = CampaignConfig.from_device_profile(path)
finally:
os.unlink(path)
assert cfg.recorder.device == "usrp_b210"
assert cfg.recorder.center_freq == pytest.approx(2.45e9)
assert cfg.recorder.sample_rate == pytest.approx(40e6)
assert cfg.recorder.gain == "auto"
def test_wifi_total_capture_time(self):
path = _write_device_profile(WIFI_PROFILE)
try:
cfg = CampaignConfig.from_device_profile(path)
finally:
os.unlink(path)
assert cfg.total_capture_time_s() == pytest.approx(270.0) # 9 × 30s
def test_bt_schedule_count(self):
"""BT: no channels, 3 traffic patterns = 3 steps."""
path = _write_device_profile(BT_PROFILE)
try:
cfg = CampaignConfig.from_device_profile(path)
finally:
os.unlink(path)
assert len(cfg.transmitters[0].schedule) == 3
def test_bt_no_channel(self):
path = _write_device_profile(BT_PROFILE)
try:
cfg = CampaignConfig.from_device_profile(path)
finally:
os.unlink(path)
for step in cfg.transmitters[0].schedule:
assert step.channel is None
def test_bt_step_labels(self):
path = _write_device_profile(BT_PROFILE)
try:
cfg = CampaignConfig.from_device_profile(path)
finally:
os.unlink(path)
labels = [s.label for s in cfg.transmitters[0].schedule]
assert labels == ["idle", "audio_stream", "data_transfer"]
def test_missing_file_raises(self):
with pytest.raises(FileNotFoundError):
CampaignConfig.from_device_profile("/nonexistent/path/profile.yml")
def test_invalid_yaml_raises(self):
with tempfile.NamedTemporaryFile(mode="w", suffix=".yml", delete=False) as f:
f.write(": bad: yaml: [\n")
path = f.name
try:
with pytest.raises(ValueError, match="Invalid YAML"):
CampaignConfig.from_device_profile(path)
finally:
os.unlink(path)
# ---------------------------------------------------------------------------
# CampaignConfig.from_yaml (full campaign format)
# ---------------------------------------------------------------------------
FULL_CAMPAIGN = {
"campaign": {"name": "wifi_capture_001", "mode": "controlled_testbed"},
"transmitters": [
{
"id": "laptop_wifi",
"type": "wifi",
"control_method": "external_script",
"script": "./scripts/wifi_control.sh",
"device": "/dev/wlan0",
"schedule": [
{"channel": 6, "bandwidth": "20MHz", "traffic": "iperf_tcp", "duration": "30s"},
{"channel": 36, "bandwidth": "40MHz", "traffic": "ping_flood", "duration": "30s"},
],
}
],
"recorder": {
"device": "usrp_b210",
"center_freq": "2.45GHz",
"sample_rate": "20MHz",
"gain": "40dB",
},
"qa": {"snr_threshold": "10dB", "min_duration": "25s", "flag_for_review": True},
"output": {"format": "sigmf", "path": "./recordings"},
}
class TestFullCampaignParsing:
def test_name(self):
path = _write_device_profile(FULL_CAMPAIGN)
try:
cfg = CampaignConfig.from_yaml(path)
finally:
os.unlink(path)
assert cfg.name == "wifi_capture_001"
def test_mode(self):
path = _write_device_profile(FULL_CAMPAIGN)
try:
cfg = CampaignConfig.from_yaml(path)
finally:
os.unlink(path)
assert cfg.mode == "controlled_testbed"
def test_transmitter_id(self):
path = _write_device_profile(FULL_CAMPAIGN)
try:
cfg = CampaignConfig.from_yaml(path)
finally:
os.unlink(path)
assert cfg.transmitters[0].id == "laptop_wifi"
assert cfg.transmitters[0].control_method == "external_script"
assert cfg.transmitters[0].script == "./scripts/wifi_control.sh"
def test_schedule_count(self):
path = _write_device_profile(FULL_CAMPAIGN)
try:
cfg = CampaignConfig.from_yaml(path)
finally:
os.unlink(path)
assert len(cfg.transmitters[0].schedule) == 2
def test_qa_config(self):
path = _write_device_profile(FULL_CAMPAIGN)
try:
cfg = CampaignConfig.from_yaml(path)
finally:
os.unlink(path)
assert cfg.qa.snr_threshold_db == pytest.approx(10.0)
assert cfg.qa.min_duration_s == pytest.approx(25.0)
assert cfg.qa.flag_for_review is True
def test_total_steps(self):
path = _write_device_profile(FULL_CAMPAIGN)
try:
cfg = CampaignConfig.from_yaml(path)
finally:
os.unlink(path)
assert cfg.total_steps() == 2
def test_no_transmitters_raises(self):
bad = dict(FULL_CAMPAIGN)
bad["transmitters"] = []
path = _write_device_profile(bad)
try:
with pytest.raises(ValueError, match="at least one transmitter"):
CampaignConfig.from_yaml(path)
finally:
os.unlink(path)
def test_missing_recorder_raises(self):
bad = {k: v for k, v in FULL_CAMPAIGN.items() if k != "recorder"}
path = _write_device_profile(bad)
try:
with pytest.raises((KeyError, ValueError)):
CampaignConfig.from_yaml(path)
finally:
os.unlink(path)