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>
229 lines
8.6 KiB
Python
229 lines
8.6 KiB
Python
"""
|
|
test_ldf_loading.py — Tests for LDF loading integration with the GUI.
|
|
|
|
Tests that the MainWindow correctly populates its tables and widgets
|
|
when an LDF file is loaded. This is the GUI integration layer —
|
|
ldf_handler parsing is tested separately in test_ldf_handler.py.
|
|
"""
|
|
|
|
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()
|
|
yield w
|
|
w.close()
|
|
|
|
|
|
@pytest.fixture
|
|
def loaded_window(window):
|
|
"""A MainWindow with the sample LDF already loaded."""
|
|
window._load_ldf_file(SAMPLE_LDF)
|
|
return window
|
|
|
|
|
|
class TestLdfLoading:
|
|
"""Test that loading an LDF updates the GUI correctly."""
|
|
|
|
def test_ldf_path_shown(self, loaded_window):
|
|
assert SAMPLE_LDF in loaded_window.ldf_path_edit.text()
|
|
|
|
def test_baud_rate_updated(self, loaded_window):
|
|
assert "19200" in loaded_window.lbl_baud_rate.text()
|
|
|
|
def test_ldf_data_stored(self, loaded_window):
|
|
assert loaded_window._ldf_data is not None
|
|
assert loaded_window._ldf_data.baudrate == 19200
|
|
|
|
|
|
class TestTxTablePopulation:
|
|
"""Test that Tx tree is filled with master frames and signals."""
|
|
|
|
def test_tx_frame_count(self, loaded_window):
|
|
"""Should have 2 master Tx frames as top-level items."""
|
|
assert loaded_window.tx_table.topLevelItemCount() == 2
|
|
|
|
def test_tx_frame_names(self, loaded_window):
|
|
names = []
|
|
for i in range(loaded_window.tx_table.topLevelItemCount()):
|
|
names.append(loaded_window.tx_table.topLevelItem(i).text(0))
|
|
assert "Motor_Command" in names
|
|
assert "Door_Command" in names
|
|
|
|
def test_tx_frame_ids(self, loaded_window):
|
|
ids = []
|
|
for i in range(loaded_window.tx_table.topLevelItemCount()):
|
|
ids.append(loaded_window.tx_table.topLevelItem(i).text(1))
|
|
assert "0x10" in ids
|
|
assert "0x11" in ids
|
|
|
|
def test_tx_frame_lengths(self, loaded_window):
|
|
for i in range(loaded_window.tx_table.topLevelItemCount()):
|
|
length = loaded_window.tx_table.topLevelItem(i).text(2)
|
|
assert length == "2"
|
|
|
|
def test_tx_value_column_shows_bytes(self, loaded_window):
|
|
"""Value column (col 4) should show frame bytes in hex mode (default)."""
|
|
for i in range(loaded_window.tx_table.topLevelItemCount()):
|
|
val = loaded_window.tx_table.topLevelItem(i).text(4)
|
|
assert val == "00 00" # 2 zero bytes in hex
|
|
|
|
def test_tx_signals_as_children(self, loaded_window):
|
|
"""Each frame should have signal children that can be expanded."""
|
|
# Motor_Command has 3 signals: MotorEnable, MotorDirection, MotorSpeed
|
|
item = loaded_window.tx_table.topLevelItem(0)
|
|
assert item.childCount() >= 2 # At least 2 signals per frame
|
|
|
|
def test_tx_signal_names(self, loaded_window):
|
|
"""Signal children should show signal names."""
|
|
item = loaded_window.tx_table.topLevelItem(0)
|
|
sig_names = [item.child(j).text(0).strip() for j in range(item.childCount())]
|
|
# Check that at least one known signal is present
|
|
all_names = " ".join(sig_names)
|
|
assert "Motor" in all_names or "Door" in all_names
|
|
|
|
def test_tx_interval_from_schedule(self, loaded_window):
|
|
"""Per-frame interval should be auto-filled from the first schedule table."""
|
|
intervals = []
|
|
for i in range(loaded_window.tx_table.topLevelItemCount()):
|
|
intervals.append(loaded_window.tx_table.topLevelItem(i).text(3))
|
|
assert "10" in intervals
|
|
|
|
|
|
class TestRxTablePopulation:
|
|
"""Test that Rx tree is prepared with slave frame info and signals."""
|
|
|
|
def test_rx_frame_count(self, loaded_window):
|
|
"""Should have 2 slave Rx frames as top-level items."""
|
|
assert loaded_window.rx_table.topLevelItemCount() == 2
|
|
|
|
def test_rx_frame_names(self, loaded_window):
|
|
names = []
|
|
for i in range(loaded_window.rx_table.topLevelItemCount()):
|
|
names.append(loaded_window.rx_table.topLevelItem(i).text(1))
|
|
assert "Motor_Status" in names
|
|
assert "Door_Status" in names
|
|
|
|
def test_rx_frame_ids(self, loaded_window):
|
|
ids = []
|
|
for i in range(loaded_window.rx_table.topLevelItemCount()):
|
|
ids.append(loaded_window.rx_table.topLevelItem(i).text(2))
|
|
assert "0x20" in ids
|
|
assert "0x21" in ids
|
|
|
|
def test_rx_timestamp_placeholder(self, loaded_window):
|
|
"""Timestamp should show placeholder until real data arrives."""
|
|
for i in range(loaded_window.rx_table.topLevelItemCount()):
|
|
ts = loaded_window.rx_table.topLevelItem(i).text(0)
|
|
assert ts == "—"
|
|
|
|
def test_rx_signals_as_children(self, loaded_window):
|
|
"""Each Rx frame should have signal children."""
|
|
item = loaded_window.rx_table.topLevelItem(0)
|
|
assert item.childCount() >= 1
|
|
|
|
|
|
class TestHexDecToggle:
|
|
"""Test the hex/dec display toggle."""
|
|
|
|
def test_hex_mode_default(self, loaded_window):
|
|
"""Default mode is hex."""
|
|
assert loaded_window.chk_hex_mode.isChecked()
|
|
|
|
def test_signal_value_hex_format(self, loaded_window):
|
|
"""Signal values should display in hex when hex mode is on."""
|
|
item = loaded_window.tx_table.topLevelItem(0)
|
|
sig = item.child(0)
|
|
val = sig.text(4)
|
|
assert val.startswith("0x")
|
|
|
|
def test_signal_value_dec_format(self, loaded_window):
|
|
"""Switching to dec mode should show decimal values."""
|
|
loaded_window.chk_hex_mode.setChecked(False)
|
|
item = loaded_window.tx_table.topLevelItem(0)
|
|
sig = item.child(0)
|
|
val = sig.text(4)
|
|
assert not val.startswith("0x")
|
|
# Restore hex mode
|
|
loaded_window.chk_hex_mode.setChecked(True)
|
|
|
|
def test_frame_value_hex_format(self, loaded_window):
|
|
"""Frame value should show hex bytes like '00 00'."""
|
|
item = loaded_window.tx_table.topLevelItem(0)
|
|
val = item.text(4)
|
|
# Hex format: each byte is 2 uppercase hex chars
|
|
assert all(len(b) == 2 for b in val.split())
|
|
|
|
|
|
class TestScheduleCombo:
|
|
"""Test schedule table dropdown population."""
|
|
|
|
def test_schedule_count(self, loaded_window):
|
|
assert loaded_window.combo_schedule.count() == 2
|
|
|
|
def test_schedule_names(self, loaded_window):
|
|
items = [loaded_window.combo_schedule.itemText(i)
|
|
for i in range(loaded_window.combo_schedule.count())]
|
|
assert "NormalSchedule" in items
|
|
assert "FastSchedule" in items
|
|
|
|
|
|
class TestErrorHandling:
|
|
"""Test that invalid files are handled gracefully."""
|
|
|
|
def test_invalid_file_no_crash(self, window, tmp_path, monkeypatch):
|
|
"""Loading an invalid file should not crash — show error dialog."""
|
|
bad_file = tmp_path / "bad.ldf"
|
|
bad_file.write_text("not valid")
|
|
# Monkeypatch QMessageBox.critical to avoid a modal dialog blocking the test.
|
|
# monkeypatch is a pytest fixture that temporarily replaces functions.
|
|
from PyQt6.QtWidgets import QMessageBox
|
|
monkeypatch.setattr(QMessageBox, "critical", lambda *args, **kwargs: None)
|
|
# Should not raise
|
|
window._load_ldf_file(str(bad_file))
|
|
# Tables should remain empty
|
|
assert window.tx_table.topLevelItemCount() == 0
|
|
|
|
def test_reload_clears_previous(self, loaded_window):
|
|
"""Loading a new file should clear previous data."""
|
|
# First load happened in fixture — 2 Tx frames
|
|
assert loaded_window.tx_table.topLevelItemCount() == 2
|
|
# Load the same file again — should still be 2 (not 4)
|
|
loaded_window._load_ldf_file(SAMPLE_LDF)
|
|
assert loaded_window.tx_table.topLevelItemCount() == 2
|
|
|
|
|
|
class TestAutoReload:
|
|
"""Test the file watcher setup."""
|
|
|
|
def test_file_watcher_active(self, loaded_window):
|
|
"""After loading, the file should be watched."""
|
|
watched = loaded_window._file_watcher.files()
|
|
assert len(watched) > 0
|
|
assert SAMPLE_LDF in watched
|
|
|
|
def test_auto_reload_checkbox_controls_reload(self, loaded_window):
|
|
"""When auto-reload is unchecked, file changes should not reload."""
|
|
loaded_window.chk_auto_reload.setChecked(False)
|
|
# Simulate file change signal
|
|
loaded_window._on_ldf_file_changed(SAMPLE_LDF)
|
|
# Should still work (no crash), data stays the same
|
|
assert loaded_window._ldf_data is not None
|