Step 2 - LDF Loading: - ldfparser integration (Python) / custom regex parser (C++) - QTreeWidget with expandable signal rows, merged Value column - Hex/Dec toggle, FreeFormat schedule entries, auto-reload - Baud rate auto-detection from LDF Step 3 - Signal Editing: - Bit packing/unpacking (signal value ↔ frame bytes) - ReadOnlyColumnDelegate for per-column editability - Value clamping to signal width, recursion guard Step 4 - Rx Panel: - receive_rx_frame() API with timestamp, signal unpacking - Change highlighting (yellow), auto-scroll toggle, clear button - Dashboard view (in-place update per frame_id) Step 5 - Connection Panel: - ConnectionManager with state machine (Disconnected/Connecting/Connected/Error) - Port scanning (pyserial / QSerialPort), connect/disconnect with UI mapping Step 6 - BabyLIN Backend: - BabyLinBackend wrapping Lipowsky BabyLIN_library.py DLL - Mock mode for macOS/CI, device scan, SDF loading, signal access - Frame callbacks, raw command access Step 7 - Master Scheduler: - QTimer-based schedule execution with start/stop/pause - Frame sent callback with visual highlighting - Mock Rx simulation, manual send, global rate override Tests: Python 171 | C++ 124 (Steps 1-5 parity, Steps 6-7 Python-first) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
176 lines
6.2 KiB
Python
176 lines
6.2 KiB
Python
"""
|
|
test_signal_editing.py — Tests for Step 3: signal ↔ frame byte sync.
|
|
|
|
Tests bit packing/unpacking: when a signal value changes, frame bytes
|
|
update, and vice versa.
|
|
"""
|
|
|
|
import sys
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
|
|
|
from PyQt6.QtWidgets import QApplication
|
|
from PyQt6.QtCore import Qt
|
|
|
|
SAMPLE_LDF = str(Path(__file__).parent.parent.parent / "resources" / "sample.ldf")
|
|
|
|
|
|
@pytest.fixture(scope="session")
|
|
def app():
|
|
application = QApplication.instance() or QApplication(sys.argv)
|
|
yield application
|
|
|
|
|
|
@pytest.fixture
|
|
def window(app):
|
|
from main_window import MainWindow
|
|
w = MainWindow()
|
|
w._load_ldf_file(SAMPLE_LDF)
|
|
return w
|
|
|
|
|
|
class TestBitPacking:
|
|
"""Test the static bit pack/unpack methods."""
|
|
|
|
def test_pack_single_bit(self, window):
|
|
"""Pack a 1-bit signal at bit 0."""
|
|
buf = [0, 0]
|
|
window._pack_signal(buf, bit_offset=0, width=1, value=1)
|
|
assert buf == [0x01, 0]
|
|
|
|
def test_pack_byte_at_offset_8(self, window):
|
|
"""Pack an 8-bit signal at bit 8 (byte 1)."""
|
|
buf = [0, 0]
|
|
window._pack_signal(buf, bit_offset=8, width=8, value=0x80)
|
|
assert buf == [0, 0x80]
|
|
|
|
def test_pack_2bit_at_offset_1(self, window):
|
|
"""Pack a 2-bit signal at bit 1."""
|
|
buf = [0, 0]
|
|
window._pack_signal(buf, bit_offset=1, width=2, value=3)
|
|
# value 3 = 0b11, at bits 1-2 → byte 0 = 0b00000110 = 0x06
|
|
assert buf[0] == 0x06
|
|
|
|
def test_pack_multiple_signals(self, window):
|
|
"""Pack multiple signals without overwriting each other."""
|
|
buf = [0, 0]
|
|
# MotorEnable: bit 0, width 1, value 1
|
|
window._pack_signal(buf, 0, 1, 1)
|
|
# MotorDirection: bit 1, width 2, value 2
|
|
window._pack_signal(buf, 1, 2, 2)
|
|
# MotorSpeed: bit 8, width 8, value 128
|
|
window._pack_signal(buf, 8, 8, 128)
|
|
# byte 0: bit0=1 (Enable), bits1-2=10 (Dir=2) → 0b00000101 = 0x05
|
|
assert buf[0] == 0x05
|
|
assert buf[1] == 0x80 # 128
|
|
|
|
def test_extract_single_bit(self, window):
|
|
buf = [0x01, 0]
|
|
val = window._extract_signal(buf, bit_offset=0, width=1)
|
|
assert val == 1
|
|
|
|
def test_extract_byte_at_offset_8(self, window):
|
|
buf = [0, 0x80]
|
|
val = window._extract_signal(buf, bit_offset=8, width=8)
|
|
assert val == 0x80
|
|
|
|
def test_extract_2bit_at_offset_1(self, window):
|
|
buf = [0x06, 0] # 0b00000110 → bits 1-2 = 0b11 = 3
|
|
val = window._extract_signal(buf, bit_offset=1, width=2)
|
|
assert val == 3
|
|
|
|
def test_pack_then_extract_roundtrip(self, window):
|
|
"""Pack a value then extract it — should get the same value."""
|
|
buf = [0, 0]
|
|
window._pack_signal(buf, 8, 8, 200)
|
|
result = window._extract_signal(buf, 8, 8)
|
|
assert result == 200
|
|
|
|
|
|
class TestSignalValueEditing:
|
|
"""Test that editing a signal value updates frame bytes."""
|
|
|
|
def test_edit_signal_updates_frame_bytes(self, window):
|
|
"""Changing MotorSpeed signal should update frame bytes."""
|
|
frame_item = window.tx_table.topLevelItem(0) # Motor_Command
|
|
speed_sig = frame_item.child(2) # MotorSpeed (bit 8, width 8)
|
|
|
|
# Simulate user edit: change the text in Value column
|
|
# This triggers itemChanged → _on_tx_item_changed → repack
|
|
speed_sig.setText(4, "128")
|
|
|
|
# Frame bytes should now reflect MotorSpeed=128
|
|
frame_data = frame_item.data(0, Qt.ItemDataRole.UserRole)
|
|
assert frame_data['bytes'][1] == 128 # byte 1 = 0x80
|
|
|
|
def test_edit_signal_preserves_other_signals(self, window):
|
|
"""Changing one signal shouldn't affect other signals in the same frame."""
|
|
frame_item = window.tx_table.topLevelItem(0) # Motor_Command
|
|
enable_sig = frame_item.child(0) # MotorEnable (bit 0, width 1)
|
|
speed_sig = frame_item.child(2) # MotorSpeed (bit 8, width 8)
|
|
|
|
# Set MotorEnable = 1 via text edit
|
|
enable_sig.setText(4, "1")
|
|
|
|
# Set MotorSpeed = 255 via text edit
|
|
speed_sig.setText(4, "255")
|
|
|
|
# Both should be preserved
|
|
frame_data = frame_item.data(0, Qt.ItemDataRole.UserRole)
|
|
assert frame_data['bytes'][0] & 0x01 == 1 # MotorEnable still 1
|
|
assert frame_data['bytes'][1] == 255 # MotorSpeed = 255
|
|
|
|
def test_signal_value_clamped_to_width(self, window):
|
|
"""Signal value should be clamped to max for its width."""
|
|
frame_item = window.tx_table.topLevelItem(0)
|
|
enable_sig = frame_item.child(0) # MotorEnable: width 1, max = 1
|
|
|
|
# Try to set value 5 on a 1-bit signal
|
|
enable_sig.setText(4, "5")
|
|
window._on_tx_item_changed(enable_sig, 4)
|
|
|
|
# Should be clamped to 1 (max for 1-bit)
|
|
stored = enable_sig.data(4, Qt.ItemDataRole.UserRole)
|
|
assert stored <= 1
|
|
|
|
|
|
class TestFrameValueEditing:
|
|
"""Test that editing frame bytes updates signal values."""
|
|
|
|
def test_edit_frame_bytes_updates_signals(self, window):
|
|
"""Changing frame bytes should unpack to signal children."""
|
|
frame_item = window.tx_table.topLevelItem(0) # Motor_Command
|
|
|
|
# Edit frame bytes to "05 C8" (hex mode)
|
|
frame_item.setText(4, "05 C8")
|
|
window._on_tx_item_changed(frame_item, 4)
|
|
|
|
# MotorEnable (bit 0, width 1): byte0 & 0x01 = 1
|
|
enable_val = frame_item.child(0).data(4, Qt.ItemDataRole.UserRole)
|
|
assert enable_val == 1
|
|
|
|
# MotorDirection (bit 1, width 2): (byte0 >> 1) & 0x03 = 2
|
|
dir_val = frame_item.child(1).data(4, Qt.ItemDataRole.UserRole)
|
|
assert dir_val == 2
|
|
|
|
# MotorSpeed (bit 8, width 8): byte1 = 0xC8 = 200
|
|
speed_val = frame_item.child(2).data(4, Qt.ItemDataRole.UserRole)
|
|
assert speed_val == 200
|
|
|
|
def test_edit_frame_bytes_invalid_reverts(self, window):
|
|
"""Invalid byte input should revert to previous values."""
|
|
frame_item = window.tx_table.topLevelItem(0)
|
|
|
|
# Store current bytes
|
|
old_data = frame_item.data(0, Qt.ItemDataRole.UserRole)['bytes'][:]
|
|
|
|
# Enter invalid text
|
|
frame_item.setText(4, "not valid hex")
|
|
window._on_tx_item_changed(frame_item, 4)
|
|
|
|
# Bytes should be unchanged
|
|
new_data = frame_item.data(0, Qt.ItemDataRole.UserRole)['bytes']
|
|
assert new_data == old_data
|