diff --git a/src/pygui/ISC/Tabs/WaferMapTab.qml b/src/pygui/ISC/Tabs/WaferMapTab.qml index 122e8bf..1755020 100644 --- a/src/pygui/ISC/Tabs/WaferMapTab.qml +++ b/src/pygui/ISC/Tabs/WaferMapTab.qml @@ -1,5 +1,6 @@ import QtQuick import QtQuick.Controls +import QtQuick.Controls.impl import QtQuick.Dialogs import QtQuick.Layouts import ISC @@ -30,11 +31,23 @@ Item { // into the trend chart's data property. The slot parseJsonToData() is defined // on the Python TrendChartItem; we use it so malformed payloads are logged // there instead of crashing the QML binding. + property string loadErrorMessage: "" Connections { target: streamController function onTrendData(avgsJson) { trendChart.setDataFromJson(avgsJson); } + // A file was picked from the rail (any tab) — loadFile() switches the + // backend to review mode; mirror that in the toolbar toggle so a + // successful load is never hidden behind a stale "Live" selection. + function onLoadedFileChanged() { + root.loadErrorMessage = ""; + if (streamController.mode === "review") + modeBar.currentIndex = 0; + } + function onLoadFileError(message) { + root.loadErrorMessage = message; + } } ColumnLayout { @@ -118,12 +131,13 @@ Item { RowLayout { visible: streamController.mode === "live" spacing: 5 - // WiFi icon (Unicode approximation; swap for SVG if available) - Label { + // Live status dot (matches the connection-flow indicator dot elsewhere) + Rectangle { id: liveIndicator - text: "◉" + width: 8; height: 8 + radius: 4 + Layout.alignment: Qt.AlignVCenter color: (deviceController.connectionStatus === "Connected" && streamController.state !== "idle") ? Theme.liveColor : Theme.bodyColor - font.pixelSize: Theme.fontSm SequentialAnimation on opacity { running: deviceController.connectionStatus === "Connected" && streamController.state !== "idle" @@ -250,6 +264,41 @@ Item { } } + // ── Load error banner ─────────────────────────────────────────── + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: errorRow.implicitHeight + 16 + visible: root.loadErrorMessage !== "" + color: Theme.errorSurface + border.color: Theme.statusErrorColor + border.width: 1 + radius: Theme.radiusSm + + RowLayout { + id: errorRow + anchors.fill: parent + anchors.margins: 8 + spacing: 8 + + IconImage { + source: "icons/triangle-alert.svg" + width: 16; height: 16 + sourceSize.width: 16 + sourceSize.height: 16 + color: Theme.errorTextSoft + Layout.alignment: Qt.AlignTop + } + + Label { + text: root.loadErrorMessage + color: Theme.errorTextSoft + font.pixelSize: Theme.fontSm + wrapMode: Text.WordWrap + Layout.fillWidth: true + } + } + } + // ── Body: 2 columns (wafer + trend + transport | readout) ────────── RowLayout { Layout.fillWidth: true diff --git a/src/pygui/ISC/Tabs/components/SourcePanel.qml b/src/pygui/ISC/Tabs/components/SourcePanel.qml index 188266c..577ef2f 100644 --- a/src/pygui/ISC/Tabs/components/SourcePanel.qml +++ b/src/pygui/ISC/Tabs/components/SourcePanel.qml @@ -122,10 +122,12 @@ ColumnLayout { } contentItem: RowLayout { spacing: 6 - Label { - text: "▤" + IconImage { + source: "../icons/folder.svg" + width: 14; height: 14 + sourceSize.width: 14 + sourceSize.height: 14 color: Theme.bodyColor - font.pixelSize: Theme.fontSm } Label { text: file_browser.currentDirectory.split("/").pop() || file_browser.currentDirectory @@ -204,7 +206,7 @@ ColumnLayout { onClicked: streamController.loadFile(modelData.fileName) background: Rectangle { - color: fileItem.isActive ? Qt.rgba(1, 1, 1, 0.06) : fileItem.hovered ? Qt.rgba(1, 1, 1, 0.04) : "transparent" + color: fileItem.isActive ? Theme.listActiveBackground : fileItem.hovered ? Theme.listHoverBackground : "transparent" radius: Theme.radiusSm Behavior on color { ColorAnimation { duration: Theme.durationFast } @@ -225,11 +227,11 @@ ColumnLayout { color: { var t = modelData.waferType; if (t === "A" || t === "E" || t === "P") - return "#3B82F6"; + return Theme.familyBlueAccent; if (t === "B" || t === "C" || t === "D") - return "#10B981"; + return Theme.familyGreenAccent; if (t === "Z") - return "#8B5CF6"; + return Theme.familyVioletAccent; return Theme.headingColor; } } @@ -249,19 +251,36 @@ ColumnLayout { color: { var t = modelData.waferType; if (t === "A" || t === "E" || t === "P") - return "#1D4ED8"; + return Theme.familyBlueFill; if (t === "B" || t === "C" || t === "D") - return "#065F46"; + return Theme.familyGreenFill; if (t === "Z") - return "#7C3AED"; - return "#374151"; + return Theme.familyVioletFill; + return Theme.familyNeutralFill; } Label { anchors.centerIn: parent text: modelData.waferType || "?" font.pixelSize: Theme.fontMd font.weight: Font.Bold - color: "#FFFFFF" + color: Theme.textOnColor + } + + // Recording indicator dot — distinguishes a live-recorded CSV + // (SessionController.startRecording) from a read-memory dump + // (DeviceController.parseAndSaveData). + Rectangle { + visible: modelData.isRecording === true + width: 10 + height: 10 + radius: 5 + color: Theme.recordColor + border.color: Theme.subtleSectionBackground + border.width: 1.5 + anchors.top: parent.top + anchors.right: parent.right + anchors.topMargin: -2 + anchors.rightMargin: -2 } } @@ -270,14 +289,38 @@ ColumnLayout { spacing: 1 Layout.fillWidth: true - // Primary: wafer type + serial number - Label { - text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName - color: Theme.headingColor - font.pixelSize: Theme.fontMd - font.weight: Font.Bold - elide: Text.ElideRight + // Primary: wafer type + serial number, plus a REC badge for recordings + RowLayout { Layout.fillWidth: true + spacing: 6 + + Label { + text: modelData.serialNumber ? (modelData.waferType + modelData.serialNumber) : modelData.baseName + color: Theme.headingColor + font.pixelSize: Theme.fontMd + font.weight: Font.Bold + elide: Text.ElideRight + Layout.fillWidth: true + } + + Rectangle { + visible: modelData.isRecording === true + radius: 4 + color: Qt.alpha(Theme.recordColor, 0.18) + implicitWidth: recLabel.implicitWidth + 10 + implicitHeight: recLabel.implicitHeight + 4 + Layout.alignment: Qt.AlignVCenter + + Label { + id: recLabel + anchors.centerIn: parent + text: "REC" + color: Theme.recordColor + font.pixelSize: Theme.font2xs + font.bold: true + font.letterSpacing: 0.6 + } + } } // Secondary: date · time diff --git a/src/pygui/backend/data/file_browser.py b/src/pygui/backend/data/file_browser.py index 220dd33..9fa6075 100644 --- a/src/pygui/backend/data/file_browser.py +++ b/src/pygui/backend/data/file_browser.py @@ -119,6 +119,11 @@ class FileBrowser(QObject): return for csv_path in sorted(self._current_directory.glob("*.csv")): + # Recorded-live CSVs are named "live__.csv" by + # SessionController.startRecording (see WaferMapTab's Record button), + # distinct from the "-.csv" convention used by + # DeviceController.parseAndSaveData for read-memory dumps. + is_recording = csv_path.stem.lower().startswith("live_") try: metadata = self._load_metadata(csv_path) wafer_type = metadata.get_wafer_type().upper() or (csv_path.stem[0].upper() if csv_path.stem else "") @@ -146,6 +151,7 @@ class FileBrowser(QObject): "masterType": master_state.get(str(csv_path), "") or metadata.master_type, "fileName": str(csv_path), "highlight": wafer_type in {"A", "B", "C"}, + "isRecording": is_recording, } ) except Exception: @@ -169,6 +175,7 @@ class FileBrowser(QObject): "masterType": master_state.get(str(csv_path), ""), "fileName": str(csv_path), "highlight": False, + "isRecording": is_recording, } ) diff --git a/tests/test_file_browser.py b/tests/test_file_browser.py new file mode 100644 index 0000000..463d163 --- /dev/null +++ b/tests/test_file_browser.py @@ -0,0 +1,30 @@ +from pathlib import Path + +from pygui.backend.data.file_browser import FileBrowser + + +def _write_csv(path, wafer_id="P00001"): + path.write_text( + f"Wafer ID={wafer_id}\n" + "Acquisition Date=03/26/2025\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" + ) + + +def test_recorded_live_file_flagged_as_recording(qapp, tmp_path): + """Files named live__.csv (SessionController.startRecording) + must be distinguishable in the browser from read-memory dumps.""" + _write_csv(tmp_path / "live_P00001_20260706_143022.csv") + _write_csv(tmp_path / "P00002-20260706_143500.csv") + + browser = FileBrowser() + browser._set_current_directory(tmp_path) + browser.refreshFiles() + + files_by_name = {Path(row["fileName"]).name: row for row in browser.files} + assert files_by_name["live_P00001_20260706_143022.csv"]["isRecording"] is True + assert files_by_name["P00002-20260706_143500.csv"]["isRecording"] is False diff --git a/tests/test_session_controller.py b/tests/test_session_controller.py index 954c1a2..d180275 100644 --- a/tests/test_session_controller.py +++ b/tests/test_session_controller.py @@ -47,6 +47,36 @@ def test_pause_stop_timer(controller): controller.stop() assert controller.frameIndex == 0 +def test_load_file_emits_error_for_empty_recording(controller, tmp_path): + """A recording with a valid header but zero data rows (e.g. Record was + started/stopped without any live frames arriving) must surface a clear + loadFileError instead of silently doing nothing.""" + empty_recording = tmp_path / "live_A00001_20260706_120427.csv" + empty_recording.write_text( + "Wafer ID=A00001\n" + "Acquisition Date=07/06/2026\n" + "Label,1,2\n" + "X (mm),0.0,10.0\n" + "Y (mm),10.0,-10.0\n" + "data\n" + ) + + mock_slot = MagicMock() + controller.loadFileError.connect(mock_slot) + controller.loadFile(str(empty_recording)) + + assert mock_slot.call_count == 1 + assert "no recorded frames" in mock_slot.call_args[0][0] + assert controller.loadedFile == "" + +def test_load_file_switches_to_review_mode(controller): + """Loading a file for playback must always land in review mode, even if + the controller was left in live mode from a previous session — otherwise + a successful load can be invisible behind a stale Live toggle.""" + controller._mode = "live" + controller.loadFile("tests/fixtures/sample_stream.csv") + assert controller.mode == "review" + def test_compare_files_integration(controller): # Mock the comparisonResult signal mock_slot = MagicMock() @@ -65,6 +95,96 @@ def test_compare_files_integration(controller): assert "series_b" in args assert args["frame_offset"] == 0 +def test_compare_files_uses_full_run_not_first_100_frames(controller, tmp_path): + """compareFiles must score the whole run, not just an initial 100-frame window. + + Regression test: max_frames used to be hard-capped at 100, so a >100-frame + run's soak/cool phases were silently excluded from the DTW comparison. + """ + num_frames = 150 + csv_path = tmp_path / "long_run.csv" + lines = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3", + "X (mm),0,10,-10", "Y (mm),10,-10,-10", "data"] + for i in range(num_frames): + t = i * 0.5 + lines.append(f"{t},{149.0 + i * 0.01},{148.5 + i * 0.01},{150.6 + i * 0.01}") + csv_path.write_text("\n".join(lines) + "\n") + + mock_slot = MagicMock() + controller.comparisonResult.connect(mock_slot) + controller.compareFiles(str(csv_path), str(csv_path)) + + assert mock_slot.call_count == 1 + result = mock_slot.call_args[0][0] + assert result["success"] is True + assert len(result["series_a"]) == num_frames + assert len(result["series_b"]) == num_frames + +def test_compare_files_rejects_mismatched_wafer_types(controller, tmp_path): + """compareFiles must refuse to compare two different wafer families. + + Otherwise the overlap view silently truncates to min(sensor_count) and + pairs up unrelated physical sensors between the two wafer types. + """ + header = ["Wafer ID=A12", "Acquisition Date=03/26/2025", "Label,1,2,3", + "X (mm),0,10,-10", "Y (mm),10,-10,-10", "data", + "0.0,149.0,148.5,150.6"] + file_a = tmp_path / "A00055-20250326.csv" + file_b = tmp_path / "X00108-20250326.csv" + file_a.write_text("\n".join(header) + "\n") + file_b.write_text("\n".join(header) + "\n") + + mock_slot = MagicMock() + controller.comparisonResult.connect(mock_slot) + controller.compareFiles(str(file_a), str(file_b)) + + assert mock_slot.call_count == 1 + result = mock_slot.call_args[0][0] + assert result["success"] is False + assert "A" in result["error"] and "X" in result["error"] + +def test_compare_files_rejects_recording_vs_read_mem(controller, tmp_path): + """A live_-prefixed recording must not be compared against a read-memory + dump. Regression: stem[0] on "live_P0001_..." is "L", which used to be + misreported as a wafer-type mismatch ("L vs P") instead of this clearer + recording-vs-read-mem error. + """ + header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3", + "X (mm),0,10,-10", "Y (mm),10,-10,-10", "data", + "0.0,149.0,148.5,150.6"] + recording = tmp_path / "live_P0001_20260706_143022.csv" + read_mem = tmp_path / "P0001-20260706_143500.csv" + recording.write_text("\n".join(header) + "\n") + read_mem.write_text("\n".join(header) + "\n") + + mock_slot = MagicMock() + controller.comparisonResult.connect(mock_slot) + controller.compareFiles(str(recording), str(read_mem)) + + assert mock_slot.call_count == 1 + result = mock_slot.call_args[0][0] + assert result["success"] is False + assert result["error"] == "Cannot compare a recording file with a read-memory file" + +def test_compare_files_allows_two_recordings_of_same_wafer_type(controller, tmp_path): + """Two live recordings of the same wafer family should compare fine — + the "live_" prefix must not be mistaken for the wafer type letter.""" + header = ["Wafer ID=P0001", "Acquisition Date=03/26/2025", "Label,1,2,3", + "X (mm),0,10,-10", "Y (mm),10,-10,-10", "data", + "0.0,149.0,148.5,150.6"] + file_a = tmp_path / "live_P0001_20260706_143022.csv" + file_b = tmp_path / "live_P0002_20260706_150000.csv" + file_a.write_text("\n".join(header) + "\n") + file_b.write_text("\n".join(header) + "\n") + + mock_slot = MagicMock() + controller.comparisonResult.connect(mock_slot) + controller.compareFiles(str(file_a), str(file_b)) + + assert mock_slot.call_count == 1 + result = mock_slot.call_args[0][0] + assert result["success"] is True + def test_comparison_result_reaches_qml(qapp, controller): """The result dict must arrive in QML as a real JS object with fields.