From 69d1506c2b5ee5289ef88349cb644d4122ac9e0f Mon Sep 17 00:00:00 2001 From: Schultz Lab at NCCU Date: Thu, 23 Jul 2026 11:59:20 -0400 Subject: [PATCH] Add searchable + faceted History browser (HIST.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The History tab was a single flat, time-ordered dropdown with no way to find a past calculation. Add a client-side search + faceted filter above it. - Text search by chemical name or formula, type-to-narrow. Name→formula resolution (e.g. "benzene" finds C6H6 results) via a new curated config.COMMON_NAME_TO_FORMULA map plus exact molecule-library name matches. Both sources are precise: synonym/substring matching is deliberately avoided so "benzene" can't drag in toluene or ethylbenzene. - Facet chips/controls for calc type, method, basis, date range, and converged status. All facets AND together. - refresh_results_browser now caches each result's parsed result.json on app._history_entries, so filtering re-narrows an in-memory list with no per-keystroke disk access. filter_history_entries is a pure function; the library/DB lookup for name resolution lives in the impure apply layer. - Preserves the existing index-0 placeholder + value-preservation contract; shows an explicit "no matches" option when every entry is filtered out. Tests: TestHistoryHardeningHist7 in tests/test_app.py (22 tests) cover the pure filter across every facet, name→formula resolution (including the toluene/ethylbenzene false-positive guards), and apply_history_filter end-to-end. Co-Authored-By: Claude Opus 4.8 --- quantui/app.py | 48 +++++++ quantui/app_builders.py | 103 +++++++++++++- quantui/app_history.py | 216 ++++++++++++++++++++++++++++ quantui/app_runflow.py | 54 +++++-- quantui/config.py | 52 +++++++ tests/test_app.py | 305 ++++++++++++++++++++++++++++++++++++++++ 6 files changed, 767 insertions(+), 11 deletions(-) diff --git a/quantui/app.py b/quantui/app.py index 9427f92..5189530 100644 --- a/quantui/app.py +++ b/quantui/app.py @@ -904,6 +904,13 @@ class QuantUIApp: past_dd: Any past_output: Any past_refresh_btn: Any + history_search: Any + history_filter_clear_btn: Any + history_count_lbl: Any + history_method_dd: Any + history_basis_dd: Any + history_date_from: Any + history_date_to: Any lib_category_dd: Any lib_search_txt: Any lib_results_dd: Any @@ -1788,6 +1795,21 @@ def _wire_callbacks(self) -> None: 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) + # History search / faceted filters (HIST.7) + for _w in ( + self.history_search, + self.history_method_dd, + self.history_basis_dd, + self.history_date_from, + self.history_date_to, + ): + _w.observe(self._safe_cb(self._on_history_filter_changed), names="value") + for _chip in ( + *self._history_calc_chips.values(), + *self._history_status_chips.values(), + ): + _chip.observe(self._safe_cb(self._on_history_filter_changed), names="value") + self.history_filter_clear_btn.on_click(self._on_history_filter_clear) # Perf stats reset self._reset_btn.on_click(self._on_reset_click) self._reset_confirm_yes.on_click(self._on_confirm_yes) @@ -3512,6 +3534,32 @@ def _on_compare_clear(self, btn) -> None: def _on_past_dd_changed(self, change) -> None: _hist_on_past_dd_changed(self, change, layout_fn=_layout) + def _on_history_filter_changed(self, change=None) -> None: + from quantui.app_history import apply_history_filter + + apply_history_filter(self) + + def _on_history_filter_clear(self, btn=None) -> None: + from quantui.app_history import apply_history_filter + + # Reset every facet widget, suspending the observer so we run a single + # filter pass at the end instead of one per widget reset. + self._history_filter_suspend = True + try: + self.history_search.value = "" + self.history_method_dd.value = "" + self.history_basis_dd.value = "" + self.history_date_from.value = None + self.history_date_to.value = None + for chip in ( + *self._history_calc_chips.values(), + *self._history_status_chips.values(), + ): + chip.value = False + finally: + self._history_filter_suspend = False + apply_history_filter(self) + def _on_past_refresh(self, btn) -> None: self._activity_begin("Refreshing history list...") try: diff --git a/quantui/app_builders.py b/quantui/app_builders.py index 28ac254..32cbb3e 100644 --- a/quantui/app_builders.py +++ b/quantui/app_builders.py @@ -430,6 +430,105 @@ def build_history_section( app._cal_accordion = widgets.Accordion(children=[cal_panel], selected_index=None) app._cal_accordion.set_title(0, "Calibrate time estimates") + # ── Search / faceted filters (HIST.7) ────────────────────────────── + # All filtering is client-side over the cached ``app._history_entries`` + # list; widgets here only hold facet state. See app_history.apply_history_filter. + from quantui.app_history import HISTORY_CALC_TYPE_FACETS, HISTORY_STATUS_FACETS + + app._history_entries = [] + app._history_filter_suspend = False + app.history_search = widgets.Text( + placeholder="search name or formula (e.g. benzene, C6H6)", + continuous_update=True, # type-to-narrow + layout=layout_fn(width="300px"), + ) + app.history_filter_clear_btn = widgets.Button( + icon="times", + tooltip="Clear all history filters", + layout=layout_fn(width="40px"), + ) + app.history_count_lbl = widgets.HTML( + '' + ) + app._history_calc_chips = { + key: widgets.ToggleButton( + value=False, + description=label, + tooltip=f"Show only {label} calculations", + layout=layout_fn(width="auto"), + ) + for label, key in HISTORY_CALC_TYPE_FACETS + } + app.history_method_dd = widgets.Dropdown( + options=[("Any method", "")], + value="", + description="Method:", + style={"description_width": "55px"}, + layout=layout_fn(width="200px"), + ) + app.history_basis_dd = widgets.Dropdown( + options=[("Any basis", "")], + value="", + description="Basis:", + style={"description_width": "55px"}, + layout=layout_fn(width="200px"), + ) + app.history_date_from = widgets.DatePicker( + description="From:", + style={"description_width": "45px"}, + layout=layout_fn(width="200px"), + ) + app.history_date_to = widgets.DatePicker( + description="To:", + style={"description_width": "35px"}, + layout=layout_fn(width="190px"), + ) + app._history_status_chips = { + key: widgets.ToggleButton( + value=False, + description=label, + layout=layout_fn(width="auto"), + ) + for label, key in HISTORY_STATUS_FACETS + } + + def _facet_label(text: str, width: str = "60px") -> widgets.HTML: + return widgets.HTML( + f'{text}' + ) + + app._history_filter_box = widgets.VBox( + [ + widgets.HBox( + [ + app.history_search, + app.history_filter_clear_btn, + app.history_count_lbl, + ], + layout=layout_fn(align_items="center", gap="6px"), + ), + widgets.HBox( + [_facet_label("Type:"), *app._history_calc_chips.values()], + layout=layout_fn(align_items="center", gap="4px", flex_wrap="wrap"), + ), + widgets.HBox( + [app.history_method_dd, app.history_basis_dd], + layout=layout_fn(align_items="center", gap="8px"), + ), + widgets.HBox( + [ + app.history_date_from, + app.history_date_to, + _facet_label("Status:", "55px"), + *app._history_status_chips.values(), + ], + layout=layout_fn(align_items="center", gap="6px", flex_wrap="wrap"), + ), + ], + layout=layout_fn(margin="0 0 8px", gap="4px"), + ) + # The History tab is now purely the result-browser. Performance stats # + Calibrate accordions moved to the System Settings tab — see below — # so the user finds benchmarking + system state in one logical place. @@ -437,8 +536,10 @@ def build_history_section( [ widgets.HTML( '

