feat(export): batch wafer-map PNG export from file list
- BatchExport worker + BatchExportController (threaded, busy flag) - SourcePanel: Export Maps button → FolderDialog → batch export with progress/summary banner (dismissible) - StreamControlPanel: name single export after loaded CSV + timestamp
This commit is contained in:
+25
@@ -0,0 +1,25 @@
|
|||||||
|
# Ubiquitous Language — ISenseCloud
|
||||||
|
|
||||||
|
Glossary only. No implementation details. Terms verified against the C# original (`~/WA/temp_ui`) and the pyGUI redesign.
|
||||||
|
|
||||||
|
## Terms
|
||||||
|
|
||||||
|
**Wafer family** — the wafer type letter (A, B, C, D, E, F, P, X, Z) identifying sensor count and physical layout. Families group into layout classes: A/E/P (150C 48pt), B/C/D (250C 29pt), F (250C 22pt), X (150C 80pt).
|
||||||
|
|
||||||
|
**Master (master file)** — the golden-reference CSV assigned per wafer family, used as the comparison baseline. Assigning a master is a per-family setting, not a layout choice. (C#: `btnAMaster`…`btnZMaster`; pyGUI: Settings tab master picker.)
|
||||||
|
|
||||||
|
**Layout template** — a spreadsheet of sensor coordinates for a layout class, exported for external use. The C# "Layout" page buttons export these files; they do not change what is displayed. Not to be confused with a layout *selector*.
|
||||||
|
|
||||||
|
**Edge–Center pair** — a fixed per-family mapping from each edge sensor to its radially-corresponding center sensor. Pairs involving a replaced sensor are skipped.
|
||||||
|
|
||||||
|
**Edge–Center delta** — |edge temperature − paired center temperature| for one pair in one frame. The Edge-Center readout reports the min and max delta across pairs, with each pair's sensor numbers and temperatures.
|
||||||
|
|
||||||
|
**Setpoint** — the target temperature the wafer run is trying to hold during the Set phase.
|
||||||
|
|
||||||
|
**Margin** — the ± tolerance band around the color target used to color sensors/heatmap.
|
||||||
|
|
||||||
|
**Sigma color (auto margin)** — coloring mode where the target is the frame average and the margin is the frame's σ, instead of the manual setpoint/margin.
|
||||||
|
|
||||||
|
**Max range** — a temperature span used to find the largest set of sensors that fit within it (centered near the average/setpoint); sensors outside the set are flagged out-of-range.
|
||||||
|
|
||||||
|
**Phase (Idle / Ramp / Set)** — the replay-time state of a frame: idle (flat, off-target), ramp (moving, colored by derivative sign), set (holding at setpoint).
|
||||||
@@ -1,13 +1,31 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Dialogs
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import QtQuick.Controls.impl
|
import QtQuick.Controls.impl
|
||||||
import ISC
|
import ISC
|
||||||
import ".."
|
import ".."
|
||||||
|
|
||||||
ColumnLayout {
|
ColumnLayout {
|
||||||
|
id: root
|
||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
|
property string exportSummary: ""
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: batchExportController
|
||||||
|
function onExportFinished(summary) {
|
||||||
|
root.exportSummary = summary
|
||||||
|
exportBannerHideTimer.restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: exportBannerHideTimer
|
||||||
|
interval: 5000
|
||||||
|
onTriggered: root.exportSummary = ""
|
||||||
|
}
|
||||||
|
|
||||||
// ── Section label & Refresh ───────────────────────────────────────────
|
// ── Section label & Refresh ───────────────────────────────────────────
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -26,6 +44,29 @@ ColumnLayout {
|
|||||||
Layout.alignment: Qt.AlignVCenter
|
Layout.alignment: Qt.AlignVCenter
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: refreshBtn
|
||||||
|
implicitWidth: 28
|
||||||
|
implicitHeight: 28
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: refreshBtn.pressed ? Theme.buttonPressed : refreshBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "../icons/refresh.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.bodyColor
|
||||||
|
}
|
||||||
|
onClicked: file_browser.refreshFiles()
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: refreshBtn.hovered
|
||||||
|
text: "Refresh"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
id: editBtn
|
id: editBtn
|
||||||
implicitWidth: 28
|
implicitWidth: 28
|
||||||
@@ -52,27 +93,90 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Item { implicitWidth: 8 }
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
id: refreshBtn
|
id: batchExportBtn
|
||||||
implicitWidth: 28
|
implicitWidth: 28
|
||||||
implicitHeight: 28
|
implicitHeight: 28
|
||||||
hoverEnabled: true
|
hoverEnabled: true
|
||||||
|
enabled: file_browser.files.length > 0 && !batchExportController.busy
|
||||||
|
opacity: enabled ? 1.0 : 0.4
|
||||||
background: Rectangle {
|
background: Rectangle {
|
||||||
color: refreshBtn.pressed ? Theme.buttonPressed : refreshBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
color: batchExportBtn.pressed ? Theme.buttonPressed : batchExportBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
radius: Theme.radiusSm
|
radius: Theme.radiusSm
|
||||||
}
|
}
|
||||||
contentItem: IconImage {
|
contentItem: IconImage {
|
||||||
source: "../icons/refresh.svg"
|
source: "../icons/images.svg"
|
||||||
sourceSize.width: 16
|
sourceSize.width: 16
|
||||||
sourceSize.height: 16
|
sourceSize.height: 16
|
||||||
color: Theme.bodyColor
|
color: Theme.bodyColor
|
||||||
}
|
}
|
||||||
onClicked: file_browser.refreshFiles()
|
onClicked: batchExportDirDialog.open()
|
||||||
|
|
||||||
AppToolTip {
|
AppToolTip {
|
||||||
visible: refreshBtn.hovered
|
visible: batchExportBtn.hovered && batchExportBtn.enabled
|
||||||
text: "Refresh"
|
text: "Batch Export — render every listed file's wafer map to a PNG in select folder"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: batchExportBtn.hovered && !batchExportBtn.enabled
|
||||||
|
text: batchExportController.busy
|
||||||
|
? "Export already in progress…"
|
||||||
|
: "No files to export — load a folder with CSV files first"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Batch export progress / summary banner ──────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
visible: batchExportController.busy || root.exportSummary !== ""
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: exportBannerRow.implicitHeight + 12
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.statusWarningColor
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: exportBannerRow
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: batchExportController.busy ? "Exporting wafer maps…" : root.exportSummary
|
||||||
|
color: Theme.statusWarningColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
visible: !batchExportController.busy
|
||||||
|
flat: true
|
||||||
|
implicitWidth: 20
|
||||||
|
implicitHeight: 20
|
||||||
|
onClicked: root.exportSummary = ""
|
||||||
|
background: Rectangle { color: "transparent" }
|
||||||
|
contentItem: Label {
|
||||||
|
text: "✕"
|
||||||
|
color: Theme.statusWarningColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FolderDialog {
|
||||||
|
id: batchExportDirDialog
|
||||||
|
title: "Export Wafer Maps To…"
|
||||||
|
onAccepted: {
|
||||||
|
var dir = String(selectedFolder).replace(/^file:\/\//, "");
|
||||||
|
var paths = file_browser.files.map(function(row) { return row.fileName; });
|
||||||
|
batchExportController.start(paths, dir);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -280,8 +280,16 @@ Rectangle {
|
|||||||
opacity: enabled ? 1 : 0.4
|
opacity: enabled ? 1 : 0.4
|
||||||
|
|
||||||
onClicked: {
|
onClicked: {
|
||||||
// Default into <saveDataDir>/Export (created on demand)
|
// Default into <saveDataDir>/Export (created on demand),
|
||||||
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/wafer_map.png"
|
// named after the loaded CSV so exports are traceable
|
||||||
|
// back to their source (falls back to a generic name for
|
||||||
|
// a live stream with no review file loaded).
|
||||||
|
var loaded = streamController.loadedFile
|
||||||
|
var baseName = loaded
|
||||||
|
? loaded.split("/").pop().replace(/\.[^.]+$/, "")
|
||||||
|
: "wafer_map"
|
||||||
|
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss")
|
||||||
|
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/" + baseName + "_" + ts + ".png"
|
||||||
exportDialog.open()
|
exportDialog.open()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24"
|
||||||
|
height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="2"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path d="M18 22H4a2 2 0 0 1-2-2V6" />
|
||||||
|
<path d="m22 13-1.296-1.296a2.41 2.41 0 0 0-3.408 0L11 18" />
|
||||||
|
<circle cx="12" cy="8" r="2" />
|
||||||
|
<rect width="16" height="16" x="6" y="2" rx="2" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 399 B |
@@ -7,6 +7,7 @@ from PySide6.QtQml import QQmlApplicationEngine
|
|||||||
from PySide6.QtQuickControls2 import QQuickStyle
|
from PySide6.QtQuickControls2 import QQuickStyle
|
||||||
from PySide6.QtWidgets import QApplication
|
from PySide6.QtWidgets import QApplication
|
||||||
|
|
||||||
|
from pygui.backend.controllers.batch_export_controller import BatchExportController
|
||||||
from pygui.backend.controllers.device_controller import DeviceController
|
from pygui.backend.controllers.device_controller import DeviceController
|
||||||
from pygui.backend.controllers.session_controller import SessionController
|
from pygui.backend.controllers.session_controller import SessionController
|
||||||
from pygui.backend.data.constants import default_data_dir
|
from pygui.backend.data.constants import default_data_dir
|
||||||
@@ -79,6 +80,10 @@ def main() -> int:
|
|||||||
stream_controller = SessionController()
|
stream_controller = SessionController()
|
||||||
engine.rootContext().setContextProperty("streamController", stream_controller)
|
engine.rootContext().setContextProperty("streamController", stream_controller)
|
||||||
|
|
||||||
|
# ===== Batch Export Controller (SourcePanel batch PNG export) =====
|
||||||
|
batch_export_controller = BatchExportController()
|
||||||
|
engine.rootContext().setContextProperty("batchExportController", batch_export_controller)
|
||||||
|
|
||||||
# ===== QML Startup =====
|
# ===== QML Startup =====
|
||||||
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
|
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
|
||||||
# package directory is the import path the engine searches for qmldir.
|
# package directory is the import path the engine searches for qmldir.
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
|
||||||
|
|
||||||
|
from pygui.backend.utils import slot_error_boundary
|
||||||
|
from pygui.backend.wafer.batch_export import BatchExportResult, export_batch
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchExportController(QObject):
|
||||||
|
busyChanged = Signal()
|
||||||
|
exportFinished = Signal(str)
|
||||||
|
|
||||||
|
_batchFinished = Signal(list)
|
||||||
|
|
||||||
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
|
super().__init__(parent)
|
||||||
|
self._busy = False
|
||||||
|
self._batchFinished.connect(self._handle_batch_finished, Qt.ConnectionType.QueuedConnection)
|
||||||
|
|
||||||
|
@Property(bool, notify=busyChanged)
|
||||||
|
def busy(self) -> bool:
|
||||||
|
return self._busy
|
||||||
|
|
||||||
|
@Slot("QVariantList", str)
|
||||||
|
def start(self, file_paths: list, output_dir: str) -> None:
|
||||||
|
if self._busy:
|
||||||
|
return
|
||||||
|
self._busy = True
|
||||||
|
self.busyChanged.emit()
|
||||||
|
threading.Thread(
|
||||||
|
target=self._worker, args=(list(file_paths), output_dir), daemon=True
|
||||||
|
).start()
|
||||||
|
|
||||||
|
def _worker(self, file_paths: list[str], output_dir: str) -> None:
|
||||||
|
try:
|
||||||
|
results = export_batch(file_paths, output_dir)
|
||||||
|
except Exception as exc:
|
||||||
|
log.exception("Batch export worker crashed: %s", exc)
|
||||||
|
results = []
|
||||||
|
self._batchFinished.emit(results)
|
||||||
|
|
||||||
|
@Slot(list)
|
||||||
|
@slot_error_boundary
|
||||||
|
def _handle_batch_finished(self, results: list[BatchExportResult]) -> None:
|
||||||
|
try:
|
||||||
|
total = len(results)
|
||||||
|
succeeded = sum(1 for r in results if r.success)
|
||||||
|
failed = total - succeeded
|
||||||
|
summary = (
|
||||||
|
f"Exported {succeeded}/{total} — {failed} failed"
|
||||||
|
if failed
|
||||||
|
else f"Exported {succeeded}/{total}"
|
||||||
|
)
|
||||||
|
self.exportFinished.emit(summary)
|
||||||
|
finally:
|
||||||
|
self._busy = False
|
||||||
|
self.busyChanged.emit()
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pygui.backend.data.data_records import (
|
||||||
|
is_official_csv,
|
||||||
|
read_data_records,
|
||||||
|
read_official_csv,
|
||||||
|
)
|
||||||
|
from pygui.backend.models.frame_player import frames_from_wafer_data
|
||||||
|
from pygui.backend.visualization.wafer_map_item import WaferMapItem
|
||||||
|
from pygui.backend.wafer.zwafer_parser import ZWaferParser
|
||||||
|
|
||||||
|
_EXPORT_SIZE = 600
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BatchExportResult:
|
||||||
|
file_path: str
|
||||||
|
output_path: str
|
||||||
|
success: bool
|
||||||
|
error: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def export_batch(
|
||||||
|
file_paths: list[str], output_dir: str, timestamp: str | None = None
|
||||||
|
) -> list[BatchExportResult]:
|
||||||
|
"""Render the last frame of each file to a PNG in output_dir.
|
||||||
|
|
||||||
|
Skips files that fail to parse or render, continuing with the rest.
|
||||||
|
"""
|
||||||
|
ts = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
results = []
|
||||||
|
for file_path in file_paths:
|
||||||
|
results.append(_export_one(file_path, output_dir, ts))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _export_one(file_path: str, output_dir: str, ts: str) -> BatchExportResult:
|
||||||
|
base_name = Path(file_path).stem
|
||||||
|
output_path = str(Path(output_dir) / f"{base_name}_{ts}.png")
|
||||||
|
try:
|
||||||
|
if is_official_csv(file_path):
|
||||||
|
sensors, records = read_official_csv(file_path)
|
||||||
|
else:
|
||||||
|
data, _ = ZWaferParser().parse(file_path)
|
||||||
|
if data is None or not data.sensors:
|
||||||
|
return BatchExportResult(file_path, output_path, False, "Invalid layout or missing sensors")
|
||||||
|
sensors = data.sensors
|
||||||
|
records = read_data_records(file_path)
|
||||||
|
|
||||||
|
frames = frames_from_wafer_data(None, records)
|
||||||
|
if not sensors or not frames:
|
||||||
|
return BatchExportResult(file_path, output_path, False, "No sensors or frames found")
|
||||||
|
|
||||||
|
item = WaferMapItem()
|
||||||
|
item.setWidth(_EXPORT_SIZE)
|
||||||
|
item.setHeight(_EXPORT_SIZE)
|
||||||
|
item.sensors = [{"label": s.label, "x": s.x, "y": s.y} for s in sensors] # type: ignore[method-assign]
|
||||||
|
item.values = frames[-1].values # type: ignore[method-assign]
|
||||||
|
|
||||||
|
if not item.export_image(output_path):
|
||||||
|
return BatchExportResult(file_path, output_path, False, "Export render failed")
|
||||||
|
return BatchExportResult(file_path, output_path, True)
|
||||||
|
except Exception as exc:
|
||||||
|
return BatchExportResult(file_path, output_path, False, str(exc))
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
"""Tests for src/pygui/backend/wafer/batch_export.py."""
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures("qapp")
|
||||||
|
|
||||||
|
|
||||||
|
def _write_single_frame_csv(path, wafer_id="F12"):
|
||||||
|
lines = [
|
||||||
|
f"Wafer ID={wafer_id}",
|
||||||
|
"Acquisition Date=03/26/2025",
|
||||||
|
"Label,1,2,3",
|
||||||
|
"X (mm),0,10,20",
|
||||||
|
"Y (mm),0,10,20",
|
||||||
|
"data",
|
||||||
|
"0.0,149.0,150.0,151.0",
|
||||||
|
]
|
||||||
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_batch_writes_png_for_valid_file(tmp_path):
|
||||||
|
from pygui.backend.wafer.batch_export import export_batch
|
||||||
|
|
||||||
|
csv_path = tmp_path / "f_run.csv"
|
||||||
|
_write_single_frame_csv(csv_path)
|
||||||
|
out_dir = tmp_path / "out"
|
||||||
|
out_dir.mkdir()
|
||||||
|
|
||||||
|
results = export_batch([str(csv_path)], str(out_dir), timestamp="20260101_000000")
|
||||||
|
|
||||||
|
assert len(results) == 1
|
||||||
|
assert results[0].success is True
|
||||||
|
expected_png = out_dir / "f_run_20260101_000000.png"
|
||||||
|
assert expected_png.exists()
|
||||||
|
assert results[0].output_path == str(expected_png)
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_batch_skips_bad_file_and_continues(tmp_path):
|
||||||
|
from pygui.backend.wafer.batch_export import export_batch
|
||||||
|
|
||||||
|
bad_csv = tmp_path / "corrupt.csv"
|
||||||
|
bad_csv.write_text("not a valid wafer csv\n", encoding="utf-8")
|
||||||
|
|
||||||
|
good_csv = tmp_path / "f_run.csv"
|
||||||
|
_write_single_frame_csv(good_csv)
|
||||||
|
|
||||||
|
out_dir = tmp_path / "out"
|
||||||
|
out_dir.mkdir()
|
||||||
|
|
||||||
|
results = export_batch(
|
||||||
|
[str(bad_csv), str(good_csv)], str(out_dir), timestamp="20260101_000000"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(results) == 2
|
||||||
|
assert results[0].success is False
|
||||||
|
assert results[0].error != ""
|
||||||
|
assert not (out_dir / "corrupt_20260101_000000.png").exists()
|
||||||
|
|
||||||
|
assert results[1].success is True
|
||||||
|
assert (out_dir / "f_run_20260101_000000.png").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_two_frame_csv(path, wafer_id="F12"):
|
||||||
|
lines = [
|
||||||
|
f"Wafer ID={wafer_id}",
|
||||||
|
"Acquisition Date=03/26/2025",
|
||||||
|
"Label,1,2,3",
|
||||||
|
"X (mm),0,10,20",
|
||||||
|
"Y (mm),0,10,20",
|
||||||
|
"data",
|
||||||
|
"0.0,100.0,101.0,102.0",
|
||||||
|
"1.0,149.0,150.0,151.0",
|
||||||
|
]
|
||||||
|
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_batch_renders_last_frame_of_multiframe_file(tmp_path):
|
||||||
|
from pygui.backend.wafer.batch_export import export_batch
|
||||||
|
|
||||||
|
multi_csv = tmp_path / "multi.csv"
|
||||||
|
_write_two_frame_csv(multi_csv)
|
||||||
|
|
||||||
|
single_csv = tmp_path / "single.csv"
|
||||||
|
_write_single_frame_csv(single_csv) # same values as multi's 2nd (last) row
|
||||||
|
|
||||||
|
out_dir = tmp_path / "out"
|
||||||
|
out_dir.mkdir()
|
||||||
|
|
||||||
|
export_batch([str(multi_csv)], str(out_dir), timestamp="20260101_000000")
|
||||||
|
export_batch([str(single_csv)], str(out_dir), timestamp="20260101_000000")
|
||||||
|
|
||||||
|
multi_png = (out_dir / "multi_20260101_000000.png").read_bytes()
|
||||||
|
single_png = (out_dir / "single_20260101_000000.png").read_bytes()
|
||||||
|
assert multi_png == single_png
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Tests for src/pygui/backend/controllers/batch_export_controller.py."""
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pygui.backend.controllers.batch_export_controller import BatchExportController
|
||||||
|
from pygui.backend.wafer.batch_export import BatchExportResult
|
||||||
|
|
||||||
|
pytestmark = pytest.mark.usefixtures("qapp")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def controller():
|
||||||
|
return BatchExportController()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_spawns_thread_and_sets_busy(controller):
|
||||||
|
with patch("threading.Thread") as mock_thread:
|
||||||
|
controller.start(["a.csv", "b.csv"], "/tmp/out")
|
||||||
|
mock_thread.assert_called_once()
|
||||||
|
assert controller.busy is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_ignores_call_while_already_busy(controller):
|
||||||
|
with patch("threading.Thread") as mock_thread:
|
||||||
|
controller.start(["a.csv"], "/tmp/out")
|
||||||
|
controller.start(["b.csv"], "/tmp/out")
|
||||||
|
mock_thread.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_batch_finished_reports_all_succeeded(controller):
|
||||||
|
finished = MagicMock()
|
||||||
|
controller.exportFinished.connect(finished)
|
||||||
|
controller._busy = True
|
||||||
|
|
||||||
|
results = [
|
||||||
|
BatchExportResult("a.csv", "a.png", True),
|
||||||
|
BatchExportResult("b.csv", "b.png", True),
|
||||||
|
BatchExportResult("c.csv", "c.png", True),
|
||||||
|
]
|
||||||
|
controller._handle_batch_finished(results)
|
||||||
|
|
||||||
|
finished.assert_called_once_with("Exported 3/3")
|
||||||
|
assert controller.busy is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_handle_batch_finished_reports_failures(controller):
|
||||||
|
finished = MagicMock()
|
||||||
|
controller.exportFinished.connect(finished)
|
||||||
|
controller._busy = True
|
||||||
|
|
||||||
|
results = [
|
||||||
|
BatchExportResult("a.csv", "a.png", True),
|
||||||
|
BatchExportResult("b.csv", "b.png", False, "parse error"),
|
||||||
|
]
|
||||||
|
controller._handle_batch_finished(results)
|
||||||
|
|
||||||
|
finished.assert_called_once_with("Exported 1/2 — 1 failed")
|
||||||
|
assert controller.busy is False
|
||||||
Reference in New Issue
Block a user