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"}