feat: implement slot_error_boundary decorator and add unit tests for session and device controllers
This commit is contained in:
@@ -12,6 +12,7 @@ from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
|||||||
from pygui.backend.controllers.session_controller import SessionController
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
from pygui.backend.data.local_settings import LocalSettings
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
from pygui.backend.models.data_model import TemperatureTableModel
|
from pygui.backend.models.data_model import TemperatureTableModel
|
||||||
|
from pygui.backend.utils import slot_error_boundary
|
||||||
from pygui.backend.visualization.graph_view import GraphView
|
from pygui.backend.visualization.graph_view import GraphView
|
||||||
from pygui.serialcomm.data_parser import (
|
from pygui.serialcomm.data_parser import (
|
||||||
convert_to_temperatures,
|
convert_to_temperatures,
|
||||||
@@ -116,6 +117,7 @@ class DeviceController(QObject):
|
|||||||
return self._save_data_dir
|
return self._save_data_dir
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def setSaveDataDir(self, path: str) -> None:
|
def setSaveDataDir(self, path: str) -> None:
|
||||||
"""Set the directory for saving parsed CSV data files."""
|
"""Set the directory for saving parsed CSV data files."""
|
||||||
self._save_data_dir = path
|
self._save_data_dir = path
|
||||||
@@ -128,6 +130,7 @@ class DeviceController(QObject):
|
|||||||
return self._selected_port
|
return self._selected_port
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def setSelectedPort(self, port: str) -> None:
|
def setSelectedPort(self, port: str) -> None:
|
||||||
"""Set the active serial port from the port selector."""
|
"""Set the active serial port from the port selector."""
|
||||||
self._selected_port = port
|
self._selected_port = port
|
||||||
@@ -170,6 +173,7 @@ class DeviceController(QObject):
|
|||||||
return self._graph_view
|
return self._graph_view
|
||||||
|
|
||||||
@Slot(result=object)
|
@Slot(result=object)
|
||||||
|
@slot_error_boundary
|
||||||
def getChartData(self) -> dict[str, Any]:
|
def getChartData(self) -> dict[str, Any]:
|
||||||
"""Extract chart-ready data from the current model.
|
"""Extract chart-ready data from the current model.
|
||||||
|
|
||||||
@@ -208,6 +212,7 @@ class DeviceController(QObject):
|
|||||||
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
self.activityLogUpdated.emit("\n".join(self._activity_log))
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def clearActivityLog(self) -> None:
|
def clearActivityLog(self) -> None:
|
||||||
"""Clear the activity log."""
|
"""Clear the activity log."""
|
||||||
self._activity_log.clear()
|
self._activity_log.clear()
|
||||||
@@ -215,6 +220,7 @@ class DeviceController(QObject):
|
|||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def clearSession(self) -> None:
|
def clearSession(self) -> None:
|
||||||
"""Clear the current session state, allowing a clean detect."""
|
"""Clear the current session state, allowing a clean detect."""
|
||||||
self._connection_status = "Disconnected"
|
self._connection_status = "Disconnected"
|
||||||
@@ -242,6 +248,7 @@ class DeviceController(QObject):
|
|||||||
# ---- Public Slots ----
|
# ---- Public Slots ----
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def refreshPorts(self) -> None:
|
def refreshPorts(self) -> None:
|
||||||
"""Scan for available serial ports and emit updated list."""
|
"""Scan for available serial ports and emit updated list."""
|
||||||
ports = self._service.enumerate_ports()
|
ports = self._service.enumerate_ports()
|
||||||
@@ -251,6 +258,7 @@ class DeviceController(QObject):
|
|||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def detectWafer(self) -> None:
|
def detectWafer(self) -> None:
|
||||||
"""Scan all serial ports for a wafer (runs in background thread).
|
"""Scan all serial ports for a wafer (runs in background thread).
|
||||||
|
|
||||||
@@ -280,6 +288,7 @@ class DeviceController(QObject):
|
|||||||
self._detectFinished.emit(port, info)
|
self._detectFinished.emit(port, info)
|
||||||
|
|
||||||
@Slot(object, object)
|
@Slot(object, object)
|
||||||
|
@slot_error_boundary
|
||||||
def _handle_detect_finished(
|
def _handle_detect_finished(
|
||||||
self, port: Optional[str], info: Optional[WaferInfo]
|
self, port: Optional[str], info: Optional[WaferInfo]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -304,6 +313,7 @@ class DeviceController(QObject):
|
|||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def readMemoryAsync(self) -> None:
|
def readMemoryAsync(self) -> None:
|
||||||
"""Read wafer memory in a background thread.
|
"""Read wafer memory in a background thread.
|
||||||
|
|
||||||
@@ -346,6 +356,7 @@ class DeviceController(QObject):
|
|||||||
self._readFinished.emit(port, family_code, data)
|
self._readFinished.emit(port, family_code, data)
|
||||||
|
|
||||||
@Slot(str, str, object)
|
@Slot(str, str, object)
|
||||||
|
@slot_error_boundary
|
||||||
def _handle_read_finished(
|
def _handle_read_finished(
|
||||||
self, port: str, family_code: str, data: Optional[bytes]
|
self, port: str, family_code: str, data: Optional[bytes]
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -367,6 +378,7 @@ class DeviceController(QObject):
|
|||||||
self._save_status()
|
self._save_status()
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def eraseMemory(self, port: str) -> None:
|
def eraseMemory(self, port: str) -> None:
|
||||||
"""Send p1 erase command."""
|
"""Send p1 erase command."""
|
||||||
self._set_operation_progress(True)
|
self._set_operation_progress(True)
|
||||||
@@ -385,6 +397,7 @@ class DeviceController(QObject):
|
|||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def readDebug(self, port: str) -> None:
|
def readDebug(self, port: str) -> None:
|
||||||
"""Read both D1 (sensor) and F1 (debug/cold-junction) data.
|
"""Read both D1 (sensor) and F1 (debug/cold-junction) data.
|
||||||
|
|
||||||
@@ -428,6 +441,7 @@ class DeviceController(QObject):
|
|||||||
self._set_operation_progress(False)
|
self._set_operation_progress(False)
|
||||||
|
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
|
def parseAndSaveData(self, family_code: str = "", port: str = "") -> None:
|
||||||
"""Parse raw bytes into temperatures and save to CSV.
|
"""Parse raw bytes into temperatures and save to CSV.
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from pygui.backend.models.frame_player import FramePlayer, frames_from_wafer_dat
|
|||||||
from pygui.backend.models.sensor_editor import SensorEditor
|
from pygui.backend.models.sensor_editor import SensorEditor
|
||||||
from pygui.backend.models.session_model import SessionModel
|
from pygui.backend.models.session_model import SessionModel
|
||||||
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
from pygui.backend.models.threshold_classifier import ThresholdConfig
|
||||||
|
from pygui.backend.utils import slot_error_boundary
|
||||||
from pygui.backend.wafer.zwafer_models import Sensor
|
from pygui.backend.wafer.zwafer_models import Sensor
|
||||||
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
from pygui.serialcomm.stream_reader import StreamReader
|
from pygui.serialcomm.stream_reader import StreamReader
|
||||||
@@ -182,6 +183,7 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# ---- mode + thresholds ----
|
# ---- mode + thresholds ----
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def setMode(self, mode: str) -> None:
|
def setMode(self, mode: str) -> None:
|
||||||
if mode == self._mode:
|
if mode == self._mode:
|
||||||
return
|
return
|
||||||
@@ -191,6 +193,7 @@ class SessionController(QObject):
|
|||||||
self.modeChanged.emit()
|
self.modeChanged.emit()
|
||||||
|
|
||||||
@Slot(float, float, bool)
|
@Slot(float, float, bool)
|
||||||
|
@slot_error_boundary
|
||||||
def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
|
def setThresholds(self, set_point: float, margin: float, auto: bool) -> None:
|
||||||
self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
|
self._model.set_thresholds(ThresholdConfig(set_point, margin, auto))
|
||||||
if self._last: # re-band current frame in place
|
if self._last: # re-band current frame in place
|
||||||
@@ -198,6 +201,7 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# ---- review: file load + playback ----
|
# ---- review: file load + playback ----
|
||||||
@Slot(str)
|
@Slot(str)
|
||||||
|
@slot_error_boundary
|
||||||
def loadFile(self, file_path: str) -> None:
|
def loadFile(self, file_path: str) -> None:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -250,30 +254,37 @@ class SessionController(QObject):
|
|||||||
self._emit_current()
|
self._emit_current()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def play(self) -> None:
|
def play(self) -> None:
|
||||||
self._play_timer.start(self._next_interval_ms())
|
self._play_timer.start(self._next_interval_ms())
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
def pause(self) -> None: self._play_timer.stop()
|
@slot_error_boundary
|
||||||
|
def pause(self) -> None:
|
||||||
|
self._play_timer.stop()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self._play_timer.stop()
|
self._play_timer.stop()
|
||||||
self._player.seek(0)
|
self._player.seek(0)
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
def step(self, delta: int) -> None:
|
def step(self, delta: int) -> None:
|
||||||
self._play_timer.stop()
|
self._play_timer.stop()
|
||||||
self._player.step(delta)
|
self._player.step(delta)
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
def seek(self, i: int) -> None:
|
def seek(self, i: int) -> None:
|
||||||
self._player.seek(i)
|
self._player.seek(i)
|
||||||
self._emit_current()
|
self._emit_current()
|
||||||
|
|
||||||
@Slot(float)
|
@Slot(float)
|
||||||
|
@slot_error_boundary
|
||||||
def setSpeed(self, x: float) -> None:
|
def setSpeed(self, x: float) -> None:
|
||||||
self._speed = max(0.1, x)
|
self._speed = max(0.1, x)
|
||||||
if self._play_timer.isActive():
|
if self._play_timer.isActive():
|
||||||
@@ -325,6 +336,7 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# ---- live: stream start/stop ----
|
# ---- live: stream start/stop ----
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
def startStream(self, port: str, family_code: str = "") -> None:
|
def startStream(self, port: str, family_code: str = "") -> None:
|
||||||
|
|
||||||
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
from pygui.backend.wafer.wafer_layouts import load_layout_for_wafer_id
|
||||||
@@ -389,6 +401,7 @@ class SessionController(QObject):
|
|||||||
self.stateChanged.emit()
|
self.stateChanged.emit()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def stopStream(self) -> None:
|
def stopStream(self) -> None:
|
||||||
self._repaint_timer.stop()
|
self._repaint_timer.stop()
|
||||||
if self._reader:
|
if self._reader:
|
||||||
@@ -409,6 +422,7 @@ class SessionController(QObject):
|
|||||||
self.stopRecording()
|
self.stopRecording()
|
||||||
|
|
||||||
@Slot(object)
|
@Slot(object)
|
||||||
|
@slot_error_boundary
|
||||||
def _on_live_frame(self, frame: Frame) -> None:
|
def _on_live_frame(self, frame: Frame) -> None:
|
||||||
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
# Main thread (queued). Record raw, process edited; mark dirty for repaint.
|
||||||
self._last_raw_frame = frame
|
self._last_raw_frame = frame
|
||||||
@@ -428,35 +442,41 @@ class SessionController(QObject):
|
|||||||
|
|
||||||
# ---- recording ----
|
# ---- recording ----
|
||||||
@Slot(str, str)
|
@Slot(str, str)
|
||||||
|
@slot_error_boundary
|
||||||
def startRecording(self, path: str, serial: str = "") -> None:
|
def startRecording(self, path: str, serial: str = "") -> None:
|
||||||
self._recorder.start(path, self._sensors, serial)
|
self._recorder.start(path, self._sensors, serial)
|
||||||
self.recordingChanged.emit()
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def stopRecording(self) -> None:
|
def stopRecording(self) -> None:
|
||||||
if self._recorder.is_recording:
|
if self._recorder.is_recording:
|
||||||
self._recorder.stop()
|
self._recorder.stop()
|
||||||
self.recordingChanged.emit()
|
self.recordingChanged.emit()
|
||||||
|
|
||||||
@Slot(int, float)
|
@Slot(int, float)
|
||||||
|
@slot_error_boundary
|
||||||
def replaceSensor(self, index: int, value: float) -> None:
|
def replaceSensor(self, index: int, value: float) -> None:
|
||||||
"""Override sensor `index` to display `value` every frame."""
|
"""Override sensor `index` to display `value` every frame."""
|
||||||
self._sensor_editor.set_replacement(index, value)
|
self._sensor_editor.set_replacement(index, value)
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
@Slot(int, float)
|
@Slot(int, float)
|
||||||
|
@slot_error_boundary
|
||||||
def offsetSensor(self, index: int, delta: float) -> None:
|
def offsetSensor(self, index: int, delta: float) -> None:
|
||||||
"""Shift sensor `index` by `delta` every frame."""
|
"""Shift sensor `index` by `delta` every frame."""
|
||||||
self._sensor_editor.set_offset(index, delta)
|
self._sensor_editor.set_offset(index, delta)
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
@Slot(int)
|
@Slot(int)
|
||||||
|
@slot_error_boundary
|
||||||
def clearSensorEdit(self, index: int) -> None:
|
def clearSensorEdit(self, index: int) -> None:
|
||||||
"""Remove all overrides for sensor `index`."""
|
"""Remove all overrides for sensor `index`."""
|
||||||
self._sensor_editor.clear(index)
|
self._sensor_editor.clear(index)
|
||||||
self._reprocess_current()
|
self._reprocess_current()
|
||||||
|
|
||||||
@Slot()
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
def clearSensorEdits(self) -> None:
|
def clearSensorEdits(self) -> None:
|
||||||
"""Remove all sensor overrides."""
|
"""Remove all sensor overrides."""
|
||||||
self._sensor_editor.clear()
|
self._sensor_editor.clear()
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import logging
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
|
def slot_error_boundary(func):
|
||||||
|
"""Wrap a @Slot method so exceptions are logged + signalled to QML.
|
||||||
|
|
||||||
|
Usage (stack under @Slot):
|
||||||
|
@Slot()
|
||||||
|
@slot_error_boundary
|
||||||
|
def myMethod(self) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
On exception:
|
||||||
|
- Logs full traceback via logger.exception()
|
||||||
|
- Emits logMessage signal with human-readable error (if available)
|
||||||
|
- Returns None so QML never sees a crash
|
||||||
|
"""
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(self, *args, **kwargs):
|
||||||
|
try:
|
||||||
|
return func(self, *args, **kwargs)
|
||||||
|
except Exception as e:
|
||||||
|
logger = logging.getLogger(self.__class__.__module__)
|
||||||
|
logger.exception("Slot '%s' raised: %s", func.__name__, e)
|
||||||
|
if hasattr(self, "logMessage"):
|
||||||
|
self.logMessage.emit(f"Error in {func.__name__}: {str(e)}")
|
||||||
|
return None
|
||||||
|
return wrapper
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import pytest
|
||||||
|
import logging
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
|
from pygui.backend.data.local_settings import LocalSettings
|
||||||
|
from pygui.serialcomm.serial_port import WaferInfo
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_settings():
|
||||||
|
s = LocalSettings()
|
||||||
|
s.save_data_dir = ""
|
||||||
|
s.connection_status = "Disconnected"
|
||||||
|
s.activity_log = []
|
||||||
|
s.last_wafer_info = {}
|
||||||
|
return s
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def controller(mock_settings):
|
||||||
|
import tempfile
|
||||||
|
import shutil
|
||||||
|
temp_dir = tempfile.mkdtemp()
|
||||||
|
yield DeviceController(mock_settings, temp_dir)
|
||||||
|
shutil.rmtree(temp_dir)
|
||||||
|
|
||||||
|
def test_refresh_ports(controller):
|
||||||
|
controller._service.enumerate_ports = MagicMock(return_value=["COM1", "COM2"])
|
||||||
|
controller.refreshPorts()
|
||||||
|
assert "COM1" in controller.availablePorts
|
||||||
|
|
||||||
|
def test_detect_wafer_spawns_thread(controller):
|
||||||
|
with patch("threading.Thread") as mock_thread:
|
||||||
|
info = WaferInfo(
|
||||||
|
family_code="P",
|
||||||
|
serial_number="P00001",
|
||||||
|
sensor_count=244,
|
||||||
|
mfg_date_hex="12345678",
|
||||||
|
sensor_assigned_hex="01",
|
||||||
|
locked_hex="00",
|
||||||
|
runtime=100,
|
||||||
|
cycle_count=10
|
||||||
|
)
|
||||||
|
controller._service.detect_all_ports = MagicMock(
|
||||||
|
return_value=("COM1", info)
|
||||||
|
)
|
||||||
|
controller.detectWafer()
|
||||||
|
mock_thread.assert_called_once()
|
||||||
|
assert controller.operationInProgress
|
||||||
|
|
||||||
|
def test_read_memory_without_detect_return_error(controller):
|
||||||
|
emitted_result = None
|
||||||
|
def on_read_result(result):
|
||||||
|
nonlocal emitted_result
|
||||||
|
emitted_result = result
|
||||||
|
|
||||||
|
controller.readResult.connect(on_read_result)
|
||||||
|
controller.readMemoryAsync()
|
||||||
|
assert emitted_result is not None
|
||||||
|
assert "error" in emitted_result
|
||||||
|
|
||||||
|
def test_setters_and_properties(controller):
|
||||||
|
controller.setSelectedPort("COM3")
|
||||||
|
assert controller.selectedPort == "COM3"
|
||||||
|
|
||||||
|
controller.setSaveDataDir("some/custom/dir")
|
||||||
|
assert controller.saveDataDir == "some/custom/dir"
|
||||||
|
|
||||||
|
assert controller.connectionStatus == "Disconnected"
|
||||||
|
assert controller.dataRowCount == 0
|
||||||
|
assert controller.dataColCount == 0
|
||||||
|
assert isinstance(controller.lastWaferInfo, list)
|
||||||
|
assert controller.dataModel is not None
|
||||||
|
assert controller.graphView is not None
|
||||||
|
|
||||||
|
def test_clear_activity_log(controller):
|
||||||
|
controller._activity_log.append("[12:00:00] Some message")
|
||||||
|
assert len(controller._activity_log) > 0
|
||||||
|
controller.clearActivityLog()
|
||||||
|
assert len(controller._activity_log) == 0
|
||||||
|
|
||||||
|
def test_clear_session(controller):
|
||||||
|
controller._connection_status = "Connected"
|
||||||
|
controller._selected_port = "COM1"
|
||||||
|
controller._last_wafer_info = {"serialNumber": "P00001"}
|
||||||
|
|
||||||
|
controller.clearSession()
|
||||||
|
assert controller.connectionStatus == "Disconnected"
|
||||||
|
assert controller.selectedPort == ""
|
||||||
|
assert controller.lastWaferInfo == ["", "", 0, "", 0, 0]
|
||||||
|
|
||||||
|
def test_erase_memory(controller):
|
||||||
|
controller._service.erase_wafer = MagicMock(return_value=True)
|
||||||
|
emitted_result = None
|
||||||
|
def on_erase_result(result):
|
||||||
|
nonlocal emitted_result
|
||||||
|
emitted_result = result
|
||||||
|
|
||||||
|
controller.eraseResult.connect(on_erase_result)
|
||||||
|
controller.eraseMemory("COM1")
|
||||||
|
assert emitted_result == {"success": True}
|
||||||
|
assert controller.connectionStatus == "Connected"
|
||||||
|
|
||||||
|
def test_read_debug(controller):
|
||||||
|
controller._service.read_wafer_data = MagicMock(return_value=bytes([12] * 512))
|
||||||
|
emitted_result = None
|
||||||
|
def on_debug_result(result):
|
||||||
|
nonlocal emitted_result
|
||||||
|
emitted_result = result
|
||||||
|
|
||||||
|
controller.debugResult.connect(on_debug_result)
|
||||||
|
controller.readDebug("COM1")
|
||||||
|
assert emitted_result is not None
|
||||||
|
assert emitted_result.get("success") is True
|
||||||
|
assert emitted_result.get("sensor_bytes") == 512
|
||||||
|
assert emitted_result.get("debug_bytes") == 512
|
||||||
|
|
||||||
|
def test_parse_and_save_data_and_get_chart_data(controller):
|
||||||
|
res = controller.getChartData()
|
||||||
|
assert res == {"success": False}
|
||||||
|
|
||||||
|
controller._raw_bytes = bytes([12] * 512)
|
||||||
|
emitted_result = None
|
||||||
|
def on_parsed(result):
|
||||||
|
nonlocal emitted_result
|
||||||
|
emitted_result = result
|
||||||
|
|
||||||
|
controller.parsedDataReady.connect(on_parsed)
|
||||||
|
controller.parseAndSaveData("P", "COM1")
|
||||||
|
|
||||||
|
assert emitted_result is not None
|
||||||
|
assert emitted_result.get("success") is True
|
||||||
|
assert controller.dataRowCount == 1
|
||||||
|
assert controller.dataColCount == 244
|
||||||
|
|
||||||
|
chart_res = controller.getChartData()
|
||||||
|
assert chart_res.get("success") is True
|
||||||
|
assert "Sensor1" in chart_res.get("sensor_names")
|
||||||
|
assert len(chart_res.get("series")) == 244
|
||||||
|
|
||||||
|
def test_logging_handler(controller):
|
||||||
|
logger = logging.getLogger("test_logger")
|
||||||
|
logger.info("Hello QML Activity Log")
|
||||||
|
assert any("Hello QML Activity Log" in line for line in controller._activity_log)
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import pytest
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def controller():
|
||||||
|
return SessionController()
|
||||||
|
|
||||||
|
def test_initial_default(controller):
|
||||||
|
assert controller.mode == "review"
|
||||||
|
assert controller.state == "idle"
|
||||||
|
assert controller.recording == False
|
||||||
|
|
||||||
|
def test_playback_flow(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
controller.play()
|
||||||
|
initial_index = controller.frameIndex
|
||||||
|
controller._advance()
|
||||||
|
assert controller.frameIndex == initial_index + 1
|
||||||
|
|
||||||
|
def test_speed_control(controller):
|
||||||
|
controller.setSpeed(2.0)
|
||||||
|
interval = controller._next_interval_ms()
|
||||||
|
controller.setSpeed(0.5)
|
||||||
|
assert controller._next_interval_ms() > interval
|
||||||
|
|
||||||
|
def test_seek(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
controller.seek(1)
|
||||||
|
assert controller.frameIndex == 1
|
||||||
|
|
||||||
|
def test_step_forward(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
initial = controller.frameIndex
|
||||||
|
controller.step(1)
|
||||||
|
assert controller.frameIndex == initial + 1
|
||||||
|
|
||||||
|
def test_step_backward(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
controller.seek(2)
|
||||||
|
controller.step(-1)
|
||||||
|
assert controller.frameIndex == 1
|
||||||
|
|
||||||
|
def test_pause_stop_timer(controller):
|
||||||
|
controller.loadFile("tests/fixtures/sample_stream.csv")
|
||||||
|
controller.seek(2)
|
||||||
|
controller.stop()
|
||||||
|
assert controller.frameIndex == 0
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Tests for slot_error_boundary decorator."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
|
from pygui.backend.utils import slot_error_boundary
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSlot:
|
||||||
|
"""Minimal QObject-like object to test the decorator against."""
|
||||||
|
|
||||||
|
def __init__(self, has_log_message=True):
|
||||||
|
self.logMessage = MagicMock() if has_log_message else None
|
||||||
|
self.__class__.__module__ = "test_module"
|
||||||
|
|
||||||
|
|
||||||
|
class TestSlotErrorBoundary:
|
||||||
|
|
||||||
|
def test_passes_through_on_success(self):
|
||||||
|
@slot_error_boundary
|
||||||
|
def good(self) -> str:
|
||||||
|
return "ok"
|
||||||
|
|
||||||
|
result = good(_FakeSlot())
|
||||||
|
assert result == "ok"
|
||||||
|
|
||||||
|
def test_catches_and_logs_exception(self, caplog):
|
||||||
|
@slot_error_boundary
|
||||||
|
def bad(self) -> None:
|
||||||
|
raise ValueError("boom")
|
||||||
|
|
||||||
|
with caplog.at_level(logging.ERROR):
|
||||||
|
bad(_FakeSlot())
|
||||||
|
|
||||||
|
assert "ValueError" in caplog.text
|
||||||
|
assert "bad" in caplog.text
|
||||||
|
|
||||||
|
def test_emits_log_message_on_error(self):
|
||||||
|
obj = _FakeSlot(has_log_message=True)
|
||||||
|
|
||||||
|
@slot_error_boundary
|
||||||
|
def bad(self) -> None:
|
||||||
|
raise RuntimeError("fail")
|
||||||
|
|
||||||
|
bad(obj)
|
||||||
|
|
||||||
|
obj.logMessage.emit.assert_called_once()
|
||||||
|
emitted_msg = obj.logMessage.emit.call_args[0][0]
|
||||||
|
assert "bad" in emitted_msg
|
||||||
|
assert "fail" in emitted_msg
|
||||||
|
|
||||||
|
def test_returns_none_on_error(self):
|
||||||
|
@slot_error_boundary
|
||||||
|
def bad(self) -> None:
|
||||||
|
raise KeyError("missing")
|
||||||
|
|
||||||
|
result = bad(_FakeSlot())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_no_log_message_signal_is_ok(self):
|
||||||
|
"""Controller without logMessage signal should not crash."""
|
||||||
|
|
||||||
|
class _NoLogMsg:
|
||||||
|
__module__ = "test_module"
|
||||||
|
|
||||||
|
@slot_error_boundary
|
||||||
|
def bad(self) -> None:
|
||||||
|
raise ValueError("boom")
|
||||||
|
|
||||||
|
# Should not raise — hasattr check guards the emit
|
||||||
|
result = bad(_NoLogMsg())
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_preserves_function_name(self):
|
||||||
|
@slot_error_boundary
|
||||||
|
def named_method(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
assert named_method.__name__ == "named_method"
|
||||||
|
|
||||||
|
def test_passes_args_through(self):
|
||||||
|
@slot_error_boundary
|
||||||
|
def with_args(self, a: int, b: str) -> tuple[int, str]:
|
||||||
|
return (a, b)
|
||||||
|
|
||||||
|
result = with_args(_FakeSlot(), 42, "hello")
|
||||||
|
assert result == (42, "hello")
|
||||||
|
|
||||||
|
def test_exception_in_wrapper_does_not_leak(self):
|
||||||
|
"""The decorator must never re-raise — QML would crash."""
|
||||||
|
|
||||||
|
@slot_error_boundary
|
||||||
|
def crashing(self) -> None:
|
||||||
|
raise TypeError("uncaught type error")
|
||||||
|
|
||||||
|
# Should return None, not raise
|
||||||
|
result = crashing(_FakeSlot())
|
||||||
|
assert result is None
|
||||||
Reference in New Issue
Block a user