"""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)