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:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user