diff --git a/gui/dialogs/bmg_prompt_dialog.py b/gui/dialogs/bmg_prompt_dialog.py deleted file mode 100644 index f651f88..0000000 --- a/gui/dialogs/bmg_prompt_dialog.py +++ /dev/null @@ -1,83 +0,0 @@ -"""Dialog shown whenever a BMG plate-reader export is loaded. - -BMG XLSX files do not carry concentration values — the reader assigns -placeholder positions ``1..N`` and flags the MeasurementSet. This dialog -surfaces that fact and offers two choices: - -* **Enter concentrations now** — the caller focuses the inline - concentration table in the Data panel. -* **Later** — closes; the user can edit the inline table at any time, - and :meth:`FittingSession.run_fit` will gate the fit with an error - if they forget. - -The dialog is shown on every BMG import; there is no opt-out. The -notification is important enough (and the corresponding fit-time block -severe enough) that hiding it would trade clarity for a minor -convenience. -""" - -from __future__ import annotations - -from PyQt6.QtCore import Qt -from PyQt6.QtWidgets import ( - QDialog, - QDialogButtonBox, - QLabel, - QPushButton, - QVBoxLayout, -) - - -class BMGConcentrationPromptDialog(QDialog): - """Notification dialog for BMG imports with placeholder concentrations. - - Result codes - ------------ - :class:`QDialog.DialogCode.Accepted` - The user chose **Enter concentrations now** — the caller should - focus the inline concentration table in the Data panel. - :class:`QDialog.DialogCode.Rejected` - The user chose **Later** (or dismissed the dialog). - """ - - def __init__( - self, - *, - filename: str, - n_replicas: int, - n_points: int, - parent=None, - ) -> None: - super().__init__(parent) - self.setWindowTitle('BMG Plate Reader Import') - self.setMinimumWidth(520) - - layout = QVBoxLayout(self) - layout.setSpacing(12) - - intro = QLabel( - f'

Loaded {n_replicas} replicas × {n_points} ' - f'measurement points from
' - f'{filename}.

' - f'

BMG plate exports do not contain concentration ' - f'values. The reader has assigned placeholder positions ' - f'1 through {n_points} so each column is distinguishable.

' - f'

You must enter the real concentration vector before a ' - f'fit can run — fits are blocked while placeholders ' - f'are in place. Click Enter concentrations… to jump ' - f'to the table in the Data panel, or edit it yourself ' - f'whenever you like.

' - ) - intro.setTextFormat(Qt.TextFormat.RichText) - intro.setWordWrap(True) - layout.addWidget(intro) - - buttons = QDialogButtonBox(self) - self._enter_btn = QPushButton('Enter concentrations…', self) - self._enter_btn.setDefault(True) - self._later_btn = QPushButton('Later', self) - buttons.addButton(self._enter_btn, QDialogButtonBox.ButtonRole.AcceptRole) - buttons.addButton(self._later_btn, QDialogButtonBox.ButtonRole.RejectRole) - buttons.accepted.connect(self.accept) - buttons.rejected.connect(self.reject) - layout.addWidget(buttons) diff --git a/gui/fitting_session.py b/gui/fitting_session.py index edaf330..f553742 100644 --- a/gui/fitting_session.py +++ b/gui/fitting_session.py @@ -126,15 +126,16 @@ def run_fit(self) -> None: QMessageBox.warning(self, 'No Data', 'Load a measurement file first.') return - # BMG placeholder guard — fitting with column indices 1..N as + # Placeholder guard — fitting with column indices 1..N as # concentrations would silently produce meaningless Ka values. + # Source-agnostic: any plate-reader import that lacks concentrations + # sets this flag, regardless of instrument. if ms.metadata.get(BMG_PLACEHOLDER_KEY): QMessageBox.warning( self, 'Concentrations Required', - 'BMG import: placeholder concentrations are still in ' - 'place. Enter the real concentration vector in the Data ' - 'panel and click Apply before running the fit.', + 'This dataset has placeholder concentrations. Enter the real ' + 'concentration vector in the Data panel before running the fit.', ) self._data_panel.focus_concentration_table() return @@ -638,30 +639,12 @@ def _on_data_loaded(self, ms: MeasurementSet) -> None: self._refresh_plot() active = ms.n_active total = ms.n_replicas - self.status_message.emit(f'Loaded: {ms.n_points} pts × {total} replicas — {active}/{total} active') - + msg = f'Loaded: {ms.n_points} pts × {total} replicas — {active}/{total} active' if ms.metadata.get(BMG_PLACEHOLDER_KEY): - self._maybe_show_bmg_prompt(ms) - - def _maybe_show_bmg_prompt(self, ms: MeasurementSet) -> None: - """Surface the BMG placeholder warning on every BMG import. - - No opt-out: the fit-time guard blocks fitting until real - concentrations are supplied, so a one-shot notification here is - the lowest-friction path to making that contract visible. - """ - from gui.dialogs.bmg_prompt_dialog import BMGConcentrationPromptDialog - - source = ms.metadata.get('source_file', '') - filename = Path(source).name if source else '(unknown)' - dlg = BMGConcentrationPromptDialog( - filename=filename, - n_replicas=ms.n_replicas, - n_points=ms.n_points, - parent=self, - ) - if dlg.exec() == dlg.DialogCode.Accepted: - self._data_panel.focus_concentration_table() + # The Data panel shows an inline cue; mirror it transiently in the + # status bar. No modal — import is never interrupted. + msg += ' — enter concentrations before fitting' + self.status_message.emit(msg) def _on_data_cleared(self) -> None: self._state.measurement_set = None diff --git a/gui/widgets/data_panel.py b/gui/widgets/data_panel.py index 743de71..d4a9f70 100644 --- a/gui/widgets/data_panel.py +++ b/gui/widgets/data_panel.py @@ -13,7 +13,6 @@ QGridLayout, QHBoxLayout, QHeaderView, - QInputDialog, QLabel, QMessageBox, QPushButton, @@ -135,6 +134,19 @@ def _build_data_help_html() -> str: """ +def _placeholder_source_name(metadata: dict) -> str: + """Name the import format for the concentration cue, from metadata. + + Source-agnostic: returns the real format when known, a neutral fallback + otherwise. Never hardcodes a single instrument. + """ + if ENSIGHT_METADATA_KEY in metadata: + return "This EnSight export" + if BMG_METADATA_KEY in metadata: + return "This BMG export" + return "This plate-reader export" + + def _fmt_cell(value: float) -> str: """Format a float for the concentration table — short, scientific when needed.""" if value == 0.0: @@ -175,6 +187,11 @@ def __init__(self, parent=None, *, initial_display_unit: str = DEFAULT_DISPLAY_U self._face_values: np.ndarray = np.array([], dtype=np.float64) self._imported_unit: str = DEFAULT_IMPORTED_UNIT self._suppress_cell_signal: bool = False + # Multi-channel support: readers that emit a `channel` column (e.g. + # EnSight) keep ALL channels in memory here so the user can switch + # between them without re-importing the file. + self._multi_channel_df = None + self._channels: list[str] = [] self._setup_ui(initial_display_unit) # ------------------------------------------------------------------ @@ -209,8 +226,31 @@ def _setup_ui(self, initial_display_unit: str) -> None: self._display_unit_combo.currentTextChanged.connect(self.display_unit_changed.emit) units_grid.addWidget(self._display_unit_combo, 1, 1) + # Channel selector — enabled only when the loaded file carries more + # than one channel (keyed off the presence of a `channel` column, not + # any single format). Switching rebuilds the MeasurementSet in-memory. + units_grid.addWidget(QLabel("Channel:"), 2, 0) + self._channel_combo = QComboBox() + self._channel_combo.setEnabled(False) + self._channel_combo.setToolTip( + "Optical channel. Enabled when the imported file has multiple channels." + ) + self._channel_combo.currentIndexChanged.connect(self._on_channel_changed) + units_grid.addWidget(self._channel_combo, 2, 1) + layout.addLayout(units_grid) + # Inline cue shown when imported data has no real concentrations + # (plate-reader exports). Source-agnostic; auto-hidden otherwise. + self._placeholder_banner = QLabel("") + self._placeholder_banner.setWordWrap(True) + self._placeholder_banner.setStyleSheet( + "QLabel { background: #fff3cd; color: #664d03; " + "border: 1px solid #ffe69c; border-radius: 4px; padding: 6px; }" + ) + self._placeholder_banner.setVisible(False) + layout.addWidget(self._placeholder_banner) + # Concentration table — single editable column of face values. # Every successful cell commit rebuilds the MeasurementSet immediately; # there is no batched-apply state, so no widget can ever be staler @@ -252,7 +292,14 @@ def _setup_ui(self, initial_display_unit: str) -> None: # ------------------------------------------------------------------ def load_file(self, path: str | None = None) -> None: - """Load a measurement file, optionally bypassing the dialog.""" + """Load a measurement file, optionally bypassing the dialog. + + The import path is identical for every format: parse → build the + MeasurementSet → emit. No modal dialog runs mid-load. When the + parsed frame carries multiple channels, the full frame is kept in + memory and the first channel is shown; the Channel combo lets the + user switch without re-reading the file. + """ if path is None: path, _ = QFileDialog.getOpenFileName( self, "Load Measurement File", "", _build_file_filter() @@ -261,21 +308,22 @@ def load_file(self, path: str | None = None) -> None: return try: df = load_measurements(path) - df = self._select_ensight_channel(df) - if df is None: - return - extra_metadata: dict = { - k: df.attrs[k] - for k in (BMG_PLACEHOLDER_KEY, BMG_METADATA_KEY, ENSIGHT_METADATA_KEY) - if k in df.attrs - } - ms = MeasurementSet.from_dataframe( - df, source_file=str(path), **extra_metadata - ) except Exception as exc: QMessageBox.warning(self, "Load Error", f"Could not load file:\n{exc}") return + has_channels = ENSIGHT_CHANNEL_COLUMN in df.columns + channels = list(df[ENSIGHT_CHANNEL_COLUMN].unique()) if has_channels else [] + try: + df_used = self._slice_channel(df, channels[0]) if channels else df + ms = self._make_ms(df_used, str(path)) + except Exception as exc: + QMessageBox.warning(self, "Load Error", f"Could not load file:\n{exc}") + return + + # Commit state only after a successful parse + build. + self._multi_channel_df = df if has_channels else None + self._channels = channels self._ms = ms self._source_path = path # Numbers in the file are taken as face values (no implicit unit @@ -283,51 +331,101 @@ def load_file(self, path: str | None = None) -> None: # historical loader behavior and preserves fits on existing files. self._face_values = np.asarray(ms.concentrations, dtype=np.float64).copy() self._imported_unit = DEFAULT_IMPORTED_UNIT + self._populate_channel_combo(df) self._refresh_after_load() + self._update_placeholder_cue(ms) self.data_loaded.emit(ms) - def _select_ensight_channel(self, df): - """Filter a multi-channel EnSight frame down to one user-picked channel. - - Returns the channel-filtered DataFrame, or ``None`` if the user - cancelled the picker. Single-channel files just drop the column. - Non-EnSight frames pass through unchanged. - """ - if ENSIGHT_CHANNEL_COLUMN not in df.columns: - return df - channels = list(df[ENSIGHT_CHANNEL_COLUMN].unique()) - if len(channels) == 1: - return df.drop(columns=ENSIGHT_CHANNEL_COLUMN) - meta = df.attrs.get(ENSIGHT_METADATA_KEY, {}) - labels = [format_channel_label(c, meta) for c in channels] - label, ok = QInputDialog.getItem( - self, - "Select EnSight channel", - "This file contains multiple optical channels. Pick one to load:", - labels, - 0, - False, + @staticmethod + def _make_ms(df, source_file: str) -> MeasurementSet: + """Build a MeasurementSet from a single-channel frame, forwarding the + placeholder/metadata flags that downstream code keys off.""" + extra_metadata = { + k: df.attrs[k] + for k in (BMG_PLACEHOLDER_KEY, BMG_METADATA_KEY, ENSIGHT_METADATA_KEY) + if k in df.attrs + } + return MeasurementSet.from_dataframe( + df, source_file=source_file, **extra_metadata ) - if not ok: - return None - picked = channels[labels.index(label)] - out = df[df[ENSIGHT_CHANNEL_COLUMN] == picked].drop( - columns=ENSIGHT_CHANNEL_COLUMN + + @staticmethod + def _slice_channel(df, channel: str): + """Return the single-channel sub-frame for *channel*, attrs preserved.""" + out = ( + df[df[ENSIGHT_CHANNEL_COLUMN] == channel] + .drop(columns=ENSIGHT_CHANNEL_COLUMN) + .reset_index(drop=True) ) - # Preserve df.attrs through the filter out.attrs.update(df.attrs) - out.attrs.setdefault(ENSIGHT_METADATA_KEY, {}) if isinstance(out.attrs.get(ENSIGHT_METADATA_KEY), dict): out.attrs[ENSIGHT_METADATA_KEY] = { **out.attrs[ENSIGHT_METADATA_KEY], - "selected_channel": picked, + "selected_channel": channel, } return out + def _populate_channel_combo(self, df) -> None: + """Fill the channel combo from the loaded frame; disable if ≤1 channel.""" + meta = df.attrs.get(ENSIGHT_METADATA_KEY, {}) if self._channels else {} + self._channel_combo.blockSignals(True) + self._channel_combo.clear() + for ch in self._channels: + self._channel_combo.addItem(format_channel_label(ch, meta), ch) + self._channel_combo.setCurrentIndex(0 if self._channels else -1) + self._channel_combo.blockSignals(False) + self._channel_combo.setEnabled(len(self._channels) > 1) + + def _on_channel_changed(self, _index: int) -> None: + """Rebuild the MeasurementSet for the newly selected channel in-memory. + + The concentration vector is physically identical across channels + (same plate columns), so a vector the user has already entered is + carried over; everything channel-specific (signals, fit, outliers) + resets via the downstream ``data_loaded`` handler. + """ + if self._multi_channel_df is None or not self._channels: + return + channel = self._channel_combo.currentData() + if channel is None: + return + # Has the user supplied real concentrations yet? Any edit drops the + # placeholder flag, so its absence means "real values are in place". + keep_entered = not ( + self._ms is not None and self._ms.metadata.get(BMG_PLACEHOLDER_KEY) + ) + try: + ms = self._make_ms( + self._slice_channel(self._multi_channel_df, channel), + self._source_path or "", + ) + except Exception as exc: + QMessageBox.warning(self, "Channel Error", f"Could not switch channel:\n{exc}") + return + + reuse = keep_entered and self._face_values.size == ms.n_points + self._ms = ms + if not reuse: + self._face_values = np.asarray(ms.concentrations, dtype=np.float64).copy() + self._refresh_after_load() + if reuse: + # Re-apply the entered vector to the new channel (emits data_loaded). + self._push_buffer_to_ms() + else: + self._update_placeholder_cue(ms) + self.data_loaded.emit(ms) + def clear(self) -> None: self._ms = None self._source_path = None self._face_values = np.array([], dtype=np.float64) + self._multi_channel_df = None + self._channels = [] + self._channel_combo.blockSignals(True) + self._channel_combo.clear() + self._channel_combo.blockSignals(False) + self._channel_combo.setEnabled(False) + self._placeholder_banner.setVisible(False) self._file_label.setText("No file loaded") self._info_label.setText("") self._populate_table() @@ -352,8 +450,8 @@ def set_display_unit(self, unit: str) -> None: def focus_concentration_table(self) -> None: """Bring keyboard focus to the inline table. - Public hook for callers (e.g. BMG import prompt) that previously - opened the modal dialog. + Public hook for callers (e.g. the fit-time placeholder guard) and + for the post-import concentration cue. """ if self._ms is None: return @@ -362,6 +460,25 @@ def focus_concentration_table(self) -> None: self._conc_table.setCurrentCell(0, 0) self._conc_table.editItem(self._conc_table.item(0, 0)) + def _update_placeholder_cue(self, ms: MeasurementSet | None) -> None: + """Show/hide the inline 'enter concentrations' banner. + + Source-agnostic: the message names the actual import format derived + from metadata, never hardcodes one. Shown only while placeholder + concentrations are in place; hidden once real values are entered. + """ + if ms is not None and ms.metadata.get(BMG_PLACEHOLDER_KEY): + source = _placeholder_source_name(ms.metadata) + self._placeholder_banner.setText( + f"{source} has no concentration values — enter the real " + f"concentrations below before fitting." + ) + self._placeholder_banner.setVisible(True) + self.focus_concentration_table() + else: + self._placeholder_banner.clear() + self._placeholder_banner.setVisible(False) + # ------------------------------------------------------------------ # Slots # ------------------------------------------------------------------ @@ -413,6 +530,9 @@ def _push_buffer_to_ms(self) -> None: QMessageBox.warning(self, "Error", f"Failed to apply concentrations:\n{exc}") return self._update_info() + # Applying real concentrations drops the placeholder flag, so the + # inline cue must re-evaluate (and hide) here too. + self._update_placeholder_cue(self._ms) self.data_loaded.emit(self._ms) def _on_load_concentrations(self) -> None: diff --git a/tests/unit/gui/test_data_panel.py b/tests/unit/gui/test_data_panel.py index b19764b..de2aa1c 100644 --- a/tests/unit/gui/test_data_panel.py +++ b/tests/unit/gui/test_data_panel.py @@ -2,12 +2,18 @@ from __future__ import annotations +from pathlib import Path + import numpy as np import pandas as pd import pytest pytest.importorskip("PyQt6") +ENSIGHT_FIXTURE = ( + Path(__file__).parent.parent.parent / "data" / "ensight" / "tryptamine.csv" +) + @pytest.fixture(scope="module") def qapp(): @@ -114,3 +120,167 @@ def test_display_unit_does_not_emit_data_loaded(self, loaded_panel): # Display unit is plot-only state — concentrations are untouched, # so no data_loaded cascade should fire. assert emissions == [] + + +def _multi_channel_frame(): + """Two-channel placeholder frame: chA signals 0.., chB signals 1000...""" + rows = [] + for ch, base in [("chA", 0.0), ("chB", 1000.0)]: + for r in range(2): + for i in range(3): + rows.append( + { + "concentration": float(i + 1), # placeholders 1..3 + "signal": base + r * 10 + i, + "replica": r, + "channel": ch, + } + ) + df = pd.DataFrame(rows) + df.attrs["bmg_placeholder_concentrations"] = True + df.attrs["ensight_metadata"] = {"channels": {"chA": {}, "chB": {}}} + return df + + +@pytest.fixture +def multi_channel_panel(qapp): + """A DataPanel set up as ``load_file`` would leave it for a 2-channel file.""" + from gui.widgets.data_panel import DataPanel + + panel = DataPanel() + df = _multi_channel_frame() + panel._multi_channel_df = df + panel._channels = ["chA", "chB"] + panel._source_path = "fake_ensight.csv" + ms0 = panel._make_ms(panel._slice_channel(df, "chA"), "fake_ensight.csv") + panel._ms = ms0 + panel._face_values = np.asarray(ms0.concentrations, dtype=np.float64).copy() + panel._imported_unit = "M" + panel._populate_channel_combo(df) + panel._refresh_after_load() + panel._update_placeholder_cue(ms0) + return panel + + +class TestChannelCombo: + """The Channel combo is enabled only for multi-channel data.""" + + def test_single_channel_combo_disabled(self, loaded_panel): + # loaded_panel comes from a plain (no `channel` column) frame. + assert not loaded_panel._channel_combo.isEnabled() + assert loaded_panel._channels == [] + + def test_multi_channel_combo_enabled_and_lists_channels(self, multi_channel_panel): + combo = multi_channel_panel._channel_combo + assert combo.isEnabled() + assert combo.count() == 2 + assert [combo.itemData(i) for i in range(combo.count())] == ["chA", "chB"] + + def test_switch_channel_adopts_new_signals(self, multi_channel_panel): + emissions = [] + multi_channel_panel.data_loaded.connect(lambda ms: emissions.append(ms)) + + before = multi_channel_panel.measurement_set().signals.copy() + multi_channel_panel._channel_combo.setCurrentIndex(1) # → chB + + after = multi_channel_panel.measurement_set().signals + assert emissions, "switching channel must emit data_loaded" + assert not np.array_equal(before, after) + # chB signals are offset by 1000 from chA. + np.testing.assert_allclose(after, before + 1000.0) + + def test_switch_preserves_entered_concentrations(self, multi_channel_panel): + # Enter real concentrations on chA (drops the placeholder flag). + real = np.array([1e-6, 2e-6, 3e-6]) + multi_channel_panel._face_values = real.copy() + multi_channel_panel._imported_unit = "M" + multi_channel_panel._push_buffer_to_ms() + assert not multi_channel_panel.measurement_set().metadata.get( + "bmg_placeholder_concentrations" + ) + + # Switch to chB — concentrations must carry over, signals must change. + multi_channel_panel._channel_combo.setCurrentIndex(1) + ms = multi_channel_panel.measurement_set() + np.testing.assert_allclose(ms.concentrations, real) + np.testing.assert_allclose(ms.signals, [[1000, 1001, 1002], [1010, 1011, 1012]]) + + def test_switch_while_placeholder_adopts_new_placeholders(self, multi_channel_panel): + # No real concentrations entered → placeholders persist across switch. + multi_channel_panel._channel_combo.setCurrentIndex(1) + ms = multi_channel_panel.measurement_set() + assert ms.metadata.get("bmg_placeholder_concentrations") + np.testing.assert_allclose(ms.concentrations, [1.0, 2.0, 3.0]) + + +class TestPlaceholderBanner: + """The inline concentration cue is source-agnostic and auto-clears.""" + + def test_banner_shown_for_placeholder_data(self, multi_channel_panel): + banner = multi_channel_panel._placeholder_banner + assert not banner.isHidden() + assert "EnSight" in banner.text() + assert "concentration" in banner.text().lower() + + def test_banner_hidden_after_entering_concentrations(self, multi_channel_panel): + multi_channel_panel._face_values = np.array([1e-6, 2e-6, 3e-6]) + multi_channel_panel._push_buffer_to_ms() + assert multi_channel_panel._placeholder_banner.isHidden() + + def test_banner_hidden_for_non_placeholder_data(self, loaded_panel): + # loaded_panel has real concentrations and no placeholder flag. + loaded_panel._update_placeholder_cue(loaded_panel.measurement_set()) + assert loaded_panel._placeholder_banner.isHidden() + + def test_banner_names_bmg_source(self, qapp): + from gui.widgets.data_panel import _placeholder_source_name + + assert "BMG" in _placeholder_source_name( + {"bmg_metadata": {}, "bmg_placeholder_concentrations": True} + ) + assert "EnSight" in _placeholder_source_name({"ensight_metadata": {}}) + assert _placeholder_source_name({}) == "This plate-reader export" + + +class TestEnsightLoadIntegration: + """End-to-end load of the real EnSight fixture through ``load_file``.""" + + def test_load_real_ensight_no_modal_multichannel(self, qapp, qtbot=None): + if not ENSIGHT_FIXTURE.exists(): + pytest.skip("EnSight fixture missing") + from gui.widgets.data_panel import DataPanel + + panel = DataPanel() + emissions = [] + panel.data_loaded.connect(lambda ms: emissions.append(ms)) + + # load_file must return without any modal interaction. + panel.load_file(str(ENSIGHT_FIXTURE)) + + assert panel.measurement_set() is not None + assert len(emissions) == 1 + # tryptamine.csv has three optical channels. + assert panel._channel_combo.isEnabled() + assert panel._channel_combo.count() == 3 + # Placeholder data → banner shown, names EnSight. + assert not panel._placeholder_banner.isHidden() + assert "EnSight" in panel._placeholder_banner.text() + # 8 replicas × 12 points per channel. + ms = panel.measurement_set() + assert ms.n_replicas == 8 + assert ms.n_points == 12 + + def test_switch_channel_resets_via_emit(self, qapp): + if not ENSIGHT_FIXTURE.exists(): + pytest.skip("EnSight fixture missing") + from gui.widgets.data_panel import DataPanel + + panel = DataPanel() + panel.load_file(str(ENSIGHT_FIXTURE)) + emissions = [] + panel.data_loaded.connect(lambda ms: emissions.append(ms)) + + first = panel.measurement_set().signals.copy() + panel._channel_combo.setCurrentIndex(2) # Fluorescence intensity 1 + assert emissions, "channel switch must re-emit data_loaded" + assert not np.array_equal(first, panel.measurement_set().signals) diff --git a/tests/unit/gui/test_fitting_session_layout.py b/tests/unit/gui/test_fitting_session_layout.py new file mode 100644 index 0000000..9b5ab87 --- /dev/null +++ b/tests/unit/gui/test_fitting_session_layout.py @@ -0,0 +1,56 @@ +"""Regression guard: the plot widget must stay embedded after any import. + +A previous EnSight code path ran a modal ``QInputDialog`` mid-load, which +detached the plot from its ``QStackedWidget`` (it overlapped the sidebar and +floated above the Fit Curve tab). The fix removed the mid-load modal. This +test asserts the plot widget remains embedded — index 0 of the stack, never a +top-level window — across an EnSight import and a subsequent channel switch. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +pytest.importorskip("PyQt6") + +ENSIGHT_FIXTURE = ( + Path(__file__).parent.parent.parent / "data" / "ensight" / "tryptamine.csv" +) + + +@pytest.fixture(scope="module") +def qapp(): + import sys + + from PyQt6.QtWidgets import QApplication + + return QApplication.instance() or QApplication(sys.argv) + + +def _assert_embedded(session): + pw = session._plot_widget + stack = session._plot_stack + assert stack.indexOf(pw) == 0 + assert not pw.isWindow() + assert pw.parent() is stack + + +def test_plot_stays_embedded_through_ensight_load_and_switch(qapp): + if not ENSIGHT_FIXTURE.exists(): + pytest.skip("EnSight fixture missing") + from gui.fitting_session import FittingSession + + session = FittingSession() + session.resize(1200, 800) + _assert_embedded(session) + + session._data_panel.load_file(str(ENSIGHT_FIXTURE)) + qapp.processEvents() + _assert_embedded(session) + + # Channel switch must not detach the plot either. + session._data_panel._channel_combo.setCurrentIndex(2) + qapp.processEvents() + _assert_embedded(session)