"""Tests for Apollo PCM frame demultiplexer.""" import pytest from apollo.constants import ( AGC_CH_DNTM1, AGC_CH_DNTM2, AGC_CH_OUTLINK, PCM_HIGH_WORDS_PER_FRAME, PCM_SYNC_WORD_LENGTH, PCM_WORD_LENGTH, ) from apollo.pcm_demux import AGC_WORD_POSITIONS, DemuxEngine from apollo.protocol import ( adc_to_voltage, generate_sync_word, ) def _make_test_frame( frame_id: int = 1, odd: bool = False, fill_value: int = 0x55, words_per_frame: int = PCM_HIGH_WORDS_PER_FRAME, ) -> bytes: """Build a raw frame byte array with known contents. Words 1-4 are the sync word; words 5+ are filled with fill_value (or custom data placed at specific positions). """ sync_word = generate_sync_word(frame_id=frame_id, odd=odd) sync_bytes = sync_word.to_bytes(4, byteorder="big") data_bytes = bytes([fill_value] * (words_per_frame - 4)) return sync_bytes + data_bytes class TestWordExtraction: """Test individual word extraction from known frames.""" def test_extract_data_words(self): """Words 5-128 should have the expected fill value.""" frame = _make_test_frame(fill_value=0xAB) engine = DemuxEngine(output_format="raw") result = engine.process_frame(frame) for word in result["words"]: assert word["raw_value"] == 0xAB def test_extract_specific_word(self): """extract_word should return the correct value at a given position.""" frame = bytearray(_make_test_frame(fill_value=0x00)) # Place a known value at word position 50 (0-indexed: 49) frame[49] = 0xDE frame = bytes(frame) engine = DemuxEngine(output_format="raw") word = engine.extract_word(frame, word_position=50) assert word["raw_value"] == 0xDE assert word["position"] == 50 def test_sync_word_parsing(self): """The parsed sync word fields should match the generated values.""" frame = _make_test_frame(frame_id=25, odd=False) engine = DemuxEngine() result = engine.process_frame(frame) sync = result["sync"] assert sync["frame_id"] == 25 # Even frame: core should match the default from apollo.constants import DEFAULT_SYNC_CORE assert sync["core"] == DEFAULT_SYNC_CORE def test_word_count(self): """Number of data words should be (words_per_frame - 4).""" frame = _make_test_frame() engine = DemuxEngine() result = engine.process_frame(frame) expected_data_words = PCM_HIGH_WORDS_PER_FRAME - (PCM_SYNC_WORD_LENGTH // PCM_WORD_LENGTH) assert len(result["words"]) == expected_data_words def test_word_positions_are_one_indexed(self): """All word positions should be 1-indexed, starting at 5.""" frame = _make_test_frame() engine = DemuxEngine() result = engine.process_frame(frame) positions = [w["position"] for w in result["words"]] assert positions[0] == 5 assert positions[-1] == PCM_HIGH_WORDS_PER_FRAME def test_raw_frame_passthrough(self): """The raw_frame field should contain the original frame bytes.""" frame = _make_test_frame(fill_value=0x42) engine = DemuxEngine() result = engine.process_frame(frame) assert result["raw_frame"] == frame def test_metadata_passthrough(self): """Metadata from the frame sync should be passed through.""" frame = _make_test_frame() engine = DemuxEngine() meta = {"frame_id": 7, "odd_frame": True} result = engine.process_frame(frame, meta=meta) assert result["meta"]["frame_id"] == 7 assert result["meta"]["odd_frame"] is True class TestADVoltageScaling: """Test A/D converter voltage scaling (section 5.3).""" def test_scaled_format_includes_voltage(self): """With output_format='scaled', words should include voltage field.""" frame = _make_test_frame(fill_value=128) engine = DemuxEngine(output_format="scaled") result = engine.process_frame(frame) assert "voltage" in result["words"][0] def test_raw_format_no_voltage(self): """With output_format='raw', words should NOT have voltage field.""" frame = _make_test_frame(fill_value=128) engine = DemuxEngine(output_format="raw") result = engine.process_frame(frame) assert "voltage" not in result["words"][0] def test_voltage_zero_code(self): """ADC code 1 should map to 0V.""" frame = _make_test_frame(fill_value=1) engine = DemuxEngine(output_format="scaled") result = engine.process_frame(frame) assert result["words"][0]["voltage"] == 0.0 def test_voltage_fullscale(self): """ADC code 254 should map to 4.98V.""" frame = _make_test_frame(fill_value=254) engine = DemuxEngine(output_format="scaled") result = engine.process_frame(frame) assert abs(result["words"][0]["voltage"] - 4.98) < 0.001 def test_voltage_midscale(self): """ADC code ~128 should be roughly 2.5V.""" frame = _make_test_frame(fill_value=128) engine = DemuxEngine(output_format="scaled") result = engine.process_frame(frame) v = result["words"][0]["voltage"] assert abs(v - 2.5) < 0.1 def test_voltage_consistency_with_protocol(self): """Voltage values should match protocol.adc_to_voltage.""" frame = _make_test_frame(fill_value=200) engine = DemuxEngine(output_format="scaled") result = engine.process_frame(frame) expected = adc_to_voltage(200) assert result["words"][0]["voltage"] == expected def test_low_level_voltage_included(self): """Scaled format should also include low-level voltage.""" frame = _make_test_frame(fill_value=128) engine = DemuxEngine(output_format="scaled") result = engine.process_frame(frame) assert "voltage_low_level" in result["words"][0] expected = adc_to_voltage(128, low_level=True) assert result["words"][0]["voltage_low_level"] == expected class TestAGCChannelExtraction: """Test extraction of AGC downlink channels (34, 35, 57).""" def test_agc_channels_extracted(self): """AGC channel words should appear in agc_data output.""" frame = bytearray(_make_test_frame(fill_value=0x00)) # Place known values at AGC word positions frame[33] = 0xAA # word 34 (ch 34, DNTM1) frame[34] = 0xBB # word 35 (ch 35, DNTM2) frame[56] = 0xCC # word 57 (ch 57, OUTLINK) frame = bytes(frame) engine = DemuxEngine() result = engine.process_frame(frame) agc = result["agc_data"] assert len(agc) == 3 # Sort by channel for predictable order agc_by_ch = {a["channel"]: a for a in agc} assert agc_by_ch[AGC_CH_DNTM1]["raw_value"] == 0xAA assert agc_by_ch[AGC_CH_DNTM2]["raw_value"] == 0xBB assert agc_by_ch[AGC_CH_OUTLINK]["raw_value"] == 0xCC def test_agc_word_positions_correct(self): """AGC entries should have correct 1-indexed word positions.""" frame = _make_test_frame() engine = DemuxEngine() result = engine.process_frame(frame) for agc in result["agc_data"]: ch = agc["channel"] # Check word position matches expected 0-indexed + 1 expected_positions = [p + 1 for p in AGC_WORD_POSITIONS[ch]] assert agc["word_position"] in expected_positions def test_agc_voltage_scaling_when_enabled(self): """AGC data should include voltage when format is 'scaled'.""" frame = bytearray(_make_test_frame(fill_value=0x00)) frame[33] = 200 # DNTM1 frame = bytes(frame) engine = DemuxEngine(output_format="scaled") result = engine.process_frame(frame) dntm1_entries = [a for a in result["agc_data"] if a["channel"] == AGC_CH_DNTM1] assert len(dntm1_entries) == 1 assert "voltage" in dntm1_entries[0] assert dntm1_entries[0]["voltage"] == adc_to_voltage(200) class TestInvalidInput: """Test error handling for bad input.""" def test_short_frame_rejected(self): """Frame shorter than words_per_frame should raise ValueError.""" engine = DemuxEngine() with pytest.raises(ValueError, match="Frame too short"): engine.process_frame(b"\x00" * 10) def test_invalid_output_format(self): """Invalid output_format should raise ValueError.""" with pytest.raises(ValueError, match="Invalid output_format"): DemuxEngine(output_format="bogus") def test_extract_word_out_of_range(self): """Word position outside 1-128 should raise ValueError.""" engine = DemuxEngine() frame = _make_test_frame() with pytest.raises(ValueError): engine.extract_word(frame, word_position=0) with pytest.raises(ValueError): engine.extract_word(frame, word_position=129)