From 028d5301571309b47bca2fa09b51c643d909b243 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 1 May 2026 17:49:15 -0400 Subject: [PATCH 01/10] Refactor analysis panels to use placeholders Replace hiding/showing of analysis accordions with in-DOM placeholders and collapsed state. Introduces a _PANEL_META class var and per-panel unavailable message + content wrappers so panels remain in the DOM but start collapsed (selected_index=None) and show an inline "not available" note until activated. Update activate/deactivate logic to toggle selected_index and swap placeholders, refresh IR and Energies views when panels are expanded, and read tooltips from _PANEL_META. Also tweak some checkbox descriptions. Tests updated to expect visible-but-collapsed accordions and to verify placeholder/content swapping. --- quantui/app.py | 114 ++++++++++++++++++++++++----------------- tests/test_app.py | 82 +++++++++++++++++++---------- tests/test_pes_scan.py | 11 ++-- 3 files changed, 128 insertions(+), 79 deletions(-) diff --git a/quantui/app.py b/quantui/app.py index cbc57df..e1ea0b3 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -675,7 +675,7 @@ def _build_shared_widgets(self) -> None: ) self.preopt_cb = widgets.Checkbox( value=False, - description="Pre-optimize geometry (fast LJ force-field)", + description="Pre-optimize geometry (for a crude starting point)", disabled=not _PREOPT_AVAILABLE, layout=widgets.Layout(width="400px"), ) @@ -753,7 +753,7 @@ def _build_shared_widgets(self) -> None: ) self._freq_preopt_cb = widgets.Checkbox( value=False, - description="Pre-optimize geometry first (recommended for unoptimised inputs)", + description="Geometry optimization (recommended for unoptimized inputs)", style={"description_width": "initial"}, layout=widgets.Layout(width="100%"), ) @@ -1459,30 +1459,42 @@ def _build_results_section(self) -> None: def _build_ana_switcher(self) -> None: """Build the always-visible panel switcher strip for the Analysis tab.""" - _PANEL_META = [ - ("Energies", self._orb_accordion, "Single Point / Geometry Opt"), - ( - "Trajectory", - self.traj_accordion, - "Geometry Opt / PES Scan / Frequency pre-opt", - ), - ("Vibrational", self.vib_accordion, "Frequency"), - ("IR Spectrum", self._ir_accordion, "Frequency"), - ("PES Scan", self._pes_scan_accordion, "PES Scan"), - ("Isosurface", self._iso_accordion, "Single Point (Linux/WSL only)"), - ("UV-Vis", self._tddft_accordion, "UV-Vis (TD-DFT)"), - ("NMR", self._nmr_accordion, "NMR Shielding"), + panel_meta = [ + (name, getattr(self, attr), when) for name, attr, when in self._PANEL_META ] - self._ana_panel_names: list = [m[0] for m in _PANEL_META] - self._ana_accordions: list = [m[1] for m in _PANEL_META] + self._ana_panel_names: list = [m[0] for m in panel_meta] + self._ana_accordions: list = [m[1] for m in panel_meta] self._ana_available: set = set() self._ana_active: str = "" self._ana_unavail_html = widgets.HTML( value="", layout=widgets.Layout(display="none", margin="4px 0 8px"), ) + + # Wrap each accordion's child so it holds both an "unavailable" message + # and the real content. Real content starts hidden; the unavailable + # message is shown until _activate_ana_panel() is called. + self._ana_unavail_msgs: dict = {} + self._ana_content_boxes: dict = {} + for name, acc, when in panel_meta: + unavail = widgets.HTML( + value=( + f'
Not available — run a {when} ' + f"calculation first.
" + ), + layout=widgets.Layout(display=""), + ) + content = acc.children[0] + self._ana_unavail_msgs[name] = unavail + self._ana_content_boxes[name] = content + content.layout.display = "none" + acc.children = (widgets.VBox([unavail, content]),) + acc.layout.display = "" # always in the DOM + acc.selected_index = None # collapsed until activated + self._ana_btns: list = [] - for name, _acc, when in _PANEL_META: + for name, _acc, when in panel_meta: btn = widgets.Button( description=name, button_style="", @@ -1508,14 +1520,12 @@ def _on_ana_panel_click(self, name: str) -> None: if name in self._ana_available: self._select_ana_panel(name) else: - # Grey out all buttons except clicked; show "not available" note + # Highlight the clicked button as a warning; show inline note. for btn in self._ana_btns: btn.button_style = "" for btn, pname in zip(self._ana_btns, self._ana_panel_names): if pname == name: btn.button_style = "warning" - for acc in self._ana_accordions: - acc.layout.display = "none" self._ana_unavail_html.value = ( f'

' @@ -1525,67 +1535,66 @@ def _on_ana_panel_click(self, name: str) -> None: self._ana_active = "" def _select_ana_panel(self, name: str) -> None: - """Show the named panel; hide all others and update button styles.""" + """Expand the named panel; collapse all others. Updates button styles.""" self._ana_active = name self._ana_unavail_html.layout.display = "none" for pname, acc, btn in zip( self._ana_panel_names, self._ana_accordions, self._ana_btns ): if pname == name: - acc.layout.display = "" acc.selected_index = 0 btn.button_style = "primary" else: - acc.layout.display = "none" + acc.selected_index = None btn.button_style = "" - # Plotly charts inside hidden accordions render with 0 dimensions and appear - # blank. Re-render the IR figure whenever its panel is brought into view so - # the chart always paints into a visible, correctly-sized container. + # Re-render Plotly charts that may have initialised into a zero-size + # container while their accordion was collapsed. if name == "IR Spectrum" and getattr(self, "_last_ir_freqs", None): self._update_ir_figure( self._ir_mode_toggle.value, self._ir_fwhm_slider.value ) + if name == "Energies" and getattr(self, "_last_orb_info", None) is not None: + self._on_orb_range_changed() def _activate_ana_panel(self, name: str, auto_select: bool = True) -> None: - """Mark a panel as available (full opacity) and optionally select it.""" + """Mark a panel as available: reveal its content, brighten its button.""" self._ana_available.add(name) for btn, pname in zip(self._ana_btns, self._ana_panel_names): if pname == name: btn.layout.opacity = "1.0" btn.tooltip = name + # Swap unavailable placeholder for real content. + if name in self._ana_unavail_msgs: + self._ana_unavail_msgs[name].layout.display = "none" + self._ana_content_boxes[name].layout.display = "" if auto_select: self._select_ana_panel(name) def _deactivate_all_ana_panels(self) -> None: - """Reset all panels to hidden/unavailable; used at start of each new run.""" + """Reset all panels to collapsed/unavailable; used at start of each new run.""" self._ana_available.clear() self._ana_active = "" self._ana_unavail_html.layout.display = "none" - for acc, btn, _name, meta in zip( - self._ana_accordions, - self._ana_btns, - self._ana_panel_names, - # Re-read tooltips from scratch — must mirror _PANEL_META order - [ - "Single Point / Geometry Opt", - "Geometry Opt / PES Scan / Frequency pre-opt", - "Frequency", - "Frequency", - "PES Scan", - "Single Point (Linux/WSL only)", - "UV-Vis (TD-DFT)", - "NMR Shielding", - ], + _when = {name: when for name, _, when in self._PANEL_META} + for name, acc, btn in zip( + self._ana_panel_names, self._ana_accordions, self._ana_btns ): - acc.layout.display = "none" + # Show the "not available" placeholder; hide real content. + if name in self._ana_unavail_msgs: + self._ana_unavail_msgs[name].layout.display = "" + self._ana_content_boxes[name].layout.display = "none" acc.selected_index = None btn.button_style = "" btn.layout.opacity = "0.35" - btn.tooltip = f"Available after: {meta}" + btn.tooltip = f"Available after: {_when.get(name, '')}" # ── Panel registry and unified applier ─────────────────────────────────── # - # _PANEL_REGISTRY maps calc_type string → ordered list of + # _PANEL_META: ordered list of (name, accordion_attr, when_str) for every + # analysis panel. Single source of truth for names, accordion references, + # and the "available after: …" tooltip text. + # + # _PANEL_REGISTRY maps calc_type → ordered list of # (panel_name, populate_method_name, auto_select) tuples. # # Rules: @@ -1596,6 +1605,17 @@ def _deactivate_all_ana_panels(self) -> None: # • If a populate method returns False / None the panel stays disabled. # • Populate methods must NOT call _activate_ana_panel themselves. + _PANEL_META: ClassVar[list] = [ + ("Energies", "_orb_accordion", "Single Point / Geometry Opt"), + ("Trajectory", "traj_accordion", "Geometry Opt / PES Scan / Frequency pre-opt"), + ("Vibrational", "vib_accordion", "Frequency"), + ("IR Spectrum", "_ir_accordion", "Frequency"), + ("PES Scan", "_pes_scan_accordion", "PES Scan"), + ("Isosurface", "_iso_accordion", "Single Point (Linux/WSL only)"), + ("UV-Vis", "_tddft_accordion", "UV-Vis (TD-DFT)"), + ("NMR", "_nmr_accordion", "NMR Shielding"), + ] + _PANEL_REGISTRY: ClassVar[dict] = { "single_point": [ ("Energies", "_pop_energies", True), diff --git a/tests/test_app.py b/tests/test_app.py index 1bcaad3..fc92abb 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -866,9 +866,10 @@ def test_ir_accordion_exists(self): assert hasattr(app, "_ir_accordion") assert isinstance(app._ir_accordion, widgets.Accordion) - def test_ir_accordion_hidden_initially(self): + def test_ir_accordion_visible_and_collapsed_initially(self): app = QuantUIApp() - assert app._ir_accordion.layout.display == "none" + assert app._ir_accordion.layout.display == "" + assert app._ir_accordion.selected_index is None def test_ir_mode_toggle_exists(self): app = QuantUIApp() @@ -914,13 +915,13 @@ def test_show_ir_spectrum_returns_true_with_data(self): ok = app._show_ir_spectrum(self._make_freq_result()) assert ok is True - def test_accordion_revealed_via_activate(self): - # _show_ir_spectrum populates widget; _activate_ana_panel reveals it. + def test_accordion_expanded_via_activate(self): + # _show_ir_spectrum populates widget; _activate_ana_panel expands it. app = QuantUIApp() app._show_ir_spectrum(self._make_freq_result()) - assert app._ir_accordion.layout.display == "none" # still hidden + assert app._ir_accordion.selected_index is None # still collapsed app._activate_ana_panel("IR Spectrum") - assert app._ir_accordion.layout.display == "" + assert app._ir_accordion.selected_index == 0 def test_fwhm_slider_shown_when_broadened(self): app = QuantUIApp() @@ -948,9 +949,10 @@ def test_orb_accordion_exists(self): app = QuantUIApp() assert hasattr(app, "_orb_accordion") - def test_orb_accordion_hidden_initially(self): + def test_orb_accordion_visible_collapsed_initially(self): app = QuantUIApp() - assert app._orb_accordion.layout.display == "none" + assert app._orb_accordion.layout.display == "" + assert app._orb_accordion.selected_index is None def test_orb_diagram_html_exists(self): app = QuantUIApp() @@ -968,11 +970,11 @@ def test_orb_iso_controls_hidden_initially(self): app = QuantUIApp() assert app._orb_iso_controls.layout.display == "none" - def test_orb_accordion_hidden_after_run_clicked(self): + def test_orb_accordion_collapsed_after_run_clicked(self): app = QuantUIApp() - app._orb_accordion.layout.display = "" + app._orb_accordion.selected_index = 0 app._on_run_clicked(None) - assert app._orb_accordion.layout.display == "none" + assert app._orb_accordion.selected_index is None class TestShowOrbitalDiagram: @@ -997,15 +999,15 @@ def test_show_orbital_diagram_returns_true_with_mo_data(self): ok = app._show_orbital_diagram(self._make_result_with_mo()) assert ok is True - def test_accordion_revealed_via_activate(self): - # _show_orbital_diagram populates widget; _activate_ana_panel reveals it. + def test_accordion_expanded_via_activate(self): + # _show_orbital_diagram populates widget; _activate_ana_panel expands it. app = QuantUIApp() app._show_orbital_diagram(self._make_result_with_mo()) - assert app._orb_accordion.layout.display == "none" # still hidden + assert app._orb_accordion.selected_index is None # still collapsed app._activate_ana_panel("Energies") - assert app._orb_accordion.layout.display == "" + assert app._orb_accordion.selected_index == 0 - def test_accordion_stays_hidden_when_no_mo_data(self): + def test_accordion_stays_collapsed_when_no_mo_data(self): from unittest.mock import MagicMock app = QuantUIApp() @@ -1013,7 +1015,7 @@ def test_accordion_stays_hidden_when_no_mo_data(self): r.mo_energy_hartree = None r.mo_occ = None app._show_orbital_diagram(r) - assert app._orb_accordion.layout.display == "none" + assert app._orb_accordion.selected_index is None def test_diagram_html_populated(self): app = QuantUIApp() @@ -1137,10 +1139,11 @@ def test_no_panels_available_initially(self): app = QuantUIApp() assert len(app._ana_available) == 0 - def test_all_accordions_hidden_initially(self): + def test_all_accordions_visible_and_collapsed_initially(self): app = QuantUIApp() for acc in app._ana_accordions: - assert acc.layout.display == "none" + assert acc.layout.display == "" + assert acc.selected_index is None def test_switcher_box_in_analysis_tab(self): app = QuantUIApp() @@ -1160,21 +1163,24 @@ def test_activate_panel_sets_opacity(self): def test_activate_panel_auto_selects(self): app = QuantUIApp() app._activate_ana_panel("Energies") - assert app._orb_accordion.layout.display == "" assert app._orb_accordion.selected_index == 0 def test_activate_panel_no_auto_select(self): app = QuantUIApp() app._activate_ana_panel("Energies", auto_select=False) - assert app._orb_accordion.layout.display == "none" + # Panel is available but not expanded; still visible in DOM. + assert "Energies" in app._ana_available + assert app._orb_accordion.selected_index is None + assert app._orb_accordion.layout.display == "" - def test_activate_hides_other_accordions(self): + def test_activate_collapses_other_accordions(self): app = QuantUIApp() app._activate_ana_panel("Energies") - # All other accordions should be hidden + # Other accordions remain visible but collapsed (not hidden). for name, acc in zip(app._ana_panel_names, app._ana_accordions): if name != "Energies": - assert acc.layout.display == "none" + assert acc.layout.display == "" + assert acc.selected_index is None def test_deactivate_all_clears_available(self): app = QuantUIApp() @@ -1183,12 +1189,14 @@ def test_deactivate_all_clears_available(self): app._deactivate_all_ana_panels() assert len(app._ana_available) == 0 - def test_deactivate_all_hides_accordions(self): + def test_deactivate_all_collapses_accordions(self): app = QuantUIApp() app._activate_ana_panel("Energies") app._deactivate_all_ana_panels() + # All panels remain visible in the DOM but are collapsed. for acc in app._ana_accordions: - assert acc.layout.display == "none" + assert acc.layout.display == "" + assert acc.selected_index is None def test_deactivate_all_dims_buttons(self): app = QuantUIApp() @@ -1197,6 +1205,26 @@ def test_deactivate_all_dims_buttons(self): for btn in app._ana_btns: assert btn.layout.opacity == "0.35" + def test_unavail_message_shown_initially(self): + app = QuantUIApp() + # Every panel starts with the unavailable placeholder visible. + for name in app._ana_panel_names: + assert app._ana_unavail_msgs[name].layout.display == "" + assert app._ana_content_boxes[name].layout.display == "none" + + def test_activate_swaps_placeholder_for_content(self): + app = QuantUIApp() + app._activate_ana_panel("Energies", auto_select=False) + assert app._ana_unavail_msgs["Energies"].layout.display == "none" + assert app._ana_content_boxes["Energies"].layout.display == "" + + def test_deactivate_restores_placeholder(self): + app = QuantUIApp() + app._activate_ana_panel("Energies", auto_select=False) + app._deactivate_all_ana_panels() + assert app._ana_unavail_msgs["Energies"].layout.display == "" + assert app._ana_content_boxes["Energies"].layout.display == "none" + def test_click_unavailable_shows_warning(self): app = QuantUIApp() app._on_ana_panel_click("Energies") @@ -1207,7 +1235,7 @@ def test_click_available_selects_panel(self): app = QuantUIApp() app._activate_ana_panel("IR Spectrum", auto_select=False) app._on_ana_panel_click("IR Spectrum") - assert app._ir_accordion.layout.display == "" + assert app._ir_accordion.selected_index == 0 assert app._ana_active == "IR Spectrum" diff --git a/tests/test_pes_scan.py b/tests/test_pes_scan.py index d694f33..3f5235c 100644 --- a/tests/test_pes_scan.py +++ b/tests/test_pes_scan.py @@ -191,11 +191,12 @@ def test_scan_steps_default(self): app = QuantUIApp() assert app._scan_steps.value == 10 - def test_pes_scan_accordion_hidden_initially(self): + def test_pes_scan_accordion_visible_collapsed_initially(self): from quantui.app import QuantUIApp app = QuantUIApp() - assert app._pes_scan_accordion.layout.display == "none" + assert app._pes_scan_accordion.layout.display == "" + assert app._pes_scan_accordion.selected_index is None def test_pes_plot_html_empty_initially(self): from quantui.app import QuantUIApp @@ -210,14 +211,14 @@ def test_on_calc_type_changed_to_pes_scan_populates_extras(self): app.calc_type_dd.value = "PES Scan" assert len(app.calc_extra_opts.children) > 0 - def test_pes_scan_accordion_cleared_on_run_clicked(self): + def test_pes_scan_accordion_collapsed_on_run_clicked(self): from quantui.app import QuantUIApp app = QuantUIApp() - app._pes_scan_accordion.layout.display = "" + app._pes_scan_accordion.selected_index = 0 app._pes_plot_html.value = "

old
" app._on_run_clicked(None) - assert app._pes_scan_accordion.layout.display == "none" + assert app._pes_scan_accordion.selected_index is None assert app._pes_plot_html.value == "" From dbb4e4f3ec45cfb7fd12c0411d648093b2e25329 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 1 May 2026 18:09:45 -0400 Subject: [PATCH 02/10] Document always-visible analysis panels and rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update Copilot instructions to reflect UI and test changes: add new utility modules (issue_tracker, log_utils, benchmarks) to the file list; change Analysis tab panels to be always present in the DOM with "Not available" placeholders; revise panel metadata to use (panel_name, accordion_attr_name, placeholder) as the single source of truth; simplify panel activation/deactivation semantics (collapse/restore placeholders, never remove from DOM); update developer steps for adding panels. Add critical quality rules: ban include_plotlyjs="cdn" (prefer "require" or inline) and require wrapping .observe() callbacks with _safe_cb to surface exceptions to the Log tab. Update __init__ attributes (unavail placeholders, content boxes, pending button removal) and refresh tests list/baseline to match new behavior. These changes improve offline reliability (Plotly), error visibility in Voilà, and make panel lifecycle explicit for history replay. --- .github/copilot-instructions.md | 62 ++++++++++++++++++++++++--------- 1 file changed, 46 insertions(+), 16 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index ccebf3a..9ecfde4 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -47,6 +47,9 @@ QuantUI/ │ ├── ir_plot.py ← IR spectrum Plotly figure builder │ ├── config.py ← All constants/defaults (methods, basis sets, etc.) │ ├── utils.py ← Session resource checks, sanitize_filename, etc. +│ ├── issue_tracker.py ← In-app issue/bug logging to issues.db +│ ├── log_utils.py ← Shared logging helpers +│ ├── benchmarks.py ← Performance benchmarking utilities │ └── security.py ← SecurityError exception class ├── notebooks/ │ ├── molecule_computations.ipynb ← Student-facing Voilà app (thin launcher) @@ -86,7 +89,7 @@ notebooks/molecule_computations.ipynb │ _build_compare_section() → compare_panel │ │ _build_output_tab() → log viewer │ │ _build_help_section() → help panel │ - │ _build_ana_switcher() → Analysis tab button strip │ + │ _build_ana_switcher() → Analysis tab always-visible panels │ │ _assemble_tabs() → root_tab (Tab widget) │ └──────────────────────────────────────────────────────────┘ │ _do_run() dispatches by calc_type_dd.value: @@ -124,7 +127,8 @@ must understand this pattern.** After a calculation completes (live or history), `_apply_analysis_context(ctx)` is the single entry point for all panel population. It: -1. Calls `_deactivate_all_ana_panels()` to reset all 8 panels to grey/hidden +1. Calls `_deactivate_all_ana_panels()` to reset all 8 panels — collapses accordions + and restores "Not available" placeholders (panels remain in the DOM — never hidden) 2. Looks up `_PANEL_REGISTRY[ctx.calc_type]` for an ordered list of `(panel_name, populate_method_name, auto_select)` tuples 3. Calls each populate method (e.g. `_pop_energies(ctx)`) @@ -173,11 +177,12 @@ _PANEL_REGISTRY = { 1. Create the accordion widget in `_build_results_section()` (follow existing pattern) 2. Add it to the `analysis_tab_panel` VBox in `_build_analysis_section()` -3. Add `(panel_name, accordion, tooltip)` to `_PANEL_META` in `_build_ana_switcher()` -4. Add the matching tooltip to `_deactivate_all_ana_panels()` tooltip list (must mirror `_PANEL_META`) -5. Write `_pop_xxx(self, ctx: _AnalysisContext) -> bool` -6. Add the entry to `_PANEL_REGISTRY` -7. If history replay needs data: ensure `save_spectra` in `_do_run` saves it +3. Add `(panel_name, accordion_attr_name, "available after X")` to `_PANEL_META` + (class-level `ClassVar` — this is the single source of truth for placeholder text + and accordion attribute lookup; `_build_ana_switcher()` reads it at init time) +4. Write `_pop_xxx(self, ctx: _AnalysisContext) -> bool` +5. Add the entry to `_PANEL_REGISTRY` +6. If history replay needs data: ensure `save_spectra` in `_do_run` saves it ### Live run vs history replay — same code path @@ -190,7 +195,12 @@ to build the context from disk. ## Analysis Tab — 8 Panels -| Panel | Accordion | Activated by calc types | +All 8 panels are **always in the DOM** (`layout.display=""`, `selected_index=None`). +Unavailable panels show a "Not available — run a X calculation first" placeholder. +`_activate_ana_panel()` swaps the placeholder for real content and expands the accordion. +`_deactivate_all_ana_panels()` restores placeholders and collapses — never hides. + +| Panel | Accordion attr | Activated by calc types | |---|---|---| | Energies | `_orb_accordion` | Single Point, Geometry Opt | | Trajectory | `traj_accordion` | Geometry Opt, PES Scan, Frequency (pre-opt only) | @@ -260,6 +270,17 @@ to build the context from disk. (`_show_orbital_diagram`, `_show_vib_animation`, `_show_ir_spectrum`, `_show_pes_scan_result`) return `bool` and must not call `_activate_ana_panel()`. +8. **Never use `include_plotlyjs="cdn"` in widget HTML.** CDN requests fail + silently in offline classroom deployments — the figure div stays blank with no + error anywhere. Use `include_plotlyjs="require"` (Voilà/RequireJS path) or + `include_plotlyjs=True` (inline bundle, works everywhere). This rule is enforced + by `tests/test_code_quality.py::test_no_cdn_plotlyjs`. + +9. **All `.observe()` callbacks must be wrapped with `_safe_cb`.** Exceptions in + raw `.observe()` handlers disappear into the kernel console — invisible in Voilà. + Use `widget.observe(self._safe_cb(self._on_x), names="value")` so exceptions are + routed to the Log tab instead. See `_safe_cb()` in `app.py`. + --- ## Supported Calculations @@ -306,10 +327,12 @@ __init__() | Attribute | Type | Purpose | | --- | --- | --- | | `self._ana_available` | `set[str]` | Names of panels with data; populated by `_activate_ana_panel()` | -| `self._ana_active` | `str` | Currently visible panel name | -| `self._ana_panel_names` | `list[str]` | Ordered panel names matching `_PANEL_META` | -| `self._ana_accordions` | `list[Accordion]` | Ordered accordions matching `_PANEL_META` | -| `self._ana_btns` | `list[Button]` | Ordered switcher buttons matching `_PANEL_META` | +| `self._ana_active` | `str` | Currently expanded panel name | +| `self._ana_panel_names` | `list[str]` | Ordered panel names derived from `_PANEL_META` | +| `self._ana_accordions` | `list[Accordion]` | Ordered accordions derived from `_PANEL_META` | +| `self._ana_unavail_msgs` | `dict[str, HTML]` | Panel name → "Not available" placeholder widget | +| `self._ana_content_boxes` | `dict[str, VBox]` | Panel name → real content VBox (hidden until activated) | +| `self._ana_btns` | `list[Button]` | Switcher buttons — **pending removal in M-PLOT-FIX.3** | | `self._pending_traj_result` | `Any` | Trajectory stub; rendered lazily on accordion expand | | `self._last_orb_info` | `Optional[...]` | Orbital info from last successful `_show_orbital_diagram` | | `self._last_ir_freqs` | `list[float]` | IR frequencies stored by `_show_ir_spectrum` | @@ -445,7 +468,7 @@ Test files in `tests/`: | File | What it covers | | --- | --- | -| `test_app.py` | QuantUIApp — widgets, panel registry, callbacks, history replay | +| `test_app.py` | QuantUIApp — widgets, panel registry, callbacks, always-visible panels | | `test_molecule.py` | Molecule parsing, validation, formula | | `test_session_calc.py` | `run_in_session()` — PySCF-gated | | `test_notebook_workflows.py` | End-to-end HF/DFT/preopt/thread-safety — PySCF-gated | @@ -453,16 +476,23 @@ Test files in `tests/`: | `test_freq_calc.py` | `run_freq_calc()` — PySCF-gated | | `test_tddft_calc.py` | `run_tddft_calc()` — PySCF-gated | | `test_nmr_calc.py` | `run_nmr_calc()` — PySCF + pyscf.prop required | -| `test_pes_scan.py` | `run_pes_scan()` — PySCF + ASE required | +| `test_pes_scan.py` | `run_pes_scan()` + PES analysis panel | +| `test_ir_plot.py` | `plot_ir_spectrum()` — stick and broadened modes | | `test_comparison.py` | Result comparison tables | | `test_results_storage.py` | Save/load/list round-trip | | `test_security.py` | `SecurityError`, `sanitize_filename()` | +| `test_code_quality.py` | Static analysis — bans CDN plotlyjs, bare except/pass *(pending M-PLOT-FIX.5)* | +| `test_sp/` | Single-point analysis history replay (end-to-end) | +| `test_geo_opt/` | Geometry opt analysis history replay | +| `test_tddft/` | TD-DFT / UV-Vis analysis history replay | +| `test_nmr/` | NMR analysis history replay | +| `test_freq_analysis_history.py` | Frequency analysis history replay (Vibrational + IR) | +| `test_pes_scan_analysis_history.py` | PES scan analysis history replay | **PySCF-gated tests** use `@pytest.mark.skipif(not _PYSCF_AVAILABLE, ...)`. On Windows, these become skips — not failures. -**Baseline (WSL, 2026-04-27):** 699 passed, 15 skipped, 10 failed (all pre-existing: -7 NMR pyscf.prop, 1 orbital viz, 1 PES scan, 1 freq thermo). +**Baseline (WSL, 2026-05-01 session 29):** 865 passed, 15 skipped. --- From 622f5d73e2dee2e1bd25daadfb0e6cb7f8c7f485 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 1 May 2026 18:38:35 -0400 Subject: [PATCH 03/10] Wrap widget callbacks; use Plotly 'require'; add tests Introduce a _safe_cb wrapper to .observe() handlers so exceptions are logged instead of silently dropped, and wire many widget observers through it (trajectory, IR/orb accordions, viz/theme/preset/calc inputs, orbital controls, vibrational controls, slider/frame updates, etc.). Refactor the Analysis panel switcher: remove the button-based switcher UI and per-button logic, simplify _select_ana_panel/_activate/_deactivate behavior to use accordion selected_index and add accordion observers to re-render Plotly charts when shown. Replace several include_plotlyjs='cdn' usages with 'require' to avoid silent failures when offline. Tighten exception handling in pubchem.smiles_to_xyz and check_pubchem_availability (catch Exception instead of bare except). Update tests to match the refactor and add tests/test_code_quality.py to forbid use of include_plotlyjs='cdn' and bare except/pass patterns. --- quantui/app.py | 196 ++++++++++++++++++------------------- quantui/pubchem.py | 4 +- tests/test_app.py | 41 +------- tests/test_code_quality.py | 29 ++++++ 4 files changed, 130 insertions(+), 140 deletions(-) create mode 100644 tests/test_code_quality.py diff --git a/quantui/app.py b/quantui/app.py index e1ea0b3..8923fa5 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1131,7 +1131,9 @@ def _build_results_section(self) -> None: ) self.traj_accordion.set_title(0, "Trajectory Viewer") self.traj_accordion.selected_index = None # collapsed by default - self.traj_accordion.observe(self._on_traj_expand, names=["selected_index"]) + self.traj_accordion.observe( + self._safe_cb(self._on_traj_expand), names=["selected_index"] + ) # Vibrational animation accordion (Frequency only — hidden until Freq completes) self.vib_mode_dd = widgets.Dropdown( @@ -1439,7 +1441,6 @@ def _build_results_section(self) -> None: self._analysis_context_lbl, self._analysis_mol_output, self._analysis_empty_html, - self._ana_switcher_box, self._ana_unavail_html, self._orb_accordion, self._pes_scan_accordion, @@ -1458,7 +1459,7 @@ def _build_results_section(self) -> None: # ── Analysis panel switcher ─────────────────────────────────────────── def _build_ana_switcher(self) -> None: - """Build the always-visible panel switcher strip for the Analysis tab.""" + """Initialise analysis panel state; wire accordion re-render observers.""" panel_meta = [ (name, getattr(self, attr), when) for name, attr, when in self._PANEL_META ] @@ -1493,76 +1494,35 @@ def _build_ana_switcher(self) -> None: acc.layout.display = "" # always in the DOM acc.selected_index = None # collapsed until activated - self._ana_btns: list = [] - for name, _acc, when in panel_meta: - btn = widgets.Button( - description=name, - button_style="", - tooltip=f"Available after: {when}", - layout=widgets.Layout(margin="0"), - ) - btn.layout.opacity = "0.35" - btn.on_click(lambda _b, n=name: self._on_ana_panel_click(n)) - self._ana_btns.append(btn) - self._ana_switcher_box = widgets.HBox( - self._ana_btns, - layout=widgets.Layout( - flex_wrap="wrap", - gap="4px", - margin="0 0 6px 0", - padding="6px 4px", - border="1px solid #e2e8f0", - border_radius="6px", - ), + # Re-render Plotly charts when their accordion is expanded by clicking + # the header directly (charts rendered into a hidden container have 0 size). + self._ir_accordion.observe( + self._safe_cb(self._on_ir_accordion_show), names=["selected_index"] + ) + self._orb_accordion.observe( + self._safe_cb(self._on_orb_accordion_show), names=["selected_index"] ) - def _on_ana_panel_click(self, name: str) -> None: - if name in self._ana_available: - self._select_ana_panel(name) - else: - # Highlight the clicked button as a warning; show inline note. - for btn in self._ana_btns: - btn.button_style = "" - for btn, pname in zip(self._ana_btns, self._ana_panel_names): - if pname == name: - btn.button_style = "warning" - self._ana_unavail_html.value = ( - f'

' - f"{name} is not available for this calculation type.

" + def _on_ir_accordion_show(self, change) -> None: + if change["new"] == 0 and getattr(self, "_last_ir_freqs", None): + self._update_ir_figure( + self._ir_mode_toggle.value, self._ir_fwhm_slider.value ) - self._ana_unavail_html.layout.display = "" - self._ana_active = "" + + def _on_orb_accordion_show(self, change) -> None: + if change["new"] == 0 and getattr(self, "_last_orb_info", None) is not None: + self._on_orb_range_changed() def _select_ana_panel(self, name: str) -> None: - """Expand the named panel; collapse all others. Updates button styles.""" + """Expand the named panel and collapse all others.""" self._ana_active = name self._ana_unavail_html.layout.display = "none" - for pname, acc, btn in zip( - self._ana_panel_names, self._ana_accordions, self._ana_btns - ): - if pname == name: - acc.selected_index = 0 - btn.button_style = "primary" - else: - acc.selected_index = None - btn.button_style = "" - # Re-render Plotly charts that may have initialised into a zero-size - # container while their accordion was collapsed. - if name == "IR Spectrum" and getattr(self, "_last_ir_freqs", None): - self._update_ir_figure( - self._ir_mode_toggle.value, self._ir_fwhm_slider.value - ) - if name == "Energies" and getattr(self, "_last_orb_info", None) is not None: - self._on_orb_range_changed() + for pname, acc in zip(self._ana_panel_names, self._ana_accordions): + acc.selected_index = 0 if pname == name else None def _activate_ana_panel(self, name: str, auto_select: bool = True) -> None: - """Mark a panel as available: reveal its content, brighten its button.""" + """Mark a panel as available: reveal its content.""" self._ana_available.add(name) - for btn, pname in zip(self._ana_btns, self._ana_panel_names): - if pname == name: - btn.layout.opacity = "1.0" - btn.tooltip = name # Swap unavailable placeholder for real content. if name in self._ana_unavail_msgs: self._ana_unavail_msgs[name].layout.display = "none" @@ -1575,18 +1535,12 @@ def _deactivate_all_ana_panels(self) -> None: self._ana_available.clear() self._ana_active = "" self._ana_unavail_html.layout.display = "none" - _when = {name: when for name, _, when in self._PANEL_META} - for name, acc, btn in zip( - self._ana_panel_names, self._ana_accordions, self._ana_btns - ): + for name, acc in zip(self._ana_panel_names, self._ana_accordions): # Show the "not available" placeholder; hide real content. if name in self._ana_unavail_msgs: self._ana_unavail_msgs[name].layout.display = "" self._ana_content_boxes[name].layout.display = "none" acc.selected_index = None - btn.button_style = "" - btn.layout.opacity = "0.35" - btn.tooltip = f"Available after: {_when.get(name, '')}" # ── Panel registry and unified applier ─────────────────────────────────── # @@ -1864,7 +1818,7 @@ def _pop_uv_vis(self, ctx: _AnalysisContext) -> bool: self._apply_plotly_theme(_fig) self._tddft_fig.value = _pio.to_html( _fig, - include_plotlyjs="cdn", + include_plotlyjs="require", full_html=False, config={"responsive": True}, ) @@ -2496,30 +2450,42 @@ def _assemble_tabs(self) -> None: def _wire_callbacks(self) -> None: # 3D viewer backend toggle (only wired when both backends are available) if self.viz_backend_toggle is not None: - self.viz_backend_toggle.observe(self._on_viz_backend_changed, names="value") + self.viz_backend_toggle.observe( + self._safe_cb(self._on_viz_backend_changed), names="value" + ) # 3D viewer style and lighting controls if VISUALIZATION_AVAILABLE: - self.viz_style_dd.observe(self._on_viz_style_changed, names="value") - self.viz_lighting_dd.observe(self._on_viz_lighting_changed, names="value") + self.viz_style_dd.observe( + self._safe_cb(self._on_viz_style_changed), names="value" + ) + self.viz_lighting_dd.observe( + self._safe_cb(self._on_viz_lighting_changed), names="value" + ) # Theme - self.theme_btn.observe(self._on_theme_changed, names="value") + self.theme_btn.observe(self._safe_cb(self._on_theme_changed), names="value") # Molecule input - self.preset_dd.observe(self._on_load_preset, names="value") + self.preset_dd.observe(self._safe_cb(self._on_load_preset), names="value") self.xyz_btn.on_click(self._on_load_xyz) self.pubchem_btn.on_click(self._on_search_pubchem) self.change_mol_btn.on_click(self._on_expand_mol_input) # Calc type - self.calc_type_dd.observe(self._on_calc_type_changed, names="value") - self._freq_seed_dd.observe(self._on_freq_seed_changed, names="value") - self._scan_type_dd.observe(self._update_scan_widgets, names="value") + self.calc_type_dd.observe( + self._safe_cb(self._on_calc_type_changed), names="value" + ) + self._freq_seed_dd.observe( + self._safe_cb(self._on_freq_seed_changed), names="value" + ) + self._scan_type_dd.observe( + self._safe_cb(self._update_scan_widgets), names="value" + ) self._freq_seed_refresh_btn.on_click( lambda _btn: self._refresh_freq_seed_options() ) # Notes + estimate - self.method_dd.observe(self._update_notes, names="value") - self.basis_dd.observe(self._update_notes, names="value") - self.method_dd.observe(self._update_estimate, names="value") - self.basis_dd.observe(self._update_estimate, names="value") + self.method_dd.observe(self._safe_cb(self._update_notes), names="value") + self.basis_dd.observe(self._safe_cb(self._update_notes), names="value") + self.method_dd.observe(self._safe_cb(self._update_estimate), names="value") + self.basis_dd.observe(self._safe_cb(self._update_estimate), names="value") # Help buttons self.method_help_btn.on_click(self._on_method_help) self.basis_help_btn.on_click(self._on_basis_help) @@ -2529,7 +2495,9 @@ def _wire_callbacks(self) -> None: # Accumulate / export self.accumulate_btn.on_click(self._on_accumulate) self.clear_btn.on_click(self._on_clear) - self.solvent_cb.observe(self._on_solvent_cb_changed, names="value") + self.solvent_cb.observe( + self._safe_cb(self._on_solvent_cb_changed), names="value" + ) self._cal_run_btn.on_click(self._on_cal_run) self._cal_stop_btn.on_click(self._on_cal_stop) self.export_btn.on_click(self._on_export) @@ -2537,7 +2505,7 @@ def _wire_callbacks(self) -> None: self.export_mol_btn.on_click(self._on_export_mol) self.export_pdb_btn.on_click(self._on_export_pdb) # History - self.past_dd.observe(self._on_past_dd_changed, names="value") + self.past_dd.observe(self._safe_cb(self._on_past_dd_changed), names="value") self.past_refresh_btn.on_click(self._on_past_refresh) self.copy_path_btn.on_click(self._on_copy_results_path) self.view_log_btn.on_click(self._on_view_log) @@ -2562,7 +2530,9 @@ def _wire_callbacks(self) -> None: self._help_btn.on_click(self._on_help_toggle) # Exit self._exit_btn.on_click(self._on_exit_clicked) - self.help_topic_dd.observe(self._on_help_topic_changed, names="value") + self.help_topic_dd.observe( + self._safe_cb(self._on_help_topic_changed), names="value" + ) # Tab navigation buttons self._go_results_btn.on_click( lambda _: setattr(self.root_tab, "selected_index", 1) @@ -2574,11 +2544,19 @@ def _wire_callbacks(self) -> None: lambda _: setattr(self.root_tab, "selected_index", 2) ) # Vibrational mode selector - self.vib_mode_dd.observe(self._on_vib_mode_changed, names="value") + self.vib_mode_dd.observe( + self._safe_cb(self._on_vib_mode_changed), names="value" + ) # Orbital diagram axis controls - self._orb_ymin_input.observe(self._on_orb_range_changed, names="value") - self._orb_ymax_input.observe(self._on_orb_range_changed, names="value") - self._orb_n_orb_input.observe(self._on_orb_range_changed, names="value") + self._orb_ymin_input.observe( + self._safe_cb(self._on_orb_range_changed), names="value" + ) + self._orb_ymax_input.observe( + self._safe_cb(self._on_orb_range_changed), names="value" + ) + self._orb_n_orb_input.observe( + self._safe_cb(self._on_orb_range_changed), names="value" + ) # Orbital isosurface generate button self._iso_generate_btn.on_click(self._on_iso_generate) @@ -4094,7 +4072,7 @@ def _on_demand(): threading.Thread(target=_on_demand, daemon=True).start() - _step_slider.observe(_update_frame, names="value") + _step_slider.observe(self._safe_cb(_update_frame), names="value") # --- Export button --- _export_btn = widgets.Button( @@ -4467,8 +4445,8 @@ def _on_fwhm(change) -> None: if self._ir_mode_toggle.value == "Broadened": self._update_ir_figure("Broadened", change["new"]) - self._ir_mode_toggle.observe(_on_mode, names="value") - self._ir_fwhm_slider.observe(_on_fwhm, names="value") + self._ir_mode_toggle.observe(self._safe_cb(_on_mode), names="value") + self._ir_fwhm_slider.observe(self._safe_cb(_on_fwhm), names="value") # Reset toggle/slider to defaults self._ir_mode_toggle.value = "Stick" @@ -4499,7 +4477,7 @@ def _update_ir_figure(self, mode: str, fwhm: float) -> None: self._apply_plotly_theme(fig) self._ir_fig.value = _pio.to_html( fig, - include_plotlyjs="cdn", + include_plotlyjs="require", full_html=False, config={"responsive": True}, ) @@ -4551,7 +4529,7 @@ def _show_orbital_diagram(self, result) -> bool: self._apply_plotly_theme(fig) html_str = _pio.to_html( fig, - include_plotlyjs="cdn", + include_plotlyjs="require", full_html=False, config={"responsive": True}, ) @@ -4646,7 +4624,7 @@ def _on_orb_range_changed(self, _change=None) -> None: self._apply_plotly_theme(fig) self._orb_diagram_html.value = _pio.to_html( fig, - include_plotlyjs="cdn", + include_plotlyjs="require", full_html=False, config={"responsive": True}, ) @@ -4786,7 +4764,7 @@ def _err(msg: str) -> None: _anim_html = _pio.to_html( anim_fig, full_html=False, - include_plotlyjs="cdn", + include_plotlyjs="require", config={"responsive": True}, ) self.vib_output.clear_output() @@ -5443,6 +5421,28 @@ def _show_help_topic(self, topic: str) -> None: def _update_log_panel(self, log_text: str, label: str = "") -> None: self._render_log(log_text, label) + def _safe_cb(self, fn): + """Wrap an .observe() handler so exceptions are logged instead of silently dropped.""" + + def _wrapper(change): + try: + fn(change) + except Exception as _e: + import traceback as _tb + + try: + from quantui import calc_log as _clog + + _clog.log_event( + "callback_error", + f"{getattr(fn, '__name__', repr(fn))}: " + f"{type(_e).__name__}: {_e}\n{_tb.format_exc()[:800]}", + ) + except Exception: + pass + + return _wrapper + def _goto_output_tab(self) -> None: self.root_tab.selected_index = 5 @@ -5985,7 +5985,7 @@ def _show_pes_scan_result(self, result) -> bool: ) self._pes_plot_html.value = pio.to_html( fig, - include_plotlyjs="cdn", + include_plotlyjs="require", full_html=False, config={"responsive": True}, ) diff --git a/quantui/pubchem.py b/quantui/pubchem.py index 2925e79..38fd640 100644 --- a/quantui/pubchem.py +++ b/quantui/pubchem.py @@ -334,7 +334,7 @@ def check_pubchem_availability() -> bool: url = f"{PUBCHEM_BASE_URL}/compound/cid/962/property/MolecularFormula/JSON" response = requests.get(url, timeout=5) return bool(response.status_code == 200) - except: + except Exception: return False @@ -383,7 +383,7 @@ def smiles_to_xyz(smiles: str, optimize_3d: bool = True) -> Tuple[str, Dict[str, # Optimize with UFF force field try: AllChem.UFFOptimizeMolecule(mol) - except: + except Exception: logger.warning("UFF optimization failed, using unoptimized coordinates") # Extract coordinates diff --git a/tests/test_app.py b/tests/test_app.py index fc92abb..23db68d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -1111,11 +1111,7 @@ def test_ir_accordion_in_analysis_tab(self): class TestAnaSwitcher: - """Panel switcher strip: buttons, state, activation, and deactivation.""" - - def test_eight_buttons_exist(self): - app = QuantUIApp() - assert len(app._ana_btns) == 8 + """Analysis panel state: activation, deactivation, and placeholder swapping.""" def test_panel_names(self): app = QuantUIApp() @@ -1130,11 +1126,6 @@ def test_panel_names(self): "NMR", ] - def test_buttons_initially_dimmed(self): - app = QuantUIApp() - for btn in app._ana_btns: - assert btn.layout.opacity == "0.35" - def test_no_panels_available_initially(self): app = QuantUIApp() assert len(app._ana_available) == 0 @@ -1145,21 +1136,11 @@ def test_all_accordions_visible_and_collapsed_initially(self): assert acc.layout.display == "" assert acc.selected_index is None - def test_switcher_box_in_analysis_tab(self): - app = QuantUIApp() - assert app._ana_switcher_box in app.analysis_tab_panel.children - def test_activate_panel_marks_available(self): app = QuantUIApp() app._activate_ana_panel("Energies") assert "Energies" in app._ana_available - def test_activate_panel_sets_opacity(self): - app = QuantUIApp() - app._activate_ana_panel("Energies") - orb_btn = app._ana_btns[0] - assert orb_btn.layout.opacity == "1.0" - def test_activate_panel_auto_selects(self): app = QuantUIApp() app._activate_ana_panel("Energies") @@ -1198,13 +1179,6 @@ def test_deactivate_all_collapses_accordions(self): assert acc.layout.display == "" assert acc.selected_index is None - def test_deactivate_all_dims_buttons(self): - app = QuantUIApp() - app._activate_ana_panel("Energies") - app._deactivate_all_ana_panels() - for btn in app._ana_btns: - assert btn.layout.opacity == "0.35" - def test_unavail_message_shown_initially(self): app = QuantUIApp() # Every panel starts with the unavailable placeholder visible. @@ -1225,19 +1199,6 @@ def test_deactivate_restores_placeholder(self): assert app._ana_unavail_msgs["Energies"].layout.display == "" assert app._ana_content_boxes["Energies"].layout.display == "none" - def test_click_unavailable_shows_warning(self): - app = QuantUIApp() - app._on_ana_panel_click("Energies") - assert app._ana_unavail_html.layout.display == "" - assert "Energies" in app._ana_unavail_html.value - - def test_click_available_selects_panel(self): - app = QuantUIApp() - app._activate_ana_panel("IR Spectrum", auto_select=False) - app._on_ana_panel_click("IR Spectrum") - assert app._ir_accordion.selected_index == 0 - assert app._ana_active == "IR Spectrum" - # --------------------------------------------------------------------------- # M-UI — Completion banner (M-UI.8) diff --git a/tests/test_code_quality.py b/tests/test_code_quality.py new file mode 100644 index 0000000..8f8e5a1 --- /dev/null +++ b/tests/test_code_quality.py @@ -0,0 +1,29 @@ +"""Static analysis guards for patterns that fail silently at runtime.""" + +import re +from pathlib import Path + +SRC = Path(__file__).parent.parent / "quantui" + + +def _grep(pattern: str) -> list[str]: + hits = [] + for path in SRC.rglob("*.py"): + for i, line in enumerate(path.read_text().splitlines(), 1): + if re.search(pattern, line): + hits.append(f"{path.relative_to(SRC.parent)}:{i}: {line.strip()}") + return hits + + +def test_no_cdn_plotlyjs(): + hits = _grep(r'include_plotlyjs\s*=\s*["\']cdn["\']') + assert not hits, "CDN plotlyjs detected (fails silently offline):\n" + "\n".join( + hits + ) + + +def test_no_bare_except_pass(): + hits = _grep(r"^\s*except\s*(\(\s*\))?\s*:\s*(pass\s*)?$") + assert not hits, "Bare except/pass detected (swallows all errors):\n" + "\n".join( + hits + ) From eb78054bff2755a8aa91af5b28820960b9ec6262 Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Fri, 1 May 2026 22:52:25 -0400 Subject: [PATCH 04/10] Docs: update app structure, tests, and deps Refresh .github/copilot-instructions.md to match recent code changes: document new QuantUI app build flow (new widgets, _wire_callbacks ordering, _assemble_tabs producing 7 root tabs), update root-tab order and note Help is a floating overlay, and add/rename helper builders (status, theme, welcome, issue widgets). Adjust Analysis panel registry and panel metadata (reorder/availability for frequency panels), tighten thread-safety guidance for Plotly rendering, and update supported methods/bases list. Update test invocation to run tests/ without coverage, bump collected test count and baseline, and add/rename several test modules (issue tracker, visualization, templates, analysis history variants), plus stricter code-quality entry. Revise dependency section into explicit groups with version hints and a new install extras command to reflect runtime and dev extras. Overall: keep the developer-facing instructions consistent with the current implementation and test matrix. --- .github/copilot-instructions.md | 123 ++++++++++++++++++-------------- 1 file changed, 70 insertions(+), 53 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 9ecfde4..cbdb355 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -54,7 +54,7 @@ QuantUI/ ├── notebooks/ │ ├── molecule_computations.ipynb ← Student-facing Voilà app (thin launcher) │ └── tutorials/ ← 01–05 step-by-step tutorial notebooks -├── tests/ ← pytest suite (~700 tests) +├── tests/ ← pytest suite (~875 tests) ├── .github/ │ └── copilot-instructions.md ← This file ├── apptainer/ @@ -79,19 +79,25 @@ notebooks/molecule_computations.ipynb │ ▼ quantui/app.py — QuantUIApp - ┌──────────────────────────────────────────────────────────┐ - │ _build_shared_widgets() → StepProgress, run_output │ - │ _build_molecule_section() → mol_input_container │ - │ _build_calc_setup() → method_dd, basis_dd, etc. │ - │ _build_run_section() → run_btn, run_panel │ - │ _build_results_section() → all panel accordions │ - │ _build_history_section() → history_panel │ - │ _build_compare_section() → compare_panel │ - │ _build_output_tab() → log viewer │ - │ _build_help_section() → help panel │ - │ _build_ana_switcher() → Analysis tab always-visible panels │ - │ _assemble_tabs() → root_tab (Tab widget) │ - └──────────────────────────────────────────────────────────┘ + ┌─────────────────────────────────────────────────────────────────────┐ + │ __init__() │ + │ _build_widgets() │ + │ _build_theme_selector() │ + │ _build_status_panel() │ + │ _build_welcome_header() │ + │ _build_shared_widgets() │ + │ _build_molecule_section() │ + │ _build_calc_setup() │ + │ _build_run_section() │ + │ _build_results_section() → Analysis accordions + _build_ana_switcher() │ + │ _build_history_section() │ + │ _build_compare_section() │ + │ _build_output_tab() │ + │ _build_help_section() │ + │ _build_issue_widgets() │ + │ _wire_callbacks() │ + │ _assemble_tabs() → root_tab (7 tabs) │ + └─────────────────────────────────────────────────────────────────────┘ │ _do_run() dispatches by calc_type_dd.value: ▼ ┌──────────────────────────────────────────────────────────────┐ @@ -110,10 +116,12 @@ notebooks/molecule_computations.ipynb └──────────────────────────────────────────────────────────────┘ │ ▼ - results_storage.save_result() calc_log.log_perf_record() + results_storage.save_result() calc_log.log_calculation()/log_event() ``` -**Tab order in the app:** Calculate (0) → History (1) → Compare (2) → Output (3) → Help (4) +**Root-tab order in the app:** Calculate (0) → Results (1) → Analysis (2) → History (3) → Compare (4) → Log (5) → Status (6) + +**Help UI:** Help is a floating overlay panel toggled by `[?]`; it is not a root tab. --- @@ -159,7 +167,7 @@ class _AnalysisContext: _PANEL_REGISTRY = { "single_point": [("Energies", "_pop_energies", True), ("Isosurface", "_pop_isosurface", False)], "geometry_opt": [("Trajectory", "_pop_geo_trajectory", True), ("Energies", "_pop_energies", False), ...], - "frequency": [("Trajectory", "_pop_preopt_trajectory", False), ("Vibrational", "_pop_vibrational", True), ("IR Spectrum", "_pop_ir_spectrum", False)], + "frequency": [("Vibrational", "_pop_vibrational", True), ("IR Spectrum", "_pop_ir_spectrum", True), ("Trajectory", "_pop_preopt_trajectory", False), ("Energies", "_pop_energies", True)], "tddft": [("UV-Vis", "_pop_uv_vis", True)], "nmr": [("NMR", "_pop_nmr_shielding", True)], "pes_scan": [("PES Scan", "_pop_pes_plot", True), ("Trajectory", "_pop_pes_trajectory", False)], @@ -176,7 +184,7 @@ _PANEL_REGISTRY = { ### Adding a new panel — exactly these steps 1. Create the accordion widget in `_build_results_section()` (follow existing pattern) -2. Add it to the `analysis_tab_panel` VBox in `_build_analysis_section()` +2. Add it to the `analysis_tab_panel` VBox in `_build_results_section()` 3. Add `(panel_name, accordion_attr_name, "available after X")` to `_PANEL_META` (class-level `ClassVar` — this is the single source of truth for placeholder text and accordion attribute lookup; `_build_ana_switcher()` reads it at init time) @@ -250,9 +258,9 @@ Unavailable panels show a "Not available — run a X calculation first" placehol logic to notebook cells. All logic belongs in `quantui/app.py`. 3. **Thread-safe widget updates only.** `_do_run()` runs in a background thread. - Widget updates from threads must use `.value =` assignment, - `.append_stdout()`, or `.append_display_data()`. Never call `display()` inside - `with output_widget:` from a background thread except via `with output_widget:` context. + Widget updates from threads must use `.value =`, `.append_stdout()`, or + `.append_display_data()`. For Plotly, prefer `plotly.io.to_html(...)` + HTML append + rather than direct `display(fig)` in a thread. 4. **No new top-level dependencies** without updating both `pyproject.toml` and the Apptainer container `apptainer/quantui.def`. @@ -294,8 +302,8 @@ Unavailable panels show a "Not available — run a X calculation first" placehol | NMR Shielding | `nmr_calc` | `run_nmr_calc()` | `NMRResult` | `"nmr"` | | PES Scan | `pes_scan` | `run_pes_scan()` | `PESScanResult` | `"pes_scan"` | -**Supported methods:** RHF, UHF, B3LYP, PBE, PBE0, M06-2X, MP2, CCSD, CAM-B3LYP, -M06-L, wB97X-D, PBE-D3 (defined in `config.SUPPORTED_METHODS`). +**Supported methods:** RHF, UHF, B3LYP, PBE, PBE0, M06-2X, wB97X-D, CAM-B3LYP, +M06-L, HSE06, PBE-D3, MP2 (defined in `config.SUPPORTED_METHODS`). **Supported basis sets:** STO-3G, 3-21G, 6-31G, 6-31G\*, 6-31G\*\*, cc-pVDZ, cc-pVTZ, def2-SVP, def2-TZVP (defined in `config.SUPPORTED_BASIS_SETS`). @@ -308,18 +316,22 @@ cc-pVTZ, def2-SVP, def2-TZVP (defined in `config.SUPPORTED_BASIS_SETS`). ``` __init__() - _build_shared_widgets() - _build_molecule_section() - _build_calc_setup() - _build_run_section() # uses self.calc_type_dd from _build_calc_setup - _build_results_section() # builds all panel accordions incl. tddft + nmr - _build_history_section() - _build_compare_section() - _build_output_tab() - _build_help_section() - _build_ana_switcher() # builds 8-panel switcher strip - _assemble_tabs() # builds self.root_tab - _wire_callbacks() # all .observe() and .on_click() wiring + _build_widgets() + _build_theme_selector() + _build_status_panel() + _build_welcome_header() + _build_shared_widgets() + _build_molecule_section() + _build_calc_setup() + _build_run_section() + _build_results_section() # also calls _build_ana_switcher() + _build_history_section() + _build_compare_section() + _build_output_tab() + _build_help_section() + _build_issue_widgets() + _wire_callbacks() # all .observe() and .on_click() wiring + _assemble_tabs() # builds self.root_tab (7 tabs) ``` ### Key instance state — Analysis tab @@ -330,9 +342,9 @@ __init__() | `self._ana_active` | `str` | Currently expanded panel name | | `self._ana_panel_names` | `list[str]` | Ordered panel names derived from `_PANEL_META` | | `self._ana_accordions` | `list[Accordion]` | Ordered accordions derived from `_PANEL_META` | +| `self._ana_unavail_html` | `widgets.HTML` | Shared unavailable-state message area (usually hidden) | | `self._ana_unavail_msgs` | `dict[str, HTML]` | Panel name → "Not available" placeholder widget | | `self._ana_content_boxes` | `dict[str, VBox]` | Panel name → real content VBox (hidden until activated) | -| `self._ana_btns` | `list[Button]` | Switcher buttons — **pending removal in M-PLOT-FIX.3** | | `self._pending_traj_result` | `Any` | Trajectory stub; rendered lazily on accordion expand | | `self._last_orb_info` | `Optional[...]` | Orbital info from last successful `_show_orbital_diagram` | | `self._last_ir_freqs` | `list[float]` | IR frequencies stored by `_show_ir_spectrum` | @@ -442,7 +454,7 @@ voila notebooks/molecule_computations.ipynb jupyter lab notebooks/molecule_computations.ipynb # Run tests -python -m pytest --tb=short -q +python -m pytest tests/ -q --no-cov # Install/update package pip install -e ".[dev]" @@ -469,44 +481,49 @@ Test files in `tests/`: | File | What it covers | | --- | --- | | `test_app.py` | QuantUIApp — widgets, panel registry, callbacks, always-visible panels | +| `test_issue_tracker.py` | Issue logging DB integration and context capture | | `test_molecule.py` | Molecule parsing, validation, formula | | `test_session_calc.py` | `run_in_session()` — PySCF-gated | +| `test_notebook_interactions.py` | Notebook widget interaction flows | | `test_notebook_workflows.py` | End-to-end HF/DFT/preopt/thread-safety — PySCF-gated | | `test_optimizer.py` | `optimize_geometry()` — PySCF + ASE required | | `test_freq_calc.py` | `run_freq_calc()` — PySCF-gated | -| `test_tddft_calc.py` | `run_tddft_calc()` — PySCF-gated | | `test_nmr_calc.py` | `run_nmr_calc()` — PySCF + pyscf.prop required | | `test_pes_scan.py` | `run_pes_scan()` + PES analysis panel | | `test_ir_plot.py` | `plot_ir_spectrum()` — stick and broadened modes | +| `test_visualization.py` / `test_visualization_integration.py` | 3D and animation rendering paths | +| `test_templates.py` | Export template/script integrity | | `test_comparison.py` | Result comparison tables | | `test_results_storage.py` | Save/load/list round-trip | | `test_security.py` | `SecurityError`, `sanitize_filename()` | -| `test_code_quality.py` | Static analysis — bans CDN plotlyjs, bare except/pass *(pending M-PLOT-FIX.5)* | -| `test_sp/` | Single-point analysis history replay (end-to-end) | -| `test_geo_opt/` | Geometry opt analysis history replay | -| `test_tddft/` | TD-DFT / UV-Vis analysis history replay | -| `test_nmr/` | NMR analysis history replay | +| `test_code_quality.py` | Static analysis — bans CDN plotlyjs and bare `except/pass` | +| `test_sp_analysis_history.py` | Single-point analysis history replay (end-to-end) | +| `test_geo_opt_analysis_history.py` | Geometry-opt analysis history replay | +| `test_tddft_analysis_history.py` | TD-DFT / UV-Vis analysis history replay | +| `test_nmr_analysis_history.py` | NMR analysis history replay | | `test_freq_analysis_history.py` | Frequency analysis history replay (Vibrational + IR) | | `test_pes_scan_analysis_history.py` | PES scan analysis history replay | **PySCF-gated tests** use `@pytest.mark.skipif(not _PYSCF_AVAILABLE, ...)`. On Windows, these become skips — not failures. -**Baseline (WSL, 2026-05-01 session 29):** 865 passed, 15 skipped. +**Baseline (WSL, 2026-05-01; `python -m pytest tests/ -q --no-cov`):** +860 passed, 15 skipped (875 collected). --- -## Optional Dependencies +## Dependency Groups -| Extra | Packages | Gated by | +| Group | Packages | Notes | | --- | --- | --- | -| `pyscf` | `pyscf>=2.3.0` | `_PYSCF_AVAILABLE` flag | -| `ase` | `ase>=3.22.0` | `ASE_AVAILABLE` flag | -| `plotly` | `plotly` | checked inline at render time | -| `plotlymol` | `plotlymol3d` (local) | checked inline at render time | -| `app` | `voila, jupyterlab` | Always present in the conda env | - -Install all: `pip install -e ".[pyscf,ase,app,dev]"` +| Core (always installed) | `jupyter`, `ipywidgets`, `notebook`, `numpy`, `matplotlib`, `plotly`, `plotlymol`, `py3Dmol` | Base UI + visualization stack | +| `pyscf` extra | `pyscf>=2.13.0`, `pyscf-properties` | Enables SCF/frequency/TDDFT/NMR/PES backends | +| `ase` extra | `ase>=3.22.0` | Geometry optimization and structure I/O | +| `app` extra | `voila>=0.5.0`, `ipykernel>=6.0.0` | Student-facing Voilà deployment | +| `notebook` extra | `nbmake>=1.4.0`, `ipykernel>=6.0.0` | Notebook smoke-testing support | +| `dev` extra | `pytest`, `pytest-cov`, `pytest-mock`, `mypy`, `ruff`, `black`, `pre-commit` | Development tooling | + +Install all runtime + dev extras: `pip install -e ".[pyscf,ase,app,notebook,dev]"` --- From bb74392cf583c5d81bf07bca1a3514951b96d5dc Mon Sep 17 00:00:00 2001 From: NCCU-Schultz-Lab Date: Sat, 2 May 2026 10:20:30 -0400 Subject: [PATCH 05/10] Render plots in Output widgets; fix IR x-axis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace widgets.HTML with widgets.Output for Plotly/matplotlib displays to ensure JS execution and proper display. Add helpers (_set_html_output, _set_plotly_figure_output, _render_plotly_figure, _clear_output_widget) and update code paths to render/clear Output widgets. Make IR control wiring main-thread safe (_wire_ir_controls, _on_ir_mode_changed, _on_ir_fwhm_changed) and queue callbacks where needed. Change IR plot x-axis ordering to low→high (400→4000) and update tests to assert Output.outputs usage and new x-axis orientation. --- quantui/app.py | 159 +++++++++++++++++++++++++++++------------ quantui/ir_plot.py | 4 +- tests/test_app.py | 10 ++- tests/test_ir_plot.py | 8 +-- tests/test_pes_scan.py | 9 ++- 5 files changed, 132 insertions(+), 58 deletions(-) diff --git a/quantui/app.py b/quantui/app.py index 8923fa5..efe4ea1 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -1108,9 +1108,7 @@ def _build_run_section(self) -> None: def _build_results_section(self) -> None: # PES scan energy plot accordion (hidden until a PES Scan completes) - self._pes_plot_html = widgets.HTML( - value="", layout=widgets.Layout(width="100%") - ) + self._pes_plot_html = widgets.Output(layout=widgets.Layout(width="100%")) self._pes_scan_accordion = widgets.Accordion( children=[ widgets.VBox( @@ -1171,7 +1169,7 @@ def _build_results_section(self) -> None: style={"description_width": "80px"}, layout=widgets.Layout(width="260px", display="none"), ) - self._ir_fig = widgets.HTML(value="", layout=widgets.Layout(width="100%")) + self._ir_fig = widgets.Output(layout=widgets.Layout(width="100%")) _ir_controls = widgets.HBox( [self._ir_mode_toggle, self._ir_fwhm_slider], @@ -1240,9 +1238,7 @@ def _build_results_section(self) -> None: margin="0 0 6px 0", ), ) - self._orb_diagram_html = widgets.HTML( - value="", layout=widgets.Layout(width="100%") - ) + self._orb_diagram_html = widgets.Output(layout=widgets.Layout(width="100%")) _orb_diagram_content: list = [_orb_controls_row, self._orb_diagram_html] self._orb_diagram_box = widgets.VBox( _orb_diagram_content, @@ -1311,7 +1307,7 @@ def _build_results_section(self) -> None: self._iso_accordion.selected_index = None # ── UV-Vis spectrum accordion (TD-DFT only — hidden until result) ── - self._tddft_fig = widgets.HTML(value="", layout=widgets.Layout(width="100%")) + self._tddft_fig = widgets.Output(layout=widgets.Layout(width="100%")) self._tddft_accordion = widgets.Accordion( children=[ widgets.VBox( @@ -1816,11 +1812,14 @@ def _pop_uv_vis(self, ctx: _AnalysisContext) -> bool: yaxis=dict(showgrid=True, gridcolor=tc["grid_color"]), ) self._apply_plotly_theme(_fig) - self._tddft_fig.value = _pio.to_html( - _fig, - include_plotlyjs="require", - full_html=False, - config={"responsive": True}, + self._set_html_output( + self._tddft_fig, + _pio.to_html( + _fig, + include_plotlyjs="require", + full_html=False, + config={"responsive": True}, + ), ) return True except Exception: @@ -2601,6 +2600,41 @@ def _apply_plotly_theme(self, fig) -> None: yaxis=dict(gridcolor=tc["grid_color"]), ) + def _set_html_output(self, out: widgets.Output, html: str) -> None: + """Render HTML into an Output widget. + + Plotly HTML contains