' - "Calculations are saved automatically. Select one below to view its results.

" + "Calculations are saved automatically. Filter below, then select " + "one to view its results.

" ), + app._history_filter_box, widgets.HBox( [ app.past_dd, diff --git a/quantui/app_history.py b/quantui/app_history.py index a2f510b..2ae006c 100644 --- a/quantui/app_history.py +++ b/quantui/app_history.py @@ -2,6 +2,7 @@ from __future__ import annotations +import datetime as _dt import json as _json import time from contextlib import contextmanager @@ -11,6 +12,221 @@ import ipywidgets as widgets from IPython.display import HTML, display +# ══ HISTORY SEARCH / FACETED FILTER (HIST.7) ══════════════════════════════ +# +# The History browser caches the parsed ``result.json`` of every saved calc as +# a list of entry dicts on ``app._history_entries`` (built in +# ``refresh_results_browser``). Filtering re-narrows that in-memory list — no +# per-keystroke disk access — and repopulates ``past_dd`` client-side. + +# Calc-type facet chips, in display order: (badge label, canonical calc_type key). +# Mirrors ``_calc_type_badge`` in app_runflow so chips read like the dropdown labels. +HISTORY_CALC_TYPE_FACETS = [ + ("SP", "single_point"), + ("GeoOpt", "geometry_opt"), + ("Freq", "frequency"), + ("UV-Vis", "tddft"), + ("NMR", "nmr"), + ("PES", "pes_scan"), + ("Reorg", "reorganization_energy"), +] + +# Status facet chips: (label, key). +HISTORY_STATUS_FACETS = [ + ("Converged", "converged"), + ("Not converged", "not_converged"), +] + + +def entry_date(timestamp: Any) -> Optional[_dt.date]: + """Parse a result timestamp (``YYYY-MM-DD_...``) into a ``date``, or None.""" + try: + return _dt.date.fromisoformat(str(timestamp)[:10]) + except (ValueError, TypeError): + return None + + +def filter_history_entries( + entries: list[dict[str, Any]], + *, + text: str = "", + name_formulas: Optional[Any] = None, + calc_types: Optional[Any] = None, + method: Optional[str] = None, + basis: Optional[str] = None, + date_from: Optional[_dt.date] = None, + date_to: Optional[_dt.date] = None, + statuses: Optional[Any] = None, +) -> list[dict[str, Any]]: + """Return the subset of *entries* matching every active facet. + + Pure function — no widget or disk access, so it is unit-testable in + isolation. *entries* is the list of dicts built by + ``refresh_results_browser`` (keys: ``formula``, ``name``, ``calc_type``, + ``method``, ``basis``, ``timestamp``, ``date``, ``converged`` ...). + + An empty / falsy facet means "no constraint" for that facet: + + - ``text``: case-insensitive substring match against formula + molecule name. + - ``name_formulas``: formulas resolved from *text* via the molecule library + (so a chemical-name query like "benzene" also matches ``C6H6`` results). + An entry passes the text facet if it matches *either* the substring *or* + one of these formulas. The DB lookup lives in the caller + (``apply_history_filter``) to keep this function pure. + - ``calc_types``: iterable of canonical ``calc_type`` keys; entry passes if + its ``calc_type`` is in the set. + - ``method`` / ``basis``: exact match when truthy. + - ``date_from`` / ``date_to``: inclusive ``date`` bounds on the entry's + parsed timestamp date (entries with an unparseable date are excluded once + any bound is set). + - ``statuses``: subset of ``{"converged", "not_converged"}``. + """ + needle = (text or "").strip().lower() + formula_set = {str(f).lower() for f in (name_formulas or [])} + ct_set = set(calc_types) if calc_types else None + status_set = set(statuses) if statuses else None + out: list[dict[str, Any]] = [] + for e in entries: + if needle: + hay = f"{e.get('formula', '')} {e.get('name', '')}".lower() + matched = needle in hay + if not matched and formula_set: + matched = str(e.get("formula", "")).lower() in formula_set + if not matched: + continue + if ct_set is not None and e.get("calc_type") not in ct_set: + continue + if method and e.get("method") != method: + continue + if basis and e.get("basis") != basis: + continue + if date_from or date_to: + d = e.get("date") or entry_date(e.get("timestamp", "")) + if d is None: + continue + if date_from and d < date_from: + continue + if date_to and d > date_to: + continue + if status_set is not None: + key = "converged" if e.get("converged") else "not_converged" + if key not in status_set: + continue + out.append(e) + return out + + +def resolve_query_formulas(query: str) -> set[str]: + """Map a free-text chemical-name query to the set of formulas it names, so + a search like "benzene" also finds ``C6H6`` results. + + Two precise sources, unioned. Neither matches on synonyms — which would + wrongly pull "methylbenzene"=toluene into a "benzene" search: + + 1. The curated ``config.COMMON_NAME_TO_FORMULA`` map — covers the simple + molecules the bundled library names by formula (benzene, water, …). + 2. Exact library-*name* matches — covers named organics the library carries + properly (toluene, aspirin, caffeine). Exact (not substring) so + "benzene" can't drag in "ethylbenzene"/"nitrobenzene" etc. + + Returns an empty set for a blank query, or if nothing resolves. Failures + (missing library) are swallowed so search never breaks. + """ + q = (query or "").strip().lower() + if not q: + return set() + formulas: set[str] = set() + try: + from quantui import config + + # Curated names use substring so type-to-narrow works within the map + # (e.g. "hydrogen" surfaces H2 + the hydrogen-X molecules as you type). + for name, formula in config.COMMON_NAME_TO_FORMULA.items(): + if q in name: + formulas.add(formula) + except Exception: + pass + try: + from quantui import molecule_library as _ml + + # Library: exact entry-*name* match only. Substring/synonym matching + # would wrongly pull derivatives ("ethylbenzene", "methylbenzene") + # into a "benzene" search. + for r in _ml.search(q, limit=200): + if r.get("formula") and str(r.get("name", "")).lower() == q: + formulas.add(r["formula"]) + except Exception: + pass + return formulas + + +def refresh_history_facet_options(app: Any, entries: list[dict[str, Any]]) -> None: + """Repopulate the Method / Basis facet dropdowns from the distinct values + present in *entries*, preserving the current selection when it survives.""" + methods = sorted( + {e["method"] for e in entries if e.get("method") and e["method"] != "?"} + ) + bases = sorted( + {e["basis"] for e in entries if e.get("basis") and e["basis"] != "?"} + ) + method_dd = getattr(app, "history_method_dd", None) + if method_dd is not None: + cur = method_dd.value + method_dd.options = [("Any method", "")] + [(m, m) for m in methods] + method_dd.value = cur if cur in methods else "" + basis_dd = getattr(app, "history_basis_dd", None) + if basis_dd is not None: + cur = basis_dd.value + basis_dd.options = [("Any basis", "")] + [(b, b) for b in bases] + basis_dd.value = cur if cur in bases else "" + + +def apply_history_filter(app: Any) -> None: + """Re-narrow the cached history entries by the current facet-widget state + and repopulate ``past_dd`` — no disk access. + + Preserves the index-0 placeholder and (via ipywidgets value-preservation) + the current selection when it survives the filter. Shows an explicit + "no matches" option when every entry is filtered out. + """ + if getattr(app, "_history_filter_suspend", False): + return + entries = getattr(app, "_history_entries", None) + if not entries: + # Nothing cached yet (pre-scan) — leave whatever placeholder is set. + return + calc_types = [ + key for key, btn in getattr(app, "_history_calc_chips", {}).items() if btn.value + ] + statuses = [ + key + for key, btn in getattr(app, "_history_status_chips", {}).items() + if btn.value + ] + text = getattr(getattr(app, "history_search", None), "value", "") or "" + matches = filter_history_entries( + entries, + text=text, + name_formulas=resolve_query_formulas(text), + calc_types=calc_types or None, + method=getattr(getattr(app, "history_method_dd", None), "value", "") or None, + basis=getattr(getattr(app, "history_basis_dd", None), "value", "") or None, + date_from=getattr(getattr(app, "history_date_from", None), "value", None), + date_to=getattr(getattr(app, "history_date_to", None), "value", None), + statuses=statuses or None, + ) + placeholder = ("(select a calculation to view)", "") + if matches: + app.past_dd.options = [placeholder] + [(e["label"], e["path"]) for e in matches] + else: + app.past_dd.options = [placeholder, ("(no matches for current filters)", "")] + count_lbl = getattr(app, "history_count_lbl", None) + if count_lbl is not None: + count_lbl.value = ( + '' + f"{len(matches)} of {len(entries)} shown" + ) + class _LoadTimer: """Per-stage timing collector for a history-load operation. diff --git a/quantui/app_runflow.py b/quantui/app_runflow.py index 7dd4333..3589030 100644 --- a/quantui/app_runflow.py +++ b/quantui/app_runflow.py @@ -1282,40 +1282,74 @@ def refresh_results_browser(app: Any) -> None: from quantui import list_results, load_result except ImportError: return + from quantui.app_history import ( + apply_history_filter, + entry_date, + refresh_history_facet_options, + ) + app.results_path_lbl.value = ( f'' f"{app._get_results_dir()}" ) dirs = list_results() if not dirs: + app._history_entries = [] app.past_dd.options = [("(no saved results)", "")] return - placeholder = ("(select a calculation to view)", "") - options = [placeholder] + # Build the entry cache once. Each entry keeps both the display label and + # the parsed facet keys so HIST.7 filtering never re-reads disk. + entries: list[dict[str, Any]] = [] for d in dirs: try: data = load_result(d) ts = data.get("timestamp", d.name) - calc_badge = _calc_type_badge(data.get("calc_type", "")) + calc_type = data.get("calc_type", "") + calc_badge = _calc_type_badge(calc_type) # Calibration-produced results get a 🔧 marker so the user # can tell them apart from user-initiated calcs. The marker # comes from result.json's ``calibration_run_id`` extras field # written by the worker. calib_marker = "🔧 " if data.get("calibration_run_id") else "" + formula = data.get("formula", "?") + method = data.get("method", "?") + basis = data.get("basis", "?") label = ( f"{ts} · [{calc_badge}] " - f"{calib_marker}{data.get('formula', '?')} " - f"{data.get('method', '?')}/{data.get('basis', '?')}" + f"{calib_marker}{formula} " + f"{method}/{basis}" + ) + entries.append( + { + "path": str(d), + "label": label, + "calc_type": calc_type, + "formula": formula, + "name": data.get("name", "") or data.get("molecule_name", ""), + "method": method, + "basis": basis, + "timestamp": ts, + "date": entry_date(ts), + "converged": bool(data.get("converged", False)), + } ) - options.append((label, str(d))) except Exception: pass - # If the only entry is the placeholder, fall back to the empty-list - # message — the loop above silently swallowed every load_result call. - if len(options) == 1: + # If every load_result call was silently swallowed above, fall back to the + # empty-list message. + if not entries: + app._history_entries = [] app.past_dd.options = [("(no saved results)", "")] return - app.past_dd.options = options + app._history_entries = entries + # Repopulate facet dropdowns + apply the current filter as one atomic step + # so the option/value churn doesn't fire a filter pass per widget. + app._history_filter_suspend = True + try: + refresh_history_facet_options(app, entries) + finally: + app._history_filter_suspend = False + apply_history_filter(app) if app.calc_type_dd.value == "Frequency": app._refresh_freq_seed_options() diff --git a/quantui/config.py b/quantui/config.py index 252acb7..e43e2b7 100644 --- a/quantui/config.py +++ b/quantui/config.py @@ -270,6 +270,58 @@ LIBRARY_HEAVY_ATOM_CEILING_CURATED: int = 30 # named drugs run a bit larger LIBRARY_HEAVY_ATOM_CEILING_BULK: int = 9 # QM9 caps here anyway +# Common chemical name → Hill formula, for History search (HIST.7). +# The bundled QM9-derived library names its simplest molecules by formula +# (benzene is stored as "C6H6", water as "H2O", …), so a name search can't +# resolve them via the library. This curated map fills that gap for classroom +# staples; named organics the library *does* carry (toluene, aspirin, caffeine) +# resolve via an exact library-name lookup instead — see +# ``app_history.resolve_query_formulas``. Formulas are Hill notation matching +# ``Molecule.get_formula`` exactly (no-carbon formulas are fully alphabetical, +# so ammonia is "H3N", sulfur dioxide "O2S", hydrogen fluoride "FH"). +COMMON_NAME_TO_FORMULA: Dict[str, str] = { + "hydrogen": "H2", + "oxygen": "O2", + "nitrogen": "N2", + "chlorine": "Cl2", + "fluorine": "F2", + "water": "H2O", + "ammonia": "H3N", + "methane": "CH4", + "carbon dioxide": "CO2", + "carbon monoxide": "CO", + "hydrogen fluoride": "FH", + "hydrogen chloride": "ClH", + "hydrogen peroxide": "H2O2", + "hydrogen cyanide": "CHN", + "ozone": "O3", + "nitric oxide": "NO", + "nitrogen dioxide": "NO2", + "sulfur dioxide": "O2S", + "hydrogen sulfide": "H2S", + "benzene": "C6H6", + "ethane": "C2H6", + "ethylene": "C2H4", + "ethene": "C2H4", + "acetylene": "C2H2", + "ethyne": "C2H2", + "propane": "C3H8", + "butane": "C4H10", + "methanol": "CH4O", + "ethanol": "C2H6O", + "formaldehyde": "CH2O", + "acetaldehyde": "C2H4O", + "formic acid": "CH2O2", + "acetic acid": "C2H4O2", + "acetone": "C3H6O", + "propene": "C3H6", + "propyne": "C3H4", + "glycine": "C2H5NO2", + "urea": "CH4N2O", + "phosphine": "H3P", + "silane": "H4Si", +} + # Molecule presets — bundled library. # The former inline literal now lives in the indexed package-data store # (quantui/data/library/library.sqlite, seeded from diff --git a/tests/test_app.py b/tests/test_app.py index ca741c4..927a4e8 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -3120,3 +3120,308 @@ def test_help_btn_exists(self): def test_help_panel_initially_hidden(self): app = QuantUIApp() assert app.help_tab_panel.layout.display == "none" + + +class TestHistoryHardeningHist7: + """HIST.7: searchable + faceted History filtering. + + The History browser caches parsed ``result.json`` dicts on + ``app._history_entries`` and filters that list client-side. Most coverage + targets the pure ``filter_history_entries`` function; a couple of tests + exercise ``apply_history_filter`` end-to-end against a lightweight fake app. + """ + + def _entries(self): + from quantui.app_history import entry_date + + raw = [ + # (path, calc_type, formula, method, basis, ts, converged, name) + ( + "d1", + "single_point", + "H2O", + "RHF", + "STO-3G", + "2026-05-01_10-00-00-000001", + True, + "water", + ), + ( + "d2", + "geometry_opt", + "C6H6", + "B3LYP", + "6-31G*", + "2026-06-15_11-00-00-000001", + True, + "benzene", + ), + ( + "d3", + "frequency", + "H2O", + "B3LYP", + "6-31G*", + "2026-07-01_09-30-00-000001", + False, + "water", + ), + ( + "d4", + "tddft", + "C6H6", + "PBE0", + "cc-pVDZ", + "2026-07-20_08-00-00-000001", + True, + "benzene", + ), + ] + return [ + { + "path": p, + "label": f"{ts} {ct} {f} {m}/{b}", + "calc_type": ct, + "formula": f, + "name": nm, + "method": m, + "basis": b, + "timestamp": ts, + "date": entry_date(ts), + "converged": conv, + } + for (p, ct, f, m, b, ts, conv, nm) in raw + ] + + # ── pure filter function ──────────────────────────────────────────── + + def test_no_facets_returns_all(self): + from quantui.app_history import filter_history_entries + + entries = self._entries() + assert filter_history_entries(entries) == entries + + def test_text_matches_formula_case_insensitive(self): + from quantui.app_history import filter_history_entries + + got = filter_history_entries(self._entries(), text="c6h6") + assert {e["path"] for e in got} == {"d2", "d4"} + + def test_text_matches_molecule_name(self): + from quantui.app_history import filter_history_entries + + got = filter_history_entries(self._entries(), text="benzene") + assert {e["path"] for e in got} == {"d2", "d4"} + + def test_text_no_match_returns_empty(self): + from quantui.app_history import filter_history_entries + + assert filter_history_entries(self._entries(), text="zzz") == [] + + def test_calc_type_facet(self): + from quantui.app_history import filter_history_entries + + got = filter_history_entries(self._entries(), calc_types=["frequency", "tddft"]) + assert {e["path"] for e in got} == {"d3", "d4"} + + def test_method_and_basis_facets_combine(self): + from quantui.app_history import filter_history_entries + + got = filter_history_entries(self._entries(), method="B3LYP", basis="6-31G*") + assert {e["path"] for e in got} == {"d2", "d3"} + + def test_date_range_inclusive(self): + import datetime as dt + + from quantui.app_history import filter_history_entries + + got = filter_history_entries( + self._entries(), + date_from=dt.date(2026, 6, 1), + date_to=dt.date(2026, 7, 1), + ) + assert {e["path"] for e in got} == {"d2", "d3"} + + def test_status_facet_converged_only(self): + from quantui.app_history import filter_history_entries + + got = filter_history_entries(self._entries(), statuses=["converged"]) + assert {e["path"] for e in got} == {"d1", "d2", "d4"} + + def test_status_facet_not_converged_only(self): + from quantui.app_history import filter_history_entries + + got = filter_history_entries(self._entries(), statuses=["not_converged"]) + assert {e["path"] for e in got} == {"d3"} + + def test_facets_are_anded_together(self): + from quantui.app_history import filter_history_entries + + got = filter_history_entries( + self._entries(), text="H2O", calc_types=["frequency"] + ) + assert {e["path"] for e in got} == {"d3"} + + def test_unparseable_date_excluded_when_bound_set(self): + import datetime as dt + + from quantui.app_history import filter_history_entries + + entries = self._entries() + entries.append( + { + "path": "bad", + "label": "bad", + "calc_type": "single_point", + "formula": "X", + "name": "", + "method": "RHF", + "basis": "STO-3G", + "timestamp": "not-a-date", + "date": None, + "converged": True, + } + ) + got = filter_history_entries(entries, date_from=dt.date(2026, 1, 1)) + assert "bad" not in {e["path"] for e in got} + + # ── apply_history_filter end-to-end (fake app) ────────────────────── + + def _fake_app(self): + import types + + entries = self._entries() + + def _chip(val=False): + return types.SimpleNamespace(value=val) + + app = types.SimpleNamespace( + _history_entries=entries, + _history_filter_suspend=False, + history_search=_chip(""), + history_method_dd=_chip(""), + history_basis_dd=_chip(""), + history_date_from=_chip(None), + history_date_to=_chip(None), + _history_calc_chips={ + "single_point": _chip(), + "geometry_opt": _chip(), + "frequency": _chip(), + "tddft": _chip(), + }, + _history_status_chips={"converged": _chip(), "not_converged": _chip()}, + past_dd=types.SimpleNamespace(options=[]), + history_count_lbl=_chip(""), + ) + return app + + def test_apply_filter_populates_options_with_placeholder(self): + from quantui.app_history import apply_history_filter + + app = self._fake_app() + apply_history_filter(app) + # index-0 placeholder + all 4 entries + assert app.past_dd.options[0] == ("(select a calculation to view)", "") + assert len(app.past_dd.options) == 5 + assert "4 of 4 shown" in app.history_count_lbl.value + + def test_apply_filter_text_narrows_and_counts(self): + from quantui.app_history import apply_history_filter + + app = self._fake_app() + app.history_search.value = "benzene" + apply_history_filter(app) + paths = [v for _, v in app.past_dd.options[1:]] + assert set(paths) == {"d2", "d4"} + assert "2 of 4 shown" in app.history_count_lbl.value + + def test_apply_filter_no_match_shows_message(self): + from quantui.app_history import apply_history_filter + + app = self._fake_app() + app.history_search.value = "zzz" + apply_history_filter(app) + assert app.past_dd.options == [ + ("(select a calculation to view)", ""), + ("(no matches for current filters)", ""), + ] + assert "0 of 4 shown" in app.history_count_lbl.value + + def test_apply_filter_suspended_is_noop(self): + from quantui.app_history import apply_history_filter + + app = self._fake_app() + app.past_dd.options = ["sentinel"] + app._history_filter_suspend = True + apply_history_filter(app) + assert app.past_dd.options == ["sentinel"] + + # ── chemical-name search (name → formula resolution) ──────────────── + + def test_text_name_resolution_matches_formula(self): + """A name query resolves to formulas so entries with no stored name + still match (e.g. 'benzene' → C6H6).""" + from quantui.app_history import filter_history_entries + + entries = self._entries() + for e in entries: + e["name"] = "" # simulate results without a stored chemical name + got = filter_history_entries(entries, text="benzene", name_formulas={"C6H6"}) + assert {e["path"] for e in got} == {"d2", "d4"} + + def test_text_substring_still_wins_without_resolution(self): + from quantui.app_history import filter_history_entries + + # No name_formulas: falls back to substring over formula + name. + got = filter_history_entries(self._entries(), text="c6h6") + assert {e["path"] for e in got} == {"d2", "d4"} + + def test_resolve_query_formulas_benzene_precise(self): + """'benzene' resolves to C6H6 via the curated map, and does NOT pull in + substituted benzenes (toluene C7H8, aniline C6H7N) that only match on + synonyms.""" + from quantui.app_history import resolve_query_formulas + + formulas = resolve_query_formulas("benzene") + assert "C6H6" in formulas + assert "C7H8" not in formulas # toluene must not sneak in + assert "C6H7N" not in formulas # aniline must not sneak in + assert "C8H10" not in formulas # ethylbenzene must not sneak in + + def test_resolve_query_formulas_curated_map(self): + from quantui.app_history import resolve_query_formulas + + assert "H2O" in resolve_query_formulas("water") + assert "H3N" in resolve_query_formulas("ammonia") + assert "CH4" in resolve_query_formulas("methane") + + def test_resolve_query_formulas_library_exact_name(self): + """Named organics the library carries resolve via exact library name + (skips if the bundled library is unavailable).""" + from quantui import molecule_library as ml + from quantui.app_history import resolve_query_formulas + + if not [ + r for r in ml.search("toluene", limit=5) if r["name"].lower() == "toluene" + ]: + pytest.skip("molecule library unavailable") + assert "C7H8" in resolve_query_formulas("toluene") + + def test_resolve_query_formulas_blank_is_empty(self): + from quantui.app_history import resolve_query_formulas + + assert resolve_query_formulas("") == set() + assert resolve_query_formulas(" ") == set() + + def test_apply_filter_name_search_via_resolver(self, monkeypatch): + import quantui.app_history as ah + from quantui.app_history import apply_history_filter + + app = self._fake_app() + for e in app._history_entries: + e["name"] = "" # no stored names → must rely on the resolver + monkeypatch.setattr(ah, "resolve_query_formulas", lambda q: {"C6H6"}) + app.history_search.value = "benzene" + apply_history_filter(app) + paths = [v for _, v in app.past_dd.options[1:]] + assert set(paths) == {"d2", "d4"}