Migrates all 43 sim modules and 23 test files (~7.8k LOC, 686 tests) from .worktrees/sim-framework/ into pm3py/sim/. Import fixes: - 4 files: ..protocol/..transport → ..core.protocol/..core.transport - trace_fmt.py: pm3py.hf_15 → pm3py.trace.ndef - test_sim_pm3medium.py: flat imports → core.* - test_sim_trace_fmt.py: flat imports → core.* - Added sim Cmd entries to core/protocol.py (EML_SETMEM, SIM_TABLE_*) 751 tests passing (686 sim + 65 core). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
168 lines
5.8 KiB
Python
168 lines
5.8 KiB
Python
"""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
|