"""Tests for pm3py.sim.frame — RFFrame with bit-level granularity.""" import pytest from bitarray import bitarray from pm3py.sim.frame import RFFrame class TestRFFrameConstruction: """Test RFFrame creation from various inputs.""" def test_from_bytes_creates_frame_with_correct_bits(self): frame = RFFrame.from_bytes(b"\xA5") assert frame.bit_count == 8 assert frame.data == b"\xA5" def test_from_bytes_multi_byte(self): frame = RFFrame.from_bytes(b"\x01\x02\x03") assert frame.bit_count == 24 assert frame.data == b"\x01\x02\x03" def test_from_bytes_empty(self): frame = RFFrame.from_bytes(b"") assert frame.bit_count == 0 assert frame.data == b"" def test_from_hex_creates_frame(self): frame = RFFrame.from_hex("A5B6") assert frame.bit_count == 16 assert frame.data == b"\xA5\xB6" def test_from_hex_case_insensitive(self): frame = RFFrame.from_hex("a5b6") assert frame.data == b"\xA5\xB6" def test_from_bits_with_partial_byte(self): """7-bit NVB response during anticollision.""" bits = bitarray("1010101") frame = RFFrame(bits=bits, bit_count=7) assert frame.bit_count == 7 # data property truncates to full bytes assert frame.data == b"" def test_from_bits_with_full_and_partial(self): """12 bits: 8 full + 4 partial.""" bits = bitarray("101010110011") frame = RFFrame(bits=bits, bit_count=12) assert frame.bit_count == 12 assert frame.data == b"\xAB" # first 8 bits = 10101011 class TestRFFrameImmutability: """RFFrame should be frozen/immutable.""" def test_cannot_set_attributes(self): frame = RFFrame.from_bytes(b"\x00") with pytest.raises(AttributeError): frame.bit_count = 99 def test_cannot_set_bits(self): frame = RFFrame.from_bytes(b"\x00") with pytest.raises(AttributeError): frame.bits = bitarray("11111111") class TestRFFrameOptionalFields: """Test optional fields: parity, crc, collision_positions, timestamp.""" def test_defaults_are_none_and_zero(self): frame = RFFrame.from_bytes(b"\x00") assert frame.parity is None assert frame.crc is None assert frame.collision_positions is None assert frame.timestamp_us == 0 def test_with_parity(self): parity = bitarray("1") frame = RFFrame.from_bytes(b"\xA5", parity=parity) assert frame.parity == parity def test_with_crc(self): frame = RFFrame.from_bytes(b"\xA5", crc=b"\x12\x34") assert frame.crc == b"\x12\x34" def test_with_collision_positions(self): frame = RFFrame.from_bytes(b"\xA5", collision_positions=[2, 5]) assert frame.collision_positions == [2, 5] def test_with_timestamp(self): frame = RFFrame.from_bytes(b"\xA5", timestamp_us=12345) assert frame.timestamp_us == 12345 def test_has_collision_property(self): clean = RFFrame.from_bytes(b"\xA5") assert not clean.has_collision collided = RFFrame.from_bytes(b"\xA5", collision_positions=[3]) assert collided.has_collision class TestRFFrameCollisionMerge: """Test bit-level collision merging of multiple frames.""" def test_merge_identical_frames_no_collision(self): f1 = RFFrame.from_bytes(b"\xA5") f2 = RFFrame.from_bytes(b"\xA5") merged = RFFrame.merge([f1, f2]) assert merged.data == b"\xA5" assert merged.collision_positions == [] def test_merge_different_frames_detects_collision(self): f1 = RFFrame.from_bytes(b"\xFF") # 11111111 f2 = RFFrame.from_bytes(b"\x00") # 00000000 merged = RFFrame.merge([f1, f2]) # All 8 bit positions should have collisions assert len(merged.collision_positions) == 8 def test_merge_partial_difference(self): f1 = RFFrame.from_hex("A0") # 10100000 f2 = RFFrame.from_hex("A5") # 10100101 merged = RFFrame.merge([f1, f2]) # Bits 5 and 7 differ (0-indexed from MSB: positions 5 and 7) assert merged.collision_positions is not None assert len(merged.collision_positions) == 2 def test_merge_single_frame_returns_copy(self): f1 = RFFrame.from_bytes(b"\xAB") merged = RFFrame.merge([f1]) assert merged.data == b"\xAB" assert merged.collision_positions == [] def test_merge_empty_list_returns_none(self): result = RFFrame.merge([]) assert result is None def test_merge_three_frames(self): f1 = RFFrame.from_hex("FF") # 11111111 f2 = RFFrame.from_hex("FE") # 11111110 f3 = RFFrame.from_hex("FF") # 11111111 merged = RFFrame.merge([f1, f2, f3]) # Only bit 7 (LSB) has collision (1 vs 0 vs 1) assert len(merged.collision_positions) == 1 def test_merge_different_lengths(self): """Shorter frame treated as not responding for extra bits.""" short = RFFrame(bits=bitarray("1010"), bit_count=4) long = RFFrame.from_bytes(b"\xAB") # 10101011, 8 bits merged = RFFrame.merge([short, long]) # First 4 bits: both respond, check for collisions # Bits 4-7: only long responds, no collision assert merged.bit_count == 8 def test_collision_bit_defaults_to_one(self): """Per ISO 14443-A: collision defaults to 1 (Manchester).""" f1 = RFFrame.from_bytes(b"\x00") # 00000000 f2 = RFFrame.from_bytes(b"\xFF") # 11111111 merged = RFFrame.merge([f1, f2]) # All collision bits should be 1 assert all(merged.bits[i] == 1 for i in merged.collision_positions) class TestRFFrameRepr: """Test string representation.""" def test_repr_shows_hex(self): frame = RFFrame.from_hex("A5B6") r = repr(frame) assert "a5b6" in r.lower() or "A5B6" in r