feat(map): add batch export and adjust layout alignment

- Implement synchronous offscreen wafer map PNG rendering and CSV summary.
- Add "Batch Export" button to file browser sidebar.
- Adjust trend pane bottom spacer to align with About button when visible.
- Fix type check errors and add pytest coverage for batch export.
This commit is contained in:
jack
2026-07-10 15:22:36 -07:00
parent 278a48a2e4
commit 69753e35f9
12 changed files with 422 additions and 187 deletions
+25 -1
View File
@@ -1,6 +1,10 @@
import pytest
from pygui.backend.visualization.chart_geometry import index_to_pixel, value_to_pixel
from pygui.backend.visualization.chart_geometry import (
elapsed_to_pixel,
index_to_pixel,
value_to_pixel,
)
def test_value_to_pixel_min_maps_to_bottom():
@@ -26,3 +30,23 @@ def test_index_to_pixel_first_and_last():
def test_index_to_pixel_single_point_centers():
assert index_to_pixel(0, 1, pixel_start=0.0, pixel_span=100.0) == pytest.approx(50.0)
def test_elapsed_to_pixel_window_start_maps_to_left():
assert elapsed_to_pixel(10.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(0.0)
def test_elapsed_to_pixel_window_end_maps_to_right():
assert elapsed_to_pixel(70.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
def test_elapsed_to_pixel_midpoint():
assert elapsed_to_pixel(40.0, window_start=10.0, window_end=70.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(100.0)
def test_elapsed_to_pixel_degenerate_window_pins_right():
assert elapsed_to_pixel(5.0, window_start=5.0, window_end=5.0,
pixel_start=0.0, pixel_span=200.0) == pytest.approx(200.0)
+9
View File
@@ -233,3 +233,12 @@ def test_device_service_port_caching(controller):
service.enumerate_ports()
assert mock_run.call_count == 2
def test_export_dir_created_under_save_data_dir(controller):
from pathlib import Path
result = controller.exportDir()
assert result == str(Path(controller.saveDataDir) / "Export")
assert Path(result).is_dir()
+31
View File
@@ -1,8 +1,11 @@
import json
from unittest.mock import MagicMock
import pytest
import pygui.backend.controllers.session_controller as session_controller_module
from pygui.backend.controllers.session_controller import SessionController
from pygui.backend.models.frame import Frame
@pytest.fixture
@@ -194,3 +197,31 @@ def test_export_segment_bad_index(qapp, controller, tmp_path):
controller.segmentExported.connect(exported)
controller.exportSegment(99)
assert exported.call_args[0][0]["success"] is False
def test_reset_live_trend_sets_start_time_and_emits_reset(controller, monkeypatch):
monkeypatch.setattr(session_controller_module.time, "monotonic", lambda: 100.0)
reset_seen = MagicMock()
controller.trendReset.connect(reset_seen)
controller._reset_live_trend()
assert controller._stream_start_time == 100.0
assert reset_seen.called
def test_on_live_frame_emits_trend_delta_with_elapsed_seconds(controller, monkeypatch):
controller._stream_start_time = 100.0
monkeypatch.setattr(session_controller_module.time, "monotonic", lambda: 102.5)
delta_seen = MagicMock()
controller.trendDelta.connect(delta_seen)
frame = Frame(seq=1, time=102.5, values=[10.0, 20.0, 30.0])
controller._on_live_frame(frame)
payload = json.loads(delta_seen.call_args[0][0])
assert len(payload) == 1
elapsed, avg = payload[0]
assert elapsed == pytest.approx(2.5)
assert avg == pytest.approx(20.0)
+82
View File
@@ -0,0 +1,82 @@
"""Tests for src/pygui/backend/visualization/trend_chart_item.py."""
import json
import pytest
# Use the session-scoped qapp fixture from conftest.py so Qt Property/Signal
# descriptors work properly.
pytestmark = pytest.mark.usefixtures("qapp")
def test_no_data_initially():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
assert item.hasData is False
def test_append_delta_adds_point_and_sets_has_data():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[1.5, 42.0]]))
assert item.hasData is True
assert item._points == [(1.5, 42.0)]
def test_append_delta_accumulates_across_calls():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[0.0, 10.0]]))
item.appendDelta(json.dumps([[1.0, 20.0]]))
assert item._points == [(0.0, 10.0), (1.0, 20.0)]
def test_append_delta_prunes_points_older_than_60s_window():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[0.0, 10.0]]))
item.appendDelta(json.dumps([[70.0, 20.0]]))
assert item._points == [(70.0, 20.0)]
def test_append_delta_ignores_malformed_json():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta("not json")
assert item._points == []
assert item.hasData is False
def test_append_delta_skips_repaint_when_no_points_added():
from unittest.mock import MagicMock
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[1.0, 10.0]]))
item.update = MagicMock()
item.appendDelta("not json")
assert item.update.called is False
def test_clear_trend_resets_points_and_has_data():
from pygui.backend.visualization.trend_chart_item import TrendChartItem
item = TrendChartItem()
item.appendDelta(json.dumps([[1.0, 10.0]]))
assert item.hasData is True
item.clearTrend()
assert item._points == []
assert item.hasData is False
+62 -28
View File
@@ -8,20 +8,6 @@ import pytest
pytestmark = pytest.mark.usefixtures("qapp")
def test_export_image_valid_path():
"""export_image saves a PNG when grabToImage succeeds."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
mock_image = MagicMock()
mock_result = MagicMock()
mock_result.image.return_value = mock_image
item.grabToImage = MagicMock(return_value=mock_result)
assert item.export_image("/tmp/test_wafer.png") is True
def test_export_image_empty_path():
"""export_image returns False for empty/None path."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
@@ -31,29 +17,23 @@ def test_export_image_empty_path():
assert item.export_image(None) is False
def test_export_image_grab_fails():
"""export_image returns False when grabToImage raises."""
def test_export_image_zero_size_item_fails():
"""export_image returns False when the item has no geometry to render."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item.grabToImage = MagicMock(side_effect=RuntimeError("grab failed"))
assert item.export_image("/tmp/test_wafer.png") is False
def test_export_image_save_fails():
"""export_image returns False when image.save raises."""
def test_export_image_unwritable_path_fails(tmp_path):
"""export_image returns False when the target directory doesn't exist."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
mock_image = MagicMock()
mock_image.save.side_effect = IOError("disk full")
mock_result = MagicMock()
mock_result.image.return_value = mock_image
item = WaferMapItem()
item.grabToImage = MagicMock(return_value=mock_result)
assert item.export_image("/tmp/test_wafer.png") is False
item.setWidth(100)
item.setHeight(100)
item.values = [10.0, 20.0, 30.0]
assert item.export_image(str(tmp_path / "no_such_dir" / "wafer.png")) is False
def test_thickness_properties():
@@ -88,3 +68,57 @@ def test_show_thickness_setter():
item.showThickness = True
assert item._show_thickness is True
def test_export_image_writes_real_png_headless(tmp_path):
"""export_image must produce an actual decodable image file without a
QQuickWindow — the button was silently failing because grabToImage()
resolves asynchronously and never completes headless/immediately."""
from PySide6.QtGui import QImage
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item.setWidth(200)
item.setHeight(200)
item.values = [10.0, 20.0, 30.0]
out = tmp_path / "wafer.png"
assert item.export_image(str(out)) is True
assert out.exists()
loaded = QImage(str(out))
assert not loaded.isNull()
assert loaded.width() == 200
def test_export_image_includes_metrics_footer(tmp_path):
"""With sensor values loaded, the export gains a footer strip below the
map carrying the metric readouts (sensors / min / max / avg)."""
from PySide6.QtGui import QImage
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item.setWidth(200)
item.setHeight(200)
item.values = [10.0, 20.0, 30.0]
out = tmp_path / "wafer.png"
assert item.export_image(str(out)) is True
loaded = QImage(str(out))
assert loaded.height() > 200 # footer strip appended
# footer is painted opaque (dark bg), not transparent
assert loaded.pixelColor(5, loaded.height() - 5).alpha() == 255
def test_export_image_no_data_fails(tmp_path):
"""No file loaded / no stream played -> nothing meaningful to export."""
from pygui.backend.visualization.wafer_map_item import WaferMapItem
item = WaferMapItem()
item.setWidth(200)
item.setHeight(200)
out = tmp_path / "wafer.png"
assert item.export_image(str(out)) is False
assert not out.exists()