diff --git a/src/gleplot/gui/__init__.py b/src/gleplot/gui/__init__.py index 03529ca..bceda6e 100644 --- a/src/gleplot/gui/__init__.py +++ b/src/gleplot/gui/__init__.py @@ -10,4 +10,14 @@ are imported. """ -__all__: list = [] +__all__ = ["open_editor"] + + +def __getattr__(name: str): + # Lazy re-export: keeps `import gleplot.gui` PySide6-free while letting + # embedders write `from gleplot.gui import open_editor`. + if name == "open_editor": + from gleplot.gui.app import open_editor + + return open_editor + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/src/gleplot/gui/app.py b/src/gleplot/gui/app.py index 695af17..b780c66 100644 --- a/src/gleplot/gui/app.py +++ b/src/gleplot/gui/app.py @@ -8,6 +8,11 @@ returns ``0`` immediately without showing the window or entering the event loop. Packaging CI uses this to verify the frozen app imports and builds its main window without a display. + +An optional positional argument names a ``.gle`` file to open on launch +(``gleplot-gui figure.gle``). It is opened *after* the window is shown, and +is ignored entirely under ``--smoke-test`` (smoke-test only verifies +construction; see :func:`main`). """ from __future__ import annotations @@ -40,6 +45,79 @@ def _set_window_icon(app: QApplication) -> None: pass +def open_editor( + path: Optional[str] = None, + *, + parent=None, + settings=None, + gle_executable: Optional[str] = None, +) -> MainWindow: + """Open a gleplot editor window inside an existing ``QApplication``. + + This is the supported embedding API for host applications (e.g. analysis + programs that export ``.gle`` figures and want to hand them to the editor + in-process). Unlike :func:`main`, it never creates a ``QApplication``, + never mutates application-global identity (name, organization, app icon), + and never runs an event loop — the host owns all of that. + + Parameters + ---------- + path : str, optional + A ``.gle`` file to open. May show the same modal prompts as + File ▸ Open (programmatic-file warning, parse-failure fallback). + parent : QWidget, optional + Qt parent for the window. + settings : QSettings, optional + Injected settings store for recent-files/last-dir state; ``None`` + uses gleplot's own ``QSettings("gleplot", "gleplot")``. + gle_executable : str, optional + Explicit GLE binary for preview rendering. When given it overrides + gleplot's persisted GLE-path preference (via + :func:`gleplot.compiler.set_gle_path_override`, which is + process-global) so the editor compiles with the same binary as the + host. ``None`` leaves gleplot's own discovery in effect. + + Returns + ------- + MainWindow + The shown editor window. The caller must keep a reference — Qt does + not keep parentless windows alive on the Python side. + + Raises + ------ + RuntimeError + If no ``QApplication`` exists. Creating one here would be a trap: + the host is expected to own the application and its event loop. + """ + if QApplication.instance() is None: + raise RuntimeError( + "open_editor() requires an existing QApplication; it is an " + "embedding API. Use gleplot.gui.app.main() to run standalone." + ) + + window = MainWindow(parent=parent, settings=settings) + + if gle_executable: + # MainWindow.__init__ applied gleplot's own persisted override; + # re-apply the host's choice (session-only, not persisted). + window.apply_gle_executable(gle_executable) + + # Window-local icon only: setWindowIcon on the QApplication would leak + # gleplot branding onto the host's windows. + try: + icon_path = Path(__file__).resolve().parent / "assets" / "gleplot.png" + if icon_path.is_file(): + window.setWindowIcon(QIcon(str(icon_path))) + except Exception: + pass + + if path is not None: + window.open_path(str(path)) + + window.show() + return window + + def main(argv: Optional[Sequence[str]] = None) -> int: """Launch the gleplot GUI editor. @@ -49,16 +127,38 @@ def main(argv: Optional[Sequence[str]] = None) -> int: Parameters ---------- argv : sequence of str, optional - Command-line arguments. Defaults to ``sys.argv``. + Command-line arguments. Defaults to ``sys.argv``, in which case + ``argv[0]`` (the program name) is skipped when looking for a file + argument. Callers passing an explicit ``argv`` are expected to pass + only real arguments (no leading program name), matching how this + module's own tests invoke ``main([...])`` — so no leading element is + skipped in that case. The first element that isn't the + ``--smoke-test`` flag is treated as a ``.gle`` file path to open. Returns ------- int - The application's exit code. + The application's exit code: ``2`` if a file argument was given but + does not exist (checked before any ``QApplication`` is created), the + Qt event loop's exit code if this call owns the application, or + ``0`` for ``--smoke-test`` or when reusing an existing application. """ args = list(argv) if argv is not None else sys.argv + scan = args[1:] if argv is None else args smoke_test = "--smoke-test" in args + file_arg = next((a for a in scan if a != "--smoke-test"), None) + + # Under --smoke-test, ignore any file argument entirely: smoke-test only + # verifies that construction succeeds, and never shows the window or + # opens anything (see module docstring). + file_path: Optional[Path] = None + if file_arg is not None and not smoke_test: + file_path = Path(file_arg) + if not file_path.is_file(): + print(f"gleplot-gui: no such file: {file_arg}", file=sys.stderr) + return 2 + if smoke_test: # Run headless so this works without a display (e.g. packaging CI). os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") @@ -83,6 +183,12 @@ def main(argv: Optional[Sequence[str]] = None) -> int: window.show() + if file_path is not None: + # After show(): modal prompts the open may raise (programmatic-file + # question, parse-failure fallback) should appear over a visible + # window rather than an invisible one. + window.open_path(str(file_path)) + if owns_app: return app.exec() return 0 diff --git a/src/gleplot/gui/main_window.py b/src/gleplot/gui/main_window.py index df161a8..5f393db 100644 --- a/src/gleplot/gui/main_window.py +++ b/src/gleplot/gui/main_window.py @@ -151,6 +151,10 @@ def __init__( # pinned binary is in effect for the very first render. compiler.set_gle_path_override(self._read_gle_override()) + #: When True, :meth:`closeEvent` skips the dirty-document confirmation + #: (set transiently by :meth:`force_close` for embedder shutdown). + self._force_close = False + # GLE-preview mode state. ``_gle_preview_path`` is the .gle file being # previewed (None => document mode). ``_gle_temp_dirs`` accumulates the # mkdtemp'd compile output directories we own and must clean up. @@ -311,7 +315,13 @@ def _create_actions(self) -> None: self.preview_controller.render_format == "svg" ) self.action_vector_preview.setEnabled(self.preview_controller.svg_available) - if not self.preview_controller.svg_available: + if self.preview_controller.svg_available: + self.action_vector_preview.setToolTip( + "Sharper zooming, but boxes/legends drawn over data may not " + "occlude it correctly (Qt SVG clip-path limitation). Switch " + "back to PNG if the preview looks wrong." + ) + else: self.action_vector_preview.setToolTip( "SVG preview is unavailable in this session; showing PNG." ) @@ -500,6 +510,18 @@ def _on_render_skipped_empty(self) -> None: def _on_toggle_vector_preview(self, checked: bool) -> None: """View ▸ Vector preview (SVG): switch ``render_format`` on toggle.""" self.preview_controller.render_format = "svg" if checked else "png" + if checked and self.preview_controller.render_format == "svg": + # Honest caveat on opt-in: QtSvg has no real clip-path support, so + # occlusion GLE achieves via clip regions (a legend or text box + # over data) renders wrongly — content bleeds through the box. + # This cannot be detected mechanically (every Cairo SVG carries + # such clips; only visual overlap makes it wrong), hence a + # message rather than a validation rule. + self.statusBar().showMessage( + "Vector preview on — boxes/legends over data may not occlude " + "correctly; switch back to PNG if the preview looks wrong.", + _STATUS_MS, + ) def _on_svg_fallback_activated(self, reason: str) -> None: """SVG rendering failed permanently this session; reflect it in the UI. @@ -699,6 +721,17 @@ def _on_open(self) -> None: return self._dispatch_open(chosen) + def open_path(self, path_str: str) -> None: + """Open ``path_str`` in the editor (public entry point). + + This is the supported way for embedders (e.g. host applications that + create a :class:`MainWindow` inside their own ``QApplication``) to load + a ``.gle`` file after construction. Interactive: may show modal dialogs + (programmatic-file prompt, parse-failure fallback offer), exactly like + File ▸ Open. + """ + self._dispatch_open(path_str) + def _dispatch_open(self, path_str: str) -> None: """Open ``path_str`` as an editable ``.gle`` figure (native format). @@ -1028,13 +1061,27 @@ def _confirm_discard_if_dirty(self, action_desc: str) -> bool: # ------------------------------------------------------------------ def closeEvent(self, event: QCloseEvent) -> None: # noqa: N802 - Qt override """Confirm discard on a dirty document, then tear down the render engine.""" - if not self._confirm_discard_if_dirty("Exit"): + if not self._force_close and not self._confirm_discard_if_dirty("Exit"): event.ignore() return self._cleanup_gle_temp_dirs() self.preview_controller.shutdown() event.accept() + def force_close(self) -> None: + """Close unconditionally, skipping the dirty-document confirmation. + + For embedders tearing down editor windows at host-application + shutdown, where a modal prompt cannot be answered — and would hang a + headless (offscreen) test run. Normal interactive closes still get + the confirmation via :meth:`closeEvent`. + """ + self._force_close = True + try: + self.close() + finally: + self._force_close = False + # ------------------------------------------------------------------ # GLE setup (executable configuration) # ------------------------------------------------------------------ @@ -1070,7 +1117,29 @@ def _on_gle_setup(self) -> None: return # cancelled — leave the setting untouched self._write_gle_override(chosen) - compiler.set_gle_path_override(chosen) + self.apply_gle_executable(chosen) + + resolved = compiler.find_gle() + self.statusBar().showMessage( + f"GLE: {resolved}" if resolved else "GLE: not found", _STATUS_MS, + ) + + def apply_gle_executable(self, path: Optional[str]) -> None: + """Apply a GLE-binary override for this process and refresh the window. + + Pushes ``path`` into :func:`gleplot.compiler.set_gle_path_override` + (process-global), re-resolves, propagates the result to the preview + controller's cached path and the status-bar label, and re-renders so + the change takes effect immediately. ``None``/empty restores + auto-detection. + + Unlike Tools ▸ GLE Setup…, this does **not** persist the choice to + gleplot's settings — embedders (host applications opening the editor + in-process via :func:`gleplot.gui.app.open_editor`) use it to impose + their own configured binary for the session without overwriting the + user's standalone-gleplot preference. + """ + compiler.set_gle_path_override(path or None) # Re-resolve and propagate: the preview controller caches the path. resolved = compiler.find_gle() @@ -1082,10 +1151,6 @@ def _on_gle_setup(self) -> None: if not self.is_gle_preview_mode: self.preview_controller.request_render() - self.statusBar().showMessage( - f"GLE: {resolved}" if resolved else "GLE: not found", _STATUS_MS, - ) - # ------------------------------------------------------------------ # Dialogs # ------------------------------------------------------------------ diff --git a/src/gleplot/gui/preview.py b/src/gleplot/gui/preview.py index 0155ee2..7e2c677 100644 --- a/src/gleplot/gui/preview.py +++ b/src/gleplot/gui/preview.py @@ -37,6 +37,27 @@ SVG rendering and fallback (Track E2) -------------------------------------- +The preview renders PNG by default; SVG is strictly opt-in per session via +:attr:`PreviewController.render_format` (View ▸ Vector preview (SVG)), gated +by a one-time probe compile on first opt-in — the raster path has proven the +more robust of the two across GLE/Cairo environments, for the reasons below. + +Known, undetectable-in-practice SVG mis-render (occlusion clips): GLE's +Cairo backend implements occlusion — a legend or filled text box drawn over +already-painted content — not by painting the box's background on top, but by +*clipping* the underlying content out of the box's rectangle (a ```` +whose path has multiple ``M`` subpaths describing the exposed region). QtSvg's +clip-path support is partial (SVG Tiny has none), so it paints such content +unclipped: whatever the box should hide bleeds through it. The SVG *file* is +correct — browsers render it fine; only the QtSvg preview is wrong. This +cannot be turned into a validation rule: every Cairo-emitted SVG carries +multi-subpath clips (verified empirically — 100% of clip defs in both a +legend-overlapping and a visually-fine figure), so their presence does not +discriminate; only actual visual overlap does, which would require geometry +intersection of painted content against clip-excluded regions. Mitigation: +PNG is the default, and the opt-in toggle carries a tooltip + status-bar +caveat pointing users back to PNG when the preview looks wrong. + GLE's Cairo-based SVG backend (``gle -d svg``) refuses to draw any PostScript font (``>> Error: PostScript fonts not supported with '-cairo'``) but still exits ``0`` and still writes a structurally valid (if incomplete -- missing @@ -355,16 +376,18 @@ def __init__( self._gle_path = find_gle() self._preview_dpi = 150 - # Render format: 'svg' by default only when QtSvg imported successfully - # AND a one-time session probe (a trivial compile) confirms GLE's - # ``-d svg`` actually produces a loadable SVG in this environment; - # otherwise 'png'. See _probe_svg_support(). ``_svg_fallback_reason`` - # is set (once, permanently) the first time an SVG-specific failure is - # detected, and pins the format to 'png' from then on. + # Render format: 'png' by default — the raster path is the most robust + # across GLE/Cairo/Qt environments. SVG (vector) is strictly opt-in via + # the ``render_format`` setter (View ▸ Vector preview (SVG)); the first + # opt-in runs a one-time session probe (a trivial compile) confirming + # GLE's ``-d svg`` output actually loads before switching. See + # _probe_svg_support(). ``_svg_fallback_reason`` is set (once, + # permanently) the first time an SVG-specific failure is detected, and + # pins the format to 'png' from then on. ``_svg_probe_ok`` caches the + # probe verdict so opting out and back in never re-probes. self._svg_fallback_reason: Optional[str] = None + self._svg_probe_ok: Optional[bool] = None self._render_format = "png" - if _QTSVG_AVAILABLE and self._gle_path and self._probe_svg_support(): - self._render_format = "svg" # Session scratch directory for scripts, data files, and PNGs. Created # lazily on first render so constructing a controller is cheap. @@ -437,12 +460,14 @@ def debounce_ms(self, value: int) -> None: @property def render_format(self) -> str: - """Current render format: ``'svg'`` (vector) or ``'png'`` (raster). - - Defaults to ``'svg'`` when ``QtSvg`` imported successfully *and* a - one-time session probe confirmed GLE's ``-d svg`` output loads, else - ``'png'``. Once :data:`fallback_activated` has fired, this is - permanently pinned to ``'png'`` -- the setter silently ignores any + """Current render format: ``'png'`` (raster) or ``'svg'`` (vector). + + Defaults to ``'png'`` — the most robust path — with SVG strictly + opt-in. The first attempt to set ``'svg'`` runs a one-time session + probe confirming GLE's ``-d svg`` output loads (``QtSvg`` must also + have imported successfully). Once :data:`fallback_activated` has + fired — from a failed probe or a failed live render — this is + permanently pinned to ``'png'``: the setter silently ignores any attempt to set ``'svg'`` again for the rest of the session (see :attr:`svg_available`). """ @@ -458,10 +483,31 @@ def render_format(self, value: str) -> None: return if value == "svg" and not _QTSVG_AVAILABLE: return + if value == "svg" and not self._ensure_svg_probe(): + return if value != self._render_format: self._render_format = value self._on_document_changed() + def _ensure_svg_probe(self) -> bool: + """Run the one-time SVG probe on first opt-in (cached thereafter). + + A failed probe (with a GLE binary present) activates the permanent + PNG fallback so the UI is told why the opt-in was refused — the + toggle unchecks/disables with the reason — instead of the setter + silently doing nothing. With no GLE binary the refusal is *not* + cached or made sticky: the user may configure GLE later and retry. + """ + if self._svg_probe_ok is None: + if not self._gle_path: + return False + self._svg_probe_ok = self._probe_svg_support() + if not self._svg_probe_ok: + self._activate_svg_fallback( + "probe compile did not produce a loadable SVG" + ) + return bool(self._svg_probe_ok) + @property def svg_available(self) -> bool: """Whether SVG can currently be selected as :attr:`render_format`. @@ -795,11 +841,11 @@ def _probe_svg_support(self) -> bool: Compiles a minimal known-good script (an axes box, the same Cairo-safe font forced for all SVG renders) in a throwaway temp directory. Never raises; any exception or a failed/invalid probe - result means SVG is not available and the controller starts in - ``'png'`` mode instead. Runs synchronously (a bare ``gle`` invocation - on a trivial script is a small fraction of a second) so the format - decision is stable and available immediately in ``__init__`` -- - the render pipeline itself remains fully async for real renders. + result means SVG is not available and the opt-in is refused (see + :meth:`_ensure_svg_probe`). Runs synchronously (a bare ``gle`` + invocation on a trivial script is a small fraction of a second) — + acceptable for a one-time response to an explicit user toggle; the + render pipeline itself remains fully async for real renders. """ probe_dir = None try: diff --git a/tests/gui/test_app.py b/tests/gui/test_app.py new file mode 100644 index 0000000..9af364a --- /dev/null +++ b/tests/gui/test_app.py @@ -0,0 +1,263 @@ +"""Tests for :mod:`gleplot.gui.app` (embedding API + CLI entry point). + +Covers ``open_editor`` (the embedding API for host applications) and +``main``'s handling of an optional ``.gle`` file argument. Offscreen, +plain-pytest; skips when PySide6 is absent. +""" + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + +pytest.importorskip("PySide6", reason="PySide6 not installed (gui extra)") + +from PySide6.QtCore import QSettings +from PySide6.QtWidgets import QApplication, QMessageBox + +import gleplot as glp +from gleplot import compiler +from gleplot.gui import app as gui_app +from gleplot.gui.main_window import MainWindow, _GLE_PATH_KEY + + +@pytest.fixture +def qapp(): + app = QApplication.instance() + if app is None: + app = QApplication([]) + yield app + + +@pytest.fixture +def scratch_settings(tmp_path): + """An ini-backed QSettings isolated to a scratch file for this test.""" + ini_path = tmp_path / "settings.ini" + return QSettings(str(ini_path), QSettings.Format.IniFormat) + + +@pytest.fixture(autouse=True) +def _reset_override(): + """Never leak the process-global GLE override between tests.""" + compiler.set_gle_path_override(None) + yield + compiler.set_gle_path_override(None) + + +def _make_gle_file(path: Path) -> Path: + """Write a minimal, non-programmatic .gle figure to ``path``.""" + fig = glp.figure() + ax = fig.add_subplot(1, 1, 1) + ax.plot([0, 1], [0, 1]) + fig.savefig(str(path)) + return path + + +# ---------------------------------------------------------------------- +# open_editor +# ---------------------------------------------------------------------- +def test_open_editor_requires_existing_qapplication(): + """No QApplication -> RuntimeError. + + This can't be tested in-process: the module-scoped ``qapp`` fixtures used + across this test session (and pytest-qt-style global state) mean a real + QApplication typically already exists by the time any test runs, and + tearing it down mid-suite would be invasive/order-dependent. Instead, + verify it in a clean subprocess that never constructs a QApplication. + """ + script = ( + "import gleplot.gui as gui\n" + "try:\n" + " gui.open_editor()\n" + "except RuntimeError as exc:\n" + " assert 'QApplication' in str(exc)\n" + " print('OK')\n" + "else:\n" + " raise SystemExit('expected RuntimeError')\n" + ) + env = dict(os.environ) + env["QT_QPA_PLATFORM"] = "offscreen" + env["PYTHONPATH"] = str(Path(__file__).parent.parent.parent / "src") + result = subprocess.run( + [sys.executable, "-c", script], + env=env, + capture_output=True, + text=True, + timeout=60, + ) + assert result.returncode == 0, result.stderr + assert "OK" in result.stdout + + +def test_open_editor_opens_path_and_shows_window(qapp, tmp_path, scratch_settings): + gle_path = _make_gle_file(tmp_path / "fig.gle") + + window = gui_app.open_editor(str(gle_path), settings=scratch_settings) + try: + assert isinstance(window, MainWindow) + assert window.isVisible() + assert window.document.project_path == gle_path + # A plain plot/savefig round trip shouldn't trip the programmatic-file + # prompt, so no modal should have been left pending. + assert not any( + w.startswith("programmatic:") for w in window.document.open_warnings + ) + finally: + window.close() + + +def test_open_editor_does_not_change_application_name(qapp, tmp_path, scratch_settings): + before = QApplication.instance().applicationName() + + gle_path = _make_gle_file(tmp_path / "fig.gle") + window = gui_app.open_editor(str(gle_path), settings=scratch_settings) + try: + assert QApplication.instance().applicationName() == before + finally: + window.close() + + +def test_open_editor_gle_executable_overrides_find_gle( + qapp, tmp_path, scratch_settings +): + fake_gle = tmp_path / "gle.exe" + fake_gle.write_text("") + + window = gui_app.open_editor( + settings=scratch_settings, gle_executable=str(fake_gle) + ) + try: + assert compiler.find_gle() == str(fake_gle) + finally: + window.close() + + +def test_apply_gle_executable_does_not_persist_to_settings( + qapp, tmp_path, scratch_settings +): + fake_gle = tmp_path / "gle.exe" + fake_gle.write_text("") + + window = MainWindow(settings=scratch_settings) + try: + before = scratch_settings.value(_GLE_PATH_KEY, "", type=str) + window.apply_gle_executable(str(fake_gle)) + + assert compiler.find_gle() == str(fake_gle) + assert scratch_settings.value(_GLE_PATH_KEY, "", type=str) == before + finally: + window.close() + + +def test_force_close_skips_dirty_confirmation(qapp, tmp_path, scratch_settings, monkeypatch): + """force_close() must never pop the discard-confirmation modal. + + Embedders (host apps) call it at shutdown, where a modal cannot be + answered — in a headless test run it would hang the worker. Make the + document dirty, poison QMessageBox.question so any modal fails loudly, + and verify the window still closes. + """ + window = MainWindow(settings=scratch_settings) + try: + window.document.new_figure() + window.document.notify_changed() # the public "mark dirty" path + assert window.document.is_dirty + + def _no_modal(*_a, **_k): + raise AssertionError("force_close must not prompt") + + monkeypatch.setattr(QMessageBox, "question", staticmethod(_no_modal)) + + window.force_close() + assert not window.isVisible() + + # A normal interactive close on a dirty document still confirms. + window2 = MainWindow(settings=scratch_settings) + try: + window2.document.new_figure() + window2.document.notify_changed() + asked = [] + monkeypatch.setattr( + QMessageBox, + "question", + staticmethod( + lambda *a, **k: asked.append(True) + or QMessageBox.StandardButton.Discard + ), + ) + window2.close() + assert asked, "interactive close should have confirmed discard" + finally: + window2.force_close() + finally: + window.force_close() + + +# ---------------------------------------------------------------------- +# main(): CLI file argument +# ---------------------------------------------------------------------- +def test_main_nonexistent_path_returns_2(qapp, tmp_path, capsys): + missing = tmp_path / "nope.gle" + rc = gui_app.main([str(missing)]) + assert rc == 2 + captured = capsys.readouterr() + assert "nope.gle" in captured.err + + +def test_main_smoke_test_with_file_arg_ignores_file(qapp, tmp_path): + """--smoke-test must keep working unmodified even with a file argument: + the file is ignored entirely (no existence check, no open).""" + missing = tmp_path / "nope.gle" + rc = gui_app.main(["--smoke-test", str(missing)]) + assert rc == 0 + + +def test_main_opens_file_after_show(qapp, tmp_path, monkeypatch): + """main() opens the file via open_path AFTER window.show(). + + main() returns only an int -- the MainWindow it constructs is a + parentless top-level widget with no other reference, so it can't be + inspected after the call returns. Instead, monkeypatch + MainWindow.open_path with a spy that records the path and whether the + window was already visible, then close the captured window ourselves. + """ + gle_path = _make_gle_file(tmp_path / "fig.gle") + + calls = [] + original_open_path = MainWindow.open_path + + def spy_open_path(self, path_str): + calls.append((path_str, self.isVisible(), self)) + return original_open_path(self, path_str) + + monkeypatch.setattr(MainWindow, "open_path", spy_open_path) + + # Suppress any modal (shouldn't fire for a plain plot, but don't hang the + # suite if it does). + monkeypatch.setattr( + QMessageBox, + "question", + staticmethod(lambda *a, **k: QMessageBox.StandardButton.Open), + ) + + try: + # qapp fixture guarantees a QApplication already exists, so main() + # reuses it (owns_app is False) and returns immediately without + # entering app.exec() -- it would otherwise block the test forever. + rc = gui_app.main([str(gle_path)]) + assert rc == 0 + + assert len(calls) == 1 + called_path, was_visible, _window = calls[0] + assert called_path == str(gle_path) + assert was_visible is True + finally: + # Close the window main() constructed -- captured via the spy since + # main() only returns an int and the window is otherwise unreferenced. + for _path, _visible, window in calls: + window.close() diff --git a/tests/gui/test_svg_preview.py b/tests/gui/test_svg_preview.py index 9151f21..042f9b4 100644 --- a/tests/gui/test_svg_preview.py +++ b/tests/gui/test_svg_preview.py @@ -133,14 +133,25 @@ def _single_axes_doc(): # --------------------------------------------------------------------------- # # PreviewController: SVG render end-to-end # --------------------------------------------------------------------------- # +def test_png_is_default_render_format(qapp): + """PNG is always the startup format; SVG is strictly opt-in.""" + doc = _make_sin_document() + ctrl = PreviewController(doc, debounce_ms=50) + try: + assert ctrl.render_format == "png" + finally: + ctrl.shutdown() + + @pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) -def test_svg_is_default_render_format_when_available(qapp): - """With QtSvg + a working GLE/Cairo install, SVG is the default format.""" +def test_svg_opt_in_probes_and_switches(qapp): + """With QtSvg + a working GLE/Cairo install, opting in switches to SVG.""" doc = _make_sin_document() ctrl = PreviewController(doc, debounce_ms=50) try: - assert ctrl.render_format == "svg" assert ctrl.svg_available + ctrl.render_format = "svg" + assert ctrl.render_format == "svg" finally: ctrl.shutdown() @@ -151,6 +162,7 @@ def test_svg_render_succeeds_and_is_valid(qapp): ctrl = PreviewController(doc, debounce_ms=50) rec = SignalRecorder(ctrl) try: + ctrl.render_format = "svg" # opt-in (runs the probe) assert ctrl.render_format == "svg" ctrl.request_render() assert _wait_until(lambda: rec.succeeded or rec.failed, 10000) @@ -184,6 +196,7 @@ def test_svg_calibration_print_line_present(qapp): ctrl = PreviewController(doc, debounce_ms=50) rec = SignalRecorder(ctrl) try: + ctrl.render_format = "svg" # opt-in (runs the probe) ctrl.request_render() assert _wait_until(lambda: rec.succeeded or rec.failed, 10000) assert not rec.failed, rec.failed @@ -206,6 +219,10 @@ def test_svg_fallback_on_invalid_output(qapp, monkeypatch, tmp_path): doc = _make_sin_document() ctrl = PreviewController(doc, debounce_ms=50) rec = SignalRecorder(ctrl) + # Opt in BEFORE poisoning the validator: the opt-in probe calls + # _svg_output_problem too, and must pass with the real validation so this + # test exercises the *live-render* fallback path, not the probe path. + ctrl.render_format = "svg" assert ctrl.render_format == "svg" # Force every SVG-output validation to report a problem, regardless of @@ -241,6 +258,27 @@ def test_svg_fallback_on_invalid_output(qapp, monkeypatch, tmp_path): ctrl.shutdown() +@pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) +def test_svg_opt_in_refused_when_probe_fails(qapp, monkeypatch): + """A failed opt-in probe keeps PNG and activates the sticky fallback.""" + doc = _make_sin_document() + ctrl = PreviewController(doc, debounce_ms=50) + rec = SignalRecorder(ctrl) + try: + monkeypatch.setattr(ctrl, "_probe_svg_support", lambda: False) + + ctrl.render_format = "svg" + + assert ctrl.render_format == "png" + assert not ctrl.svg_available + assert rec.fallbacks # UI was told why the opt-in was refused + # Sticky: a second attempt is silently ignored, without re-probing. + ctrl.render_format = "svg" + assert ctrl.render_format == "png" + finally: + ctrl.shutdown() + + @pytest.mark.xfail(not _GLE_AVAILABLE, reason="GLE not installed", strict=False) def test_render_format_setter_rejects_bad_value(qapp): doc = _make_sin_document()