feat(import): Z-wafer CSV import button with result banner
- importZWaferCsv slot on FileBrowser — converts and adds to list - SourcePanel: import icon button → FileDialog → importSummary banner (auto-dismisses after 5s, loads the converted file into streamController) - wafer_map_item: showExtremes default → true
This commit is contained in:
-25
@@ -1,25 +0,0 @@
|
|||||||
# 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,5 +1,6 @@
|
|||||||
import QtQuick
|
import QtQuick
|
||||||
import QtQuick.Controls
|
import QtQuick.Controls
|
||||||
|
import QtQuick.Controls.impl
|
||||||
import QtQuick.Dialogs
|
import QtQuick.Dialogs
|
||||||
import QtQuick.Layouts
|
import QtQuick.Layouts
|
||||||
import ISC
|
import ISC
|
||||||
@@ -146,17 +147,55 @@ ColumnLayout {
|
|||||||
|
|
||||||
SectionTitle { text: "DISPLAY" }
|
SectionTitle { text: "DISPLAY" }
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
Layout.fillWidth: true
|
||||||
|
spacing: 4
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
id: thicknessToggle
|
id: thicknessToggle
|
||||||
text: "Show Thickness"
|
text: "Show Thickness"
|
||||||
checked: false
|
checked: false
|
||||||
font.pixelSize: Theme.fontSm
|
font.pixelSize: Theme.fontSm
|
||||||
|
hoverEnabled: true
|
||||||
// First check with no data prompts for the customer CSV;
|
// First check with no data prompts for the customer CSV;
|
||||||
// unchecking only hides the overlay, data stays loaded.
|
// unchecking only hides the overlay, data stays loaded.
|
||||||
onToggled: {
|
onToggled: {
|
||||||
if (checked && !root.hasThicknessData)
|
if (checked && !root.hasThicknessData)
|
||||||
thicknessDialog.open();
|
thicknessDialog.open();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: thicknessToggle.hovered
|
||||||
|
text: "Overlays customer-provided wafer thickness data\nto check if temperature spots line up with\nphysical thickness variation."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Item { Layout.fillWidth: true }
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: changeThicknessFileBtn
|
||||||
|
visible: root.hasThicknessData
|
||||||
|
implicitWidth: 26
|
||||||
|
implicitHeight: 26
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: changeThicknessFileBtn.pressed ? Theme.buttonPressed
|
||||||
|
: changeThicknessFileBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "../icons/folder.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.bodyColor
|
||||||
|
}
|
||||||
|
onClicked: thicknessDialog.open()
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: changeThicknessFileBtn.hovered
|
||||||
|
text: "Load a different thickness file"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
FileDialog {
|
FileDialog {
|
||||||
@@ -164,7 +203,9 @@ ColumnLayout {
|
|||||||
title: "Select Thickness Data File"
|
title: "Select Thickness Data File"
|
||||||
nameFilters: ["CSV files (*.csv)"]
|
nameFilters: ["CSV files (*.csv)"]
|
||||||
onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
|
onAccepted: root.thicknessFileChosen(String(selectedFile).replace(/^file:\/\//, ""))
|
||||||
onRejected: thicknessToggle.checked = false
|
// Only uncheck on cancel if there was nothing loaded yet —
|
||||||
|
// canceling a file swap shouldn't hide an already-visible overlay.
|
||||||
|
onRejected: if (!root.hasThicknessData) thicknessToggle.checked = false
|
||||||
}
|
}
|
||||||
|
|
||||||
PanelCheckBox {
|
PanelCheckBox {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ ColumnLayout {
|
|||||||
spacing: 6
|
spacing: 6
|
||||||
|
|
||||||
property string exportSummary: ""
|
property string exportSummary: ""
|
||||||
|
property string importSummary: ""
|
||||||
|
|
||||||
Connections {
|
Connections {
|
||||||
target: batchExportController
|
target: batchExportController
|
||||||
@@ -20,12 +21,28 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Connections {
|
||||||
|
target: file_browser
|
||||||
|
function onImportFinished(path, message) {
|
||||||
|
root.importSummary = message
|
||||||
|
importBannerHideTimer.restart()
|
||||||
|
if (path !== "")
|
||||||
|
streamController.loadFile(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Timer {
|
Timer {
|
||||||
id: exportBannerHideTimer
|
id: exportBannerHideTimer
|
||||||
interval: 5000
|
interval: 5000
|
||||||
onTriggered: root.exportSummary = ""
|
onTriggered: root.exportSummary = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Timer {
|
||||||
|
id: importBannerHideTimer
|
||||||
|
interval: 5000
|
||||||
|
onTriggered: root.importSummary = ""
|
||||||
|
}
|
||||||
|
|
||||||
// ── Section label & Refresh ───────────────────────────────────────────
|
// ── Section label & Refresh ───────────────────────────────────────────
|
||||||
RowLayout {
|
RowLayout {
|
||||||
Layout.fillWidth: true
|
Layout.fillWidth: true
|
||||||
@@ -93,6 +110,29 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
id: importBtn
|
||||||
|
implicitWidth: 28
|
||||||
|
implicitHeight: 28
|
||||||
|
hoverEnabled: true
|
||||||
|
background: Rectangle {
|
||||||
|
color: importBtn.pressed ? Theme.buttonPressed : importBtn.hovered ? Theme.buttonNeutralHover : "transparent"
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
}
|
||||||
|
contentItem: IconImage {
|
||||||
|
source: "../icons/import.svg"
|
||||||
|
sourceSize.width: 16
|
||||||
|
sourceSize.height: 16
|
||||||
|
color: Theme.bodyColor
|
||||||
|
}
|
||||||
|
onClicked: importFileDialog.open()
|
||||||
|
|
||||||
|
AppToolTip {
|
||||||
|
visible: importBtn.hovered
|
||||||
|
text: "Import Data — bring in a raw Z-wafer export CSV"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Item { implicitWidth: 8 }
|
Item { implicitWidth: 8 }
|
||||||
|
|
||||||
Button {
|
Button {
|
||||||
@@ -170,6 +210,54 @@ ColumnLayout {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Import summary banner ─────────────────────────────────────────────
|
||||||
|
Rectangle {
|
||||||
|
visible: root.importSummary !== ""
|
||||||
|
Layout.fillWidth: true
|
||||||
|
height: importBannerRow.implicitHeight + 12
|
||||||
|
radius: Theme.radiusSm
|
||||||
|
color: Theme.cardBackground
|
||||||
|
border.color: Theme.statusWarningColor
|
||||||
|
border.width: 1
|
||||||
|
|
||||||
|
RowLayout {
|
||||||
|
id: importBannerRow
|
||||||
|
anchors.fill: parent
|
||||||
|
anchors.margins: 6
|
||||||
|
spacing: 6
|
||||||
|
|
||||||
|
Label {
|
||||||
|
text: root.importSummary
|
||||||
|
color: Theme.statusWarningColor
|
||||||
|
font.pixelSize: Theme.fontSm
|
||||||
|
wrapMode: Text.WordWrap
|
||||||
|
Layout.fillWidth: true
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
flat: true
|
||||||
|
implicitWidth: 20
|
||||||
|
implicitHeight: 20
|
||||||
|
onClicked: root.importSummary = ""
|
||||||
|
background: Rectangle { color: "transparent" }
|
||||||
|
contentItem: Label {
|
||||||
|
text: "✕"
|
||||||
|
color: Theme.statusWarningColor
|
||||||
|
font.pixelSize: Theme.fontXs
|
||||||
|
horizontalAlignment: Text.AlignHCenter
|
||||||
|
verticalAlignment: Text.AlignVCenter
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
FileDialog {
|
||||||
|
id: importFileDialog
|
||||||
|
title: "Select Data File To Import"
|
||||||
|
nameFilters: ["CSV files (*.csv)"]
|
||||||
|
onAccepted: file_browser.importZWaferCsv(String(selectedFile).replace(/^file:\/\//, ""))
|
||||||
|
}
|
||||||
|
|
||||||
FolderDialog {
|
FolderDialog {
|
||||||
id: batchExportDirDialog
|
id: batchExportDirDialog
|
||||||
title: "Export Wafer Maps To…"
|
title: "Export Wafer Maps To…"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<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="M12 3v12" />
|
||||||
|
<path d="m8 11 4 4 4-4" />
|
||||||
|
<path d="M8 5H4a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-4" />
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 350 B |
@@ -18,6 +18,7 @@ class FileBrowser(QObject):
|
|||||||
# ===== Change Signals =====
|
# ===== Change Signals =====
|
||||||
filesChanged = Signal()
|
filesChanged = Signal()
|
||||||
currentDirectoryChanged = Signal()
|
currentDirectoryChanged = Signal()
|
||||||
|
importFinished = Signal(str, str) # (new file path, or "" on failure; message)
|
||||||
|
|
||||||
# ===== Lifecycle =====
|
# ===== Lifecycle =====
|
||||||
def __init__(self, parent: QObject | None = None) -> None:
|
def __init__(self, parent: QObject | None = None) -> None:
|
||||||
@@ -110,6 +111,66 @@ class FileBrowser(QObject):
|
|||||||
self.filesChanged.emit()
|
self.filesChanged.emit()
|
||||||
self._show_info(f"Saved metadata for:\n{csv_path.name}")
|
self._show_info(f"Saved metadata for:\n{csv_path.name}")
|
||||||
|
|
||||||
|
# ===== Z-Wafer Import =====
|
||||||
|
@Slot(str)
|
||||||
|
def importZWaferCsv(self, file_path: str) -> None:
|
||||||
|
"""Port of C#'s ImportZWaferCSV (Form1.cs:1711): parse a raw Z-wafer
|
||||||
|
export, strip its leading row-index column, and write it into the
|
||||||
|
current directory as a normalized `{serial}-{timestamp}.csv`.
|
||||||
|
|
||||||
|
The timestamp is the source file's own acquisition date, not "now" —
|
||||||
|
deliberately, so re-importing the same source collides on purpose.
|
||||||
|
No fallback to wall-clock time: a file with no parseable acquisition
|
||||||
|
date still gets a deterministic (if degenerate) filename, so the
|
||||||
|
duplicate-import guard below still fires on a second attempt.
|
||||||
|
"""
|
||||||
|
source_path = Path(file_path)
|
||||||
|
parser_data, _ = self._parser.parse(file_path)
|
||||||
|
if parser_data is None:
|
||||||
|
self.importFinished.emit("", f"Failed to parse CSV:\n{source_path.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
serial = parser_data.serial or "UNKNOWN"
|
||||||
|
new_filename = f"{serial}-{parser_data.date.strftime('%Y%m%d_%H%M%S')}.csv"
|
||||||
|
new_path = self._current_directory / new_filename
|
||||||
|
|
||||||
|
if new_path.exists():
|
||||||
|
self.importFinished.emit("", f"Already imported to:\n{new_filename}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
data_rows = self._parser.parse_data_rows(file_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self.importFinished.emit("", f"Failed to read data rows:\n{exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._current_directory.mkdir(parents=True, exist_ok=True)
|
||||||
|
lines = [",".join(parser_data.csv_headers), *data_rows]
|
||||||
|
new_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
except Exception as exc:
|
||||||
|
self.importFinished.emit("", f"Failed to write imported file:\n{exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
metadata = CSVFileMetadata(
|
||||||
|
wafer=serial,
|
||||||
|
date=CSVFileMetadata.format_date(parser_data.date),
|
||||||
|
chamber=parser_data.header.get("Chamber", ""),
|
||||||
|
notes=parser_data.header.get("Comments", ""),
|
||||||
|
filename=str(new_path),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
self._sidecar_path(new_path).write_text(
|
||||||
|
json.dumps(metadata.to_dict(), indent=4), encoding="utf-8"
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive UI path
|
||||||
|
self._refresh_files(show_empty_message=False)
|
||||||
|
self.importFinished.emit(str(new_path), f"Imported, but failed to save metadata:\n{exc}")
|
||||||
|
return
|
||||||
|
|
||||||
|
self._refresh_files(show_empty_message=False)
|
||||||
|
self.importFinished.emit(str(new_path), f"Imported {new_filename}")
|
||||||
|
|
||||||
# ===== Internal Data Loading =====
|
# ===== Internal Data Loading =====
|
||||||
def _refresh_files(self, show_empty_message: bool) -> None:
|
def _refresh_files(self, show_empty_message: bool) -> None:
|
||||||
selected_state = {
|
selected_state = {
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ class WaferMapItem(QQuickPaintedItem):
|
|||||||
self._margin: float = 1.0
|
self._margin: float = 1.0
|
||||||
self._blend: float = 0.0
|
self._blend: float = 0.0
|
||||||
self._show_labels: bool = True
|
self._show_labels: bool = True
|
||||||
self._show_extremes: bool = False
|
self._show_extremes: bool = True
|
||||||
self._shape: str = "round"
|
self._shape: str = "round"
|
||||||
self._size: float = 300.0
|
self._size: float = 300.0
|
||||||
self._thickness_data: list[list[float]] = [] # [x_mm, y_mm, t2] triples
|
self._thickness_data: list[list[float]] = [] # [x_mm, y_mm, t2] triples
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional, Tuple
|
from typing import Optional, Tuple
|
||||||
@@ -74,6 +76,31 @@ class ZWaferParser:
|
|||||||
|
|
||||||
return wafer_data # Incomplete file — return partial data
|
return wafer_data # Incomplete file — return partial data
|
||||||
|
|
||||||
|
# ===== Data Row Extraction =====
|
||||||
|
def parse_data_rows(self, file_path: str) -> list[str]:
|
||||||
|
"""Return data rows after the 'data' marker, with the leading
|
||||||
|
row-index column stripped (each row realigned to `csv_headers`).
|
||||||
|
|
||||||
|
Uses the same line normalization as `_process_header` so the split
|
||||||
|
point matches exactly what header parsing used.
|
||||||
|
"""
|
||||||
|
rows: list[str] = []
|
||||||
|
seen_data_marker = False
|
||||||
|
with Path(file_path).open("r", encoding="utf-8") as f:
|
||||||
|
for raw_line in f:
|
||||||
|
line = raw_line.rstrip().rstrip(",").strip()
|
||||||
|
if not line or line.replace(",", "").strip() == "":
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = [p.strip() for p in line.split(",")]
|
||||||
|
if not seen_data_marker:
|
||||||
|
if parts[0].lower() == "data":
|
||||||
|
seen_data_marker = True
|
||||||
|
continue
|
||||||
|
|
||||||
|
rows.append(",".join(parts[1:]))
|
||||||
|
return rows
|
||||||
|
|
||||||
# ===== Metadata Parsing =====
|
# ===== Metadata Parsing =====
|
||||||
def _parse_header_line(self, wafer_data: ZWaferData, parts: list) -> bool:
|
def _parse_header_line(self, wafer_data: ZWaferData, parts: list) -> bool:
|
||||||
"""Parse key=value pairs from header line. Returns True if handled."""
|
"""Parse key=value pairs from header line. Returns True if handled."""
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pygui.backend.data.file_browser import FileBrowser
|
from pygui.backend.data.file_browser import FileBrowser
|
||||||
@@ -15,6 +16,24 @@ def _write_csv(path, wafer_id="P00001"):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_raw_zwafer_export(path, wafer_id="P00001", chamber="Chamber A", comments="test run"):
|
||||||
|
"""A raw fixture-export CSV, as ZWaferParser/ImportZWaferCSV expect it:
|
||||||
|
row-indexed data rows (leading column dropped on import), unlike the
|
||||||
|
already-normalized `{serial}-{timestamp}.csv` files _write_csv produces."""
|
||||||
|
path.write_text(
|
||||||
|
f"Wafer ID={wafer_id}\n"
|
||||||
|
"Acquisition Date=03/26/2025\n"
|
||||||
|
f"Chamber={chamber}\n"
|
||||||
|
f"Comments={comments}\n"
|
||||||
|
"Label,1,2,3\n"
|
||||||
|
"X (mm),0,10,-10\n"
|
||||||
|
"Y (mm),10,-10,-10\n"
|
||||||
|
"data\n"
|
||||||
|
"0.0,149.0,148.5,150.6\n"
|
||||||
|
"1.0,149.2,148.7,150.9\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_recorded_live_file_flagged_as_recording(qapp, tmp_path):
|
def test_recorded_live_file_flagged_as_recording(qapp, tmp_path):
|
||||||
"""Files named live_<serial>_<timestamp>.csv (SessionController.startRecording)
|
"""Files named live_<serial>_<timestamp>.csv (SessionController.startRecording)
|
||||||
must be distinguishable in the browser from read-memory dumps."""
|
must be distinguishable in the browser from read-memory dumps."""
|
||||||
@@ -41,3 +60,103 @@ def test_set_current_directory_updates_dir_and_refreshes(qapp, tmp_path):
|
|||||||
assert browser.currentDirectory == str(tmp_path)
|
assert browser.currentDirectory == str(tmp_path)
|
||||||
assert len(browser.files) == 1
|
assert len(browser.files) == 1
|
||||||
assert Path(browser.files[0]["fileName"]).name == "P00003-20260706_143500.csv"
|
assert Path(browser.files[0]["fileName"]).name == "P00003-20260706_143500.csv"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_zwafer_csv_writes_normalized_file_and_sidecar(qapp, tmp_path):
|
||||||
|
"""Mirrors C#'s ImportZWaferCSV (Form1.cs:1711): strip the leading row-index
|
||||||
|
column, rename to {serial}-{timestamp}.csv (no Z prefix — matches this app's
|
||||||
|
other write paths), write a metadata sidecar, and refresh the file list."""
|
||||||
|
source_dir = tmp_path / "incoming"
|
||||||
|
source_dir.mkdir()
|
||||||
|
source = source_dir / "raw_export.csv"
|
||||||
|
_write_raw_zwafer_export(source)
|
||||||
|
|
||||||
|
browser = FileBrowser()
|
||||||
|
browser._set_current_directory(tmp_path)
|
||||||
|
|
||||||
|
signals = []
|
||||||
|
browser.importFinished.connect(lambda path, message: signals.append((path, message)))
|
||||||
|
browser.importZWaferCsv(str(source))
|
||||||
|
|
||||||
|
new_path = tmp_path / "P00001-20250326_000000.csv"
|
||||||
|
assert new_path.exists()
|
||||||
|
assert new_path.read_text().splitlines() == [
|
||||||
|
"1,2,3",
|
||||||
|
"149.0,148.5,150.6",
|
||||||
|
"149.2,148.7,150.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
sidecar = json.loads((tmp_path / "P00001-20250326_000000.csv.json").read_text())
|
||||||
|
assert sidecar["wafer"] == "P00001"
|
||||||
|
assert sidecar["chamber"] == "Chamber A"
|
||||||
|
assert sidecar["notes"] == "test run"
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0][0] == str(new_path)
|
||||||
|
assert any(Path(row["fileName"]).name == new_path.name for row in browser.files)
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_zwafer_csv_blocks_reimporting_same_source(qapp, tmp_path):
|
||||||
|
"""Re-importing the identical source file must be blocked, not overwritten —
|
||||||
|
the output filename is derived from the source's own acquisition-date
|
||||||
|
header, so a second import of the same file collides on purpose."""
|
||||||
|
source_dir = tmp_path / "incoming"
|
||||||
|
source_dir.mkdir()
|
||||||
|
source = source_dir / "raw_export.csv"
|
||||||
|
_write_raw_zwafer_export(source)
|
||||||
|
|
||||||
|
browser = FileBrowser()
|
||||||
|
browser._set_current_directory(tmp_path)
|
||||||
|
|
||||||
|
signals = []
|
||||||
|
browser.importFinished.connect(lambda path, message: signals.append((path, message)))
|
||||||
|
|
||||||
|
browser.importZWaferCsv(str(source))
|
||||||
|
browser.importZWaferCsv(str(source))
|
||||||
|
|
||||||
|
assert len(signals) == 2
|
||||||
|
assert signals[1][0] == ""
|
||||||
|
assert "already" in signals[1][1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_zwafer_csv_without_date_header_still_blocks_duplicate(qapp, tmp_path):
|
||||||
|
"""No fallback to wall-clock time: a source missing 'Acquisition Date='
|
||||||
|
must still get a deterministic filename, so re-importing it a second
|
||||||
|
time is blocked instead of silently succeeding with a fresh timestamp."""
|
||||||
|
source_dir = tmp_path / "incoming"
|
||||||
|
source_dir.mkdir()
|
||||||
|
source = source_dir / "raw_export.csv"
|
||||||
|
source.write_text(
|
||||||
|
"Wafer ID=P00002\n"
|
||||||
|
"Label,1,2,3\n"
|
||||||
|
"X (mm),0,10,-10\n"
|
||||||
|
"Y (mm),10,-10,-10\n"
|
||||||
|
"data\n"
|
||||||
|
"0.0,149.0,148.5,150.6\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
browser = FileBrowser()
|
||||||
|
browser._set_current_directory(tmp_path)
|
||||||
|
|
||||||
|
signals = []
|
||||||
|
browser.importFinished.connect(lambda path, message: signals.append((path, message)))
|
||||||
|
|
||||||
|
browser.importZWaferCsv(str(source))
|
||||||
|
browser.importZWaferCsv(str(source))
|
||||||
|
|
||||||
|
assert len(signals) == 2
|
||||||
|
assert signals[0][0] != "" # first import succeeds
|
||||||
|
assert signals[1][0] == "" # second is blocked
|
||||||
|
assert "already" in signals[1][1].lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_zwafer_csv_reports_missing_source(qapp, tmp_path):
|
||||||
|
browser = FileBrowser()
|
||||||
|
browser._set_current_directory(tmp_path)
|
||||||
|
|
||||||
|
signals = []
|
||||||
|
browser.importFinished.connect(lambda path, message: signals.append((path, message)))
|
||||||
|
browser.importZWaferCsv(str(tmp_path / "missing.csv"))
|
||||||
|
|
||||||
|
assert len(signals) == 1
|
||||||
|
assert signals[0][0] == ""
|
||||||
|
|||||||
Reference in New Issue
Block a user