diff --git a/requirements.txt b/requirements.txt index a77faeed..4fde2dd3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ pydm pandas +scipy pytest pytest-qt qtawesome diff --git a/trace/examples/FormulaExample.trc b/trace/examples/FormulaExample.trc index b982790b..11d868f5 100644 --- a/trace/examples/FormulaExample.trc +++ b/trace/examples/FormulaExample.trc @@ -1,52 +1,37 @@ { "archiver_url": "http://lcls-archapp.slac.stanford.edu", "plot": { - "refreshInterval": 0.0, + "refreshInterval": 1.0, "title": "", "xGrid": false, "yGrid": false, "opacity": 128, "backgroundColor": "#ffffff", "legend": false, - "crosshair": false, "mouseMode": 1 }, "time_axis": { "name": "Main Time Axis", - "start": "2024-09-04 11:03:44", - "end": "2024-09-04 12:03:44", + "start": "-1m", + "end": "now", "location": "bottom" }, "y-axes": [ { "name": "Axis 1", "orientation": "left", - "minRange": -1.3843656974520628e-09, - "maxRange": 4.752694382245206e-08, + "label": "Axis 1", + "minRange": 1.1067500225324897e-08, + "maxRange": 2.6286015399675106e-08, "autoRange": true, "logMode": false }, { - "name": "Axis 2", + "name": "torr", "orientation": "left", - "minRange": 9.047133561624635e-10, - "maxRange": 1.3621653831337537e-08, - "autoRange": true, - "logMode": false - }, - { - "name": "Axis 3", - "orientation": "left", - "minRange": 1.3015564414604301e-08, - "maxRange": 4.65547480853957e-08, - "autoRange": true, - "logMode": false - }, - { - "name": "Axis 4", - "orientation": "left", - "minRange": -1.04, - "maxRange": 1.04, + "label": "torr", + "minRange": 1.0577346003248955e-09, + "maxRange": 1.6276249774675104e-08, "autoRange": true, "logMode": false } @@ -55,25 +40,27 @@ { "useArchiveData": true, "liveData": true, + "showExtensionLine": false, "channel": "KLYS:LI22:31:KVAC", "name": "KLYS:LI22:31:KVAC", "color": "#008cf9", "lineStyle": 1, "lineWidth": 1, "symbolSize": 10, - "yAxisName": "Axis 1", + "yAxisName": "torr", "thresholdColor": "white" }, { "useArchiveData": true, "liveData": true, + "showExtensionLine": false, "channel": "KLYS:LI22:41:KVAC", "name": "KLYS:LI22:41:KVAC", "color": "#006e00", "lineStyle": 1, "lineWidth": 1, "symbolSize": 10, - "yAxisName": "Axis 1", + "yAxisName": "torr", "thresholdColor": "white" } ], @@ -82,12 +69,12 @@ "useArchiveData": true, "liveData": true, "plot_style": "Line", - "formula": "f://{A}+{B}", + "formula": "f://{x1}+{x2}", "curveDict": { - "A": "KLYS:LI22:31:KVAC", - "B": "KLYS:LI22:41:KVAC" + "x1": "KLYS:LI22:31:KVAC", + "x2": "KLYS:LI22:41:KVAC" }, - "name": "f://{A}+{B}", + "name": "f://{x1}+{x2}", "color": "#b80058", "lineStyle": 1, "lineWidth": 1, diff --git a/trace/tests/conftest.py b/trace/tests/conftest.py index e44ba1e4..0ab2c0cc 100644 --- a/trace/tests/conftest.py +++ b/trace/tests/conftest.py @@ -3,6 +3,7 @@ from unittest import mock import pytest +from qtpy.QtWidgets import QMenu from pydm.application import PyDMApplication @@ -73,10 +74,15 @@ def qtrace(qtbot, qapp): ------ An instance of TraceDisplay. """ - trace = TraceDisplay() + # TraceDisplay.__init__ returns early when app.main_window is None (use_main_window=False). + # Patch it with a MagicMock so build_ui() and configure_app() run normally. + # construct_trace_menu is also patched because QMenu rejects a MagicMock parent. + with mock.patch.object(qapp, "main_window", mock.MagicMock()): + with mock.patch.object(TraceDisplay, "construct_trace_menu", return_value=QMenu()): + trace = TraceDisplay() # updateXAxis would be called on application render; necessary for testing X-Axis - trace.main_plot.updateXAxis(True) + trace.plot.updateXAxis(True) yield trace trace.close() diff --git a/trace/tests/test_file_io/test_file_handler.py b/trace/tests/test_file_io/test_file_handler.py new file mode 100644 index 00000000..1b266739 --- /dev/null +++ b/trace/tests/test_file_io/test_file_handler.py @@ -0,0 +1,175 @@ +from os import environ +from unittest.mock import Mock, patch + +import pytest + +from file_io import TraceFileHandler + +DUMMY_ARCHIVER_URL = "http://dummy.archiver.url/retrieval" + + +@pytest.fixture +def file_handler(qapp): + """Fixture for an instance of TraceFileHandler with a mocked plot. + + Yields + ------ + An instance of TraceFileHandler. + """ + mock_plot = Mock() + with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): + with patch("file_io.file_handler.QMessageBox.warning", return_value=Mock()): + yield TraceFileHandler(mock_plot) + + +def test_open_file_curves_signal_includes_formulas(file_handler, get_test_file, qtbot): + """Test that opening a .trc file with formulas emits curves_signal with both + regular curves and formula curves combined into a single list. + + Parameters + ---------- + file_handler : fixture + Instance of TraceFileHandler for testing + get_test_file : fixture + A fixture used to get test files from the test_data directory + qtbot : fixture + pytest-qt fixture for Qt testing + + Expectations + ------------ + curves_signal is emitted once with a list whose length equals the number + of regular curves plus the number of formulas in the file. + """ + test_file = get_test_file("test_file.trc") + + with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): + with qtbot.wait_signal(file_handler.curves_signal) as blocker: + file_handler.open_file(test_file) + + # test_file.trc has 2 regular curves + 1 formula + received_curves = blocker.args[0] + assert len(received_curves) == 3 + + +def test_open_file_formula_entries_have_formula_key(file_handler, get_test_file, qtbot): + """Test that formula entries in the emitted curves_signal have the 'formula' key. + + Parameters + ---------- + file_handler : fixture + Instance of TraceFileHandler for testing + get_test_file : fixture + A fixture used to get test files from the test_data directory + qtbot : fixture + pytest-qt fixture for Qt testing + + Expectations + ------------ + Exactly one entry in curves_signal has a 'formula' key, and its value + matches the formula string from the test file. + """ + test_file = get_test_file("test_file.trc") + + with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): + with qtbot.wait_signal(file_handler.curves_signal) as blocker: + file_handler.open_file(test_file) + + received_curves = blocker.args[0] + formula_entries = [c for c in received_curves if "formula" in c] + + assert len(formula_entries) == 1 + assert formula_entries[0]["formula"] == "f://{x0}+{x1}" + + +def test_open_file_regular_curve_entries_have_channel_key(file_handler, get_test_file, qtbot): + """Test that regular curve entries in the emitted curves_signal have the 'channel' key. + + Parameters + ---------- + file_handler : fixture + Instance of TraceFileHandler for testing + get_test_file : fixture + A fixture used to get test files from the test_data directory + qtbot : fixture + pytest-qt fixture for Qt testing + + Expectations + ------------ + Exactly two entries in curves_signal have a 'channel' key, corresponding + to the regular PV curves in the test file. + """ + test_file = get_test_file("test_file.trc") + + with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): + with qtbot.wait_signal(file_handler.curves_signal) as blocker: + file_handler.open_file(test_file) + + received_curves = blocker.args[0] + channel_entries = [c for c in received_curves if "channel" in c] + + assert len(channel_entries) == 2 + + +def test_open_file_formulas_appended_after_curves(file_handler, get_test_file, qtbot): + """Test that formula entries appear after regular curve entries in curves_signal, + reflecting the file_data["curves"] + file_data["formula"] concatenation order. + + Parameters + ---------- + file_handler : fixture + Instance of TraceFileHandler for testing + get_test_file : fixture + A fixture used to get test files from the test_data directory + qtbot : fixture + pytest-qt fixture for Qt testing + + Expectations + ------------ + The first entries in curves_signal have 'channel' keys (regular curves), + and the last entries have 'formula' keys (formula curves). + """ + test_file = get_test_file("test_file.trc") + + with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): + with qtbot.wait_signal(file_handler.curves_signal) as blocker: + file_handler.open_file(test_file) + + received_curves = blocker.args[0] + + assert "channel" in received_curves[0] + assert "channel" in received_curves[1] + assert "formula" in received_curves[2] + + +def test_open_file_formula_properties_match_trc_file(file_handler, get_test_file, qtbot): + """Test that the formula entry in curves_signal contains all properties + from the .trc file, including name, color, yAxisName, and curveDict. + + Parameters + ---------- + file_handler : fixture + Instance of TraceFileHandler for testing + get_test_file : fixture + A fixture used to get test files from the test_data directory + qtbot : fixture + pytest-qt fixture for Qt testing + + Expectations + ------------ + The formula entry in curves_signal has the correct formula string, name, + color, yAxisName, lineWidth, and curveDict matching the test file. + """ + test_file = get_test_file("test_file.trc") + + with patch.dict(environ, {"PYDM_ARCHIVER_URL": DUMMY_ARCHIVER_URL}): + with qtbot.wait_signal(file_handler.curves_signal) as blocker: + file_handler.open_file(test_file) + + received_curves = blocker.args[0] + formula_entry = next(c for c in received_curves if "formula" in c) + + assert formula_entry["name"] == "formula0" + assert formula_entry["formula"] == "f://{x0}+{x1}" + assert formula_entry["yAxisName"] == "Main Range Axis" + assert formula_entry["color"] == "#00ff00" + assert formula_entry["curveDict"] == {"x0": "KLYS:LI22:31:KVAC", "x1": "KLYS:LI22:41:KVAC"} diff --git a/trace/tests/test_widgets/test_control_panel.py b/trace/tests/test_widgets/test_control_panel.py new file mode 100644 index 00000000..2c304050 --- /dev/null +++ b/trace/tests/test_widgets/test_control_panel.py @@ -0,0 +1,181 @@ +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture +def control_panel(qtrace): + """Fixture for the ControlPanel embedded in a TraceDisplay instance. + + Yields + ------ + The ControlPanel instance from TraceDisplay. + """ + yield qtrace.control_panel + + +@pytest.fixture +def control_panel_with_axis(control_panel): + """Fixture for a ControlPanel with one real axis named 'Y-Axis 0'. + + Yields + ------ + Tuple of (ControlPanel, AxisItem) for 'Y-Axis 0'. + """ + axis_item = control_panel.add_empty_axis("Y-Axis 0") + yield control_panel, axis_item + + +def test_set_curves_formula_calls_add_formula_curve(control_panel_with_axis): + """Test that set_curves calls add_formula_curve when a formula entry is provided. + + Parameters + ---------- + control_panel_with_axis : fixture + A ControlPanel with an existing axis named 'Y-Axis 0' + + Expectations + ------------ + When a curve dict with a 'formula' key is passed to set_curves, + add_formula_curve should be called on the correct AxisItem with + the formula string. + """ + cp, axis_item = control_panel_with_axis + formula = "f://{x1}+1" + + with patch.object(axis_item, "add_formula_curve", return_value=MagicMock()) as mock_add: + cp.set_curves([{"formula": formula, "yAxisName": "Y-Axis 0"}]) + + mock_add.assert_called_once_with(formula) + + +def test_set_curves_formula_uses_default_axis_when_yaxisname_missing(control_panel_with_axis): + """Test that set_curves routes to 'Y-Axis 0' when yAxisName is absent from the curve dict. + + Parameters + ---------- + control_panel_with_axis : fixture + A ControlPanel with an existing axis named 'Y-Axis 0' + + Expectations + ------------ + When a formula curve dict has no 'yAxisName' key, set_curves defaults + to 'Y-Axis 0' and calls add_formula_curve on that axis. + """ + cp, axis_item = control_panel_with_axis + formula = "f://{x1}+1" + + with patch.object(axis_item, "add_formula_curve", return_value=MagicMock()) as mock_add: + cp.set_curves([{"formula": formula}]) + + mock_add.assert_called_once_with(formula) + + +def test_set_curves_formula_targets_named_axis(control_panel_with_axis, qtrace): + """Test that set_curves adds the formula curve to the axis matching yAxisName. + + Parameters + ---------- + control_panel_with_axis : fixture + A ControlPanel with an existing axis named 'Y-Axis 0' + qtrace : fixture + The TraceDisplay instance + + Expectations + ------------ + The formula is routed to the axis specified by yAxisName, not a + different axis. + """ + cp, axis_item = control_panel_with_axis + other_axis = cp.add_empty_axis("Other Axis") + + formula = "f://{x1}*2" + + with patch.object(axis_item, "add_formula_curve", return_value=MagicMock()) as mock_target, \ + patch.object(other_axis, "add_formula_curve", return_value=MagicMock()) as mock_other: # fmt: skip + cp.set_curves([{"formula": formula, "yAxisName": "Y-Axis 0"}]) + + mock_target.assert_called_once_with(formula) + mock_other.assert_not_called() + + +def test_set_curves_formula_creates_axis_when_missing(control_panel_with_axis): + """Test that set_curves creates a new axis when yAxisName does not exist. + + Parameters + ---------- + control_panel_with_axis : fixture + A ControlPanel with an existing axis (used so axis_list is non-empty + and the trailing axis_list.itemAt() call in set_curves doesn't crash) + + Expectations + ------------ + When a formula curve dict references an axis that doesn't exist, + add_empty_axis is called and add_formula_curve is then called on + the newly created axis. + """ + cp, axis_item = control_panel_with_axis + formula = "f://{x1}+{x2}" + + with patch.object(cp, "get_axis_item", return_value=None), \ + patch.object(cp, "add_empty_axis", return_value=axis_item) as mock_add_axis, \ + patch.object(axis_item, "add_formula_curve", return_value=MagicMock()) as mock_add_formula: # fmt: skip + cp.set_curves([{"formula": formula, "yAxisName": "Missing Axis"}]) + + mock_add_axis.assert_called_once_with("Missing Axis") + mock_add_formula.assert_called_once_with(formula) + + +def test_set_curves_channel_removes_channel_key_before_add_curve(control_panel_with_axis): + """Test that the 'channel' key is removed from curve_dict before calling add_curve. + + Parameters + ---------- + control_panel_with_axis : fixture + A ControlPanel with an existing axis named 'Y-Axis 0' + + Expectations + ------------ + add_curve is called with the PV name and the curve_dict, where the + 'channel' key has been deleted to avoid conflicts with y_channel. + """ + cp, axis_item = control_panel_with_axis + curve_dict = {"channel": "SOME:PV", "color": "#ff0000", "yAxisName": "Y-Axis 0"} + + with patch.object(axis_item, "add_curve", return_value=MagicMock()) as mock_add_curve: + cp.set_curves([curve_dict]) + + args = mock_add_curve.call_args + pv_name_arg, dict_arg = args[0] + + assert pv_name_arg == "SOME:PV" + assert "channel" not in dict_arg + + +def test_set_curves_mixed_channel_and_formula(control_panel_with_axis): + """Test that set_curves correctly handles a mix of channel and formula entries. + + Parameters + ---------- + control_panel_with_axis : fixture + A ControlPanel with an existing axis named 'Y-Axis 0' + + Expectations + ------------ + Each entry is routed correctly: channel entries call add_curve and + formula entries call add_formula_curve, each exactly once. + """ + cp, axis_item = control_panel_with_axis + formula = "f://{x1}+{x2}" + + with patch.object(axis_item, "add_curve", return_value=MagicMock()) as mock_add_curve, \ + patch.object(axis_item, "add_formula_curve", return_value=MagicMock()) as mock_add_formula: # fmt: skip + cp.set_curves( + [ + {"channel": "SOME:PV", "yAxisName": "Y-Axis 0"}, + {"formula": formula, "yAxisName": "Y-Axis 0"}, + ] + ) + + mock_add_curve.assert_called_once() + mock_add_formula.assert_called_once_with(formula) diff --git a/trace/widgets/control_panel.py b/trace/widgets/control_panel.py index 45e97427..767e7662 100644 --- a/trace/widgets/control_panel.py +++ b/trace/widgets/control_panel.py @@ -401,9 +401,13 @@ def set_curves(self, curves: list[dict] = None) -> None: if axis_item is None: axis_item = self.add_empty_axis(axis_name) - pv_name = curve_dict.get("channel", "") - del curve_dict["channel"] # Remove channel key to avoid conflicts with y_channel - axis_item.add_curve(pv_name, curve_dict) + if "channel" in curve_dict: + pv_name = curve_dict.get("channel", "") + del curve_dict["channel"] # Remove channel key to avoid conflicts with y_channel + axis_item.add_curve(pv_name, curve_dict) + elif "formula" in curve_dict: + formula = curve_dict.get("formula", "f://") + axis_item.add_formula_curve(formula) self.plot.redrawPlot() self.axis_list.itemAt(self.axis_list.count() - 2).widget()